Public Access
1
0

feat: add billing plan foundations and refresh LocaleScope pricing

Introduce a shared pricing and entitlement model so plan metadata can drive public pricing and future subscription enforcement. Rebrand the product to LocaleScope and align the UI copy around market intelligence and business research workflows.
This commit is contained in:
pguerrerox
2026-05-13 03:50:29 +00:00
parent 1f7737e5cb
commit f1a46c79f2
17 changed files with 1826 additions and 131 deletions
+4
View File
@@ -9,10 +9,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Added
- Added an authenticated `Account` page with editable profile settings, workspace details, usage summaries, and placeholders for upcoming billing and team management.
- Added workspace and workspace-membership schema foundations plus new account API endpoints so each user now has a default personal workspace for future company, billing, and team features.
- Added a shared billing catalog, entitlement policy helpers, and feature-gate logic for Starter, Growth, Pro, and Enterprise packaging so pricing and future subscription enforcement can share one source of truth.
### Changed
- Normalized the product UI around shared design primitives for buttons, cards, alerts, tabs, and page shells to keep public, auth, research, results, dashboard, map, and account surfaces visually aligned.
- Refined the authenticated app for mobile with a phone-friendly top bar, bottom tab navigation, shorter inline research maps, touch-friendlier map gestures, and a mobile lead-card presentation in the dashboard while preserving desktop layouts.
- Rebranded public, auth, worker, and supporting documentation copy from `Leads4less` to `LocaleScope` and repositioned the product around local market intelligence and territory research.
- Reworked the public pricing section and account billing placeholder to read from shared plan metadata, laying groundwork for future subscription, usage, and upgrade controls.
- Updated dashboard, map, and results copy to describe saved businesses and research outputs instead of lead-focused terminology.
## [2026-05-01]
+2 -2
View File
@@ -1,6 +1,6 @@
# Leads4Less
# LocaleScope
Leads4Less is a React + Vite app for finding local business leads, saving them in Postgres, and browsing them in dashboard and map views.
LocaleScope is a React + Vite app for researching local markets, saving business results in Postgres, and reviewing them in dashboard and map views.
## Stack
+269
View File
@@ -0,0 +1,269 @@
# TODO: LocaleScope Pricing & Packaging Design
## Goals
- [ ] Position LocaleScope as a local market intelligence platform, not a scraper/export commodity.
- [ ] Align pricing, packaging, product capabilities, and billing enforcement to a single plan model.
- [ ] Protect infrastructure with quotas, credits, throttling, and priority processing.
- [ ] Preserve room for future AI, enrichment, API, collaboration, and enterprise expansion.
## 1) Product & Marketing Alignment
- [x] Update product messaging to emphasize:
- local market intelligence
- geographic prospecting
- territory discovery
- operational prospecting workflows
- [x] Remove or reduce copy that frames the product as lead scraping or raw export tooling.
- [x] Define a concise plan-comparison narrative for Starter, Growth, Pro, and Enterprise.
- [x] Make Growth the obvious value anchor in pricing page design and copy.
- [ ] Decide whether to update historical/internal naming artifacts separately:
- `CHANGELOG.md` historical branding references
- `package.json` package name
## 2) Canonical Plan Definitions
- [x] Create a single source of truth for canonical plan definitions in code.
- [x] Keep the canonical catalog separate from presentation metadata:
- catalog = entitlements/commercial packaging data
- presentation = pricing-card copy, marketing bullets, comparison-table labels
- [x] Keep step `#2` scoped to catalog/type design and pricing-page integration only.
- [x] Define these initial SKUs:
- `starter_monthly`
- `growth_monthly`
- `pro_monthly`
- `enterprise_custom`
- [x] Add annual counterparts with 20% discount support.
- [x] Reserve type support for future founder/LTD SKUs without adding them to the active catalog yet.
- [x] Add explicit catalog identity fields for each plan:
- `tier`
- `billingInterval`
- `isSelfServe`
- `contactSalesRequired`
- [x] Include in each plan definition:
- pricing
- monthly usage limits
- workspace/user limits
- feature flags
- queue priority / processing tier
- add-on eligibility
- [x] Treat workspace/user limits as commercial allowances first, not guaranteed enforceable constraints yet.
- [x] Use customer-facing `researchRunsPerMonth` in the initial catalog and defer internal credit-ledger semantics to step `#3`.
- [x] Add lightweight helper accessors around the catalog, for example:
- `getPlanByCode`
- `getSelfServePlans`
- `isAnnualPlan`
- `getPlanDisplayMeta`
- [x] Expand shared billing/account types only enough to support future plan display:
- current plan code nullable
- billing interval nullable
- billing status/message
- no real subscription persistence yet
- [x] Add explicit listing semantics so public pricing visibility does not depend on billing interval.
- [x] Add plan family / sibling linkage to support future annual toggles, plan switching, and analytics rollups.
- [x] Reduce quantitative pricing bullet duplication by deriving core plan facts from structured catalog limits.
- [x] Encode internal feature readiness notes for marketed-but-not-yet-enforced capabilities.
- [ ] Follow-up recommendation: clarify whether `getPlanByCode` should stay active-catalog-only or be renamed to make reserved-code behavior explicit.
- [ ] Follow-up recommendation: revisit whether `planFamily` should remain separate from `tier` or be consolidated later.
- [ ] Follow-up recommendation: consider moving shared plan price/period formatting helpers into the billing domain once account and pricing UI expand.
- [ ] Follow-up recommendation: extend readiness modeling beyond feature flags if later steps need readiness for support, processing, or add-on availability.
## 3) Packaging & Entitlement Model
- [x] Decide the internal usage model:
- plan-based research runs, or
- credit ledger with variable credit consumption per action
- [x] Recommended default: use a credit system internally and simpler plan language externally.
- [x] Keep the public catalog and pricing page centered on plan allowances, not internal billing mechanics.
- [x] Define the research credit schedule, for example:
- small local search = 1 credit
- multi-radius query = 3-5 credits
- enriched search = 10 credits
- [x] Define export limits by plan:
- Starter = 2,500/month
- Growth = 15,000/month
- Pro = 75,000/month
- [x] Define what happens at limit exhaustion:
- block
- upgrade prompt
- add-on purchase path
- enterprise/contact sales path
- [x] Implement a shared entitlement policy layer with:
- usage resources/actions
- plan-to-allowance translation helpers
- action cost estimation helpers
- pure entitlement decision helpers
- [x] Separate capability gating from allowance checks in the entitlement layer.
- [x] Add explicit allowance semantics so `null` does not silently mean allowed/unlimited.
- [x] Add canonical action policy definitions for:
- `basic_search_run`
- `deep_research_preview`
- `deep_research_batch_run`
- `csv_export`
- future `enrichment_run`
- future `api_request`
- [x] Keep step `#3` policy-only:
- no DB persistence yet
- no route enforcement yet
- no billing-provider integration yet
- [ ] Future note: `evaluateActionEntitlement()` is policy-only and later steps must provide real remaining-usage inputs from subscription/account state.
- [ ] Future note: missing readiness metadata currently implies `launch_ready`; keep readiness annotations current as new gated features are added.
- [ ] Future note: `api_requests` and `enrichments` are modeled ahead of full product implementation; do not treat them as launch-ready by default.
- [ ] Future note: deep research costing should stay aligned with preview-derived estimates and should not diverge into a second billing algorithm.
- [ ] Future note: export policy is defined, but reliable export enforcement requires a future backend export endpoint.
- [ ] Future note: usage subject remains `user` until workspace-scoped ownership and pooled usage are ready.
- [ ] Future note: `territoryMapping` currently carries deep-research capability semantics and may need a dedicated capability later if gating becomes more granular.
## 4) Feature Gates by Plan
- [x] Implement a shared feature-gate interpreter layer that resolves feature state by plan using:
- plan feature flags
- feature readiness metadata
- self-serve vs enterprise upgrade paths
- [x] Starter
- [x] CSV export
- [x] map search
- [x] radius search
- [x] basic filters
- [x] exclude automations
- [x] exclude API access
- [x] exclude enrichments
- [x] exclude CRM integrations
- [x] exclude collaboration
- [x] Growth
- [x] saved searches
- [x] territory mapping
- [x] advanced filtering
- [x] deduplication
- [x] export history
- [x] tagging & notes
- [x] faster processing
- [x] priority support
- [x] Pro
- [x] shared lists
- [x] scheduled research
- [x] bulk exports
- [x] CRM integrations
- [x] webhooks/API
- [x] enrichment credits
- [x] collaboration features
- [x] Enterprise
- [x] pooled or custom usage
- [x] SSO
- [x] SLA
- [x] white-labeling
- [x] onboarding / account management
- [x] dedicated infrastructure options
- [x] custom integrations
- [x] Align feature-gate interpretation with entitlement action mappings in shared code.
- [x] Keep step `#4` shared-policy only:
- no broad UI rollout yet
- no backend route enforcement yet
- no usage-ledger coupling yet
- [ ] Future note: make upgrade recommendations readiness-aware so users are not prompted to upgrade into tiers where the target feature is still `coming_soon`.
- [ ] Future note: consolidate action ↔ feature mapping into one canonical source shared by `entitlements.ts` and `feature-gates.ts` to avoid drift between UI gating and backend action policy.
- [ ] Future note: for Enterprise plans, included-but-not-ready features should usually resolve to `coming_soon` instead of `contact_sales`.
- [ ] Future note: revisit the fallback `coming_soon` state for unavailable or unmapped features before broad UI rollout so hidden vs upgrade vs future behavior stays intentional.
## 5) Billing & Data Model Design
- [ ] Design subscription/account state separately from the canonical plan catalog.
- [ ] Keep billing-provider identifiers out of the canonical catalog until payments integration work begins.
- [ ] Design subscription state storage for current plan, billing interval, and status.
- [ ] Design a monthly usage ledger for:
- research credits/runs
- exports
- enrichments
- API usage (future)
- [ ] Design add-on purchases and remaining balances.
- [ ] Define renewal/reset behavior for monthly quotas.
- [ ] Define annual billing behavior and renewal terms.
- [ ] Define LTD handling with monthly quotas and non-unlimited usage.
## 6) Enforcement Architecture
- [ ] Create a centralized entitlement/usage policy service on the backend.
- [ ] Ensure all high-cost actions check entitlements before execution.
- [ ] Start with enforcement on:
- research routes
- export routes
- enrichment routes (future)
- [ ] Add queue prioritization by plan tier.
- [ ] Add throttling/fair-usage controls.
- [ ] Add clear API responses for quota exhaustion and upgrade flows.
## 7) Workspace, User, and Collaboration Readiness
- [ ] Review whether current data ownership is sufficiently workspace-scoped for plan promises.
- [ ] Identify gaps between current user-scoped data model and promised team/workspace packaging.
- [ ] Document which catalog limits can be enforced immediately versus only represented commercially at launch.
- [ ] Define how to enforce:
- users included
- workspace limits
- shared assets/lists
- collaboration permissions
- [ ] Decide whether some collaboration features need phased rollout rather than immediate sale.
## 8) Add-On Strategy
- [ ] Define export add-ons:
- +10k exports = $29
- +50k exports = $99
- [ ] Define enrichment packs:
- 1,000 enrichments = $49
- [ ] Reserve future add-ons for:
- AI prospecting assistant
- white-label / agency tools
- higher API capacity
- [ ] Decide whether add-ons are one-time, monthly recurring, or both.
## 9) Founder / LTD Strategy
- [ ] Decide whether to launch founder LTD at all.
- [ ] If yes, define strict quantity cap (e.g. first 100-250 customers).
- [ ] Define founder SKUs:
- Founder Plan = $249 one-time
- Founder Pro = $499 one-time
- [ ] Ensure founder plans have monthly quotas and exclude unlimited compute/API.
- [ ] Define which future features are excluded from LTD plans.
## 10) Pricing Page & Account UX
- [ ] Build pricing page from canonical plan definitions instead of hardcoded copy.
- [ ] Derive pricing-card and comparison-table content from presentation metadata layered on top of the canonical catalog.
- [ ] Add plan comparison table.
- [ ] Add annual/monthly toggle.
- [ ] Add upgrade CTAs and contact-sales CTA.
- [ ] Add account/billing page showing:
- current plan
- billing interval
- usage this month
- remaining quota
- available add-ons
- upgrade options
- [ ] Add quota warning UX before hard exhaustion.
## 11) Payments Integration
- [ ] Choose billing provider (likely Stripe).
- [ ] Map internal SKUs to external billing products/prices.
- [ ] Support subscriptions, annual billing, add-ons, and enterprise/manual invoicing.
- [ ] Define webhook handling for subscription state changes.
- [ ] Define downgrade, cancellation, retry, and grace-period behavior.
- [ ] Add internal admin visibility for billing state.
## 12) Analytics, Ops, and Revenue Instrumentation
- [ ] Track pricing-page conversion by plan.
- [ ] Track quota exhaustion events.
- [ ] Track upgrade triggers:
- export limit hit
- research limit hit
- feature-gate encounter
- [ ] Track add-on attach rate.
- [ ] Track plan mix, churn, expansion revenue, and annual conversion.
- [ ] Add internal dashboards for billing and usage health.
## 13) Rollout Plan
- [ ] Phase 1: finalize canonical plan definitions, presentation metadata boundaries, and entitlement model.
- [ ] Phase 2: implement usage ledger and backend enforcement.
- [ ] Phase 3: update pricing page and account/billing UI.
- [ ] Phase 4: integrate payments and subscription lifecycle handling.
- [ ] Phase 5: launch add-ons and annual billing.
- [ ] Phase 6: launch collaboration, API, enrichment, and enterprise features as architecture matures.
## Open Questions
- [ ] Will research capacity be marketed as runs, credits, or both?
- [ ] Which collaboration/team features are truly launch-ready?
- [ ] Should workspace limits be hard-enforced at launch or soft-gated initially?
- [ ] Which add-ons launch on day one vs later?
- [ ] Is founder/LTD part of launch or a separate campaign?
- [ ] What exact enterprise triggers require custom sales instead of self-serve?
- [ ] Which plan data belongs in the canonical catalog versus presentation metadata?
+1 -1
View File
@@ -1,6 +1,6 @@
# Postal Datasets
Leads4less expects local GeoJSON files for deep research postal overlays.
LocaleScope expects local GeoJSON files for deep research postal overlays.
## Supported v1 datasets
+3 -2
View File
@@ -159,8 +159,9 @@ export async function buildAccountPageData(db: DbClient, user: AppUser): Promise
summary,
billing: {
status: 'not_configured',
planName: null,
message: 'Billing is not configured yet. Subscription management will appear here in a future update.',
planCode: null,
billingInterval: null,
message: 'Subscription management is being prepared. Plan details, usage tracking, and billing controls will appear here in a future update.',
},
team: {
canManageMembers: workspace.role === 'owner' || workspace.role === 'admin',
+1 -1
View File
@@ -7,7 +7,7 @@ const boss = await getBoss();
await registerJobs(boss);
console.log(`Leads4less worker started with pg-boss schema '${env.PG_BOSS_SCHEMA}'`);
console.log(`LocaleScope worker started with pg-boss schema '${env.PG_BOSS_SCHEMA}'`);
const shutdown = async () => {
await stopBoss();
+486
View File
@@ -0,0 +1,486 @@
import type { ActivePlanCode, BillingInterval, FeatureReadiness, PlanCode, PlanFeatures } from './plans.js';
import { getPlanByCode } from './plans.js';
// Public pricing stays expressed as research runs per month, but internal accounting
// uses research credits so future actions can consume variable amounts.
export type UsageSubjectType = 'user';
export type UsageResource = 'research_credits' | 'exports' | 'enrichments' | 'api_requests';
export type UsageAllowanceAvailability = 'included' | 'not_available' | 'custom' | 'unlimited';
export type UsageAction =
| 'basic_search_run'
| 'deep_research_preview'
| 'deep_research_batch_run'
| 'csv_export'
| 'enrichment_run'
| 'api_request';
export type EntitlementDecisionStatus =
| 'allowed'
| 'blocked_upgrade_required'
| 'blocked_addon_available'
| 'contact_sales_required';
export type EntitlementDenialReason = 'feature_not_available' | 'quota_exhausted' | 'custom_enterprise_only' | 'not_launch_ready';
export interface UsageSubject {
type: UsageSubjectType;
id: string;
}
export interface UsageAmount {
resource: UsageResource;
amount: number;
}
export interface UsageAllowance {
resource: UsageResource;
included: number | null;
billingInterval: BillingInterval | null;
pooled: boolean;
availability: UsageAllowanceAvailability;
}
export interface PlanUsageAllowance {
planCode: ActivePlanCode;
billingInterval: BillingInterval;
researchCredits: UsageAllowance;
exports: UsageAllowance;
enrichments: UsageAllowance;
apiRequests: UsageAllowance;
}
export interface UsageCostEstimate {
action: UsageAction;
amounts: UsageAmount[];
isChargeable: boolean;
details?: Record<string, number | string | boolean | null>;
}
export interface ActionPolicy {
action: UsageAction;
chargeable: boolean;
consumedResources: UsageResource[];
requiredFeatures: Array<keyof PlanFeatures>;
readinessFeatures: Array<keyof PlanFeatures>;
}
export interface EntitlementDecision {
status: EntitlementDecisionStatus;
denialReason: EntitlementDenialReason | null;
action: UsageAction;
resource: UsageResource;
requiredAmount: number;
remainingAmount: number | null;
currentPlanCode: ActivePlanCode;
suggestedUpgradePlanCode: ActivePlanCode | null;
addonEligible: boolean;
contactSalesRequired: boolean;
}
export interface DeepResearchBatchUsageInput {
estimatedChildJobs?: number | null;
totalAreas?: number | null;
}
export interface ActionEntitlementEvaluationInput {
planCode: ActivePlanCode;
action: UsageAction;
resource: UsageResource;
requiredAmount: number;
remainingAmount: number | null;
}
const actionPolicies: Record<UsageAction, ActionPolicy> = {
basic_search_run: {
action: 'basic_search_run',
chargeable: true,
consumedResources: ['research_credits'],
requiredFeatures: [],
readinessFeatures: [],
},
deep_research_preview: {
action: 'deep_research_preview',
chargeable: false,
consumedResources: [],
requiredFeatures: ['territoryMapping'],
readinessFeatures: ['territoryMapping'],
},
deep_research_batch_run: {
action: 'deep_research_batch_run',
chargeable: true,
consumedResources: ['research_credits'],
requiredFeatures: ['territoryMapping'],
readinessFeatures: ['territoryMapping'],
},
csv_export: {
action: 'csv_export',
chargeable: true,
consumedResources: ['exports'],
requiredFeatures: ['csvExport'],
readinessFeatures: ['csvExport'],
},
enrichment_run: {
action: 'enrichment_run',
chargeable: true,
consumedResources: ['enrichments'],
requiredFeatures: ['enrichments'],
readinessFeatures: ['enrichments'],
},
api_request: {
action: 'api_request',
chargeable: true,
consumedResources: ['api_requests'],
requiredFeatures: ['apiAccess'],
readinessFeatures: ['apiAccess'],
},
};
export function isChargeableAction(action: UsageAction) {
return actionPolicies[action].chargeable;
}
export function getActionPolicy(action: UsageAction) {
return actionPolicies[action];
}
export function getIncludedResearchCredits(planCode: ActivePlanCode) {
const plan = getRequiredActivePlan(planCode);
return plan.limits.researchRunsPerMonth;
}
export function getIncludedExports(planCode: ActivePlanCode) {
const plan = getRequiredActivePlan(planCode);
return plan.limits.exportsPerMonth;
}
export function getUsageAllowanceForPlan(planCode: ActivePlanCode): PlanUsageAllowance {
const plan = getRequiredActivePlan(planCode);
return {
planCode,
billingInterval: plan.billingInterval,
researchCredits: {
resource: 'research_credits',
included: plan.limits.researchRunsPerMonth,
billingInterval: plan.billingInterval,
pooled: plan.limits.pooledUsage,
availability: plan.limits.researchRunsPerMonth === null ? 'custom' : 'included',
},
exports: {
resource: 'exports',
included: plan.limits.exportsPerMonth,
billingInterval: plan.billingInterval,
pooled: plan.limits.pooledUsage,
availability: plan.limits.exportsPerMonth === null ? 'custom' : 'included',
},
enrichments: {
resource: 'enrichments',
included: plan.limits.enrichmentCreditsIncluded,
billingInterval: plan.billingInterval,
pooled: plan.limits.pooledUsage,
availability: plan.features.enrichments ? (plan.limits.enrichmentCreditsIncluded === null ? 'custom' : 'included') : 'not_available',
},
apiRequests: {
resource: 'api_requests',
included: null,
billingInterval: plan.billingInterval,
pooled: plan.limits.pooledUsage,
availability: plan.features.apiAccess ? 'custom' : 'not_available',
},
};
}
export function estimateBasicSearchCost(): UsageCostEstimate {
return {
action: 'basic_search_run',
amounts: [{ resource: 'research_credits', amount: 1 }],
isChargeable: true,
};
}
export function estimateDeepResearchPreviewCost(): UsageCostEstimate {
return {
action: 'deep_research_preview',
amounts: [],
isChargeable: false,
details: { estimatedChargeableRuns: 0 },
};
}
export function estimateDeepResearchBatchCost(input: DeepResearchBatchUsageInput): UsageCostEstimate {
const estimatedChildJobs = normalizePositiveWholeNumber(input.estimatedChildJobs);
const totalAreas = normalizePositiveWholeNumber(input.totalAreas);
const amount = estimatedChildJobs ?? totalAreas ?? 0;
const estimateConfidence = estimatedChildJobs !== null ? 'high' : totalAreas !== null ? 'fallback' : 'unknown';
return {
action: 'deep_research_batch_run',
amounts: [{ resource: 'research_credits', amount }],
isChargeable: amount > 0,
details: {
estimatedChildJobs: estimatedChildJobs ?? null,
totalAreas: totalAreas ?? null,
billingBasis: estimatedChildJobs !== null ? 'estimated_child_jobs' : totalAreas !== null ? 'total_areas' : 'unknown',
estimateConfidence,
},
};
}
export function estimateExportUsage(rowCount: number): UsageCostEstimate {
const amount = Math.max(0, Math.floor(rowCount));
return {
action: 'csv_export',
amounts: [{ resource: 'exports', amount }],
isChargeable: amount > 0,
details: { exportedRows: amount },
};
}
export function canAllowanceCoverCost(allowance: UsageAllowance, amount: number) {
if (amount <= 0) {
return true;
}
if (allowance.availability === 'not_available') {
return false;
}
if (allowance.availability === 'custom' || allowance.availability === 'unlimited') {
return true;
}
if (allowance.included === null) {
return false;
}
return allowance.included >= amount;
}
// Quantity-only helper. This does not check whether the action itself is enabled for the plan.
export function canPlanCoverEstimatedCost(planCode: ActivePlanCode, estimate: UsageCostEstimate) {
if (!estimate.isChargeable) {
return true;
}
const allowance = getUsageAllowanceForPlan(planCode);
return estimate.amounts.every((usageAmount) => {
const matchingAllowance = getAllowanceByResource(allowance, usageAmount.resource);
return canAllowanceCoverCost(matchingAllowance, usageAmount.amount);
});
}
export function isFeatureAvailableForAction(planCode: ActivePlanCode, action: UsageAction) {
const plan = getRequiredActivePlan(planCode);
const policy = getActionPolicy(action);
return policy.requiredFeatures.every((featureKey) => plan.features[featureKey]);
}
export function canPlanUseAction(planCode: ActivePlanCode, action: UsageAction) {
const plan = getRequiredActivePlan(planCode);
const policy = getActionPolicy(action);
if (!isFeatureAvailableForAction(planCode, action)) {
return false;
}
return policy.readinessFeatures.every((featureKey) => {
const readiness = getFeatureReadiness(plan.featureReadiness[featureKey]);
return readiness !== 'future';
});
}
export function buildEntitlementDecision(input: {
planCode: ActivePlanCode;
action: UsageAction;
resource: UsageResource;
requiredAmount: number;
remainingAmount: number | null;
}): EntitlementDecision {
const plan = getRequiredActivePlan(input.planCode);
const addonEligible = hasEligibleAddonForResource(plan.code, input.resource);
const actionFeatureAvailable = isFeatureAvailableForAction(plan.code, input.action);
const actionReady = canPlanUseAction(plan.code, input.action);
const canCover = input.remainingAmount === null || input.remainingAmount >= input.requiredAmount;
if (!actionFeatureAvailable) {
return {
status: plan.contactSalesRequired ? 'contact_sales_required' : 'blocked_upgrade_required',
denialReason: 'feature_not_available',
action: input.action,
resource: input.resource,
requiredAmount: input.requiredAmount,
remainingAmount: input.remainingAmount,
currentPlanCode: plan.code,
suggestedUpgradePlanCode: getSuggestedUpgradePlanCode(plan.code, input.resource),
addonEligible: false,
contactSalesRequired: plan.contactSalesRequired,
};
}
if (!actionReady) {
return {
status: plan.contactSalesRequired ? 'contact_sales_required' : 'blocked_upgrade_required',
denialReason: 'not_launch_ready',
action: input.action,
resource: input.resource,
requiredAmount: input.requiredAmount,
remainingAmount: input.remainingAmount,
currentPlanCode: plan.code,
suggestedUpgradePlanCode: getSuggestedUpgradePlanCode(plan.code, input.resource),
addonEligible: false,
contactSalesRequired: plan.contactSalesRequired,
};
}
if (canCover) {
return {
status: 'allowed',
denialReason: null,
action: input.action,
resource: input.resource,
requiredAmount: input.requiredAmount,
remainingAmount: input.remainingAmount,
currentPlanCode: plan.code,
suggestedUpgradePlanCode: null,
addonEligible,
contactSalesRequired: false,
};
}
if (!plan.isSelfServe || plan.contactSalesRequired) {
return {
status: 'contact_sales_required',
denialReason: 'custom_enterprise_only',
action: input.action,
resource: input.resource,
requiredAmount: input.requiredAmount,
remainingAmount: input.remainingAmount,
currentPlanCode: plan.code,
suggestedUpgradePlanCode: null,
addonEligible,
contactSalesRequired: true,
};
}
if (addonEligible) {
return {
status: 'blocked_addon_available',
denialReason: 'quota_exhausted',
action: input.action,
resource: input.resource,
requiredAmount: input.requiredAmount,
remainingAmount: input.remainingAmount,
currentPlanCode: plan.code,
suggestedUpgradePlanCode: getSuggestedUpgradePlanCode(plan.code, input.resource),
addonEligible: true,
contactSalesRequired: false,
};
}
return {
status: 'blocked_upgrade_required',
denialReason: 'quota_exhausted',
action: input.action,
resource: input.resource,
requiredAmount: input.requiredAmount,
remainingAmount: input.remainingAmount,
currentPlanCode: plan.code,
suggestedUpgradePlanCode: getSuggestedUpgradePlanCode(plan.code, input.resource),
addonEligible: false,
contactSalesRequired: false,
};
}
export function evaluateActionEntitlement(input: ActionEntitlementEvaluationInput) {
return buildEntitlementDecision({
planCode: input.planCode,
action: input.action,
resource: input.resource,
requiredAmount: input.requiredAmount,
remainingAmount: input.remainingAmount,
});
}
function getRequiredActivePlan(planCode: ActivePlanCode) {
const plan = getPlanByCode(planCode);
if (!plan) {
throw new Error(`Unknown active plan code: ${planCode}`);
}
return plan;
}
function getAllowanceByResource(allowance: PlanUsageAllowance, resource: UsageResource) {
switch (resource) {
case 'research_credits':
return allowance.researchCredits;
case 'exports':
return allowance.exports;
case 'enrichments':
return allowance.enrichments;
case 'api_requests':
return allowance.apiRequests;
}
}
function getSuggestedUpgradePlanCode(planCode: ActivePlanCode, resource: UsageResource): ActivePlanCode | null {
switch (planCode) {
case 'starter_monthly':
return resource === 'research_credits' || resource === 'exports' ? 'growth_monthly' : 'growth_monthly';
case 'starter_annual':
return resource === 'research_credits' || resource === 'exports' ? 'growth_annual' : 'growth_annual';
case 'growth_monthly':
return 'pro_monthly';
case 'growth_annual':
return 'pro_annual';
case 'pro_monthly':
case 'pro_annual':
return 'enterprise_custom';
case 'enterprise_custom':
return null;
}
}
function hasEligibleAddonForResource(planCode: ActivePlanCode, resource: UsageResource) {
const plan = getRequiredActivePlan(planCode);
switch (resource) {
case 'exports':
return plan.eligibleAddonCodes.includes('export_pack_10k') || plan.eligibleAddonCodes.includes('export_pack_50k');
case 'enrichments':
return plan.eligibleAddonCodes.includes('enrichment_pack_1k');
case 'research_credits':
case 'api_requests':
return false;
}
}
function getFeatureReadiness(readiness?: FeatureReadiness) {
return readiness ?? 'launch_ready';
}
function normalizePositiveWholeNumber(value: number | null | undefined) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null;
}
const rounded = Math.max(0, Math.ceil(value));
return rounded > 0 ? rounded : 0;
}
// Reserved plan codes are intentionally excluded from the active policy helpers until
// founder/LTD packaging is activated in a later step.
export function isActivePlanCodeForEntitlements(planCode: PlanCode): planCode is ActivePlanCode {
return getPlanByCode(planCode) !== null;
}
// Export policy is defined here for later backend enforcement, but current CSV export
// remains client-side until a server-backed export endpoint exists.
+320
View File
@@ -0,0 +1,320 @@
import type { UsageAction } from './entitlements.js';
import type { ActivePlanCode, FeatureReadiness, PlanFeatures } from './plans.js';
import { getPlanByCode } from './plans.js';
export type FeatureGateState = 'available' | 'upgrade_required' | 'coming_soon' | 'contact_sales' | 'hidden';
export interface FeatureGateResult {
feature: keyof PlanFeatures;
state: FeatureGateState;
currentPlanCode: ActivePlanCode;
upgradePlanCode: ActivePlanCode | null;
message: string | null;
}
export interface FeatureGatePolicy {
enterpriseOnly?: boolean;
hiddenWhenUnavailable?: boolean;
upgradeTargetByPlan?: Partial<Record<ActivePlanCode, ActivePlanCode | 'enterprise_custom'>>;
}
const featureGatePolicies: Partial<Record<keyof PlanFeatures, FeatureGatePolicy>> = {
advancedFilters: {
upgradeTargetByPlan: {
starter_monthly: 'growth_monthly',
starter_annual: 'growth_annual',
},
},
savedSearches: {
upgradeTargetByPlan: {
starter_monthly: 'growth_monthly',
starter_annual: 'growth_annual',
},
},
territoryMapping: {
upgradeTargetByPlan: {
starter_monthly: 'growth_monthly',
starter_annual: 'growth_annual',
},
},
deduplication: {
upgradeTargetByPlan: {
starter_monthly: 'growth_monthly',
starter_annual: 'growth_annual',
},
},
exportHistory: {
upgradeTargetByPlan: {
starter_monthly: 'growth_monthly',
starter_annual: 'growth_annual',
},
},
taggingNotes: {
upgradeTargetByPlan: {
starter_monthly: 'growth_monthly',
starter_annual: 'growth_annual',
},
},
prioritySupport: {
upgradeTargetByPlan: {
starter_monthly: 'growth_monthly',
starter_annual: 'growth_annual',
},
},
sharedLists: {
hiddenWhenUnavailable: true,
upgradeTargetByPlan: {
starter_monthly: 'pro_monthly',
starter_annual: 'pro_annual',
growth_monthly: 'pro_monthly',
growth_annual: 'pro_annual',
},
},
scheduledResearch: {
hiddenWhenUnavailable: true,
upgradeTargetByPlan: {
starter_monthly: 'pro_monthly',
starter_annual: 'pro_annual',
growth_monthly: 'pro_monthly',
growth_annual: 'pro_annual',
},
},
bulkExports: {
hiddenWhenUnavailable: true,
upgradeTargetByPlan: {
starter_monthly: 'pro_monthly',
starter_annual: 'pro_annual',
growth_monthly: 'pro_monthly',
growth_annual: 'pro_annual',
},
},
crmIntegrations: {
hiddenWhenUnavailable: true,
upgradeTargetByPlan: {
starter_monthly: 'pro_monthly',
starter_annual: 'pro_annual',
growth_monthly: 'pro_monthly',
growth_annual: 'pro_annual',
},
},
apiAccess: {
hiddenWhenUnavailable: true,
upgradeTargetByPlan: {
starter_monthly: 'pro_monthly',
starter_annual: 'pro_annual',
growth_monthly: 'pro_monthly',
growth_annual: 'pro_annual',
},
},
webhooks: {
hiddenWhenUnavailable: true,
upgradeTargetByPlan: {
starter_monthly: 'pro_monthly',
starter_annual: 'pro_annual',
growth_monthly: 'pro_monthly',
growth_annual: 'pro_annual',
},
},
collaboration: {
hiddenWhenUnavailable: true,
upgradeTargetByPlan: {
starter_monthly: 'pro_monthly',
starter_annual: 'pro_annual',
growth_monthly: 'pro_monthly',
growth_annual: 'pro_annual',
},
},
enrichments: {
hiddenWhenUnavailable: true,
upgradeTargetByPlan: {
starter_monthly: 'pro_monthly',
starter_annual: 'pro_annual',
growth_monthly: 'pro_monthly',
growth_annual: 'pro_annual',
},
},
sso: { enterpriseOnly: true },
sla: { enterpriseOnly: true },
whiteLabel: { enterpriseOnly: true },
};
export function getFeatureGate(planCode: ActivePlanCode, feature: keyof PlanFeatures): FeatureGateResult {
const plan = getRequiredActivePlan(planCode);
const included = plan.features[feature];
const readiness = resolveFeatureReadiness(planCode, feature);
const upgradePlanCode = getUpgradePlanForFeature(planCode, feature);
if (included && readiness === 'launch_ready') {
return {
feature,
state: 'available',
currentPlanCode: planCode,
upgradePlanCode: null,
message: getFeatureGateMessageForState('available', feature, null),
};
}
if (included && readiness !== 'launch_ready') {
const comingSoonState = shouldUseContactSalesForReadyLaterFeature(feature) ? 'contact_sales' : 'coming_soon';
return {
feature,
state: comingSoonState,
currentPlanCode: planCode,
upgradePlanCode: comingSoonState === 'contact_sales' ? 'enterprise_custom' : null,
message: getFeatureGateMessageForState(comingSoonState, feature, upgradePlanCode),
};
}
if (upgradePlanCode) {
const upgradeState = upgradePlanCode === 'enterprise_custom' ? 'contact_sales' : 'upgrade_required';
return {
feature,
state: upgradeState,
currentPlanCode: planCode,
upgradePlanCode,
message: getFeatureGateMessageForState(upgradeState, feature, upgradePlanCode),
};
}
if (isEnterpriseOnlyFeature(feature)) {
return {
feature,
state: 'contact_sales',
currentPlanCode: planCode,
upgradePlanCode: 'enterprise_custom',
message: getFeatureGateMessageForState('contact_sales', feature, 'enterprise_custom'),
};
}
const hidden = shouldHideWhenUnavailable(feature);
return {
feature,
state: hidden ? 'hidden' : 'coming_soon',
currentPlanCode: planCode,
upgradePlanCode: null,
message: getFeatureGateMessageForState(hidden ? 'hidden' : 'coming_soon', feature, null),
};
}
export function isFeatureAvailable(planCode: ActivePlanCode, feature: keyof PlanFeatures) {
return getFeatureGate(planCode, feature).state === 'available';
}
export function getUpgradePlanForFeature(planCode: ActivePlanCode, feature: keyof PlanFeatures): ActivePlanCode | null {
const policy = featureGatePolicies[feature];
const directTarget = policy?.upgradeTargetByPlan?.[planCode] ?? null;
if (directTarget) {
return directTarget;
}
if (policy?.enterpriseOnly) {
return 'enterprise_custom';
}
return null;
}
export function getFeatureGateMessage(planCode: ActivePlanCode, feature: keyof PlanFeatures) {
const result = getFeatureGate(planCode, feature);
return result.message;
}
// territoryMapping currently proxies deep research capability until a dedicated
// deep-research feature key exists in the plan catalog.
export function getFeatureForAction(action: UsageAction): keyof PlanFeatures | null {
switch (action) {
case 'basic_search_run':
return null;
case 'deep_research_preview':
case 'deep_research_batch_run':
return 'territoryMapping';
case 'csv_export':
return 'csvExport';
case 'enrichment_run':
return 'enrichments';
case 'api_request':
return 'apiAccess';
}
}
function resolveFeatureReadiness(planCode: ActivePlanCode, feature: keyof PlanFeatures): FeatureReadiness {
const plan = getRequiredActivePlan(planCode);
return plan.featureReadiness[feature] ?? 'launch_ready';
}
function isEnterpriseOnlyFeature(feature: keyof PlanFeatures) {
return featureGatePolicies[feature]?.enterpriseOnly === true;
}
function shouldHideWhenUnavailable(feature: keyof PlanFeatures) {
return featureGatePolicies[feature]?.hiddenWhenUnavailable === true;
}
function shouldUseContactSalesForReadyLaterFeature(feature: keyof PlanFeatures) {
return isEnterpriseOnlyFeature(feature);
}
function getFeatureGateMessageForState(
state: FeatureGateState,
feature: keyof PlanFeatures,
upgradePlanCode: ActivePlanCode | null,
) {
switch (state) {
case 'available':
return null;
case 'upgrade_required':
return upgradePlanCode ? `Upgrade to ${getRequiredActivePlan(upgradePlanCode).name} to access ${formatFeatureName(feature)}.` : `Upgrade to access ${formatFeatureName(feature)}.`;
case 'coming_soon':
return `${formatFeatureName(feature)} is included in this tier and coming soon.`;
case 'contact_sales':
return `Contact sales to access ${formatFeatureName(feature)}.`;
case 'hidden':
return null;
}
}
function getRequiredActivePlan(planCode: ActivePlanCode) {
const plan = getPlanByCode(planCode);
if (!plan) {
throw new Error(`Unknown active plan code: ${planCode}`);
}
return plan;
}
function formatFeatureName(feature: keyof PlanFeatures) {
const names: Record<keyof PlanFeatures, string> = {
csvExport: 'CSV export',
mapSearch: 'map search',
radiusSearch: 'radius search',
basicFilters: 'basic filters',
advancedFilters: 'advanced filters',
savedSearches: 'saved searches',
territoryMapping: 'territory mapping',
deduplication: 'deduplication',
exportHistory: 'export history',
taggingNotes: 'tagging and notes',
sharedLists: 'shared lists',
scheduledResearch: 'scheduled research',
bulkExports: 'bulk exports',
crmIntegrations: 'CRM integrations',
apiAccess: 'API access',
webhooks: 'webhooks',
collaboration: 'collaboration features',
enrichments: 'enrichments',
prioritySupport: 'priority support',
sso: 'SSO',
sla: 'SLA support',
whiteLabel: 'white-labeling',
};
return names[feature];
}
// This module interprets plan feature availability and rollout state. It does not
// perform route enforcement or usage quota checks.
+607
View File
@@ -0,0 +1,607 @@
export type PlanTier = 'starter' | 'growth' | 'pro' | 'enterprise';
export type BillingInterval = 'monthly' | 'annual' | 'custom';
export type PlanListingCategory = 'pricing_page_primary' | 'pricing_page_hidden' | 'internal_only';
export type FeatureReadiness = 'launch_ready' | 'marketed_not_enforced' | 'future';
export type ActivePlanCode =
| 'starter_monthly'
| 'starter_annual'
| 'growth_monthly'
| 'growth_annual'
| 'pro_monthly'
| 'pro_annual'
| 'enterprise_custom';
export type ReservedPlanCode = 'founder_lifetime' | 'founder_pro_lifetime';
export type PlanCode = ActivePlanCode | ReservedPlanCode;
export type AddonCode = 'export_pack_10k' | 'export_pack_50k' | 'enrichment_pack_1k' | 'ai_assistant_monthly' | 'white_label_monthly';
export type ProcessingTier = 'standard' | 'priority' | 'dedicated';
export interface PlanLimits {
researchRunsPerMonth: number | null;
exportsPerMonth: number | null;
// These are commercial packaging allowances for now. Hard enforcement comes later
// after workspace-scoped ownership and collaboration rules are ready.
usersIncluded: number | null;
workspacesIncluded: number | null;
enrichmentCreditsIncluded: number | null;
pooledUsage: boolean;
}
export interface PlanFeatures {
csvExport: boolean;
mapSearch: boolean;
radiusSearch: boolean;
basicFilters: boolean;
advancedFilters: boolean;
savedSearches: boolean;
territoryMapping: boolean;
deduplication: boolean;
exportHistory: boolean;
taggingNotes: boolean;
sharedLists: boolean;
scheduledResearch: boolean;
bulkExports: boolean;
crmIntegrations: boolean;
apiAccess: boolean;
webhooks: boolean;
collaboration: boolean;
enrichments: boolean;
prioritySupport: boolean;
sso: boolean;
sla: boolean;
whiteLabel: boolean;
}
export interface PlanDefinition {
code: ActivePlanCode;
tier: PlanTier;
planFamily: PlanTier;
name: string;
billingInterval: BillingInterval;
listingCategory: PlanListingCategory;
isSelfServe: boolean;
contactSalesRequired: boolean;
priceCents: number | null;
currencyCode: 'USD';
annualDiscountPercent: number | null;
limits: PlanLimits;
features: PlanFeatures;
featureReadiness: Partial<Record<keyof PlanFeatures, FeatureReadiness>>;
processingTier: ProcessingTier;
eligibleAddonCodes: AddonCode[];
}
export interface PlanDisplayMeta {
audience: string;
summary: string;
ctaLabel: string;
ctaMode: 'sign_in' | 'sign_up';
badgeLabel?: string;
qualitativeBullets: string[];
}
function createFeatureFlags(overrides: Partial<PlanFeatures>): PlanFeatures {
return {
csvExport: false,
mapSearch: false,
radiusSearch: false,
basicFilters: false,
advancedFilters: false,
savedSearches: false,
territoryMapping: false,
deduplication: false,
exportHistory: false,
taggingNotes: false,
sharedLists: false,
scheduledResearch: false,
bulkExports: false,
crmIntegrations: false,
apiAccess: false,
webhooks: false,
collaboration: false,
enrichments: false,
prioritySupport: false,
sso: false,
sla: false,
whiteLabel: false,
...overrides,
};
}
function createPlan(plan: PlanDefinition): PlanDefinition {
return plan;
}
const activePlanCatalog = [
createPlan({
code: 'starter_monthly',
tier: 'starter',
planFamily: 'starter',
name: 'Starter',
billingInterval: 'monthly',
listingCategory: 'pricing_page_primary',
isSelfServe: true,
contactSalesRequired: false,
priceCents: 3900,
currencyCode: 'USD',
annualDiscountPercent: null,
limits: {
researchRunsPerMonth: 25,
exportsPerMonth: 2500,
usersIncluded: 1,
workspacesIncluded: 1,
enrichmentCreditsIncluded: 0,
pooledUsage: false,
},
features: createFeatureFlags({
csvExport: true,
mapSearch: true,
radiusSearch: true,
basicFilters: true,
}),
featureReadiness: {},
processingTier: 'standard',
eligibleAddonCodes: ['export_pack_10k', 'export_pack_50k'],
}),
createPlan({
code: 'starter_annual',
tier: 'starter',
planFamily: 'starter',
name: 'Starter',
billingInterval: 'annual',
listingCategory: 'pricing_page_hidden',
isSelfServe: true,
contactSalesRequired: false,
priceCents: 37440,
currencyCode: 'USD',
annualDiscountPercent: 20,
limits: {
researchRunsPerMonth: 25,
exportsPerMonth: 2500,
usersIncluded: 1,
workspacesIncluded: 1,
enrichmentCreditsIncluded: 0,
pooledUsage: false,
},
features: createFeatureFlags({
csvExport: true,
mapSearch: true,
radiusSearch: true,
basicFilters: true,
}),
featureReadiness: {},
processingTier: 'standard',
eligibleAddonCodes: ['export_pack_10k', 'export_pack_50k'],
}),
createPlan({
code: 'growth_monthly',
tier: 'growth',
planFamily: 'growth',
name: 'Growth',
billingInterval: 'monthly',
listingCategory: 'pricing_page_primary',
isSelfServe: true,
contactSalesRequired: false,
priceCents: 9900,
currencyCode: 'USD',
annualDiscountPercent: null,
limits: {
researchRunsPerMonth: 150,
exportsPerMonth: 15000,
usersIncluded: 3,
workspacesIncluded: 5,
enrichmentCreditsIncluded: 0,
pooledUsage: false,
},
features: createFeatureFlags({
csvExport: true,
mapSearch: true,
radiusSearch: true,
basicFilters: true,
advancedFilters: true,
savedSearches: true,
territoryMapping: true,
deduplication: true,
exportHistory: true,
taggingNotes: true,
prioritySupport: true,
}),
featureReadiness: {
savedSearches: 'marketed_not_enforced',
territoryMapping: 'launch_ready',
deduplication: 'marketed_not_enforced',
exportHistory: 'marketed_not_enforced',
taggingNotes: 'marketed_not_enforced',
},
processingTier: 'priority',
eligibleAddonCodes: ['export_pack_10k', 'export_pack_50k', 'enrichment_pack_1k'],
}),
createPlan({
code: 'growth_annual',
tier: 'growth',
planFamily: 'growth',
name: 'Growth',
billingInterval: 'annual',
listingCategory: 'pricing_page_hidden',
isSelfServe: true,
contactSalesRequired: false,
priceCents: 95040,
currencyCode: 'USD',
annualDiscountPercent: 20,
limits: {
researchRunsPerMonth: 150,
exportsPerMonth: 15000,
usersIncluded: 3,
workspacesIncluded: 5,
enrichmentCreditsIncluded: 0,
pooledUsage: false,
},
features: createFeatureFlags({
csvExport: true,
mapSearch: true,
radiusSearch: true,
basicFilters: true,
advancedFilters: true,
savedSearches: true,
territoryMapping: true,
deduplication: true,
exportHistory: true,
taggingNotes: true,
prioritySupport: true,
}),
featureReadiness: {
savedSearches: 'marketed_not_enforced',
territoryMapping: 'launch_ready',
deduplication: 'marketed_not_enforced',
exportHistory: 'marketed_not_enforced',
taggingNotes: 'marketed_not_enforced',
},
processingTier: 'priority',
eligibleAddonCodes: ['export_pack_10k', 'export_pack_50k', 'enrichment_pack_1k'],
}),
createPlan({
code: 'pro_monthly',
tier: 'pro',
planFamily: 'pro',
name: 'Pro',
billingInterval: 'monthly',
listingCategory: 'pricing_page_primary',
isSelfServe: true,
contactSalesRequired: false,
priceCents: 24900,
currencyCode: 'USD',
annualDiscountPercent: null,
limits: {
researchRunsPerMonth: 500,
exportsPerMonth: 75000,
usersIncluded: 15,
workspacesIncluded: null,
enrichmentCreditsIncluded: 0,
pooledUsage: false,
},
features: createFeatureFlags({
csvExport: true,
mapSearch: true,
radiusSearch: true,
basicFilters: true,
advancedFilters: true,
savedSearches: true,
territoryMapping: true,
deduplication: true,
exportHistory: true,
taggingNotes: true,
sharedLists: true,
scheduledResearch: true,
bulkExports: true,
crmIntegrations: true,
apiAccess: true,
webhooks: true,
collaboration: true,
enrichments: true,
prioritySupport: true,
}),
featureReadiness: {
savedSearches: 'marketed_not_enforced',
deduplication: 'marketed_not_enforced',
exportHistory: 'marketed_not_enforced',
taggingNotes: 'marketed_not_enforced',
sharedLists: 'marketed_not_enforced',
scheduledResearch: 'marketed_not_enforced',
bulkExports: 'marketed_not_enforced',
crmIntegrations: 'marketed_not_enforced',
apiAccess: 'marketed_not_enforced',
webhooks: 'marketed_not_enforced',
collaboration: 'marketed_not_enforced',
enrichments: 'marketed_not_enforced',
},
processingTier: 'priority',
eligibleAddonCodes: ['export_pack_10k', 'export_pack_50k', 'enrichment_pack_1k', 'ai_assistant_monthly', 'white_label_monthly'],
}),
createPlan({
code: 'pro_annual',
tier: 'pro',
planFamily: 'pro',
name: 'Pro',
billingInterval: 'annual',
listingCategory: 'pricing_page_hidden',
isSelfServe: true,
contactSalesRequired: false,
priceCents: 239040,
currencyCode: 'USD',
annualDiscountPercent: 20,
limits: {
researchRunsPerMonth: 500,
exportsPerMonth: 75000,
usersIncluded: 15,
workspacesIncluded: null,
enrichmentCreditsIncluded: 0,
pooledUsage: false,
},
features: createFeatureFlags({
csvExport: true,
mapSearch: true,
radiusSearch: true,
basicFilters: true,
advancedFilters: true,
savedSearches: true,
territoryMapping: true,
deduplication: true,
exportHistory: true,
taggingNotes: true,
sharedLists: true,
scheduledResearch: true,
bulkExports: true,
crmIntegrations: true,
apiAccess: true,
webhooks: true,
collaboration: true,
enrichments: true,
prioritySupport: true,
}),
featureReadiness: {
savedSearches: 'marketed_not_enforced',
deduplication: 'marketed_not_enforced',
exportHistory: 'marketed_not_enforced',
taggingNotes: 'marketed_not_enforced',
sharedLists: 'marketed_not_enforced',
scheduledResearch: 'marketed_not_enforced',
bulkExports: 'marketed_not_enforced',
crmIntegrations: 'marketed_not_enforced',
apiAccess: 'marketed_not_enforced',
webhooks: 'marketed_not_enforced',
collaboration: 'marketed_not_enforced',
enrichments: 'marketed_not_enforced',
},
processingTier: 'priority',
eligibleAddonCodes: ['export_pack_10k', 'export_pack_50k', 'enrichment_pack_1k', 'ai_assistant_monthly', 'white_label_monthly'],
}),
createPlan({
code: 'enterprise_custom',
tier: 'enterprise',
planFamily: 'enterprise',
name: 'Enterprise',
billingInterval: 'custom',
listingCategory: 'pricing_page_primary',
isSelfServe: false,
contactSalesRequired: true,
priceCents: null,
currencyCode: 'USD',
annualDiscountPercent: null,
limits: {
researchRunsPerMonth: null,
exportsPerMonth: null,
usersIncluded: null,
workspacesIncluded: null,
enrichmentCreditsIncluded: null,
pooledUsage: true,
},
features: createFeatureFlags({
csvExport: true,
mapSearch: true,
radiusSearch: true,
basicFilters: true,
advancedFilters: true,
savedSearches: true,
territoryMapping: true,
deduplication: true,
exportHistory: true,
taggingNotes: true,
sharedLists: true,
scheduledResearch: true,
bulkExports: true,
crmIntegrations: true,
apiAccess: true,
webhooks: true,
collaboration: true,
enrichments: true,
prioritySupport: true,
sso: true,
sla: true,
whiteLabel: true,
}),
featureReadiness: {
sharedLists: 'marketed_not_enforced',
scheduledResearch: 'marketed_not_enforced',
bulkExports: 'marketed_not_enforced',
crmIntegrations: 'marketed_not_enforced',
apiAccess: 'marketed_not_enforced',
webhooks: 'marketed_not_enforced',
collaboration: 'marketed_not_enforced',
enrichments: 'marketed_not_enforced',
sso: 'future',
sla: 'future',
whiteLabel: 'future',
},
processingTier: 'dedicated',
eligibleAddonCodes: ['export_pack_10k', 'export_pack_50k', 'enrichment_pack_1k', 'ai_assistant_monthly', 'white_label_monthly'],
}),
] as const satisfies readonly PlanDefinition[];
const activePlanCatalogByCode = new Map(activePlanCatalog.map((plan) => [plan.code, plan]));
const planDisplayMetaByCode: Record<ActivePlanCode, PlanDisplayMeta> = {
starter_monthly: {
audience: 'For freelancers and solo operators',
summary: 'A focused entry point for recurring local market research.',
ctaLabel: 'Choose Starter',
ctaMode: 'sign_up',
qualitativeBullets: ['CSV export, map search, radius search, and basic filters'],
},
starter_annual: {
audience: 'For freelancers and solo operators',
summary: 'A focused entry point for recurring local market research.',
ctaLabel: 'Choose Starter',
ctaMode: 'sign_up',
badgeLabel: 'Save 20%',
qualitativeBullets: ['CSV export, map search, radius search, and basic filters'],
},
growth_monthly: {
audience: 'For agencies and outbound teams',
summary: 'The best-value plan for repeatable territory workflows.',
ctaLabel: 'Choose Growth',
ctaMode: 'sign_up',
badgeLabel: 'Best Value',
qualitativeBullets: ['Saved searches, territory mapping, advanced filtering, deduplication, export history, and priority support'],
},
growth_annual: {
audience: 'For agencies and outbound teams',
summary: 'The best-value plan for repeatable territory workflows.',
ctaLabel: 'Choose Growth',
ctaMode: 'sign_up',
badgeLabel: 'Save 20%',
qualitativeBullets: ['Saved searches, territory mapping, advanced filtering, deduplication, export history, and priority support'],
},
pro_monthly: {
audience: 'For power users and scaling teams',
summary: 'Operational infrastructure for teams that need more scale and automation.',
ctaLabel: 'Choose Pro',
ctaMode: 'sign_up',
qualitativeBullets: ['Scheduled research, bulk exports, CRM integrations, API access, enrichment access, and collaboration features'],
},
pro_annual: {
audience: 'For power users and scaling teams',
summary: 'Operational infrastructure for teams that need more scale and automation.',
ctaLabel: 'Choose Pro',
ctaMode: 'sign_up',
badgeLabel: 'Save 20%',
qualitativeBullets: ['Scheduled research, bulk exports, CRM integrations, API access, enrichment access, and collaboration features'],
},
enterprise_custom: {
audience: 'For multi-location and enterprise rollouts',
summary: 'Custom market intelligence infrastructure for security, governance, and scale.',
ctaLabel: 'Talk to sales',
ctaMode: 'sign_in',
qualitativeBullets: ['SSO, SLA, onboarding, and account management', 'White-labeling, custom enrichments, and custom integrations', 'Dedicated infrastructure and advanced API scaling'],
},
};
export const ACTIVE_PLAN_CATALOG = [...activePlanCatalog];
export function getActivePlanCatalog() {
return ACTIVE_PLAN_CATALOG;
}
export function getPlanByCode(code: PlanCode) {
return activePlanCatalogByCode.get(code as ActivePlanCode) ?? null;
}
export function getSelfServePlans() {
return ACTIVE_PLAN_CATALOG.filter((plan) => plan.isSelfServe);
}
export function getAnnualPlans() {
return ACTIVE_PLAN_CATALOG.filter((plan) => plan.billingInterval === 'annual');
}
export function getPublicPricingPlans() {
return ACTIVE_PLAN_CATALOG.filter((plan) => plan.listingCategory === 'pricing_page_primary');
}
export function getPlanVariant(tier: PlanTier, billingInterval: BillingInterval) {
return ACTIVE_PLAN_CATALOG.find((plan) => plan.planFamily === tier && plan.billingInterval === billingInterval) ?? null;
}
export function getSiblingPlans(code: ActivePlanCode) {
const selectedPlan = getPlanByCode(code);
if (!selectedPlan) {
return [];
}
return ACTIVE_PLAN_CATALOG.filter((plan) => plan.planFamily === selectedPlan.planFamily);
}
export function isAnnualPlan(code: ActivePlanCode) {
return getPlanByCode(code)?.billingInterval === 'annual';
}
export function getPlanDisplayMeta(code: ActivePlanCode) {
return planDisplayMetaByCode[code];
}
export function getPlanCardBullets(code: ActivePlanCode) {
const plan = getPlanByCode(code);
const display = getPlanDisplayMeta(code);
if (!plan) {
return display.qualitativeBullets;
}
const quantitativeBullets = [
formatResearchRunsBullet(plan.limits.researchRunsPerMonth),
formatExportsBullet(plan.limits.exportsPerMonth),
formatSeatAllowanceBullet(plan.limits.usersIncluded, plan.limits.workspacesIncluded, plan.limits.pooledUsage),
].filter((bullet): bullet is string => bullet !== null);
return [...quantitativeBullets, ...display.qualitativeBullets];
}
function formatResearchRunsBullet(researchRunsPerMonth: number | null) {
if (researchRunsPerMonth === null) {
return 'Pooled or custom research capacity';
}
return `${formatCount(researchRunsPerMonth)} research runs / month`;
}
function formatExportsBullet(exportsPerMonth: number | null) {
if (exportsPerMonth === null) {
return null;
}
return `${formatCount(exportsPerMonth)} exports / month`;
}
function formatSeatAllowanceBullet(usersIncluded: number | null, workspacesIncluded: number | null, pooledUsage: boolean) {
if (pooledUsage) {
return 'Pooled or custom team usage';
}
if (usersIncluded === null && workspacesIncluded === null) {
return null;
}
if (usersIncluded !== null && workspacesIncluded === null) {
return usersIncluded === 1 ? '1 user and unlimited workspaces' : `${formatCount(usersIncluded)} users and unlimited workspaces`;
}
if (usersIncluded === null && workspacesIncluded !== null) {
return workspacesIncluded === 1 ? '1 workspace' : `${formatCount(workspacesIncluded)} workspaces`;
}
const userLabel = usersIncluded === 1 ? '1 user' : `${formatCount(usersIncluded as number)} users`;
const workspaceLabel = workspacesIncluded === 1 ? '1 workspace' : `${formatCount(workspacesIncluded as number)} workspaces`;
return `${userLabel} and ${workspaceLabel}`;
}
function formatCount(value: number) {
return new Intl.NumberFormat('en-US').format(value);
}
+9 -4
View File
@@ -1,3 +1,5 @@
import type { BillingInterval, PlanCode } from './billing/plans.js';
export type JobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'stopped';
export interface AppUser {
@@ -30,9 +32,12 @@ export interface AccountSummary {
totalBusinesses: number;
}
export interface AccountBillingPlaceholder {
status: 'not_configured';
planName: string | null;
export type AccountBillingStatus = 'not_configured' | 'inactive' | 'active' | 'past_due' | 'canceled';
export interface AccountBillingState {
status: AccountBillingStatus;
planCode: PlanCode | null;
billingInterval: BillingInterval | null;
message: string;
}
@@ -45,7 +50,7 @@ export interface AccountPageData {
profile: AppUser;
workspace: AccountWorkspace;
summary: AccountSummary;
billing: AccountBillingPlaceholder;
billing: AccountBillingState;
team: AccountTeamPlaceholder;
}
+73 -73
View File
@@ -22,6 +22,7 @@ import { MapView } from './components/MapView';
import { ResearchWorkspace } from './components/ResearchWorkspace';
import { ResultsWorkspace } from './components/ResultsWorkspace';
import { Alert, Badge, Button, Card, FieldLabel, Input, Surface } from './components/ui';
import { getPlanCardBullets, getPlanDisplayMeta, getPublicPricingPlans } from '../shared/billing/plans';
import type { SessionUser } from '../shared/types';
import { getLocalSessionUser, signInWithLocalAuth, signOutWithLocalAuth, signUpWithLocalAuth } from './lib/auth';
import { hasApiConfig } from './lib/api';
@@ -301,6 +302,26 @@ function navigatePublicPage(page: 'landing' | 'auth', setPublicPage: (page: 'lan
setPublicPage(page);
}
function formatPlanPrice(priceCents: number | null, currencyCode: string) {
if (priceCents === null) {
return 'Custom';
}
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
maximumFractionDigits: 0,
}).format(priceCents / 100);
}
function formatPlanPeriod(billingInterval: 'monthly' | 'annual' | 'custom', contactSalesRequired: boolean) {
if (contactSalesRequired || billingInterval === 'custom') {
return 'pricing';
}
return billingInterval === 'annual' ? '/year' : '/month';
}
function LandingPage(props: {
onGoToAuth: (mode: 'sign_in' | 'sign_up') => void;
}) {
@@ -310,72 +331,44 @@ function LandingPage(props: {
{
icon: Search,
title: 'Research Runs',
description: 'Search by city, radius, business type, and keywords without juggling spreadsheets or manual lookups.',
description: 'Research local markets by city, radius, business type, and keywords without juggling spreadsheets or manual lookups.',
},
{
icon: MapPinned,
title: 'Deep Research',
description: 'Drop one pin and expand intelligently into nearby postal areas to widen market coverage.',
description: 'Drop one pin and expand intelligently into nearby postal areas to uncover surrounding territory opportunities.',
},
{
icon: Map,
title: 'Clean Map View',
description: 'Review returned businesses on a focused map built for operational decision-making, not map clutter.',
title: 'Territory Mapping',
description: 'Review returned businesses on a focused map built for territory analysis and operational decision-making.',
},
{
icon: Briefcase,
title: 'Lead Workspace',
description: 'Keep past runs, saved businesses, and mapped results in one place for repeatable prospecting.',
title: 'Operational Workspace',
description: 'Keep past runs, saved businesses, and mapped results in one place for repeatable prospecting workflows.',
},
] as const;
const audienceCards = [
{
icon: User,
title: 'Personal Use',
description: 'For solo operators, freelancers, and independent prospectors who need a focused local research workflow.',
title: 'Starter Teams',
description: 'For solo operators, freelancers, and small local teams that need a structured market research workflow.',
},
{
icon: Building2,
title: 'Small Business',
description: 'For agencies and local teams running prospecting every week across multiple service areas.',
title: 'Growth Teams',
description: 'For agencies and outbound teams running recurring territory research across multiple service areas.',
},
{
icon: Sparkles,
title: 'Enterprise',
description: 'For larger organizations that need custom research volume, rollout support, and tailored operating limits.',
description: 'For larger organizations that need custom market intelligence capacity, rollout support, and tailored operating controls.',
},
] as const;
const plans = [
{
name: 'Personal',
audience: 'For solo operators',
price: '$19',
period: '/month',
cta: 'Start free',
featured: false,
items: ['1 user workspace', '40 research runs / month', 'Map view and dashboard history', 'Email support'],
},
{
name: 'Small Business',
audience: 'For growing local teams',
price: '$79',
period: '/month',
cta: 'Choose Small Business',
featured: true,
items: ['Everything in Personal', '250 research runs / month', 'Deep research workflows', 'Extended lead history', 'Priority support'],
},
{
name: 'Enterprise',
audience: 'For custom rollouts',
price: 'Contact',
period: 'sales',
cta: 'Talk to sales',
featured: false,
items: ['Custom research volume', 'Custom onboarding plan', 'Tailored support model', 'Deployment and process guidance'],
},
] as const;
const pricingPlans = getPublicPricingPlans();
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.08),_transparent_28%),linear-gradient(180deg,#fafaf9_0%,#f5f5f4_100%)] text-stone-900">
@@ -387,8 +380,8 @@ function LandingPage(props: {
<Briefcase className="h-5 w-5" />
</div>
<div>
<p className="text-lg font-bold tracking-tight">Leads4less</p>
<p className="text-sm text-stone-500">Local market research for modern teams</p>
<p className="text-lg font-bold tracking-tight">LocaleScope</p>
<p className="text-sm text-stone-500">Local market intelligence for modern outbound teams</p>
</div>
</div>
@@ -421,15 +414,15 @@ function LandingPage(props: {
<div className="space-y-8">
<Badge variant="primary" className="px-4 py-2 text-sm">
<Sparkles className="h-4 w-4" />
Built for local lead generation workflows
Geographic prospecting intelligence platform
</Badge>
<div className="space-y-5">
<h1 className="max-w-4xl text-5xl font-bold tracking-tight text-stone-950 sm:text-6xl">
Research local markets, map opportunities, and build better lead lists faster.
Discover underserved markets, map territories, and turn local research into repeatable intelligence.
</h1>
<p className="max-w-3xl text-lg leading-8 text-stone-600 sm:text-xl">
Run targeted searches, expand coverage from a single pin, and review every result in one focused workspace designed for real prospecting operations.
Run targeted local research, expand coverage from a single pin, and review every result in one focused workspace built for modern prospecting operations.
</p>
</div>
@@ -451,9 +444,9 @@ function LandingPage(props: {
<section id="product" className="rounded-[2rem] border border-stone-200 bg-white px-6 py-10 shadow-sm sm:px-8 lg:px-10">
<div className="max-w-3xl">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-emerald-600">Product</p>
<h2 className="mt-3 text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl">One workspace for local lead generation</h2>
<h2 className="mt-3 text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl">One workspace for local market intelligence</h2>
<p className="mt-4 text-base leading-8 text-stone-600">
Leads4less keeps market research, deep area expansion, map review, and saved business history in a single operating flow so your team can move faster without losing context.
LocaleScope keeps market research, deep area expansion, map review, and saved business history in a single operating flow so teams can move faster without losing context.
</p>
</div>
@@ -462,13 +455,13 @@ function LandingPage(props: {
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-emerald-300">Operational Workflow</p>
<p className="mt-4 text-2xl font-bold tracking-tight">Search a market, expand intelligently, and review results visually.</p>
<p className="mt-4 max-w-2xl text-sm leading-7 text-stone-300">
Instead of stitching together Google tabs, spreadsheets, and hand-written notes, run the full prospecting loop from one product surface built for repeatable research. Launch deeper market coverage from a single map interaction while keeping research, deep research, dashboard, and map review connected in one workspace.
Instead of stitching together Google tabs, spreadsheets, and hand-written notes, run the full territory research loop from one product surface built for repeatable analysis. Launch deeper market coverage from a single map interaction while keeping research, deep research, dashboard, and map review connected in one workspace.
</p>
</div>
<div className="rounded-3xl border border-stone-200 bg-stone-50 p-6">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Best Fit</p>
<p className="mt-4 text-2xl font-bold tracking-tight text-stone-900">Local teams who need speed and structure</p>
<p className="mt-4 text-sm leading-7 text-stone-600">Built for recurring lead generation, territory research, and targeted market expansion.</p>
<p className="mt-4 text-sm leading-7 text-stone-600">Built for recurring territory research, geographic prospecting, and targeted market expansion.</p>
</div>
</div>
</section>
@@ -498,7 +491,7 @@ function LandingPage(props: {
<section className="rounded-[2rem] border border-stone-200 bg-white px-6 py-10 shadow-sm sm:px-8 lg:px-10">
<div className="max-w-3xl">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-emerald-600">Who It's For</p>
<h2 className="mt-3 text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl">Designed for personal use, small business teams, and enterprise rollouts</h2>
<h2 className="mt-3 text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl">Designed for growing prospecting teams and enterprise rollouts</h2>
</div>
<div className="mt-8 grid gap-4 lg:grid-cols-3">
@@ -517,51 +510,57 @@ function LandingPage(props: {
<section id="pricing" className="py-16">
<div className="max-w-3xl">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-emerald-600">Pricing</p>
<h2 className="mt-3 text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl">Choose the plan that matches your research volume</h2>
<h2 className="mt-3 text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl">Choose the plan that matches your market intelligence workflow</h2>
<p className="mt-4 text-base leading-8 text-stone-600">
Start small, run local research consistently, and upgrade when your market coverage or team needs expand.
Start with focused territory research, then upgrade as your operational scale, collaboration needs, and research capacity expand.
</p>
</div>
<div className="mt-8 grid gap-5 xl:grid-cols-3">
{plans.map((plan) => (
{pricingPlans.map((plan) => {
const display = getPlanDisplayMeta(plan.code);
const isFeatured = display.badgeLabel === 'Best Value';
return (
<div
key={plan.name}
key={plan.code}
className={`rounded-[2rem] border p-7 shadow-sm ${
plan.featured ? 'border-emerald-300 bg-emerald-50/60 shadow-emerald-100' : 'border-stone-200 bg-white'
isFeatured ? 'border-emerald-300 bg-emerald-50/60 shadow-emerald-100' : 'border-stone-200 bg-white'
}`}
>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xl font-bold tracking-tight text-stone-900">{plan.name}</p>
<p className="mt-2 text-sm text-stone-600">{plan.audience}</p>
<p className="mt-2 text-sm text-stone-600">{display.audience}</p>
</div>
{plan.featured && (
{display.badgeLabel ? (
<span className="rounded-full bg-emerald-600 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white">
Most Popular
{display.badgeLabel}
</span>
)}
) : null}
</div>
<div className="mt-8 flex items-end gap-1">
<span className="text-4xl font-bold tracking-tight text-stone-950">{plan.price}</span>
<span className="pb-1 text-sm text-stone-500">{plan.period}</span>
<span className="text-4xl font-bold tracking-tight text-stone-950">{formatPlanPrice(plan.priceCents, plan.currencyCode)}</span>
<span className="pb-1 text-sm text-stone-500">{formatPlanPeriod(plan.billingInterval, plan.contactSalesRequired)}</span>
</div>
<p className="mt-3 text-sm leading-7 text-stone-600">{display.summary}</p>
<button
type="button"
onClick={() => onGoToAuth(plan.name === 'Enterprise' ? 'sign_in' : 'sign_up')}
onClick={() => onGoToAuth(display.ctaMode)}
className={`mt-8 inline-flex w-full items-center justify-center rounded-2xl px-4 py-3 text-sm font-semibold transition ${
plan.featured
isFeatured
? 'bg-emerald-600 text-white hover:bg-emerald-700'
: 'border border-stone-200 bg-white text-stone-800 hover:bg-stone-50'
}`}
>
{plan.cta}
{display.ctaLabel}
</button>
<div className="mt-8 space-y-3">
{plan.items.map((item) => (
{getPlanCardBullets(plan.code).map((item) => (
<div key={item} className="flex items-start gap-3 text-sm text-stone-700">
<div className="mt-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
<Check className="h-3.5 w-3.5" />
@@ -571,7 +570,8 @@ function LandingPage(props: {
))}
</div>
</div>
))}
);
})}
</div>
</section>
@@ -581,7 +581,7 @@ function LandingPage(props: {
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-emerald-300">Start Now</p>
<h2 className="mt-3 text-3xl font-bold tracking-tight sm:text-4xl">Turn local market research into a repeatable system.</h2>
<p className="mt-4 text-base leading-8 text-stone-300">
Create an account, run your first research job, and build a cleaner lead workflow from day one.
Create an account, run your first research job, and start building a repeatable local intelligence workflow from day one.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
@@ -648,8 +648,8 @@ function AuthPage(props: {
<Briefcase className="h-5 w-5" />
</div>
<div>
<p className="text-lg font-bold tracking-tight">Leads4less</p>
<p className="text-sm text-stone-500">Local market research for modern teams</p>
<p className="text-lg font-bold tracking-tight">LocaleScope</p>
<p className="text-sm text-stone-500">Local market intelligence for modern outbound teams</p>
</div>
</button>
@@ -665,12 +665,12 @@ function AuthPage(props: {
<div className="space-y-8">
<Badge variant="primary" className="px-4 py-2 text-sm">
<Sparkles className="h-4 w-4" />
Secure access to your lead workspace
Secure access to your intelligence workspace
</Badge>
<div className="space-y-5">
<h1 className="max-w-3xl text-5xl font-bold tracking-tight text-stone-950 sm:text-6xl">
{authMode === 'sign_up' ? 'Create your workspace and start researching local markets.' : 'Sign in and continue your lead research workflow.'}
{authMode === 'sign_up' ? 'Create your workspace and start researching local markets.' : 'Sign in and continue your market intelligence workflow.'}
</h1>
<p className="max-w-2xl text-lg leading-8 text-stone-600 sm:text-xl">
Access research runs, deep research coverage, clean map review, and saved business history from one focused operating surface.
@@ -681,7 +681,7 @@ function AuthPage(props: {
{[
['Targeted search', 'Run location-based business research with clear inputs and repeatable jobs.'],
['Map review', 'Inspect returned businesses on a cleaner map built for operational use.'],
['Persistent history', 'Keep lead runs and saved businesses available whenever you come back.'],
['Persistent history', 'Keep research runs and saved businesses available whenever you come back.'],
].map(([title, description]) => (
<Surface key={title} className="p-5">
<p className="text-base font-bold tracking-tight text-stone-900">{title}</p>
@@ -699,7 +699,7 @@ function AuthPage(props: {
{authMode === 'sign_up' ? 'Create account' : 'Sign in'}
</h2>
<p className="mt-2 text-sm text-stone-600">
{authMode === 'sign_up' ? 'Set up your account to start using Leads4less.' : 'Use your account to continue where you left off.'}
{authMode === 'sign_up' ? 'Set up your account to start using LocaleScope.' : 'Use your account to continue where you left off.'}
</p>
</div>
<div className="rounded-2xl bg-stone-100 p-3 text-stone-900">
+6 -3
View File
@@ -1,5 +1,6 @@
import { Building2, CreditCard, Loader2, Shield, Users } from 'lucide-react';
import { useEffect, useState } from 'react';
import { getPlanByCode } from '../../shared/billing/plans';
import type { AccountPageData, AppUser } from '../../shared/types';
import { getAccountPageData, updateAccountProfile } from '../lib/account';
import { Alert, Badge, Button, Card, FieldLabel, Input, LoadingState, PageContainer, PageShell, SectionHeader, StatCard } from './ui';
@@ -99,12 +100,14 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
);
}
const activePlan = account.billing.planCode ? getPlanByCode(account.billing.planCode) : null;
return (
<PageShell>
<PageContainer>
<SectionHeader
title="Account"
description="Manage your profile, workspace, and upcoming billing settings."
description="Manage your profile, workspace, and subscription settings."
/>
{error ? <Alert variant="error">{error}</Alert> : null}
@@ -185,7 +188,7 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
<Badge>{account.workspace.memberCount === 1 ? '1 member' : `${account.workspace.memberCount} members`}</Badge>
</div>
<p className="mt-4 text-sm text-stone-600">
This workspace is the foundation for future team access, billing, and shared company management.
This workspace is the foundation for future team access, subscriptions, and shared territory research workflows.
</p>
</Card>
@@ -196,7 +199,7 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
</div>
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Plan & Billing</p>
<h3 className="text-lg font-semibold text-stone-950">Billing coming soon</h3>
<h3 className="text-lg font-semibold text-stone-950">{activePlan ? activePlan.name : 'Subscription foundation in progress'}</h3>
</div>
</div>
<p className="mt-4 text-sm text-stone-600">{account.billing.message}</p>
+1 -1
View File
@@ -246,7 +246,7 @@ export function BasicResultsView({ user, selectedJobIds, onToggleJobSelection, o
</div>
<div className="mt-4 flex items-center justify-between border-t border-stone-100 pt-4 text-sm font-medium text-stone-500">
<span>{job.totalResults === 1 ? '1 lead found' : `${job.totalResults} leads found`}</span>
<span>{job.totalResults === 1 ? '1 business found' : `${job.totalResults} businesses found`}</span>
<span className={isSelected ? 'text-emerald-700' : 'text-stone-400'}>{isSelected ? 'Included in selection' : 'Available to select'}</span>
</div>
</button>
+6 -6
View File
@@ -117,7 +117,7 @@ export function Dashboard({ user }: DashboardProps) {
: 0;
return [
{ name: 'Total Leads', value: total, icon: Briefcase },
{ name: 'Saved Businesses', value: total, icon: Briefcase },
{ name: 'With Website', value: withWebsite, icon: Globe },
{ name: 'With Phone', value: withPhone, icon: Phone },
{ name: 'Avg Rating', value: avgRating.toFixed(1), icon: Star },
@@ -149,7 +149,7 @@ export function Dashboard({ user }: DashboardProps) {
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `leads_export_${new Date().toISOString().split('T')[0]}.csv`);
link.setAttribute('download', `localescope_export_${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
@@ -180,8 +180,8 @@ export function Dashboard({ user }: DashboardProps) {
<PageShell>
<PageContainer>
<SectionHeader
title="Lead Dashboard"
description="Browse saved search results from your local workspace and export targeted lead lists."
title="Research Dashboard"
description="Browse saved search results from your workspace and export targeted business datasets."
actions={
<Button onClick={handleExport} size="lg" className="w-full sm:w-auto">
<Download className="h-5 w-5" />
@@ -238,7 +238,7 @@ export function Dashboard({ user }: DashboardProps) {
</Card>
{filteredBusinesses.length === 0 ? (
<EmptyState title="No leads found matching your filters." description="Try adjusting your search term or filters to see more businesses." />
<EmptyState title="No businesses found matching your filters." description="Try adjusting your search term or filters to see more results." />
) : (
<Card className="overflow-hidden p-0">
<div className="md:hidden">
@@ -353,7 +353,7 @@ export function Dashboard({ user }: DashboardProps) {
<p className="text-sm text-stone-500">
Showing <span className="font-medium">{(currentPage - 1) * itemsPerPage + 1}</span> to{' '}
<span className="font-medium">{Math.min(currentPage * itemsPerPage, filteredBusinesses.length)}</span> of{' '}
<span className="font-medium">{filteredBusinesses.length}</span> leads
<span className="font-medium">{filteredBusinesses.length}</span> businesses
</p>
<div className="flex items-center justify-end gap-2">
<Button
+1 -1
View File
@@ -90,7 +90,7 @@ export function DeepResearchResultsView({ onShowBatchOnMap }: DeepResearchResult
<p className="mt-1 text-base font-bold text-stone-900">{batch.childJobCount}</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-stone-400">Leads</p>
<p className="text-xs font-semibold uppercase tracking-wide text-stone-400">Businesses</p>
<p className="mt-1 text-base font-bold text-stone-900">{batch.totalResults}</p>
</div>
</div>
+3 -3
View File
@@ -37,7 +37,7 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La
<Briefcase className="h-5 w-5" />
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-stone-950">Leads4less</p>
<p className="truncate text-sm font-semibold text-stone-950">LocaleScope</p>
<p className="truncate text-xs text-stone-500">{activeNavigationItem.name}</p>
</div>
</div>
@@ -54,8 +54,8 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La
<Briefcase className="h-6 w-6" />
</div>
<div>
<p className="text-lg font-semibold tracking-tight text-stone-950">Leads4less</p>
<p className="text-sm text-stone-500">Professional lead research workspace</p>
<p className="text-lg font-semibold tracking-tight text-stone-950">LocaleScope</p>
<p className="text-sm text-stone-500">Operational local market intelligence workspace</p>
</div>
</div>
</div>
+5 -5
View File
@@ -29,7 +29,7 @@ export function MapView({ user, jobIds }: MapViewProps) {
const nextBusinesses = selectedJobCount > 0 ? await listBusinessesForJobs(user.id, jobIds ?? []) : await listBusinesses(user.id);
setBusinesses(nextBusinesses);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load map leads.');
setError(err instanceof Error ? err.message : 'Failed to load map results.');
} finally {
setLoading(false);
}
@@ -105,10 +105,10 @@ export function MapView({ user, jobIds }: MapViewProps) {
<div className="w-full max-w-lg">
<EmptyState
icon={MapPin}
title="No leads to show on the map"
title="No businesses to show on the map"
description={selectedJobCount > 0
? 'The selected research jobs do not have saved map results yet. Try completed jobs or run the research again.'
: 'No saved leads are available yet. Run a research job to populate the map.'}
: 'No saved businesses are available yet. Run a research job to populate the map.'}
/>
{error ? <Alert variant="error" className="mt-4">{error}</Alert> : null}
</div>
@@ -210,11 +210,11 @@ export function MapView({ user, jobIds }: MapViewProps) {
<h4 className="mb-2 text-sm font-semibold text-stone-950">Map Summary</h4>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-stone-500">Total Leads on Map</span>
<span className="text-stone-500">Businesses on Map</span>
<span className="font-bold text-emerald-600">{businesses.length}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-stone-500">Selected Lead</span>
<span className="text-stone-500">Selected Business</span>
<span className="max-w-[140px] truncate font-bold text-stone-900 lg:max-w-[120px]">{selected ? selected.name : 'None'}</span>
</div>
{selectedJobCount > 0 && (