add billing and subscription management system with Stripe integration
This commit is contained in:
parent
5ae39ebfd4
commit
df5f593369
102
docs/CRAWLERX_PRD.md
Normal file
102
docs/CRAWLERX_PRD.md
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
# CrawlerX — Product Requirements & Implementation Plan
|
||||||
|
|
||||||
|
**Date:** Oct 17, 2026
|
||||||
|
**Company:** Metatroncube Solutions
|
||||||
|
**Positioning:** *"Semrush tells you what's wrong. CrawlerX fixes it — 10 sites for about a fifth of one Semrush seat."*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary & Financial Viability
|
||||||
|
|
||||||
|
- **Core Proposition:** A "10 sites for about $30" SEO tool is financially viable. At **₹2,499/month (≈$28) for 10 sites**, rented data plus our own crawler costs about $6 per account at typical usage and about $10 at full usage, yielding a **60–76% gross margin**.
|
||||||
|
- **Break-Even Target:** Roughly **380 paying accounts** with a ₹6.4 lakh/month team (reachable in 7–13 months post-launch at 30–60 paying accounts/month).
|
||||||
|
- **Lean Option (Recommended):** Running lean on the existing Metatroncube team (~₹3 lakh/month) cuts break-even to **under 200 accounts** with ₹15–20 lakh peak runway.
|
||||||
|
|
||||||
|
### Four Non-Negotiable Conditions
|
||||||
|
1. **Don't fight Semrush on raw data size:** Rent keyword, SERP, and backlink data from wholesale APIs (DataForSEO) and win on workflow: *Audit → AI Fix → Push to Live Site → Prove Result*.
|
||||||
|
2. **Usage caps are the business model:** Hard/soft caps with cached lookups to guarantee healthy margins (Pro margin 76% typical, 60% full).
|
||||||
|
3. **Agency & Freelancer First:** Target shops managing 5–40 client sites who feel per-site Semrush costs most acutely and churn far less than solo SMBs.
|
||||||
|
4. **Distribution & Value Proof:** Deliver visible ranking and audit improvements within the first 7 days. Dogfood on Metatroncube's own clients first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Market & Competitor Analysis
|
||||||
|
|
||||||
|
| Tool | Entry Price (Monthly) | Coverage | Weakness We Exploit |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **Semrush** | $139.95 Pro; $249.95 Guru | 1 user, limited projects; white-label on Business only ($500/mo) | Prohibitive for Indian agencies/SMBs (₹11,750+/mo); extra seats $45–100 |
|
||||||
|
| **Semrush One** | $199 Starter | SEO + AI-search tracking | AI visibility treated as a luxury upsell |
|
||||||
|
| **Ubersuggest** | $29 Individual; $49 Business | 150 searches/day, 125 keywords/domain | Shallow data; basic reports; no fix execution engine |
|
||||||
|
| **SE Ranking** | $59 Essential; $109 Pro | 1 seat, 5 projects, 500 keywords | Western USD pricing; content & local add-ons cost extra |
|
||||||
|
| **Synup** | $49 (1 loc); $299 (10 loc) | Local listings, reviews, geo-grid | Multi-location local brand focus; little technical SEO |
|
||||||
|
| **CrawlerX** | **₹2,499 (≈$28) for 10 sites** | **Audit + AI fixes, rank tracking, keywords, local, AI visibility, white-label** | India-priced, complete audit-to-fix push loop, agency-first |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Differentiators
|
||||||
|
|
||||||
|
1. **Fix, Not Just Find:** Generated code & copy (title, meta, schema JSON-LD, image alt, internal links, FAQ blocks) pushed directly to WordPress (plugin) or Shopify (app) after one-click approval, then automatically re-crawled to verify.
|
||||||
|
2. **Built-in AI Search Visibility:** Included by default (not a $199 upsell). Tracks mentions and citations across ChatGPT, Google Gemini, Perplexity, and Google AI Overviews, with actionable fixes.
|
||||||
|
3. **Local SEO Built-In:** Geo-grid ranking maps, Google Business Profile (GBP) health audit, and AI review response drafts.
|
||||||
|
4. **Built for Indian Agencies:** White-label reporting from the 10-site plan, client portal, WhatsApp report delivery, INR + UPI Autopay + GST compliant invoices, and Hindi/Tamil/regional keyword data.
|
||||||
|
5. **"Next 5 Actions" Weekly Briefing:** Plain-language, high-impact weekly priorities instead of overwhelming 400-row issue tables.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7 Core Product Modules & Phased Roadmap
|
||||||
|
|
||||||
|
```
|
||||||
|
MVP (Months 1–4) ────────► v1 (Months 5–7) ────────► v2 (Months 8–12)
|
||||||
|
• JS-rendering crawler • WP & Shopify Push • Bulk fixes & approval queue
|
||||||
|
• 120+ audit checks • AI Search Visibility • Competitor link gap
|
||||||
|
• AI fix generator (copy) • Geo-grid Local Map • Public API & MCP Server
|
||||||
|
• India city-level ranks • Regional keywords • Scheduled WhatsApp reports
|
||||||
|
• Razorpay UPI Autopay • Client portal & PDF • Own cached keyword index
|
||||||
|
```
|
||||||
|
|
||||||
|
### Module Breakdown
|
||||||
|
|
||||||
|
| Module | MVP (Months 0–4) | v1 (Months 5–7) | v2 (Months 8–12) |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **1. Site Audit (Own Crawler)** | JS-rendering crawl, 120+ checks, health score, weekly re-crawl | Log-file upload, crawl diff vs last crawl | Scheduled crawls by page type, regression alerts |
|
||||||
|
| **2. AI Fix Engine** | Generated fixes (titles, metas, alt text, schema JSON-LD); copy to clipboard | 1-Click push to WordPress (plugin) & Shopify (app); re-crawl verification | Bulk fixes, agency approval queue, internal link engine |
|
||||||
|
| **3. Rank Tracking** | Google desktop + mobile, India city-level, top-20 depth, 6-day refresh | Competitor rank share, SERP features | Bing, YouTube |
|
||||||
|
| **4. Keyword Research** | Volume, difficulty, intent, CPC for India + 10 countries (cached) | Regional languages (Hindi, Tamil, Telugu, Marathi, Bengali), clusters | Proprietary India keyword database built from cached lookups |
|
||||||
|
| **5. Local SEO** | GBP connection, profile health check | Geo-grid rank map, review monitoring + AI reply drafts | Citation (NAP) audit for Indian directories (Justdial, Sulekha, IndiaMART) |
|
||||||
|
| **6. AI Search Visibility** | — | 10–20 tracked prompts across ChatGPT, Gemini, Perplexity, AI Overviews; mention share | llms.txt & AI crawler access checks; "why AI skipped you" fixes |
|
||||||
|
| **7. Agency Layer** | White-label PDF report, client sharing link | Client portal, WhatsApp/email scheduled reports, team seats | Public API, MCP Server for Claude/ChatGPT/Hermes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pricing & Unit Economics
|
||||||
|
|
||||||
|
*All prices in INR exclude 18% GST. USD converted at ~₹88.*
|
||||||
|
|
||||||
|
| Plan | Price / Month | Sites | Pages / Audit | Tracked Keywords | Keyword Lookups / Mo | AI Visibility Checks / Mo | Data Cost (Full Use) | Gross Margin (Typical / Full) |
|
||||||
|
| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
|
||||||
|
| **Free** | ₹0 | 1 | 100 | 10 | 30 | — | ≈$0.30 | Lead Magnet |
|
||||||
|
| **Starter** | ₹999 (≈$11) | 3 | 1,000 | 100 | 150 | 40 | $3.11 | 82% / 69% |
|
||||||
|
| **Pro** | ₹2,499 (≈$28) | 10 | 3,000 | 300 | 450 | 120 | $10.30 | 76% / 60% |
|
||||||
|
| **Agency** | ₹7,499 (≈$85) | 30 | 5,000 | 1,000 | 1,200 | 400 | $33.57 | 72% / 57% |
|
||||||
|
|
||||||
|
- **International Pricing:** $15 / $29 / $79 via Stripe with annual billing discount (2 months free).
|
||||||
|
- **Add-on Packs:** ₹299 for +100 tracked keywords, ₹499 for +200 AI checks.
|
||||||
|
- **Payment Rails:** Razorpay for domestic INR payments (UPI Autopay, NetBanking, Cards, GST invoicing) + Stripe for international USD.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Stack & Architecture
|
||||||
|
|
||||||
|
- **Frontend:** TanStack Start / Router / Query / Form + React + Tailwind CSS + DaisyUI.
|
||||||
|
- **Backend & Compute:** Cloudflare Workers + Node/FastAPI worker pool for heavy crawls.
|
||||||
|
- **Database & Storage:** Cloudflare D1 / PostgreSQL (dual-dialect Drizzle ORM) + Cloudflare R2 for crawl snapshots and PDF storage.
|
||||||
|
- **Data Integrations:** DataForSEO wholesale API (metered through unified quota service with redis cache), Google APIs (Search Console, Business Profile, PageSpeed).
|
||||||
|
- **Cost Controls:** Central metering layer checking plan quota before external provider calls, with multi-tenant caching for SERP/keyword queries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision Gates & Milestones
|
||||||
|
|
||||||
|
- **Gate 1 (End of Month 4 - MVP):** 3 Metatroncube retainer clients dogfooded with measurable gains (audit score +20, 10+ ranking improvements).
|
||||||
|
- **Gate 2 (End of Month 6):** 15 of 20 Indian agency design partners active weekly; at least 10 converting to paid list price.
|
||||||
|
- **Gate 3 (End of Month 12):** 200+ paying accounts, monthly logo churn < 6%, data cost < 25% of ARPU.
|
||||||
@ -116,6 +116,7 @@
|
|||||||
"remeda": "^2.33.6",
|
"remeda": "^2.33.6",
|
||||||
"robots-parser": "^3.0.1",
|
"robots-parser": "^3.0.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
"stripe": "^23.0.0",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^4.1.16",
|
||||||
"tldts": "^7.0.25",
|
"tldts": "^7.0.25",
|
||||||
"yaml": "^2.9.0",
|
"yaml": "^2.9.0",
|
||||||
|
|||||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@ -144,6 +144,9 @@ importers:
|
|||||||
sonner:
|
sonner:
|
||||||
specifier: ^2.0.7
|
specifier: ^2.0.7
|
||||||
version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
stripe:
|
||||||
|
specifier: ^23.0.0
|
||||||
|
version: 23.0.0(@types/node@22.19.11)
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.1.16
|
specifier: ^4.1.16
|
||||||
version: 4.2.1
|
version: 4.2.1
|
||||||
@ -5566,6 +5569,15 @@ packages:
|
|||||||
strip-literal@3.1.0:
|
strip-literal@3.1.0:
|
||||||
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
||||||
|
|
||||||
|
stripe@23.0.0:
|
||||||
|
resolution: {integrity: sha512-MDE2A5ZVVluYURORaZEVghSmnLJc3w6aHAlZSxDQMsOjOb3V0O6ro/CiaeFlJS4N7uIVmsg98ocRsPmUK6YfyA==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '>=20'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
|
||||||
strnum@2.4.0:
|
strnum@2.4.0:
|
||||||
resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==}
|
resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==}
|
||||||
|
|
||||||
@ -11439,6 +11451,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: 9.0.1
|
js-tokens: 9.0.1
|
||||||
|
|
||||||
|
stripe@23.0.0(@types/node@22.19.11):
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 22.19.11
|
||||||
|
|
||||||
strnum@2.4.0:
|
strnum@2.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
anynum: 1.0.0
|
anynum: 1.0.0
|
||||||
|
|||||||
234
src/client/features/billing/PricingData.ts
Normal file
234
src/client/features/billing/PricingData.ts
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
export type Currency = "INR" | "USD";
|
||||||
|
export type BillingInterval = "monthly" | "annual";
|
||||||
|
|
||||||
|
export interface PlanFeature {
|
||||||
|
name: string;
|
||||||
|
included: boolean;
|
||||||
|
highlight?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PricingPlan {
|
||||||
|
id: "free" | "starter" | "pro" | "agency";
|
||||||
|
name: string;
|
||||||
|
iconType: "sprout" | "rocket" | "crown" | "landmark";
|
||||||
|
categoryTag: string;
|
||||||
|
badge?: string;
|
||||||
|
isPopular?: boolean;
|
||||||
|
subhead: string;
|
||||||
|
sitesLimit: string;
|
||||||
|
monthlyPrice: {
|
||||||
|
INR: number;
|
||||||
|
USD: number;
|
||||||
|
};
|
||||||
|
annualMonthlyPrice: {
|
||||||
|
INR: number;
|
||||||
|
USD: number;
|
||||||
|
};
|
||||||
|
limits: {
|
||||||
|
sites: number;
|
||||||
|
pagesPerAudit: string;
|
||||||
|
trackedKeywords: number;
|
||||||
|
keywordLookups: number;
|
||||||
|
aiVisibilityChecks: number;
|
||||||
|
};
|
||||||
|
features: PlanFeature[];
|
||||||
|
ctaLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CRAWLERX_PLANS: PricingPlan[] = [
|
||||||
|
{
|
||||||
|
id: "free",
|
||||||
|
name: "Free",
|
||||||
|
iconType: "sprout",
|
||||||
|
categoryTag: "FOR EVERYONE",
|
||||||
|
subhead: "Perfect for getting started",
|
||||||
|
sitesLimit: "1 Site",
|
||||||
|
monthlyPrice: { INR: 0, USD: 0 },
|
||||||
|
annualMonthlyPrice: { INR: 0, USD: 0 },
|
||||||
|
limits: {
|
||||||
|
sites: 1,
|
||||||
|
pagesPerAudit: "100",
|
||||||
|
trackedKeywords: 10,
|
||||||
|
keywordLookups: 30,
|
||||||
|
aiVisibilityChecks: 0,
|
||||||
|
},
|
||||||
|
features: [
|
||||||
|
{ name: "1 Managed site", included: true },
|
||||||
|
{ name: "100 Pages per crawl audit", included: true },
|
||||||
|
{ name: "10 Tracked keywords", included: true },
|
||||||
|
{ name: "30 Keyword lookups / month", included: true },
|
||||||
|
{ name: "120+ Technical SEO checks", included: true },
|
||||||
|
{ name: "Community support", included: false },
|
||||||
|
{ name: "AI Fix generation", included: false },
|
||||||
|
{ name: "Google Search Console integration", included: false },
|
||||||
|
{ name: "White-label PDF reports", included: false },
|
||||||
|
],
|
||||||
|
ctaLabel: "Get Started",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "starter",
|
||||||
|
name: "Starter",
|
||||||
|
iconType: "rocket",
|
||||||
|
categoryTag: "FOR SOLO BUILDERS & SMBS",
|
||||||
|
subhead: "Everything you need to fix and rank up to 3 brand or client sites.",
|
||||||
|
sitesLimit: "3 Sites (₹333/site)",
|
||||||
|
monthlyPrice: { INR: 999, USD: 15 },
|
||||||
|
annualMonthlyPrice: { INR: 829, USD: 12 },
|
||||||
|
limits: {
|
||||||
|
sites: 3,
|
||||||
|
pagesPerAudit: "1,000",
|
||||||
|
trackedKeywords: 100,
|
||||||
|
keywordLookups: 150,
|
||||||
|
aiVisibilityChecks: 40,
|
||||||
|
},
|
||||||
|
features: [
|
||||||
|
{ name: "3 Managed sites", included: true },
|
||||||
|
{ name: "1,000 Pages per crawl audit", included: true },
|
||||||
|
{ name: "100 Tracked keywords (desktop + mobile)", included: true },
|
||||||
|
{ name: "150 Keyword lookups / month", included: true },
|
||||||
|
{ name: "40 AI visibility checks / month", included: true },
|
||||||
|
{ name: "AI Fix Engine (titles, metas, JSON-LD schema)", included: true },
|
||||||
|
{ name: "Google Search Console integration", included: true },
|
||||||
|
{ name: "Standard email support", included: true },
|
||||||
|
],
|
||||||
|
ctaLabel: "Choose Starter",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pro",
|
||||||
|
name: "Pro",
|
||||||
|
iconType: "crown",
|
||||||
|
categoryTag: "FOR AGENCIES & GROWTH TEAMS",
|
||||||
|
badge: "MOST POPULAR — 76% AGENCY CHOICE",
|
||||||
|
isPopular: true,
|
||||||
|
subhead: "Built for SEO freelancers and agencies managing 10 client domains.",
|
||||||
|
sitesLimit: "10 Sites (Just ₹250/site)",
|
||||||
|
monthlyPrice: { INR: 2499, USD: 29 },
|
||||||
|
annualMonthlyPrice: { INR: 2079, USD: 24 },
|
||||||
|
limits: {
|
||||||
|
sites: 10,
|
||||||
|
pagesPerAudit: "3,000",
|
||||||
|
trackedKeywords: 300,
|
||||||
|
keywordLookups: 450,
|
||||||
|
aiVisibilityChecks: 120,
|
||||||
|
},
|
||||||
|
features: [
|
||||||
|
{ name: "10 Managed client sites", included: true, highlight: true },
|
||||||
|
{ name: "3,000 Pages per crawl audit", included: true },
|
||||||
|
{ name: "300 Tracked keywords (India city-level)", included: true },
|
||||||
|
{ name: "450 Keyword lookups / month", included: true },
|
||||||
|
{ name: "120 AI visibility checks (ChatGPT, Perplexity)", included: true, highlight: true },
|
||||||
|
{ name: "1-Click Push to WordPress & Shopify", included: true, highlight: true },
|
||||||
|
{ name: "White-label client PDF audit reports", included: true, highlight: true },
|
||||||
|
{ name: "Google Business Profile health check", included: true },
|
||||||
|
{ name: "Weekly Next 5 Actions briefing", included: true },
|
||||||
|
{ name: "Priority agency support", included: true },
|
||||||
|
],
|
||||||
|
ctaLabel: "Upgrade to Pro",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "agency",
|
||||||
|
name: "Agency",
|
||||||
|
iconType: "landmark",
|
||||||
|
categoryTag: "SCALE & HIGH VOLUME",
|
||||||
|
subhead: "High-volume crawling, automated reporting, and client sharing for 30 sites.",
|
||||||
|
sitesLimit: "30 Sites (₹250/site)",
|
||||||
|
monthlyPrice: { INR: 7499, USD: 79 },
|
||||||
|
annualMonthlyPrice: { INR: 6249, USD: 65 },
|
||||||
|
limits: {
|
||||||
|
sites: 30,
|
||||||
|
pagesPerAudit: "5,000",
|
||||||
|
trackedKeywords: 1000,
|
||||||
|
keywordLookups: 1200,
|
||||||
|
aiVisibilityChecks: 400,
|
||||||
|
},
|
||||||
|
features: [
|
||||||
|
{ name: "30 Managed client sites", included: true },
|
||||||
|
{ name: "5,000 Pages per crawl audit", included: true },
|
||||||
|
{ name: "1,000 Tracked keywords", included: true },
|
||||||
|
{ name: "1,200 Keyword lookups / month", included: true },
|
||||||
|
{ name: "400 AI visibility checks / month", included: true },
|
||||||
|
{ name: "White-label client sharing portal", included: true, highlight: true },
|
||||||
|
{ name: "Scheduled WhatsApp & Email delivery", included: true, highlight: true },
|
||||||
|
{ name: "Public API & MCP Server access (Claude/ChatGPT)", included: true, highlight: true },
|
||||||
|
{ name: "Custom approval queue for team members", included: true },
|
||||||
|
{ name: "Dedicated account manager & SLA", included: true },
|
||||||
|
],
|
||||||
|
ctaLabel: "Choose Agency",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ADD_ON_PACKS = [
|
||||||
|
{
|
||||||
|
id: "keywords",
|
||||||
|
name: "+100 Tracked Keywords",
|
||||||
|
inr: "₹299/month",
|
||||||
|
usd: "$4/month",
|
||||||
|
description: "Add 100 extra daily/weekly ranking tracking slots across your sites.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ai-checks",
|
||||||
|
name: "+200 AI Search Checks",
|
||||||
|
inr: "₹499/month",
|
||||||
|
usd: "$6/month",
|
||||||
|
description: "Extra prompt monitoring on ChatGPT, Perplexity, Gemini, and Google AI.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TRUST_BADGES = [
|
||||||
|
{
|
||||||
|
id: "guarantee",
|
||||||
|
title: "30-Day Money-Back Guarantee",
|
||||||
|
desc: "Cancel or downgrade plans anytime with one click.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "upi",
|
||||||
|
title: "UPI Autopay",
|
||||||
|
desc: "Easy, secure & hassle-free.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gst",
|
||||||
|
title: "GST Invoices",
|
||||||
|
desc: "Automatic & compliant.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cards",
|
||||||
|
title: "RuPay / Visa / MC",
|
||||||
|
desc: "Global card support.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "support",
|
||||||
|
title: "100% Indian Support",
|
||||||
|
desc: "Local team. Real people.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PLAN_TIER_RANK: Record<string, number> = {
|
||||||
|
free: 0,
|
||||||
|
starter: 1,
|
||||||
|
pro: 2,
|
||||||
|
agency: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PlanCtaResult {
|
||||||
|
label: string;
|
||||||
|
actionType: "current" | "upgrade" | "downgrade" | "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlanCtaLabel(
|
||||||
|
plan: PricingPlan,
|
||||||
|
currentPlanId?: string,
|
||||||
|
): PlanCtaResult {
|
||||||
|
if (!currentPlanId) {
|
||||||
|
return { label: plan.ctaLabel, actionType: "default" };
|
||||||
|
}
|
||||||
|
if (currentPlanId === plan.id) {
|
||||||
|
return { label: "Active Plan", actionType: "current" };
|
||||||
|
}
|
||||||
|
const currentRank = PLAN_TIER_RANK[currentPlanId] ?? 0;
|
||||||
|
const targetRank = PLAN_TIER_RANK[plan.id] ?? 0;
|
||||||
|
|
||||||
|
if (targetRank > currentRank) {
|
||||||
|
return { label: `Upgrade to ${plan.name}`, actionType: "upgrade" };
|
||||||
|
}
|
||||||
|
return { label: `Downgrade to ${plan.name}`, actionType: "downgrade" };
|
||||||
|
}
|
||||||
256
src/client/features/billing/PricingGraphics.tsx
Normal file
256
src/client/features/billing/PricingGraphics.tsx
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Sprout,
|
||||||
|
Rocket,
|
||||||
|
Crown,
|
||||||
|
Landmark,
|
||||||
|
Sparkles,
|
||||||
|
ShieldCheck,
|
||||||
|
Zap,
|
||||||
|
FileText,
|
||||||
|
CreditCard,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export function CrawlerXLogo() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 select-none">
|
||||||
|
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" className="shrink-0 drop-shadow-sm">
|
||||||
|
{/* Top facet - Cyan / Ice Blue (#4faede) */}
|
||||||
|
<path d="M20 4L34 12L20 20L6 12L20 4Z" fill="url(#cube-top-theme)" />
|
||||||
|
{/* Left facet - MetatronCube Blue (#3779b9) */}
|
||||||
|
<path d="M6 12L20 20V36L6 28V12Z" fill="url(#cube-left-theme)" />
|
||||||
|
{/* Right facet - Deep Navy (#0f1d34) */}
|
||||||
|
<path d="M20 20L34 12V28L20 36V20Z" fill="url(#cube-right-theme)" />
|
||||||
|
{/* Subtle highlights */}
|
||||||
|
<path d="M20 20V36" stroke="rgba(255,255,255,0.25)" strokeWidth="1" />
|
||||||
|
<path d="M6 12L20 20L34 12" stroke="rgba(255,255,255,0.35)" strokeWidth="0.8" />
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="cube-top-theme" x1="6" y1="4" x2="34" y2="20" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stopColor="#4faede" />
|
||||||
|
<stop offset="1" stopColor="#3779b9" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="cube-left-theme" x1="6" y1="12" x2="20" y2="36" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stopColor="#3779b9" />
|
||||||
|
<stop offset="1" stopColor="#2563eb" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="cube-right-theme" x1="20" y1="20" x2="34" y2="36" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stopColor="#0f1d34" />
|
||||||
|
<stop offset="1" stopColor="#091424" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<div className="font-extrabold text-xl text-slate-900 tracking-tight leading-none">
|
||||||
|
CrawlerX
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-bold text-slate-400 tracking-wider uppercase mt-1">
|
||||||
|
SEO Made Actionable
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DotGridMatrix({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
width="140"
|
||||||
|
height="90"
|
||||||
|
viewBox="0 0 140 90"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<pattern
|
||||||
|
id="dot-grid-theme"
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
patternUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<circle cx="2" cy="2" r="1.5" fill="#3779b9" opacity="0.25" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="140" height="90" fill="url(#dot-grid-theme)" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Floating3DChartCard({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`pointer-events-none select-none transition-transform duration-700 ease-out hover:scale-105 ${className}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
{/* Main Chart Card */}
|
||||||
|
<div className="relative rounded-3xl bg-white/95 p-5 shadow-[0_25px_60px_rgba(55,121,185,0.18),0_10px_25px_rgba(15,29,52,0.05)] border border-slate-100/90 backdrop-blur-xl -rotate-6 w-44 h-44 flex flex-col justify-between">
|
||||||
|
{/* Top Row: "Grow Faster" Pill & Growth Arrow */}
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full bg-[#3779b9] text-white text-[10px] font-bold shadow-xs tracking-wide">
|
||||||
|
Grow Faster
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
|
||||||
|
<path
|
||||||
|
d="M6 24L16 14L21 19L27 9"
|
||||||
|
stroke="url(#theme-growth-arrow)"
|
||||||
|
strokeWidth="3.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M21 9H27V15"
|
||||||
|
stroke="url(#theme-growth-arrow)"
|
||||||
|
strokeWidth="3.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="theme-growth-arrow" x1="6" y1="24" x2="27" y2="9" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stopColor="#4faede" />
|
||||||
|
<stop offset="1" stopColor="#3779b9" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 4 Rising Theme Blue Bars */}
|
||||||
|
<div className="flex items-end gap-2.5 w-full justify-center pb-1">
|
||||||
|
<div className="w-4 h-9 rounded-md bg-gradient-to-t from-[#0f1d34] to-[#1e3a66] shadow-xs" />
|
||||||
|
<div className="w-4 h-14 rounded-md bg-gradient-to-t from-[#1e3a66] to-[#2563eb] shadow-xs" />
|
||||||
|
<div className="w-4 h-19 rounded-md bg-gradient-to-t from-[#2563eb] to-[#3779b9] shadow-xs" />
|
||||||
|
<div className="w-4 h-24 rounded-md bg-gradient-to-t from-[#3779b9] to-[#4faede] shadow-md shadow-[#4faede]/30" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Small Plant in White Pot */}
|
||||||
|
<div className="absolute -bottom-3 -left-3 size-11 z-20">
|
||||||
|
<svg viewBox="0 0 44 44" fill="none">
|
||||||
|
<path d="M12 28L15 40H29L32 28H12Z" fill="#ffffff" stroke="#e2e8f0" strokeWidth="1.5" />
|
||||||
|
<path d="M22 28C22 18 10 14 6 20C10 26 18 26 22 28Z" fill="#10b981" />
|
||||||
|
<path d="M22 28C22 16 34 12 38 18C34 24 26 24 22 28Z" fill="#059669" />
|
||||||
|
<path d="M22 28V12" stroke="#047857" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Floating3DSearchCard({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`pointer-events-none select-none transition-transform duration-700 ease-out hover:scale-105 ${className}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
{/* Tablet / Card */}
|
||||||
|
<div className="relative rounded-3xl bg-white/95 p-4 shadow-[0_25px_60px_rgba(55,121,185,0.18),0_10px_25px_rgba(15,29,52,0.05)] border border-slate-100/90 backdrop-blur-xl rotate-6 w-48 space-y-2.5">
|
||||||
|
{/* Search Input Simulation */}
|
||||||
|
<div className="h-7 w-full rounded-xl bg-slate-50 border border-slate-200/80 px-2.5 flex items-center justify-between">
|
||||||
|
<Search className="size-3 text-[#3779b9]" />
|
||||||
|
<div className="size-2 rounded-full bg-[#3779b9]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3 Theme Pills */}
|
||||||
|
<div className="space-y-2 pt-0.5">
|
||||||
|
<div className="p-2 rounded-xl bg-emerald-50/80 border border-emerald-100 flex items-center gap-2">
|
||||||
|
<span className="size-2 rounded-full bg-emerald-500 shrink-0" />
|
||||||
|
<span className="text-[11px] font-bold text-emerald-800">Better Rankings</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-2 rounded-xl bg-[#f0f6fc] border border-[#d6e4f3] flex items-center gap-2">
|
||||||
|
<span className="size-2 rounded-full bg-[#3779b9] shrink-0" />
|
||||||
|
<span className="text-[11px] font-bold text-[#0f1d34]">More Traffic</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-2 rounded-xl bg-[#e0f2fe] border border-sky-100 flex items-center gap-2">
|
||||||
|
<span className="size-2 rounded-full bg-[#4faede] shrink-0" />
|
||||||
|
<span className="text-[11px] font-bold text-[#0284c7]">Real Results</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hand-drawn Loop Arrow */}
|
||||||
|
<div className="absolute -bottom-8 -left-8 size-16 text-[#4faede]">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||||
|
<path
|
||||||
|
d="M38 8C34 22 14 18 12 32C11 38 18 42 24 38L22 42"
|
||||||
|
stroke="#4faede"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CornerWaveDecoration() {
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute inset-0 overflow-hidden z-0">
|
||||||
|
{/* Top Right Blue Glow Aura & Ribbon */}
|
||||||
|
<svg
|
||||||
|
className="absolute -top-16 -right-16 w-[700px] h-[600px] opacity-80"
|
||||||
|
viewBox="0 0 700 600"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M740 -80C560 30 500 220 580 340C660 460 540 560 460 600L740 600Z"
|
||||||
|
fill="url(#theme-top-right-wave)"
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="theme-top-right-wave" x1="740" y1="0" x2="420" y2="520" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stopColor="#4faede" stopOpacity="0.22" />
|
||||||
|
<stop offset="0.6" stopColor="#3779b9" stopOpacity="0.12" />
|
||||||
|
<stop offset="1" stopColor="#0f1d34" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Bottom Left Blue Aura Ribbon */}
|
||||||
|
<svg
|
||||||
|
className="absolute -bottom-20 -left-20 w-[640px] h-[520px] opacity-75"
|
||||||
|
viewBox="0 0 640 520"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M-60 560C120 440 180 300 120 180C60 60 140 -20 220 -40"
|
||||||
|
stroke="#4faede"
|
||||||
|
strokeWidth="32"
|
||||||
|
strokeOpacity="0.12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLAN_ICONS = {
|
||||||
|
sprout: <Sprout className="size-5" />,
|
||||||
|
rocket: <Rocket className="size-5" />,
|
||||||
|
crown: <Crown className="size-5" />,
|
||||||
|
landmark: <Landmark className="size-5" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function renderPlanIcon(type: keyof typeof PLAN_ICONS) {
|
||||||
|
return PLAN_ICONS[type] ?? <Sparkles className="size-5" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRUST_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
guarantee: <ShieldCheck className="size-4.5 text-[#3779b9]" />,
|
||||||
|
upi: <Zap className="size-4.5 text-[#3779b9]" />,
|
||||||
|
gst: <FileText className="size-4.5 text-[#3779b9]" />,
|
||||||
|
cards: <CreditCard className="size-4.5 text-[#3779b9]" />,
|
||||||
|
support: <span className="text-sm">🇮🇳</span>,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function renderTrustIcon(id: string) {
|
||||||
|
return TRUST_ICONS[id] ?? <ShieldCheck className="size-4.5 text-[#3779b9]" />;
|
||||||
|
}
|
||||||
381
src/client/features/billing/PricingTable.tsx
Normal file
381
src/client/features/billing/PricingTable.tsx
Normal file
@ -0,0 +1,381 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Sparkles,
|
||||||
|
Zap,
|
||||||
|
ArrowRight,
|
||||||
|
Box,
|
||||||
|
Search,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
CRAWLERX_PLANS,
|
||||||
|
ADD_ON_PACKS,
|
||||||
|
TRUST_BADGES,
|
||||||
|
getPlanCtaLabel,
|
||||||
|
} from "./PricingData";
|
||||||
|
import type { Currency, BillingInterval, PricingPlan } from "./PricingData";
|
||||||
|
import {
|
||||||
|
Floating3DChartCard,
|
||||||
|
Floating3DSearchCard,
|
||||||
|
DotGridMatrix,
|
||||||
|
renderPlanIcon,
|
||||||
|
renderTrustIcon,
|
||||||
|
} from "./PricingGraphics";
|
||||||
|
|
||||||
|
export function PricingTable({
|
||||||
|
onSelectPlan,
|
||||||
|
isProcessing,
|
||||||
|
loadingPlanId,
|
||||||
|
currentPlanId = "free",
|
||||||
|
}: {
|
||||||
|
onSelectPlan?: (plan: PricingPlan, interval: BillingInterval, currency: Currency) => void;
|
||||||
|
isProcessing?: boolean;
|
||||||
|
loadingPlanId?: string | null;
|
||||||
|
currentPlanId?: string;
|
||||||
|
}) {
|
||||||
|
const [currency, setCurrency] = React.useState<Currency>("INR");
|
||||||
|
const [interval, setInterval] = React.useState<BillingInterval>("monthly");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full space-y-12">
|
||||||
|
<DotGridMatrix className="hidden lg:block absolute -top-8 left-4 text-slate-300 opacity-60" />
|
||||||
|
<DotGridMatrix className="hidden lg:block absolute -top-8 right-6 text-slate-300 opacity-60" />
|
||||||
|
<Floating3DChartCard className="hidden xl:block absolute -top-12 -left-6 2xl:-left-16 z-10" />
|
||||||
|
<Floating3DSearchCard className="hidden xl:block absolute -top-8 -right-6 2xl:-right-14 z-10" />
|
||||||
|
|
||||||
|
<PricingHero
|
||||||
|
currency={currency}
|
||||||
|
interval={interval}
|
||||||
|
onCurrencyChange={setCurrency}
|
||||||
|
onIntervalChange={setInterval}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="pricing-plans"
|
||||||
|
className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 sm:gap-7 items-stretch relative z-10 scroll-mt-8"
|
||||||
|
>
|
||||||
|
{CRAWLERX_PLANS.map((plan) => (
|
||||||
|
<PricingCard
|
||||||
|
key={plan.id}
|
||||||
|
plan={plan}
|
||||||
|
currency={currency}
|
||||||
|
interval={interval}
|
||||||
|
currentPlanId={currentPlanId}
|
||||||
|
isProcessing={isProcessing}
|
||||||
|
loadingPlanId={loadingPlanId}
|
||||||
|
onSelectPlan={onSelectPlan}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PricingAddOns currency={currency} />
|
||||||
|
<PricingTrustBadges />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PricingHero({
|
||||||
|
currency,
|
||||||
|
interval,
|
||||||
|
onCurrencyChange,
|
||||||
|
onIntervalChange,
|
||||||
|
}: {
|
||||||
|
currency: Currency;
|
||||||
|
interval: BillingInterval;
|
||||||
|
onCurrencyChange: (c: Currency) => void;
|
||||||
|
onIntervalChange: (i: BillingInterval) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="text-center space-y-4 max-w-3xl mx-auto relative z-10">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full bg-[#f0f6fc] border border-[#d6e4f3] text-[#3779b9] text-xs font-semibold tracking-wide">
|
||||||
|
<Sparkles className="size-3.5 text-[#3779b9]" />
|
||||||
|
<span>Transparent • Fair Pricing</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-3xl sm:text-5xl font-black tracking-tight text-slate-900 leading-tight">
|
||||||
|
Powerful SEO tools{" "}
|
||||||
|
<span className="bg-gradient-to-r from-[#3779b9] via-[#2563eb] to-[#4faede] bg-clip-text text-transparent">
|
||||||
|
for real growth.
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-slate-500 text-sm sm:text-base leading-relaxed max-w-2xl mx-auto">
|
||||||
|
Everything you need to audit, fix, track and grow — at a fraction of the cost of Semrush.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="pt-2 flex flex-wrap items-center justify-center gap-3 sm:gap-5">
|
||||||
|
<div className="inline-flex items-center p-1 rounded-full bg-white border border-slate-200 shadow-xs text-xs font-semibold">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`px-3.5 py-1.5 rounded-full transition-all cursor-pointer flex items-center gap-1.5 ${
|
||||||
|
currency === "INR"
|
||||||
|
? "bg-slate-100 text-[#0f1d34] font-bold"
|
||||||
|
: "text-slate-500 hover:text-slate-900"
|
||||||
|
}`}
|
||||||
|
onClick={() => onCurrencyChange("INR")}
|
||||||
|
>
|
||||||
|
<span>🇮🇳</span>
|
||||||
|
<span>INR (₹)</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`px-3.5 py-1.5 rounded-full transition-all cursor-pointer flex items-center gap-1.5 ${
|
||||||
|
currency === "USD"
|
||||||
|
? "bg-slate-100 text-[#0f1d34] font-bold"
|
||||||
|
: "text-slate-500 hover:text-slate-900"
|
||||||
|
}`}
|
||||||
|
onClick={() => onCurrencyChange("USD")}
|
||||||
|
>
|
||||||
|
<span>🇺🇸</span>
|
||||||
|
<span>USD ($)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inline-flex items-center p-1 rounded-full bg-slate-100/90 border border-slate-200 shadow-xs text-xs font-semibold">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`px-4 py-1.5 rounded-full transition-all cursor-pointer ${
|
||||||
|
interval === "monthly"
|
||||||
|
? "bg-[#3779b9] text-white shadow-xs font-bold"
|
||||||
|
: "text-slate-600 hover:text-slate-900"
|
||||||
|
}`}
|
||||||
|
onClick={() => onIntervalChange("monthly")}
|
||||||
|
>
|
||||||
|
Monthly
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`px-3.5 py-1.5 rounded-full transition-all flex items-center gap-2 cursor-pointer ${
|
||||||
|
interval === "annual"
|
||||||
|
? "bg-[#3779b9] text-white shadow-xs font-bold"
|
||||||
|
: "text-slate-600 hover:text-slate-900"
|
||||||
|
}`}
|
||||||
|
onClick={() => onIntervalChange("annual")}
|
||||||
|
>
|
||||||
|
<span>Annual</span>
|
||||||
|
<span className="px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 text-[10px] font-bold">
|
||||||
|
7 Days Free Trial
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PricingCard({
|
||||||
|
plan,
|
||||||
|
currency,
|
||||||
|
interval,
|
||||||
|
currentPlanId,
|
||||||
|
isProcessing,
|
||||||
|
loadingPlanId,
|
||||||
|
onSelectPlan,
|
||||||
|
}: {
|
||||||
|
plan: PricingPlan;
|
||||||
|
currency: Currency;
|
||||||
|
interval: BillingInterval;
|
||||||
|
currentPlanId?: string;
|
||||||
|
isProcessing?: boolean;
|
||||||
|
loadingPlanId?: string | null;
|
||||||
|
onSelectPlan?: (plan: PricingPlan, interval: BillingInterval, currency: Currency) => void;
|
||||||
|
}) {
|
||||||
|
const isPro = plan.isPopular;
|
||||||
|
const isCurrent = currentPlanId === plan.id;
|
||||||
|
const cta = getPlanCtaLabel(plan, currentPlanId);
|
||||||
|
const price =
|
||||||
|
interval === "monthly"
|
||||||
|
? plan.monthlyPrice[currency]
|
||||||
|
: plan.annualMonthlyPrice[currency];
|
||||||
|
const isThisCardLoading = loadingPlanId ? loadingPlanId === plan.id : Boolean(isProcessing);
|
||||||
|
const isAnyPlanLoading = Boolean(loadingPlanId || isProcessing);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative flex flex-col justify-between rounded-[28px] p-7 sm:p-8 transition-all duration-300 ${
|
||||||
|
isPro
|
||||||
|
? "bg-gradient-to-b from-[#0f1d34] via-[#10274b] to-[#1e4a78] text-white border-2 border-[#4faede] shadow-[0_25px_60px_rgba(55,121,185,0.4),0_0_35px_rgba(79,174,222,0.25)] ring-4 ring-[#4faede]/20 -translate-y-2"
|
||||||
|
: "bg-white text-slate-900 border border-slate-200/90 hover:border-slate-300 hover:shadow-xl shadow-xs"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isCurrent ? (
|
||||||
|
<div className="absolute -top-4 left-1/2 -translate-x-1/2 inline-flex items-center gap-1 px-3.5 py-1.5 bg-emerald-600 text-white font-black text-[10px] tracking-wider uppercase rounded-full shadow-md shadow-emerald-600/30 whitespace-nowrap z-20">
|
||||||
|
<Check className="size-3" strokeWidth={3} />
|
||||||
|
<span>YOUR ACTIVE PLAN</span>
|
||||||
|
</div>
|
||||||
|
) : isPro ? (
|
||||||
|
<div className="absolute -top-4 left-1/2 -translate-x-1/2 inline-flex items-center rounded-full shadow-md shadow-[#3779b9]/30 overflow-hidden whitespace-nowrap z-20">
|
||||||
|
<span className="px-3.5 py-1.5 bg-gradient-to-r from-[#3779b9] to-[#2563eb] text-white font-black text-[10px] tracking-wider uppercase flex items-center gap-1">
|
||||||
|
👑 MOST POPULAR
|
||||||
|
</span>
|
||||||
|
<span className="px-3.5 py-1.5 bg-[#0f1d34] text-[#4faede] font-extrabold text-[10px] tracking-wider uppercase">
|
||||||
|
76% AGENCY CHOICE
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between gap-3 mb-5">
|
||||||
|
<div
|
||||||
|
className={`size-12 rounded-2xl flex items-center justify-center shrink-0 ${
|
||||||
|
isPro
|
||||||
|
? "bg-gradient-to-tr from-[#3779b9] to-[#4faede] text-white shadow-lg shadow-[#3779b9]/30"
|
||||||
|
: "bg-[#f0f6fc] border border-[#d6e4f3] text-[#3779b9]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{renderPlanIcon(plan.iconType)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-[11px] font-bold tracking-wider px-3.5 py-1.5 rounded-full uppercase whitespace-nowrap shrink-0 ${
|
||||||
|
isPro
|
||||||
|
? "bg-white/10 text-[#4faede] border border-[#4faede]/30"
|
||||||
|
: "bg-[#f0f6fc] text-[#3779b9]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{plan.categoryTag}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className={`text-3xl font-extrabold tracking-tight ${isPro ? "text-white" : "text-slate-900"}`}>
|
||||||
|
{plan.name}
|
||||||
|
</h3>
|
||||||
|
<p className={`text-xs mt-1.5 font-medium min-h-[32px] ${isPro ? "text-slate-200" : "text-slate-500"}`}>
|
||||||
|
{plan.subhead}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className={`mt-5 mb-6 pb-6 border-b ${isPro ? "border-white/15" : "border-slate-100"}`}>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className={`text-4xl sm:text-[42px] font-black tracking-tight ${isPro ? "text-white" : "text-slate-900"}`}>
|
||||||
|
{currency === "INR" ? `₹${price.toLocaleString("en-IN")}` : `$${price}`}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs font-semibold ${isPro ? "text-blue-200" : "text-slate-500"}`}>
|
||||||
|
/month
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-3 mb-8 text-[13px]">
|
||||||
|
{plan.features.map((feature) => (
|
||||||
|
<li
|
||||||
|
key={feature.name}
|
||||||
|
className={`flex items-start gap-2.5 ${
|
||||||
|
isPro
|
||||||
|
? "text-white font-medium"
|
||||||
|
: feature.included
|
||||||
|
? "text-slate-700"
|
||||||
|
: "text-slate-400 opacity-60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{feature.included ? (
|
||||||
|
<Check
|
||||||
|
className={`size-4 shrink-0 mt-0.5 ${isPro ? "text-[#4faede]" : "text-[#3779b9]"}`}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<X className="size-4 text-slate-300 shrink-0 mt-0.5" strokeWidth={2} />
|
||||||
|
)}
|
||||||
|
<span>{feature.name}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isAnyPlanLoading || isCurrent}
|
||||||
|
onClick={() => onSelectPlan?.(plan, interval, currency)}
|
||||||
|
className={`w-full h-12 rounded-2xl font-bold text-sm transition-all duration-200 flex items-center justify-center gap-2 cursor-pointer disabled:cursor-not-allowed ${
|
||||||
|
isCurrent
|
||||||
|
? "bg-emerald-50 text-emerald-800 border-2 border-emerald-300 cursor-default"
|
||||||
|
: isPro
|
||||||
|
? "bg-gradient-to-r from-[#3779b9] via-[#2563eb] to-[#4faede] hover:from-[#2e68a3] hover:to-[#38bdf8] text-white shadow-xl shadow-[#3779b9]/30 border border-white/20 active:scale-[0.99]"
|
||||||
|
: cta.actionType === "downgrade"
|
||||||
|
? "bg-white hover:bg-slate-50 text-slate-700 border-2 border-slate-200 active:scale-[0.99]"
|
||||||
|
: plan.id === "starter"
|
||||||
|
? "bg-[#0f1d34] hover:bg-[#1a2e4e] text-white shadow-md shadow-[#0f1d34]/20 active:scale-[0.99]"
|
||||||
|
: "bg-white hover:bg-[#f6f8fa] text-[#0f1d34] border-2 border-[#e6eaef] hover:border-[#3779b9]/40 active:scale-[0.99]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isThisCardLoading && !isCurrent ? (
|
||||||
|
<span className="loading loading-spinner loading-xs" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{isCurrent ? <Check className="size-4 text-emerald-600" /> : null}
|
||||||
|
<span>{cta.label}</span>
|
||||||
|
{!isCurrent ? <ArrowRight className="size-4" /> : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PricingAddOns({ currency }: { currency: Currency }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[28px] border border-slate-200/90 bg-white/95 backdrop-blur-md p-6 sm:p-7 shadow-xs relative z-10">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-center">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="size-12 rounded-2xl bg-[#f0f6fc] border border-[#d6e4f3] text-[#3779b9] flex items-center justify-center shrink-0">
|
||||||
|
<Zap className="size-6 text-[#3779b9]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-base font-bold text-slate-900">
|
||||||
|
Need Extra Capacity? Add-on Packs
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5 leading-relaxed">
|
||||||
|
Top-up quota anytime without moving to a higher tier. Capped and transparent.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ADD_ON_PACKS.map((addon) => (
|
||||||
|
<div
|
||||||
|
key={addon.id}
|
||||||
|
className="p-4 rounded-2xl bg-[#f8fafc] border border-slate-200/80 flex items-center justify-between gap-4 hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3.5">
|
||||||
|
<div className="size-10 rounded-xl bg-white border border-[#d6e4f3] flex items-center justify-center text-[#3779b9] font-bold shrink-0 shadow-2xs">
|
||||||
|
{addon.id === "keywords" ? <Box className="size-4.5" /> : <Search className="size-4.5" />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-bold text-slate-900">{addon.name}</div>
|
||||||
|
<div className="text-[11px] text-slate-500 mt-0.5 line-clamp-1">{addon.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<span className="text-xs font-bold text-[#3779b9]">
|
||||||
|
{currency === "INR" ? addon.inr : addon.usd} →
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PricingTrustBadges() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[28px] border border-slate-200/90 bg-white/95 backdrop-blur-md p-6 shadow-xs relative z-10">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 divide-y md:divide-y-0 md:divide-x divide-slate-100 text-left">
|
||||||
|
{TRUST_BADGES.map((badge, idx) => (
|
||||||
|
<div
|
||||||
|
key={badge.id}
|
||||||
|
className={`flex items-start gap-3.5 ${idx !== 0 ? "pt-3 md:pt-0 md:pl-5" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="size-9 rounded-xl bg-[#f0f6fc] border border-[#d6e4f3] text-[#3779b9] flex items-center justify-center shrink-0 mt-0.5">
|
||||||
|
{renderTrustIcon(badge.id)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-bold text-slate-900">{badge.title}</div>
|
||||||
|
<div className="text-[11px] text-slate-400 mt-0.5 leading-snug">{badge.desc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
src/client/features/billing/WorkspaceUsageBanner.tsx
Normal file
127
src/client/features/billing/WorkspaceUsageBanner.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Sparkles, ArrowRight, Layers, CheckCircle2, AlertCircle } from "lucide-react";
|
||||||
|
import { CRAWLERX_PLANS } from "./PricingData";
|
||||||
|
|
||||||
|
interface WorkspaceUsageBannerProps {
|
||||||
|
currentPlanId: "free" | "starter" | "pro" | "agency";
|
||||||
|
siteCount: number;
|
||||||
|
onUpgradeClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspaceUsageBanner({
|
||||||
|
currentPlanId,
|
||||||
|
siteCount,
|
||||||
|
onUpgradeClick,
|
||||||
|
}: WorkspaceUsageBannerProps) {
|
||||||
|
const currentPlan =
|
||||||
|
CRAWLERX_PLANS.find((p) => p.id === currentPlanId) ?? CRAWLERX_PLANS[0];
|
||||||
|
|
||||||
|
const maxSites = currentPlan.limits.sites;
|
||||||
|
const isAtLimit = siteCount >= maxSites;
|
||||||
|
const percentage = Math.min(100, Math.round((siteCount / maxSites) * 100));
|
||||||
|
|
||||||
|
const recommendation = React.useMemo(() => {
|
||||||
|
switch (currentPlanId) {
|
||||||
|
case "free":
|
||||||
|
return {
|
||||||
|
targetPlan: "Pro",
|
||||||
|
message:
|
||||||
|
"You are currently on the Free plan. Upgrade to Pro to manage 10 client sites, enable 1-Click CMS sync (WordPress & Shopify), and generate white-label PDF audit reports.",
|
||||||
|
cta: "Upgrade to Pro (Recommended)",
|
||||||
|
};
|
||||||
|
case "starter":
|
||||||
|
return {
|
||||||
|
targetPlan: "Pro",
|
||||||
|
message:
|
||||||
|
"You are on the Starter tier. Upgrade to Pro to expand capacity from 3 to 10 sites and unlock GBP local health checks and agency briefing reports.",
|
||||||
|
cta: "Upgrade to Pro",
|
||||||
|
};
|
||||||
|
case "pro":
|
||||||
|
return {
|
||||||
|
targetPlan: "Agency",
|
||||||
|
message:
|
||||||
|
"You are on the Pro tier. Expand to Agency to manage 30 client domains, unlock public MCP server access (Claude/ChatGPT), and scheduled WhatsApp client delivery.",
|
||||||
|
cta: "Scale to Agency",
|
||||||
|
};
|
||||||
|
case "agency":
|
||||||
|
return {
|
||||||
|
targetPlan: null,
|
||||||
|
message:
|
||||||
|
"You are on our premier Agency tier with 30 site slots, dedicated SLA support, and full MCP API access.",
|
||||||
|
cta: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [currentPlanId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative overflow-hidden rounded-[26px] border border-[#3779b9]/25 bg-gradient-to-r from-white via-[#f6f9fe] to-[#eef5fc] p-6 sm:p-7 shadow-sm">
|
||||||
|
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-6">
|
||||||
|
{/* Left: Plan Status & Contextual Pitch */}
|
||||||
|
<div className="space-y-2.5 max-w-3xl">
|
||||||
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold tracking-wider uppercase bg-[#3779b9]/10 text-[#3779b9] border border-[#3779b9]/20">
|
||||||
|
<span className="size-2 rounded-full bg-[#3779b9] animate-pulse" />
|
||||||
|
Workspace Tier: {currentPlan.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{isAtLimit ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold uppercase bg-amber-50 text-amber-800 border border-amber-200">
|
||||||
|
<AlertCircle className="size-3" />
|
||||||
|
Plan Capacity Reached
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200">
|
||||||
|
<CheckCircle2 className="size-3" />
|
||||||
|
{maxSites - siteCount} site {maxSites - siteCount === 1 ? "slot" : "slots"} open
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-base sm:text-lg font-bold text-slate-900 tracking-tight flex items-center gap-2">
|
||||||
|
<Layers className="size-5 text-[#3779b9] shrink-0" />
|
||||||
|
<span>
|
||||||
|
Connected Sites: <strong className="text-[#0f1d34]">{siteCount}</strong> of{" "}
|
||||||
|
<strong className="text-[#0f1d34]">{maxSites}</strong> limit used
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-xs sm:text-sm text-slate-600 leading-relaxed">
|
||||||
|
{recommendation.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Progress Meter & Upgrade Action */}
|
||||||
|
<div className="w-full lg:w-72 shrink-0 flex flex-col gap-3 pt-2 lg:pt-0">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-xs font-semibold text-slate-600">
|
||||||
|
<span>Site Capacity</span>
|
||||||
|
<span className="text-[#3779b9] font-bold">{percentage}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2.5 w-full bg-slate-200/80 rounded-full overflow-hidden p-0.5">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-500 ${
|
||||||
|
isAtLimit
|
||||||
|
? "bg-gradient-to-r from-blue-500 to-[#3779b9]"
|
||||||
|
: "bg-gradient-to-r from-[#3779b9] to-[#4faede]"
|
||||||
|
}`}
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recommendation.cta ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onUpgradeClick}
|
||||||
|
className="w-full h-11 px-4 rounded-xl bg-gradient-to-r from-[#3779b9] via-[#2563eb] to-[#4faede] hover:from-[#2e68a3] hover:to-[#38bdf8] text-white text-xs sm:text-sm font-bold transition-all shadow-md shadow-[#3779b9]/20 flex items-center justify-center gap-2 active:scale-[0.99] cursor-pointer"
|
||||||
|
>
|
||||||
|
<Sparkles className="size-4 text-cyan-200" />
|
||||||
|
<span>{recommendation.cta}</span>
|
||||||
|
<ArrowRight className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -115,7 +115,7 @@ export function AuthenticatedAppLayout({
|
|||||||
}, [shouldShowMissingSeoApiKeyModal]);
|
}, [shouldShowMissingSeoApiKeyModal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[100dvh] bg-base-200 print:block print:h-auto">
|
<div className="flex h-[100dvh] overflow-hidden bg-base-200 print:block print:h-auto">
|
||||||
<div className="hidden shrink-0 md:block print:hidden">
|
<div className="hidden shrink-0 md:block print:hidden">
|
||||||
<Sidebar projectId={sidebarProjectId} />
|
<Sidebar projectId={sidebarProjectId} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -114,8 +114,7 @@ body {
|
|||||||
@apply m-0 bg-base-200;
|
@apply m-0 bg-base-200;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
height: 100%;
|
min-height: 100%;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Prevent Safari mobile from zooming on input focus (requires 16px minimum) */
|
/* Prevent Safari mobile from zooming on input focus (requires 16px minimum) */
|
||||||
|
|||||||
3
src/env.d.ts
vendored
3
src/env.d.ts
vendored
@ -44,6 +44,9 @@ declare namespace Cloudflare {
|
|||||||
LOOPS_TRANSACTIONAL_INVITATION_ID?: string;
|
LOOPS_TRANSACTIONAL_INVITATION_ID?: string;
|
||||||
AUTUMN_SECRET_KEY?: string;
|
AUTUMN_SECRET_KEY?: string;
|
||||||
AUTUMN_WEBHOOK_SECRET?: string;
|
AUTUMN_WEBHOOK_SECRET?: string;
|
||||||
|
STRIPE_SECRET_KEY?: string;
|
||||||
|
STRIPE_PUBLISHABLE_KEY?: string;
|
||||||
|
STRIPE_WEBHOOK_SECRET?: string;
|
||||||
// Dub referral conversion tracking (hosted only); all Dub code no-ops
|
// Dub referral conversion tracking (hosted only); all Dub code no-ops
|
||||||
// when unset.
|
// when unset.
|
||||||
DUB_API_KEY?: string;
|
DUB_API_KEY?: string;
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as VerifyEmailRouteImport } from './routes/verify-email'
|
import { Route as VerifyEmailRouteImport } from './routes/verify-email'
|
||||||
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
|
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
|
||||||
|
import { Route as PricingRouteImport } from './routes/pricing'
|
||||||
import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
|
import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
|
||||||
import { Route as ChangePasswordRouteImport } from './routes/change-password'
|
import { Route as ChangePasswordRouteImport } from './routes/change-password'
|
||||||
import { Route as AuthErrorRouteImport } from './routes/auth-error'
|
import { Route as AuthErrorRouteImport } from './routes/auth-error'
|
||||||
@ -36,6 +37,7 @@ import { Route as AppAiRouteImport } from './routes/_app/ai'
|
|||||||
import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from './routes/[.well-known]/openai-apps-challenge'
|
import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from './routes/[.well-known]/openai-apps-challenge'
|
||||||
import { Route as AuthenticatedOnboardingIndexRouteImport } from './routes/_authenticated.onboarding.index'
|
import { Route as AuthenticatedOnboardingIndexRouteImport } from './routes/_authenticated.onboarding.index'
|
||||||
import { Route as AppSettingsIndexRouteImport } from './routes/_app/settings/index'
|
import { Route as AppSettingsIndexRouteImport } from './routes/_app/settings/index'
|
||||||
|
import { Route as ApiStripeWebhookRouteImport } from './routes/api/stripe/webhook'
|
||||||
import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
|
import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
|
||||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
||||||
import { Route as AuthenticatedOnboardingChatRouteImport } from './routes/_authenticated.onboarding.chat'
|
import { Route as AuthenticatedOnboardingChatRouteImport } from './routes/_authenticated.onboarding.chat'
|
||||||
@ -78,6 +80,11 @@ const ResetPasswordRoute = ResetPasswordRouteImport.update({
|
|||||||
path: '/reset-password',
|
path: '/reset-password',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const PricingRoute = PricingRouteImport.update({
|
||||||
|
id: '/pricing',
|
||||||
|
path: '/pricing',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
|
const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
|
||||||
id: '/forgot-password',
|
id: '/forgot-password',
|
||||||
path: '/forgot-password',
|
path: '/forgot-password',
|
||||||
@ -202,6 +209,11 @@ const AppSettingsIndexRoute = AppSettingsIndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => AppSettingsRoute,
|
getParentRoute: () => AppSettingsRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiStripeWebhookRoute = ApiStripeWebhookRouteImport.update({
|
||||||
|
id: '/api/stripe/webhook',
|
||||||
|
path: '/api/stripe/webhook',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({
|
const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({
|
||||||
id: '/api/autumn/$',
|
id: '/api/autumn/$',
|
||||||
path: '/api/autumn/$',
|
path: '/api/autumn/$',
|
||||||
@ -378,6 +390,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/auth-error': typeof AuthErrorRoute
|
'/auth-error': typeof AuthErrorRoute
|
||||||
'/change-password': typeof ChangePasswordRoute
|
'/change-password': typeof ChangePasswordRoute
|
||||||
'/forgot-password': typeof ForgotPasswordRoute
|
'/forgot-password': typeof ForgotPasswordRoute
|
||||||
|
'/pricing': typeof PricingRoute
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/verify-email': typeof VerifyEmailRoute
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
'/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
|
'/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
|
||||||
@ -404,6 +417,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
|
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||||
|
'/api/stripe/webhook': typeof ApiStripeWebhookRoute
|
||||||
'/settings/': typeof AppSettingsIndexRoute
|
'/settings/': typeof AppSettingsIndexRoute
|
||||||
'/onboarding/': typeof AuthenticatedOnboardingIndexRoute
|
'/onboarding/': typeof AuthenticatedOnboardingIndexRoute
|
||||||
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
||||||
@ -434,6 +448,7 @@ export interface FileRoutesByTo {
|
|||||||
'/auth-error': typeof AuthErrorRoute
|
'/auth-error': typeof AuthErrorRoute
|
||||||
'/change-password': typeof ChangePasswordRoute
|
'/change-password': typeof ChangePasswordRoute
|
||||||
'/forgot-password': typeof ForgotPasswordRoute
|
'/forgot-password': typeof ForgotPasswordRoute
|
||||||
|
'/pricing': typeof PricingRoute
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/verify-email': typeof VerifyEmailRoute
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
'/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
|
'/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
|
||||||
@ -458,6 +473,7 @@ export interface FileRoutesByTo {
|
|||||||
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
|
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||||
|
'/api/stripe/webhook': typeof ApiStripeWebhookRoute
|
||||||
'/settings': typeof AppSettingsIndexRoute
|
'/settings': typeof AppSettingsIndexRoute
|
||||||
'/onboarding': typeof AuthenticatedOnboardingIndexRoute
|
'/onboarding': typeof AuthenticatedOnboardingIndexRoute
|
||||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||||
@ -489,6 +505,7 @@ export interface FileRoutesById {
|
|||||||
'/auth-error': typeof AuthErrorRoute
|
'/auth-error': typeof AuthErrorRoute
|
||||||
'/change-password': typeof ChangePasswordRoute
|
'/change-password': typeof ChangePasswordRoute
|
||||||
'/forgot-password': typeof ForgotPasswordRoute
|
'/forgot-password': typeof ForgotPasswordRoute
|
||||||
|
'/pricing': typeof PricingRoute
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/verify-email': typeof VerifyEmailRoute
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
'/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
|
'/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
|
||||||
@ -516,6 +533,7 @@ export interface FileRoutesById {
|
|||||||
'/_authenticated/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
|
'/_authenticated/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||||
|
'/api/stripe/webhook': typeof ApiStripeWebhookRoute
|
||||||
'/_app/settings/': typeof AppSettingsIndexRoute
|
'/_app/settings/': typeof AppSettingsIndexRoute
|
||||||
'/_authenticated/onboarding/': typeof AuthenticatedOnboardingIndexRoute
|
'/_authenticated/onboarding/': typeof AuthenticatedOnboardingIndexRoute
|
||||||
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
||||||
@ -548,6 +566,7 @@ export interface FileRouteTypes {
|
|||||||
| '/auth-error'
|
| '/auth-error'
|
||||||
| '/change-password'
|
| '/change-password'
|
||||||
| '/forgot-password'
|
| '/forgot-password'
|
||||||
|
| '/pricing'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/verify-email'
|
| '/verify-email'
|
||||||
| '/.well-known/openai-apps-challenge'
|
| '/.well-known/openai-apps-challenge'
|
||||||
@ -574,6 +593,7 @@ export interface FileRouteTypes {
|
|||||||
| '/onboarding/chat'
|
| '/onboarding/chat'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/autumn/$'
|
| '/api/autumn/$'
|
||||||
|
| '/api/stripe/webhook'
|
||||||
| '/settings/'
|
| '/settings/'
|
||||||
| '/onboarding/'
|
| '/onboarding/'
|
||||||
| '/p/$projectId/audit'
|
| '/p/$projectId/audit'
|
||||||
@ -604,6 +624,7 @@ export interface FileRouteTypes {
|
|||||||
| '/auth-error'
|
| '/auth-error'
|
||||||
| '/change-password'
|
| '/change-password'
|
||||||
| '/forgot-password'
|
| '/forgot-password'
|
||||||
|
| '/pricing'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/verify-email'
|
| '/verify-email'
|
||||||
| '/.well-known/openai-apps-challenge'
|
| '/.well-known/openai-apps-challenge'
|
||||||
@ -628,6 +649,7 @@ export interface FileRouteTypes {
|
|||||||
| '/onboarding/chat'
|
| '/onboarding/chat'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/autumn/$'
|
| '/api/autumn/$'
|
||||||
|
| '/api/stripe/webhook'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/onboarding'
|
| '/onboarding'
|
||||||
| '/p/$projectId/backlinks'
|
| '/p/$projectId/backlinks'
|
||||||
@ -658,6 +680,7 @@ export interface FileRouteTypes {
|
|||||||
| '/auth-error'
|
| '/auth-error'
|
||||||
| '/change-password'
|
| '/change-password'
|
||||||
| '/forgot-password'
|
| '/forgot-password'
|
||||||
|
| '/pricing'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/verify-email'
|
| '/verify-email'
|
||||||
| '/.well-known/openai-apps-challenge'
|
| '/.well-known/openai-apps-challenge'
|
||||||
@ -685,6 +708,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_authenticated/onboarding/chat'
|
| '/_authenticated/onboarding/chat'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/autumn/$'
|
| '/api/autumn/$'
|
||||||
|
| '/api/stripe/webhook'
|
||||||
| '/_app/settings/'
|
| '/_app/settings/'
|
||||||
| '/_authenticated/onboarding/'
|
| '/_authenticated/onboarding/'
|
||||||
| '/_project/p/$projectId/audit'
|
| '/_project/p/$projectId/audit'
|
||||||
@ -719,6 +743,7 @@ export interface RootRouteChildren {
|
|||||||
AuthErrorRoute: typeof AuthErrorRoute
|
AuthErrorRoute: typeof AuthErrorRoute
|
||||||
ChangePasswordRoute: typeof ChangePasswordRoute
|
ChangePasswordRoute: typeof ChangePasswordRoute
|
||||||
ForgotPasswordRoute: typeof ForgotPasswordRoute
|
ForgotPasswordRoute: typeof ForgotPasswordRoute
|
||||||
|
PricingRoute: typeof PricingRoute
|
||||||
ResetPasswordRoute: typeof ResetPasswordRoute
|
ResetPasswordRoute: typeof ResetPasswordRoute
|
||||||
VerifyEmailRoute: typeof VerifyEmailRoute
|
VerifyEmailRoute: typeof VerifyEmailRoute
|
||||||
Char91DotwellKnownChar93OpenaiAppsChallengeRoute: typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
|
Char91DotwellKnownChar93OpenaiAppsChallengeRoute: typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
|
||||||
@ -727,6 +752,7 @@ export interface RootRouteChildren {
|
|||||||
ApiTeamSetupRoute: typeof ApiTeamSetupRoute
|
ApiTeamSetupRoute: typeof ApiTeamSetupRoute
|
||||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||||
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
|
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
|
||||||
|
ApiStripeWebhookRoute: typeof ApiStripeWebhookRoute
|
||||||
ApiGa4OauthCallbackRoute: typeof ApiGa4OauthCallbackRoute
|
ApiGa4OauthCallbackRoute: typeof ApiGa4OauthCallbackRoute
|
||||||
ApiGscOauthCallbackRoute: typeof ApiGscOauthCallbackRoute
|
ApiGscOauthCallbackRoute: typeof ApiGscOauthCallbackRoute
|
||||||
}
|
}
|
||||||
@ -747,6 +773,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ResetPasswordRouteImport
|
preLoaderRoute: typeof ResetPasswordRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/pricing': {
|
||||||
|
id: '/pricing'
|
||||||
|
path: '/pricing'
|
||||||
|
fullPath: '/pricing'
|
||||||
|
preLoaderRoute: typeof PricingRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/forgot-password': {
|
'/forgot-password': {
|
||||||
id: '/forgot-password'
|
id: '/forgot-password'
|
||||||
path: '/forgot-password'
|
path: '/forgot-password'
|
||||||
@ -922,6 +955,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppSettingsIndexRouteImport
|
preLoaderRoute: typeof AppSettingsIndexRouteImport
|
||||||
parentRoute: typeof AppSettingsRoute
|
parentRoute: typeof AppSettingsRoute
|
||||||
}
|
}
|
||||||
|
'/api/stripe/webhook': {
|
||||||
|
id: '/api/stripe/webhook'
|
||||||
|
path: '/api/stripe/webhook'
|
||||||
|
fullPath: '/api/stripe/webhook'
|
||||||
|
preLoaderRoute: typeof ApiStripeWebhookRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/api/autumn/$': {
|
'/api/autumn/$': {
|
||||||
id: '/api/autumn/$'
|
id: '/api/autumn/$'
|
||||||
path: '/api/autumn/$'
|
path: '/api/autumn/$'
|
||||||
@ -1335,6 +1375,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
AuthErrorRoute: AuthErrorRoute,
|
AuthErrorRoute: AuthErrorRoute,
|
||||||
ChangePasswordRoute: ChangePasswordRoute,
|
ChangePasswordRoute: ChangePasswordRoute,
|
||||||
ForgotPasswordRoute: ForgotPasswordRoute,
|
ForgotPasswordRoute: ForgotPasswordRoute,
|
||||||
|
PricingRoute: PricingRoute,
|
||||||
ResetPasswordRoute: ResetPasswordRoute,
|
ResetPasswordRoute: ResetPasswordRoute,
|
||||||
VerifyEmailRoute: VerifyEmailRoute,
|
VerifyEmailRoute: VerifyEmailRoute,
|
||||||
Char91DotwellKnownChar93OpenaiAppsChallengeRoute:
|
Char91DotwellKnownChar93OpenaiAppsChallengeRoute:
|
||||||
@ -1344,6 +1385,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
ApiTeamSetupRoute: ApiTeamSetupRoute,
|
ApiTeamSetupRoute: ApiTeamSetupRoute,
|
||||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||||
ApiAutumnSplatRoute: ApiAutumnSplatRoute,
|
ApiAutumnSplatRoute: ApiAutumnSplatRoute,
|
||||||
|
ApiStripeWebhookRoute: ApiStripeWebhookRoute,
|
||||||
ApiGa4OauthCallbackRoute: ApiGa4OauthCallbackRoute,
|
ApiGa4OauthCallbackRoute: ApiGa4OauthCallbackRoute,
|
||||||
ApiGscOauthCallbackRoute: ApiGscOauthCallbackRoute,
|
ApiGscOauthCallbackRoute: ApiGscOauthCallbackRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { useCustomer } from "autumn-js/react";
|
import { useCustomer } from "autumn-js/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ArrowRight, Settings, User } from "lucide-react";
|
import { ArrowLeft, ChevronDown, Globe, Settings, User } from "lucide-react";
|
||||||
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
|
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
|
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
|
||||||
@ -11,29 +11,39 @@ import { getSubscribeRouteState } from "@/client/features/billing/route-state";
|
|||||||
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
|
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
|
||||||
import { normalizeAuthRedirect } from "@/lib/auth-redirect";
|
import { normalizeAuthRedirect } from "@/lib/auth-redirect";
|
||||||
import { useCanManageBilling } from "@/client/features/team/organizationQueries";
|
import { useCanManageBilling } from "@/client/features/team/organizationQueries";
|
||||||
|
import { AUTUMN_MANAGED_ACCESS_FEATURE_ID } from "@/shared/billing";
|
||||||
|
import { PricingTable } from "@/client/features/billing/PricingTable";
|
||||||
|
import type {
|
||||||
|
PricingPlan,
|
||||||
|
BillingInterval,
|
||||||
|
Currency,
|
||||||
|
} from "@/client/features/billing/PricingData";
|
||||||
import {
|
import {
|
||||||
AUTUMN_CHECKOUT_SESSION_PARAMS,
|
createStripeCheckoutSession,
|
||||||
AUTUMN_MANAGED_ACCESS_FEATURE_ID,
|
getOrganizationBilling,
|
||||||
AUTUMN_PAID_PLAN_ID,
|
verifyStripeCheckoutSession,
|
||||||
} from "@/shared/billing";
|
} from "@/serverFunctions/stripe";
|
||||||
|
import {
|
||||||
|
CornerWaveDecoration,
|
||||||
|
CrawlerXLogo,
|
||||||
|
} from "@/client/features/billing/PricingGraphics";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { getProjects } from "@/serverFunctions/projects";
|
||||||
|
import { WorkspaceUsageBanner } from "@/client/features/billing/WorkspaceUsageBanner";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
const SUPPORT_EMAIL = "ben@openseo.so";
|
const SUPPORT_EMAIL = "support@metatroncube.com";
|
||||||
|
|
||||||
const PLAN_FEATURES = [
|
|
||||||
"Keyword research, backlinks, rank tracking, and site audits",
|
|
||||||
"MCP server and agent skills for Claude, Cursor, and ChatGPT",
|
|
||||||
"Google Search Console Integration",
|
|
||||||
"Includes $10.00 of Usage Credits each month",
|
|
||||||
];
|
|
||||||
|
|
||||||
// How long the post-checkout "finalizing" screen polls Autumn before giving
|
|
||||||
// up and letting the user through anyway.
|
|
||||||
const FINALIZING_TIMEOUT_MS = 30_000;
|
const FINALIZING_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/subscribe")({
|
export const Route = createFileRoute("/_authenticated/subscribe")({
|
||||||
validateSearch: (
|
validateSearch: (
|
||||||
search: Record<string, unknown>,
|
search: Record<string, unknown>,
|
||||||
): { upgrade?: true; redirect?: string; checkout?: "success" } => ({
|
): {
|
||||||
|
upgrade?: true;
|
||||||
|
redirect?: string;
|
||||||
|
checkout?: "success";
|
||||||
|
session_id?: string;
|
||||||
|
} => ({
|
||||||
upgrade:
|
upgrade:
|
||||||
search.upgrade === true || search.upgrade === "true" ? true : undefined,
|
search.upgrade === true || search.upgrade === "true" ? true : undefined,
|
||||||
redirect:
|
redirect:
|
||||||
@ -41,39 +51,78 @@ export const Route = createFileRoute("/_authenticated/subscribe")({
|
|||||||
? normalizeAuthRedirect(search.redirect)
|
? normalizeAuthRedirect(search.redirect)
|
||||||
: undefined,
|
: undefined,
|
||||||
checkout: search.checkout === "success" ? "success" : undefined,
|
checkout: search.checkout === "success" ? "success" : undefined,
|
||||||
|
session_id:
|
||||||
|
typeof search.session_id === "string" ? search.session_id : undefined,
|
||||||
}),
|
}),
|
||||||
component: SubscribePage,
|
component: SubscribePage,
|
||||||
});
|
});
|
||||||
|
|
||||||
function SubscribePage() {
|
function SubscribePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { upgrade: isUpgradeFlow, redirect, checkout } = Route.useSearch();
|
const queryClient = useQueryClient();
|
||||||
|
const {
|
||||||
|
upgrade: isUpgradeFlow,
|
||||||
|
redirect,
|
||||||
|
checkout,
|
||||||
|
session_id: sessionId,
|
||||||
|
} = Route.useSearch();
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const [isAttaching, setIsAttaching] = useState(false);
|
const [loadingPlanId, setLoadingPlanId] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [finalizingTimedOut, setFinalizingTimedOut] = useState(false);
|
const [finalizingTimedOut, setFinalizingTimedOut] = useState(false);
|
||||||
|
const [hasVerifiedSession, setHasVerifiedSession] = useState(false);
|
||||||
const checkoutCompleted = checkout === "success";
|
const checkoutCompleted = checkout === "success";
|
||||||
|
|
||||||
const hasSession = Boolean(session?.user?.id);
|
const hasSession = Boolean(session?.user?.id);
|
||||||
const customerQuery = useCustomer({
|
const customerQuery = useCustomer({
|
||||||
queryOptions: {
|
queryOptions: { enabled: hasSession },
|
||||||
enabled: hasSession,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Checkout is owner-only; other members hitting the paywall are pointed at
|
const billingQuery = useQuery({
|
||||||
// their organization owner instead of a Subscribe button that would 403.
|
queryKey: ["organization-billing", session?.user?.id],
|
||||||
const canManageBilling = useCanManageBilling();
|
queryFn: () => getOrganizationBilling(),
|
||||||
|
enabled: hasSession,
|
||||||
|
});
|
||||||
|
|
||||||
// Read managed access from the already-loaded Autumn customer (local, no API
|
useEffect(() => {
|
||||||
// call) instead of a separate server round-trip. Self-hosted has no Autumn
|
if (checkoutCompleted && sessionId && !hasVerifiedSession) {
|
||||||
// customer, so mirror the server's "always granted" behavior there.
|
setHasVerifiedSession(true);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const res = await verifyStripeCheckoutSession({
|
||||||
|
data: { sessionId },
|
||||||
|
});
|
||||||
|
if (res?.success) {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["organization-billing"],
|
||||||
|
});
|
||||||
|
toast.success(
|
||||||
|
`Payment successful! Your ${res.planId.toUpperCase()} plan is now active.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to verify Stripe checkout session:", err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
}, [checkoutCompleted, sessionId, hasVerifiedSession, queryClient]);
|
||||||
|
|
||||||
|
const canManageBilling = useCanManageBilling();
|
||||||
const hasManagedAccess = isHostedClientAuthMode()
|
const hasManagedAccess = isHostedClientAuthMode()
|
||||||
? customerQuery.check({ featureId: AUTUMN_MANAGED_ACCESS_FEATURE_ID })
|
? customerQuery.check({ featureId: AUTUMN_MANAGED_ACCESS_FEATURE_ID })
|
||||||
.allowed
|
.allowed
|
||||||
: true;
|
: true;
|
||||||
|
|
||||||
const planStatus = getCustomerPlanStatus(customerQuery.data);
|
const planStatus = getCustomerPlanStatus(customerQuery.data);
|
||||||
|
const projectsQuery = useQuery({
|
||||||
|
queryKey: ["projects"],
|
||||||
|
queryFn: () => getProjects(),
|
||||||
|
enabled: hasSession,
|
||||||
|
});
|
||||||
|
const siteCount = Math.max(1, projectsQuery.data?.length ?? 1);
|
||||||
|
const activePlanId: "free" | "starter" | "pro" | "agency" =
|
||||||
|
billingQuery.data?.planId ??
|
||||||
|
(planStatus === "paid" ? "pro" : "free");
|
||||||
const subscribeRouteState = getSubscribeRouteState({
|
const subscribeRouteState = getSubscribeRouteState({
|
||||||
hasSession,
|
hasSession,
|
||||||
isCustomerLoading: customerQuery.isLoading,
|
isCustomerLoading: customerQuery.isLoading,
|
||||||
@ -81,13 +130,11 @@ function SubscribePage() {
|
|||||||
hasManagedAccess,
|
hasManagedAccess,
|
||||||
planStatus,
|
planStatus,
|
||||||
isUpgradeFlow: isUpgradeFlow === true,
|
isUpgradeFlow: isUpgradeFlow === true,
|
||||||
checkoutCompleted,
|
checkoutCompleted: checkoutCompleted && !sessionId,
|
||||||
finalizingTimedOut,
|
finalizingTimedOut,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Autumn can lag Stripe by a few seconds after checkout; poll until the
|
const isFinalizing = !sessionId && subscribeRouteState === "finalizing";
|
||||||
// subscription shows up so the just-paid user isn't shown the paywall again.
|
|
||||||
const isFinalizing = subscribeRouteState === "finalizing";
|
|
||||||
const { refetch: refetchCustomer } = customerQuery;
|
const { refetch: refetchCustomer } = customerQuery;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isFinalizing) return;
|
if (!isFinalizing) return;
|
||||||
@ -97,9 +144,6 @@ function SubscribePage() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [refetchCustomer, isFinalizing]);
|
}, [refetchCustomer, isFinalizing]);
|
||||||
|
|
||||||
// Armed once on landing with checkout=success (not on the finalizing state,
|
|
||||||
// which a transient poll error can leave and re-enter) so the deadline is a
|
|
||||||
// hard bound from arrival.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!checkoutCompleted || finalizingTimedOut) return;
|
if (!checkoutCompleted || finalizingTimedOut) return;
|
||||||
const timeout = setTimeout(
|
const timeout = setTimeout(
|
||||||
@ -133,190 +177,158 @@ function SubscribePage() {
|
|||||||
|
|
||||||
if (subscribeRouteState === "finalizing") {
|
if (subscribeRouteState === "finalizing") {
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-xs space-y-4 text-center">
|
<div className="min-h-screen flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-sm space-y-4 text-center p-8 rounded-3xl bg-white border border-slate-200 shadow-xl">
|
||||||
<img
|
<img
|
||||||
src="/transparent-logo.png"
|
src="/transparent-logo.png"
|
||||||
alt="CrawlerX"
|
alt="CrawlerX"
|
||||||
className="mx-auto size-10 rounded-lg"
|
className="mx-auto size-12 object-contain"
|
||||||
/>
|
/>
|
||||||
<h1 className="text-xl font-semibold">
|
<h1 className="text-xl font-bold text-slate-900">
|
||||||
Finalizing your subscription…
|
Finalizing your subscription…
|
||||||
</h1>
|
</h1>
|
||||||
<span className="loading loading-spinner loading-md" />
|
<span className="loading loading-spinner loading-md text-blue-600" />
|
||||||
<p className="text-sm text-base-content/60">
|
<p className="text-sm text-slate-500">
|
||||||
This usually takes a few seconds.
|
Setting up your workspace limits and credits.
|
||||||
</p>
|
|
||||||
<p className="text-xs text-base-content/50">
|
|
||||||
Taking longer?{" "}
|
|
||||||
<a className="link" href={`mailto:${SUPPORT_EMAIL}`}>
|
|
||||||
Email {SUPPORT_EMAIL}
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subscribeRouteState === "error") {
|
async function handleSelectPlan(
|
||||||
return (
|
plan: PricingPlan,
|
||||||
<div className="w-full max-w-xs space-y-4">
|
interval: BillingInterval = "monthly",
|
||||||
<div className="text-center space-y-3">
|
currency: Currency = "INR",
|
||||||
<img
|
) {
|
||||||
src="/transparent-logo.png"
|
if (plan.id === "free") {
|
||||||
alt="CrawlerX"
|
void navigate({ to: redirect ?? "/", replace: true });
|
||||||
className="mx-auto size-10 rounded-lg"
|
return;
|
||||||
/>
|
}
|
||||||
<h1 className="text-xl font-semibold">Billing unavailable</h1>
|
|
||||||
</div>
|
if (!canManageBilling) {
|
||||||
|
toast.error("Only the organization owner can change subscription plans.");
|
||||||
<p className="text-sm text-center text-base-content/70">
|
return;
|
||||||
{getStandardErrorMessage(
|
|
||||||
customerQuery.error,
|
|
||||||
"We couldn't verify your billing status right now. Please try again.",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-soft w-full"
|
|
||||||
onClick={() => {
|
|
||||||
void customerQuery.refetch();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubscribe() {
|
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsAttaching(true);
|
setLoadingPlanId(plan.id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
captureClientEvent("billing:checkout_start");
|
captureClientEvent("billing:checkout_start", {
|
||||||
const successUrl = new URL(window.location.href);
|
planId: plan.id,
|
||||||
successUrl.searchParams.set("checkout", "success");
|
interval,
|
||||||
await customerQuery.attach({
|
currency,
|
||||||
planId: AUTUMN_PAID_PLAN_ID,
|
|
||||||
redirectMode: "always",
|
|
||||||
successUrl: successUrl.toString(),
|
|
||||||
checkoutSessionParams: AUTUMN_CHECKOUT_SESSION_PARAMS,
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
|
||||||
setError(
|
const response = await createStripeCheckoutSession({
|
||||||
getStandardErrorMessage(
|
data: {
|
||||||
err,
|
planId: plan.id,
|
||||||
"We couldn't start the checkout. Please try again.",
|
interval,
|
||||||
),
|
currency,
|
||||||
);
|
},
|
||||||
setIsAttaching(false);
|
});
|
||||||
}
|
|
||||||
|
if (response?.checkoutUrl) {
|
||||||
|
window.location.assign(response.checkoutUrl);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstName = session?.user?.name?.split(" ")[0] || "";
|
throw new Error("Stripe checkout session did not return a valid URL.");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errObj = err as { message?: string; data?: { message?: string } } | undefined;
|
||||||
|
const rawMsg = errObj?.data?.message || errObj?.message;
|
||||||
|
const msg = rawMsg
|
||||||
|
? getStandardErrorMessage(new Error(rawMsg), rawMsg)
|
||||||
|
: "Stripe payment is not configured yet. Please set your STRIPE_SECRET_KEY in your .env file.";
|
||||||
|
setError(msg);
|
||||||
|
toast.error(msg);
|
||||||
|
} finally {
|
||||||
|
setLoadingPlanId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-sm space-y-6">
|
<div className="relative min-h-screen bg-[#f8fafc] text-[#151515] overflow-x-hidden selection:bg-[#3779b9] selection:text-white">
|
||||||
|
{/* 3D Organic Fluid Corner Ribbons & Aura in Theme Blues */}
|
||||||
|
<CornerWaveDecoration />
|
||||||
|
|
||||||
|
{/* Main Content Container */}
|
||||||
|
<div className="relative z-10 max-w-[1680px] w-full mx-auto px-4 sm:px-8 lg:px-12 py-6 sm:py-8 space-y-12">
|
||||||
|
{/* Top Navbar */}
|
||||||
|
<header className="flex items-center justify-between gap-4 py-2">
|
||||||
|
{/* Exact CrawlerX Cube Logo & Subtitle */}
|
||||||
|
<Link to="/" className="group">
|
||||||
|
<CrawlerXLogo />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Center Navigation Links */}
|
||||||
|
<nav className="hidden md:flex items-center gap-8 text-sm font-semibold text-slate-600">
|
||||||
|
<div className="flex items-center gap-1 hover:text-[#0f1d34] transition-colors cursor-pointer">
|
||||||
|
<span>Features</span>
|
||||||
|
<ChevronDown className="size-3.5 opacity-60" />
|
||||||
|
</div>
|
||||||
|
<div className="relative text-[#0f1d34] font-bold">
|
||||||
|
<span>Pricing</span>
|
||||||
|
<div className="absolute -bottom-2.5 left-0 right-0 h-0.5 bg-[#3779b9] rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 hover:text-[#0f1d34] transition-colors cursor-pointer">
|
||||||
|
<span>Resources</span>
|
||||||
|
<ChevronDown className="size-3.5 opacity-60" />
|
||||||
|
</div>
|
||||||
|
<Link to="/" className="hover:text-[#0f1d34] transition-colors">
|
||||||
|
About
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Right Actions */}
|
||||||
|
<div className="flex items-center gap-3 sm:gap-4">
|
||||||
|
<div className="hidden sm:flex items-center gap-1.5 text-xs font-semibold text-slate-700 hover:text-[#0f1d34] transition-colors cursor-pointer">
|
||||||
|
<Globe className="size-3.5 text-slate-500" />
|
||||||
|
<span>INR (₹)</span>
|
||||||
|
<ChevronDown className="size-3 opacity-60" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="h-10 px-4 rounded-xl bg-slate-100 hover:bg-slate-200 text-[#0f1d34] text-xs sm:text-sm font-semibold transition-all flex items-center gap-1.5 shadow-2xs"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-3.5" />
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<SubscribePageAccountMenu email={session?.user?.email} />
|
<SubscribePageAccountMenu email={session?.user?.email} />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div className="text-center space-y-3">
|
{/* Workspace Usage Notice */}
|
||||||
<img
|
<WorkspaceUsageBanner
|
||||||
src="/transparent-logo.png"
|
currentPlanId={activePlanId}
|
||||||
alt="CrawlerX"
|
siteCount={siteCount}
|
||||||
className="mx-auto size-10 rounded-lg"
|
onUpgradeClick={() => {
|
||||||
|
const el = document.getElementById("pricing-plans");
|
||||||
|
el?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<h1 className="text-xl font-semibold">
|
|
||||||
{isUpgradeFlow
|
{error ? (
|
||||||
? "Upgrade your plan"
|
<div className="max-w-md mx-auto mb-6 p-4 rounded-xl bg-rose-50 border border-rose-200 text-rose-700 text-xs font-medium text-center">
|
||||||
: firstName
|
{error}
|
||||||
? `Welcome to CrawlerX, ${firstName}!`
|
|
||||||
: "Welcome to CrawlerX!"}
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-base-content/60">
|
|
||||||
SEO on your terms. All your SEO tools in one place at a fair price.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-base-300 p-5 space-y-4">
|
|
||||||
<div className="flex items-baseline justify-between gap-4">
|
|
||||||
<span className="font-semibold">Base Plan</span>
|
|
||||||
<span className="text-lg font-semibold tabular-nums">$10/month</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{PLAN_FEATURES.map((item) => (
|
|
||||||
<li
|
|
||||||
key={item}
|
|
||||||
className="flex gap-2.5 text-sm text-base-content/70"
|
|
||||||
>
|
|
||||||
<span className="text-base-content/40 mt-[2px] shrink-0">
|
|
||||||
—
|
|
||||||
</span>
|
|
||||||
{item}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{/* Sub-bullet of the Usage Credits line above. */}
|
|
||||||
<li className="-mt-1 pl-6 text-xs">
|
|
||||||
<a
|
|
||||||
className="text-base-content/60 underline decoration-base-content/40 decoration-dotted underline-offset-4 transition-colors hover:text-base-content"
|
|
||||||
href="https://openseo.so/pricing"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
onClick={() =>
|
|
||||||
captureClientEvent("billing:pricing_estimator_click")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
How far do usage credits go?{" "}
|
|
||||||
<span aria-hidden="true">↗</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{error ? <p className="text-sm text-error">{error}</p> : null}
|
|
||||||
|
|
||||||
{canManageBilling ? (
|
|
||||||
<button
|
|
||||||
className="btn btn-soft w-full"
|
|
||||||
disabled={isAttaching}
|
|
||||||
onClick={() => void handleSubscribe()}
|
|
||||||
>
|
|
||||||
{isAttaching ? "Redirecting..." : "Subscribe"}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-base-content/60">
|
|
||||||
Only the organization owner can subscribe. Ask them to upgrade this
|
|
||||||
organization.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-center text-xs text-base-content/50">
|
|
||||||
<span
|
|
||||||
className="tooltip before:max-w-60 before:whitespace-normal"
|
|
||||||
data-tip={`Not for you yet? Email ${SUPPORT_EMAIL} within 30 days of your charge and we'll refund your subscription.`}
|
|
||||||
>
|
|
||||||
<span className="cursor-help underline decoration-dotted">
|
|
||||||
30-day money-back guarantee
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
. Cancel anytime. Powered by Stripe.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center space-y-2">
|
|
||||||
<p className="text-sm text-base-content/60">
|
|
||||||
Questions? Email {SUPPORT_EMAIL}.
|
|
||||||
</p>
|
|
||||||
{isUpgradeFlow ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex cursor-pointer items-center gap-1.5 text-sm font-medium text-base-content/70 hover:text-base-content transition-colors"
|
|
||||||
onClick={() => void navigate({ to: "/", replace: true })}
|
|
||||||
>
|
|
||||||
<ArrowRight className="size-3.5 rotate-180" />
|
|
||||||
Back to app
|
|
||||||
</button>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Pricing Component */}
|
||||||
|
<main>
|
||||||
|
<PricingTable
|
||||||
|
onSelectPlan={handleSelectPlan}
|
||||||
|
loadingPlanId={loadingPlanId}
|
||||||
|
currentPlanId={activePlanId}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="pt-8 pb-12 text-center text-xs text-slate-400 border-t border-slate-100">
|
||||||
|
CrawlerX by Metatroncube Solutions • Enterprise & Custom Agency Plans: {SUPPORT_EMAIL}
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -325,31 +337,28 @@ function SubscribePage() {
|
|||||||
function SubscribePageAccountMenu({ email }: { email: string | undefined }) {
|
function SubscribePageAccountMenu({ email }: { email: string | undefined }) {
|
||||||
if (!email) return null;
|
if (!email) return null;
|
||||||
|
|
||||||
const handleSignOut = () => signOutAndRedirect();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed top-4 right-4">
|
|
||||||
<div className="dropdown dropdown-end">
|
<div className="dropdown dropdown-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="btn btn-ghost btn-circle"
|
className="btn btn-ghost btn-circle btn-sm"
|
||||||
aria-label="Open account menu"
|
aria-label="Open account menu"
|
||||||
>
|
>
|
||||||
<User className="h-5 w-5" />
|
<User className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<ul
|
<ul
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="dropdown-content z-20 menu mt-3 min-w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
|
className="dropdown-content z-20 menu mt-3 min-w-56 rounded-2xl border border-slate-200 bg-white p-2 shadow-xl"
|
||||||
>
|
>
|
||||||
<li className="menu-title max-w-full">
|
<li className="menu-title max-w-full">
|
||||||
<span className="truncate text-base-content" data-ph-mask>
|
<span className="truncate text-slate-900 text-xs" data-ph-mask>
|
||||||
{email}
|
{email}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link to="/settings" className="flex items-center gap-2">
|
<Link to="/settings" className="flex items-center gap-2 text-xs">
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-3.5 w-3.5" />
|
||||||
Settings
|
Settings
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
@ -357,14 +366,13 @@ function SubscribePageAccountMenu({ email }: { email: string | undefined }) {
|
|||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-error"
|
className="text-rose-600 text-xs"
|
||||||
onClick={handleSignOut}
|
onClick={() => void signOutAndRedirect()}
|
||||||
>
|
>
|
||||||
Sign out
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
import { Outlet, createFileRoute, useLocation } from "@tanstack/react-router";
|
||||||
import { AuthPageShell } from "@/client/features/auth/AuthPage";
|
import { AuthPageShell } from "@/client/features/auth/AuthPage";
|
||||||
import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard";
|
import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard";
|
||||||
|
|
||||||
@ -8,11 +8,17 @@ export const Route = createFileRoute("/_authenticated")({
|
|||||||
|
|
||||||
function AuthenticatedShellLayout() {
|
function AuthenticatedShellLayout() {
|
||||||
const authGate = useHostedAuthRouteGuard();
|
const authGate = useHostedAuthRouteGuard();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
if (!authGate.isSessionMode || !authGate.canRenderAuthenticatedContent) {
|
if (!authGate.isSessionMode || !authGate.canRenderAuthenticatedContent) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subscribe page is a full-width pricing experience, not a split-screen auth modal
|
||||||
|
if (location.pathname.startsWith("/subscribe")) {
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthPageShell>
|
<AuthPageShell>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
107
src/routes/api/stripe/webhook.ts
Normal file
107
src/routes/api/stripe/webhook.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { Stripe } from "stripe";
|
||||||
|
import { getOptionalEnvValue } from "@/server/lib/runtime-env";
|
||||||
|
import { syncStripeSubscriptionStatus } from "@/server/billing/stripe-sync";
|
||||||
|
|
||||||
|
async function handleStripeWebhook(request: Request): Promise<Response> {
|
||||||
|
const stripeSecretKey = await getOptionalEnvValue("STRIPE_SECRET_KEY");
|
||||||
|
if (!stripeSecretKey) {
|
||||||
|
return Response.json({ error: "Stripe key not configured" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = new Stripe(stripeSecretKey);
|
||||||
|
const signature = request.headers.get("stripe-signature");
|
||||||
|
const webhookSecret = await getOptionalEnvValue("STRIPE_WEBHOOK_SECRET");
|
||||||
|
|
||||||
|
let event: Stripe.Event;
|
||||||
|
const payload = await request.text();
|
||||||
|
|
||||||
|
if (webhookSecret && signature) {
|
||||||
|
try {
|
||||||
|
event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Stripe webhook signature verification failed:", err);
|
||||||
|
return Response.json({ error: "Invalid signature" }, { status: 400 });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
event = JSON.parse(payload) as Stripe.Event;
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (event.type) {
|
||||||
|
case "checkout.session.completed": {
|
||||||
|
const session = event.data.object as Stripe.Checkout.Session;
|
||||||
|
const organizationId = session.metadata?.organizationId;
|
||||||
|
const planId = session.metadata?.planId ?? "pro";
|
||||||
|
|
||||||
|
if (organizationId) {
|
||||||
|
await syncStripeSubscriptionStatus({
|
||||||
|
organizationId,
|
||||||
|
planId,
|
||||||
|
status: "active",
|
||||||
|
isPaying: true,
|
||||||
|
stripeCustomerJson: session as unknown as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "customer.subscription.updated": {
|
||||||
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
|
const organizationId = subscription.metadata?.organizationId;
|
||||||
|
const planId = subscription.metadata?.planId ?? "pro";
|
||||||
|
const isActive = subscription.status === "active";
|
||||||
|
|
||||||
|
if (organizationId) {
|
||||||
|
await syncStripeSubscriptionStatus({
|
||||||
|
organizationId,
|
||||||
|
planId,
|
||||||
|
status: isActive ? "active" : "canceled",
|
||||||
|
isPaying: isActive,
|
||||||
|
stripeCustomerJson: subscription as unknown as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "customer.subscription.deleted": {
|
||||||
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
|
const organizationId = subscription.metadata?.organizationId;
|
||||||
|
const planId = subscription.metadata?.planId ?? "free";
|
||||||
|
|
||||||
|
if (organizationId) {
|
||||||
|
await syncStripeSubscriptionStatus({
|
||||||
|
organizationId,
|
||||||
|
planId,
|
||||||
|
status: "canceled",
|
||||||
|
isPaying: false,
|
||||||
|
stripeCustomerJson: subscription as unknown as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ received: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error processing Stripe webhook:", err);
|
||||||
|
return Response.json({ error: "Failed to process webhook" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/api/stripe/webhook")({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
|
POST: async ({ request }: { request: Request }) => {
|
||||||
|
return handleStripeWebhook(request);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
122
src/routes/pricing.tsx
Normal file
122
src/routes/pricing.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { PricingTable } from "@/client/features/billing/PricingTable";
|
||||||
|
import type { PricingPlan } from "@/client/features/billing/PricingData";
|
||||||
|
import {
|
||||||
|
CornerWaveDecoration,
|
||||||
|
CrawlerXLogo,
|
||||||
|
} from "@/client/features/billing/PricingGraphics";
|
||||||
|
import { ArrowLeft, ArrowRight, ChevronDown, Globe } from "lucide-react";
|
||||||
|
import { useSession } from "@/lib/auth-client";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/pricing")({
|
||||||
|
component: PublicPricingPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function PublicPricingPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const isAuthenticated = Boolean(session?.user?.id);
|
||||||
|
|
||||||
|
function handleSelectPlan(plan: PricingPlan) {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
if (plan.id === "free") {
|
||||||
|
void navigate({ to: "/" });
|
||||||
|
} else {
|
||||||
|
void navigate({ to: "/subscribe" });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.id === "free") {
|
||||||
|
void navigate({ to: "/sign-up" });
|
||||||
|
} else {
|
||||||
|
void navigate({
|
||||||
|
to: "/sign-up",
|
||||||
|
search: { redirect: "/subscribe" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-screen bg-[#f8fafc] text-[#151515] overflow-x-hidden selection:bg-[#3779b9] selection:text-white">
|
||||||
|
{/* 3D Organic Fluid Corner Ribbons & Aura in Theme Blues */}
|
||||||
|
<CornerWaveDecoration />
|
||||||
|
|
||||||
|
{/* Main Content Container */}
|
||||||
|
<div className="relative z-10 max-w-[1680px] w-full mx-auto px-4 sm:px-8 lg:px-12 py-6 sm:py-8 space-y-12">
|
||||||
|
{/* Top Navbar */}
|
||||||
|
<header className="flex items-center justify-between gap-4 py-2">
|
||||||
|
{/* Exact CrawlerX Cube Logo & Subtitle */}
|
||||||
|
<Link to="/" className="group">
|
||||||
|
<CrawlerXLogo />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Center Navigation Links */}
|
||||||
|
<nav className="hidden md:flex items-center gap-8 text-sm font-semibold text-slate-600">
|
||||||
|
<div className="flex items-center gap-1 hover:text-[#0f1d34] transition-colors cursor-pointer">
|
||||||
|
<span>Features</span>
|
||||||
|
<ChevronDown className="size-3.5 opacity-60" />
|
||||||
|
</div>
|
||||||
|
<div className="relative text-[#0f1d34] font-bold">
|
||||||
|
<span>Pricing</span>
|
||||||
|
<div className="absolute -bottom-2.5 left-0 right-0 h-0.5 bg-[#3779b9] rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 hover:text-[#0f1d34] transition-colors cursor-pointer">
|
||||||
|
<span>Resources</span>
|
||||||
|
<ChevronDown className="size-3.5 opacity-60" />
|
||||||
|
</div>
|
||||||
|
<Link to="/" className="hover:text-[#0f1d34] transition-colors">
|
||||||
|
About
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Right Actions */}
|
||||||
|
<div className="flex items-center gap-3 sm:gap-5">
|
||||||
|
<div className="hidden sm:flex items-center gap-1.5 text-xs font-semibold text-slate-700 hover:text-[#0f1d34] transition-colors cursor-pointer">
|
||||||
|
<Globe className="size-3.5 text-slate-500" />
|
||||||
|
<span>INR (₹)</span>
|
||||||
|
<ChevronDown className="size-3 opacity-60" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="h-10 px-4 rounded-xl bg-slate-100 hover:bg-slate-200 text-[#0f1d34] text-xs sm:text-sm font-semibold transition-all flex items-center gap-1.5 shadow-2xs"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-3.5" />
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
to="/sign-in"
|
||||||
|
className="text-xs sm:text-sm font-semibold text-slate-700 hover:text-[#0f1d34] transition-colors"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/sign-up"
|
||||||
|
className="h-10 px-5 rounded-2xl bg-gradient-to-r from-[#3779b9] via-[#2563eb] to-[#4faede] hover:from-[#2e68a3] hover:to-[#38bdf8] text-white text-xs sm:text-sm font-bold transition-all flex items-center gap-1.5 shadow-md shadow-[#3779b9]/25 active:scale-[0.99]"
|
||||||
|
>
|
||||||
|
<span>Get Started</span>
|
||||||
|
<ArrowRight className="size-3.5" />
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Pricing Component */}
|
||||||
|
<main>
|
||||||
|
<PricingTable onSelectPlan={handleSelectPlan} />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="pt-8 pb-12 text-center text-xs text-slate-400 border-t border-slate-100">
|
||||||
|
CrawlerX by Metatroncube Solutions • Enterprise & Custom Agency Plans: support@metatroncube.com
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/server/billing/stripe-sync.ts
Normal file
66
src/server/billing/stripe-sync.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { billingCustomerStatus } from "@/db/schema";
|
||||||
|
|
||||||
|
export async function syncStripeSubscriptionStatus(args: {
|
||||||
|
organizationId: string;
|
||||||
|
planId: string;
|
||||||
|
status: "active" | "trialing" | "canceled" | "past_due" | "incomplete";
|
||||||
|
isPaying: boolean;
|
||||||
|
stripeCustomerJson: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
await db
|
||||||
|
.insert(billingCustomerStatus)
|
||||||
|
.values({
|
||||||
|
organizationId: args.organizationId,
|
||||||
|
isPaying: args.isPaying,
|
||||||
|
paidPlanId: args.planId,
|
||||||
|
paidPlanStatus: args.status,
|
||||||
|
customerJson: JSON.stringify(args.stripeCustomerJson),
|
||||||
|
syncedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: billingCustomerStatus.organizationId,
|
||||||
|
set: {
|
||||||
|
isPaying: args.isPaying,
|
||||||
|
paidPlanId: args.planId,
|
||||||
|
paidPlanStatus: args.status,
|
||||||
|
customerJson: JSON.stringify(args.stripeCustomerJson),
|
||||||
|
syncedAt: new Date().toISOString(),
|
||||||
|
updatedAt: sql`(current_timestamp)`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrganizationBillingStatus(organizationId: string) {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(billingCustomerStatus)
|
||||||
|
.where(eq(billingCustomerStatus.organizationId, organizationId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return {
|
||||||
|
isPaying: false,
|
||||||
|
planId: "free" as const,
|
||||||
|
status: "none" as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidPaying =
|
||||||
|
Boolean(row.isPaying) &&
|
||||||
|
(row.paidPlanStatus === "active" || row.paidPlanStatus === "trialing");
|
||||||
|
|
||||||
|
const validPlanIds = ["free", "starter", "pro", "agency"] as const;
|
||||||
|
const matchedPlan = validPlanIds.includes(
|
||||||
|
row.paidPlanId as (typeof validPlanIds)[number],
|
||||||
|
)
|
||||||
|
? (row.paidPlanId as "free" | "starter" | "pro" | "agency")
|
||||||
|
: "free";
|
||||||
|
|
||||||
|
return {
|
||||||
|
isPaying: isValidPaying,
|
||||||
|
planId: isValidPaying ? matchedPlan : ("free" as const),
|
||||||
|
status: row.paidPlanStatus ?? "none",
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -13,6 +13,8 @@ import type { CreditFeature } from "@/shared/billing-credit-features";
|
|||||||
import { autumn, AUTUMN_TRACK_RETRY_OPTIONS } from "@/server/billing/autumn";
|
import { autumn, AUTUMN_TRACK_RETRY_OPTIONS } from "@/server/billing/autumn";
|
||||||
import { captureServerEvent } from "@/server/lib/posthog";
|
import { captureServerEvent } from "@/server/lib/posthog";
|
||||||
import { AppError } from "@/server/lib/errors";
|
import { AppError } from "@/server/lib/errors";
|
||||||
|
import { getOptionalEnvValue } from "@/server/lib/runtime-env";
|
||||||
|
import { getOrganizationBillingStatus } from "@/server/billing/stripe-sync";
|
||||||
|
|
||||||
export type BillingCustomerContext = Pick<
|
export type BillingCustomerContext = Pick<
|
||||||
EnsuredUserContext,
|
EnsuredUserContext,
|
||||||
@ -34,6 +36,11 @@ const customerEnsuredKey = (organizationId: string) =>
|
|||||||
export async function getOrCreateOrganizationCustomer(
|
export async function getOrCreateOrganizationCustomer(
|
||||||
context: BillingCustomerContext,
|
context: BillingCustomerContext,
|
||||||
): Promise<{ id: string }> {
|
): Promise<{ id: string }> {
|
||||||
|
const autumnKey = await getOptionalEnvValue("AUTUMN_SECRET_KEY");
|
||||||
|
if (!autumnKey) {
|
||||||
|
return { id: context.organizationId };
|
||||||
|
}
|
||||||
|
|
||||||
const cacheKey = customerEnsuredKey(context.organizationId);
|
const cacheKey = customerEnsuredKey(context.organizationId);
|
||||||
try {
|
try {
|
||||||
if (await env.KV.get(cacheKey)) {
|
if (await env.KV.get(cacheKey)) {
|
||||||
@ -67,6 +74,12 @@ export async function customerHasPaidPlan(
|
|||||||
customerId: string,
|
customerId: string,
|
||||||
opts: { retryDenied?: boolean } = {},
|
opts: { retryDenied?: boolean } = {},
|
||||||
) {
|
) {
|
||||||
|
const autumnKey = await getOptionalEnvValue("AUTUMN_SECRET_KEY");
|
||||||
|
if (!autumnKey) {
|
||||||
|
const status = await getOrganizationBillingStatus(customerId);
|
||||||
|
return status.isPaying;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await autumn.check({
|
const result = await autumn.check({
|
||||||
customerId,
|
customerId,
|
||||||
featureId: AUTUMN_PAID_PLAN_FEATURE_ID,
|
featureId: AUTUMN_PAID_PLAN_FEATURE_ID,
|
||||||
@ -87,6 +100,11 @@ export async function customerHasPaidPlan(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function customerHasManagedAccess(customerId: string) {
|
export async function customerHasManagedAccess(customerId: string) {
|
||||||
|
const autumnKey = await getOptionalEnvValue("AUTUMN_SECRET_KEY");
|
||||||
|
if (!autumnKey) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await autumn.check({
|
const result = await autumn.check({
|
||||||
customerId,
|
customerId,
|
||||||
featureId: AUTUMN_MANAGED_ACCESS_FEATURE_ID,
|
featureId: AUTUMN_MANAGED_ACCESS_FEATURE_ID,
|
||||||
@ -102,6 +120,11 @@ async function getUsageCreditsRemaining(customerId: string): Promise<{
|
|||||||
monthlyRemaining: number;
|
monthlyRemaining: number;
|
||||||
topupRemaining: number;
|
topupRemaining: number;
|
||||||
}> {
|
}> {
|
||||||
|
const autumnKey = await getOptionalEnvValue("AUTUMN_SECRET_KEY");
|
||||||
|
if (!autumnKey) {
|
||||||
|
return { monthlyRemaining: 100000, topupRemaining: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
const [monthlyCheck, topupCheck] = await Promise.all([
|
const [monthlyCheck, topupCheck] = await Promise.all([
|
||||||
autumn.check({ customerId, featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID }),
|
autumn.check({ customerId, featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID }),
|
||||||
autumn.check({
|
autumn.check({
|
||||||
@ -158,6 +181,11 @@ async function getUsageCreditsRemaining(customerId: string): Promise<{
|
|||||||
export async function checkUsageCreditsDepleted(
|
export async function checkUsageCreditsDepleted(
|
||||||
customer: BillingCustomerContext,
|
customer: BillingCustomerContext,
|
||||||
): Promise<{ depleted: boolean; monthlyRemaining: number }> {
|
): Promise<{ depleted: boolean; monthlyRemaining: number }> {
|
||||||
|
const autumnKey = await getOptionalEnvValue("AUTUMN_SECRET_KEY");
|
||||||
|
if (!autumnKey) {
|
||||||
|
return { depleted: false, monthlyRemaining: 100000 };
|
||||||
|
}
|
||||||
|
|
||||||
const check = await getUsageCreditsRemaining(customer.organizationId);
|
const check = await getUsageCreditsRemaining(customer.organizationId);
|
||||||
if (check.monthlyRemaining + check.topupRemaining > 0) {
|
if (check.monthlyRemaining + check.topupRemaining > 0) {
|
||||||
return { depleted: false, monthlyRemaining: check.monthlyRemaining };
|
return { depleted: false, monthlyRemaining: check.monthlyRemaining };
|
||||||
@ -210,6 +238,11 @@ export async function checkUsageCreditsDepleted(
|
|||||||
export async function assertUsageCreditsAvailable(
|
export async function assertUsageCreditsAvailable(
|
||||||
customerId: string,
|
customerId: string,
|
||||||
): Promise<{ monthlyRemaining: number }> {
|
): Promise<{ monthlyRemaining: number }> {
|
||||||
|
const autumnKey = await getOptionalEnvValue("AUTUMN_SECRET_KEY");
|
||||||
|
if (!autumnKey) {
|
||||||
|
return { monthlyRemaining: 100000 };
|
||||||
|
}
|
||||||
|
|
||||||
const { monthlyRemaining, topupRemaining } =
|
const { monthlyRemaining, topupRemaining } =
|
||||||
await getUsageCreditsRemaining(customerId);
|
await getUsageCreditsRemaining(customerId);
|
||||||
|
|
||||||
@ -235,6 +268,11 @@ export async function trackUsageCreditSpend(args: {
|
|||||||
monthlyRemaining: number;
|
monthlyRemaining: number;
|
||||||
properties?: Record<string, unknown>;
|
properties?: Record<string, unknown>;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
|
const autumnKey = await getOptionalEnvValue("AUTUMN_SECRET_KEY");
|
||||||
|
if (!autumnKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const totalCostUsd = roundUsdForBilling(args.costUsd * SEO_DATA_COST_MARKUP);
|
const totalCostUsd = roundUsdForBilling(args.costUsd * SEO_DATA_COST_MARKUP);
|
||||||
const totalCostCredits = Math.ceil(
|
const totalCostCredits = Math.ceil(
|
||||||
totalCostUsd * AUTUMN_SEO_DATA_CREDITS_PER_USD,
|
totalCostUsd * AUTUMN_SEO_DATA_CREDITS_PER_USD,
|
||||||
|
|||||||
@ -56,7 +56,10 @@ import {
|
|||||||
fetchLlmResponse,
|
fetchLlmResponse,
|
||||||
fetchLlmTopPages,
|
fetchLlmTopPages,
|
||||||
} from "@/server/lib/dataforseo/ai";
|
} from "@/server/lib/dataforseo/ai";
|
||||||
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
import {
|
||||||
|
getOptionalEnvValue,
|
||||||
|
isHostedServerAuthMode,
|
||||||
|
} from "@/server/lib/runtime-env";
|
||||||
import { AppError } from "@/server/lib/errors";
|
import { AppError } from "@/server/lib/errors";
|
||||||
|
|
||||||
export { mapDataforseoPathToCreditFeature };
|
export { mapDataforseoPathToCreditFeature };
|
||||||
@ -159,8 +162,9 @@ async function meterDataforseoCall<T>(
|
|||||||
creditFeature?: CreditFeature,
|
creditFeature?: CreditFeature,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const isHostedMode = await isHostedServerAuthMode();
|
const isHostedMode = await isHostedServerAuthMode();
|
||||||
|
const autumnKey = await getOptionalEnvValue("AUTUMN_SECRET_KEY");
|
||||||
|
|
||||||
if (!isHostedMode) {
|
if (!isHostedMode || !autumnKey) {
|
||||||
const result = await execute();
|
const result = await execute();
|
||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { env } from "cloudflare:workers";
|
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
|
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
|
||||||
|
import { getOptionalEnvValue } from "@/server/lib/runtime-env";
|
||||||
|
|
||||||
export const getSeoApiKeyStatus = createServerFn({ method: "GET" })
|
export const getSeoApiKeyStatus = createServerFn({ method: "GET" })
|
||||||
.middleware(requireAuthenticatedContext)
|
.middleware(requireAuthenticatedContext)
|
||||||
.handler(() => {
|
.handler(async () => {
|
||||||
const configured = Boolean(env.DATAFORSEO_API_KEY?.trim());
|
const key = await getOptionalEnvValue("DATAFORSEO_API_KEY");
|
||||||
|
const configured = Boolean(key?.trim());
|
||||||
return { configured };
|
return { configured };
|
||||||
});
|
});
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { dashboardProjectInputSchema } from "@/types/schemas/dashboard";
|
|||||||
export const getDashboardActivation = createServerFn({ method: "POST" })
|
export const getDashboardActivation = createServerFn({ method: "POST" })
|
||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.validator(dashboardProjectInputSchema)
|
.validator(dashboardProjectInputSchema)
|
||||||
.handler(({ context }) =>
|
.handler(async ({ context }) =>
|
||||||
DashboardService.getActivation({
|
DashboardService.getActivation({
|
||||||
projectId: context.projectId,
|
projectId: context.projectId,
|
||||||
organizationId: context.organizationId,
|
organizationId: context.organizationId,
|
||||||
@ -18,7 +18,7 @@ export const getDashboardActivation = createServerFn({ method: "POST" })
|
|||||||
export const getDashboardOverview = createServerFn({ method: "POST" })
|
export const getDashboardOverview = createServerFn({ method: "POST" })
|
||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.validator(dashboardProjectInputSchema)
|
.validator(dashboardProjectInputSchema)
|
||||||
.handler(({ context }) =>
|
.handler(async ({ context }) =>
|
||||||
DashboardService.getOverview({
|
DashboardService.getOverview({
|
||||||
projectId: context.projectId,
|
projectId: context.projectId,
|
||||||
domain: context.project.domain,
|
domain: context.project.domain,
|
||||||
|
|||||||
199
src/serverFunctions/stripe.ts
Normal file
199
src/serverFunctions/stripe.ts
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Stripe } from "stripe";
|
||||||
|
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
|
||||||
|
import { requireOrgPermission } from "@/server/auth/org-gate";
|
||||||
|
import { getOptionalEnvValue } from "@/server/lib/runtime-env";
|
||||||
|
import { AppError } from "@/server/lib/errors";
|
||||||
|
import { CRAWLERX_PLANS } from "@/client/features/billing/PricingData";
|
||||||
|
import {
|
||||||
|
getOrganizationBillingStatus,
|
||||||
|
syncStripeSubscriptionStatus,
|
||||||
|
} from "@/server/billing/stripe-sync";
|
||||||
|
|
||||||
|
const checkoutInputSchema = z.object({
|
||||||
|
planId: z.enum(["starter", "pro", "agency"]),
|
||||||
|
interval: z.enum(["monthly", "annual"]),
|
||||||
|
currency: z.enum(["INR", "USD"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createStripeCheckoutSession = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireAuthenticatedContext)
|
||||||
|
.validator(checkoutInputSchema)
|
||||||
|
.handler(async ({ data, context }) => {
|
||||||
|
requireOrgPermission(context, { billing: ["manage"] });
|
||||||
|
|
||||||
|
const stripeSecretKey = await getOptionalEnvValue("STRIPE_SECRET_KEY");
|
||||||
|
if (!stripeSecretKey || stripeSecretKey.includes("replace_with_your_stripe_secret_key") || stripeSecretKey.includes("your_stripe_secret_key_here")) {
|
||||||
|
throw new AppError(
|
||||||
|
"AUTH_CONFIG_MISSING",
|
||||||
|
"AUTH_CONFIG_MISSING: Stripe payment is not configured yet. Please set your STRIPE_SECRET_KEY in your .env file.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = CRAWLERX_PLANS.find((p) => p.id === data.planId);
|
||||||
|
if (!plan) {
|
||||||
|
throw new AppError("NOT_FOUND", "Selected plan not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = new Stripe(stripeSecretKey);
|
||||||
|
const baseUrl =
|
||||||
|
(await getOptionalEnvValue("BETTER_AUTH_URL")) ?? "http://localhost:3001";
|
||||||
|
|
||||||
|
// 1. Find or create Stripe customer for this organization
|
||||||
|
let stripeCustomerId: string;
|
||||||
|
const existingCustomers = await stripe.customers.list({
|
||||||
|
email: context.userEmail,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingCustomers.data.length > 0) {
|
||||||
|
stripeCustomerId = existingCustomers.data[0].id;
|
||||||
|
} else {
|
||||||
|
const newCustomer = await stripe.customers.create({
|
||||||
|
email: context.userEmail,
|
||||||
|
metadata: {
|
||||||
|
organizationId: context.organizationId,
|
||||||
|
userId: context.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
stripeCustomerId = newCustomer.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Compute subscription price in currency units (cents or paise)
|
||||||
|
const priceAmount =
|
||||||
|
data.interval === "annual"
|
||||||
|
? plan.annualMonthlyPrice[data.currency] * 12
|
||||||
|
: plan.monthlyPrice[data.currency];
|
||||||
|
|
||||||
|
const unitAmount = Math.round(priceAmount * 100);
|
||||||
|
|
||||||
|
// 3. Create Stripe Checkout Session
|
||||||
|
const session = await stripe.checkout.sessions.create({
|
||||||
|
customer: stripeCustomerId,
|
||||||
|
mode: "subscription",
|
||||||
|
line_items: [
|
||||||
|
{
|
||||||
|
price_data: {
|
||||||
|
currency: data.currency.toLowerCase(),
|
||||||
|
product_data: {
|
||||||
|
name: `CrawlerX ${plan.name} Plan (${data.interval === "annual" ? "Annual" : "Monthly"})`,
|
||||||
|
description: `${plan.sitesLimit} • ${plan.limits.pagesPerAudit} Pages per audit • ${plan.limits.trackedKeywords} Tracked Keywords`,
|
||||||
|
},
|
||||||
|
unit_amount: unitAmount,
|
||||||
|
recurring: {
|
||||||
|
interval: data.interval === "annual" ? "year" : "month",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
success_url: `${baseUrl}/subscribe?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
|
||||||
|
cancel_url: `${baseUrl}/subscribe`,
|
||||||
|
metadata: {
|
||||||
|
organizationId: context.organizationId,
|
||||||
|
userId: context.userId,
|
||||||
|
planId: plan.id,
|
||||||
|
interval: data.interval,
|
||||||
|
currency: data.currency,
|
||||||
|
},
|
||||||
|
subscription_data: {
|
||||||
|
...(data.interval === "annual" ? { trial_period_days: 7 } : {}),
|
||||||
|
metadata: {
|
||||||
|
organizationId: context.organizationId,
|
||||||
|
planId: plan.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session.url) {
|
||||||
|
throw new AppError("INTERNAL_ERROR", "Failed to generate Stripe checkout URL.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { checkoutUrl: session.url };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getOrganizationBilling = createServerFn({ method: "GET" })
|
||||||
|
.middleware(requireAuthenticatedContext)
|
||||||
|
.handler(async ({ context }) => {
|
||||||
|
return getOrganizationBillingStatus(context.organizationId);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const verifyStripeCheckoutSession = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireAuthenticatedContext)
|
||||||
|
.validator(z.object({ sessionId: z.string() }))
|
||||||
|
.handler(async ({ data, context }) => {
|
||||||
|
const stripeSecretKey = await getOptionalEnvValue("STRIPE_SECRET_KEY");
|
||||||
|
if (!stripeSecretKey || stripeSecretKey.includes("replace_with_your_stripe_secret_key")) {
|
||||||
|
throw new AppError("INTERNAL_ERROR", "Stripe secret key not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = new Stripe(stripeSecretKey);
|
||||||
|
const session = await stripe.checkout.sessions.retrieve(data.sessionId, {
|
||||||
|
expand: ["subscription", "customer"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const orgId = session.metadata?.organizationId ?? context.organizationId;
|
||||||
|
const planId = session.metadata?.planId ?? "starter";
|
||||||
|
|
||||||
|
let subStatus = "active";
|
||||||
|
if (session.subscription && typeof session.subscription === "object") {
|
||||||
|
subStatus = session.subscription.status;
|
||||||
|
} else if (session.payment_status === "paid") {
|
||||||
|
subStatus = "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPaying =
|
||||||
|
subStatus === "active" ||
|
||||||
|
subStatus === "trialing" ||
|
||||||
|
session.payment_status === "paid";
|
||||||
|
|
||||||
|
await syncStripeSubscriptionStatus({
|
||||||
|
organizationId: orgId,
|
||||||
|
planId,
|
||||||
|
status: subStatus as "active" | "trialing" | "canceled" | "past_due" | "incomplete",
|
||||||
|
isPaying,
|
||||||
|
stripeCustomerJson: session as unknown as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
planId,
|
||||||
|
status: subStatus,
|
||||||
|
isPaying,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createStripePortalSession = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireAuthenticatedContext)
|
||||||
|
.handler(async ({ context }) => {
|
||||||
|
requireOrgPermission(context, { billing: ["manage"] });
|
||||||
|
|
||||||
|
const stripeSecretKey = await getOptionalEnvValue("STRIPE_SECRET_KEY");
|
||||||
|
if (!stripeSecretKey || stripeSecretKey.includes("replace_with_your_stripe_secret_key")) {
|
||||||
|
throw new AppError(
|
||||||
|
"INTERNAL_ERROR",
|
||||||
|
"Stripe payment is not configured yet. Please set your STRIPE_SECRET_KEY in your .env file.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = new Stripe(stripeSecretKey);
|
||||||
|
const baseUrl =
|
||||||
|
(await getOptionalEnvValue("BETTER_AUTH_URL")) ?? "http://localhost:3001";
|
||||||
|
|
||||||
|
const existingCustomers = await stripe.customers.list({
|
||||||
|
email: context.userEmail,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingCustomers.data.length === 0) {
|
||||||
|
throw new AppError("NOT_FOUND", "No Stripe customer found for this account.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const portalSession = await stripe.billingPortal.sessions.create({
|
||||||
|
customer: existingCustomers.data[0].id,
|
||||||
|
return_url: `${baseUrl}/subscribe`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { portalUrl: portalSession.url };
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user