diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cf75f0..786fb15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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] diff --git a/README.md b/README.md index 966ed78..1e2d881 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/TODO-pricing.md b/TODO-pricing.md new file mode 100644 index 0000000..5ca2ac9 --- /dev/null +++ b/TODO-pricing.md @@ -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? diff --git a/db/datasets/README.md b/db/datasets/README.md index 31086e1..62ba26a 100644 --- a/db/datasets/README.md +++ b/db/datasets/README.md @@ -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 diff --git a/server/src/account/repository.ts b/server/src/account/repository.ts index b8ba6cd..94e8991 100644 --- a/server/src/account/repository.ts +++ b/server/src/account/repository.ts @@ -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', diff --git a/server/src/worker.ts b/server/src/worker.ts index f9b3088..f5039d1 100644 --- a/server/src/worker.ts +++ b/server/src/worker.ts @@ -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(); diff --git a/shared/billing/entitlements.ts b/shared/billing/entitlements.ts new file mode 100644 index 0000000..4fd30ee --- /dev/null +++ b/shared/billing/entitlements.ts @@ -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; +} + +export interface ActionPolicy { + action: UsageAction; + chargeable: boolean; + consumedResources: UsageResource[]; + requiredFeatures: Array; + readinessFeatures: Array; +} + +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 = { + 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. diff --git a/shared/billing/feature-gates.ts b/shared/billing/feature-gates.ts new file mode 100644 index 0000000..1f996d1 --- /dev/null +++ b/shared/billing/feature-gates.ts @@ -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>; +} + +const featureGatePolicies: Partial> = { + 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 = { + 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. diff --git a/shared/billing/plans.ts b/shared/billing/plans.ts new file mode 100644 index 0000000..0fcebb1 --- /dev/null +++ b/shared/billing/plans.ts @@ -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>; + 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 { + 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 = { + 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); +} diff --git a/shared/types.ts b/shared/types.ts index be084a3..a693d15 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -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; } diff --git a/src/App.tsx b/src/App.tsx index 82ee46f..12c3cb8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 (
@@ -387,8 +380,8 @@ function LandingPage(props: {
-

Leads4less

-

Local market research for modern teams

+

LocaleScope

+

Local market intelligence for modern outbound teams

@@ -421,15 +414,15 @@ function LandingPage(props: {
- Built for local lead generation workflows + Geographic prospecting intelligence platform

- Research local markets, map opportunities, and build better lead lists faster. + Discover underserved markets, map territories, and turn local research into repeatable intelligence.

- 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.

@@ -451,9 +444,9 @@ function LandingPage(props: {

Product

-

One workspace for local lead generation

+

One workspace for local market intelligence

- 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.

@@ -462,13 +455,13 @@ function LandingPage(props: {

Operational Workflow

Search a market, expand intelligently, and review results visually.

- 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.

Best Fit

Local teams who need speed and structure

-

Built for recurring lead generation, territory research, and targeted market expansion.

+

Built for recurring territory research, geographic prospecting, and targeted market expansion.

@@ -498,7 +491,7 @@ function LandingPage(props: {

Who It's For

-

Designed for personal use, small business teams, and enterprise rollouts

+

Designed for growing prospecting teams and enterprise rollouts

@@ -517,61 +510,68 @@ function LandingPage(props: {

Pricing

-

Choose the plan that matches your research volume

+

Choose the plan that matches your market intelligence workflow

- 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.

- {plans.map((plan) => ( -
-
-
-

{plan.name}

-

{plan.audience}

-
- {plan.featured && ( - - Most Popular - - )} -
+ {pricingPlans.map((plan) => { + const display = getPlanDisplayMeta(plan.code); + const isFeatured = display.badgeLabel === 'Best Value'; -
- {plan.price} - {plan.period} -
- - - -
- {plan.items.map((item) => ( -
-
- -
- {item} +
+
+

{plan.name}

+

{display.audience}

- ))} + {display.badgeLabel ? ( + + {display.badgeLabel} + + ) : null} +
+ +
+ {formatPlanPrice(plan.priceCents, plan.currencyCode)} + {formatPlanPeriod(plan.billingInterval, plan.contactSalesRequired)} +
+ +

{display.summary}

+ + + +
+ {getPlanCardBullets(plan.code).map((item) => ( +
+
+ +
+ {item} +
+ ))} +
-
- ))} + ); + })}
@@ -581,7 +581,7 @@ function LandingPage(props: {

Start Now

Turn local market research into a repeatable system.

- 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.

@@ -648,8 +648,8 @@ function AuthPage(props: {
-

Leads4less

-

Local market research for modern teams

+

LocaleScope

+

Local market intelligence for modern outbound teams

@@ -665,12 +665,12 @@ function AuthPage(props: {
- Secure access to your lead workspace + Secure access to your intelligence workspace

- {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.'}

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]) => (

{title}

@@ -699,7 +699,7 @@ function AuthPage(props: { {authMode === 'sign_up' ? 'Create account' : 'Sign in'}

- {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.'}

diff --git a/src/components/AccountPage.tsx b/src/components/AccountPage.tsx index 7c50e61..8057854 100644 --- a/src/components/AccountPage.tsx +++ b/src/components/AccountPage.tsx @@ -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 ( {error ? {error} : null} @@ -185,7 +188,7 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) { {account.workspace.memberCount === 1 ? '1 member' : `${account.workspace.memberCount} members`}

- 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.

@@ -196,7 +199,7 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {

Plan & Billing

-

Billing coming soon

+

{activePlan ? activePlan.name : 'Subscription foundation in progress'}

{account.billing.message}

diff --git a/src/components/BasicResultsView.tsx b/src/components/BasicResultsView.tsx index 04059cd..bf9ed25 100644 --- a/src/components/BasicResultsView.tsx +++ b/src/components/BasicResultsView.tsx @@ -246,7 +246,7 @@ export function BasicResultsView({ user, selectedJobIds, onToggleJobSelection, o
- {job.totalResults === 1 ? '1 lead found' : `${job.totalResults} leads found`} + {job.totalResults === 1 ? '1 business found' : `${job.totalResults} businesses found`} {isSelected ? 'Included in selection' : 'Available to select'}
diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index aca082a..8a91e5d 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -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) { @@ -238,7 +238,7 @@ export function Dashboard({ user }: DashboardProps) { {filteredBusinesses.length === 0 ? ( - + ) : (
@@ -353,7 +353,7 @@ export function Dashboard({ user }: DashboardProps) {

Showing {(currentPage - 1) * itemsPerPage + 1} to{' '} {Math.min(currentPage * itemsPerPage, filteredBusinesses.length)} of{' '} - {filteredBusinesses.length} leads + {filteredBusinesses.length} businesses

-

Leads

+

Businesses

{batch.totalResults}

diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 01215d1..7b885a1 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -37,7 +37,7 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La
-

Leads4less

+

LocaleScope

{activeNavigationItem.name}

@@ -54,8 +54,8 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La
-

Leads4less

-

Professional lead research workspace

+

LocaleScope

+

Operational local market intelligence workspace

diff --git a/src/components/MapView.tsx b/src/components/MapView.tsx index 177c642..85b9f9f 100644 --- a/src/components/MapView.tsx +++ b/src/components/MapView.tsx @@ -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) {
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 ? {error} : null}
@@ -210,11 +210,11 @@ export function MapView({ user, jobIds }: MapViewProps) {

Map Summary

- Total Leads on Map + Businesses on Map {businesses.length}
- Selected Lead + Selected Business {selected ? selected.name : 'None'}
{selectedJobCount > 0 && (