add billing and subscription management system with Stripe integration
Some checks failed
CI / ci (push) Has been cancelled
CI / docker-build (push) Has been cancelled
Publish Docker image / docker (push) Has been cancelled
Upload sourcemaps / upload (push) Has been cancelled

This commit is contained in:
Alaguraj0361 2026-10-17 19:40:15 +05:30
parent 5ae39ebfd4
commit df5f593369
21 changed files with 1962 additions and 250 deletions

102
docs/CRAWLERX_PRD.md Normal file
View 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.

View File

@ -7,7 +7,7 @@
"packageManager": "pnpm@10.30.1", "packageManager": "pnpm@10.30.1",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"start": " vite preview --port 3001", "start": " vite preview --port 3001",
"dev:clear-chat": "rm -rf .wrangler/state/v3/do/open-seo-OnboardingChatAgent", "dev:clear-chat": "rm -rf .wrangler/state/v3/do/open-seo-OnboardingChatAgent",
"dev:agents": "mkdir -p .logs && portless run vite dev 2>&1 | tee .logs/dev-server.log", "dev:agents": "mkdir -p .logs && portless run vite dev 2>&1 | tee .logs/dev-server.log",
"dev:agents:force": "mkdir -p .logs && portless --force run vite dev 2>&1 | tee .logs/dev-server.log", "dev:agents:force": "mkdir -p .logs && portless --force run vite dev 2>&1 | tee .logs/dev-server.log",
@ -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
View File

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

View 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" };
}

View 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]" />;
}

View 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>
);
}

View 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>
);
}

View File

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

View File

@ -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
View File

@ -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;

View File

@ -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,
} }

View File

@ -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">
<img <div className="w-full max-w-sm space-y-4 text-center p-8 rounded-3xl bg-white border border-slate-200 shadow-xl">
src="/transparent-logo.png"
alt="CrawlerX"
className="mx-auto size-10 rounded-lg"
/>
<h1 className="text-xl font-semibold">
Finalizing your subscription&hellip;
</h1>
<span className="loading loading-spinner loading-md" />
<p className="text-sm text-base-content/60">
This usually takes a few seconds.
</p>
<p className="text-xs text-base-content/50">
Taking longer?{" "}
<a className="link" href={`mailto:${SUPPORT_EMAIL}`}>
Email {SUPPORT_EMAIL}
</a>
.
</p>
</div>
);
}
if (subscribeRouteState === "error") {
return (
<div className="w-full max-w-xs space-y-4">
<div className="text-center space-y-3">
<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">Billing unavailable</h1> <h1 className="text-xl font-bold text-slate-900">
Finalizing your subscription&hellip;
</h1>
<span className="loading loading-spinner loading-md text-blue-600" />
<p className="text-sm text-slate-500">
Setting up your workspace limits and credits.
</p>
</div> </div>
<p className="text-sm text-center text-base-content/70">
{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> </div>
); );
} }
async function handleSubscribe() { async function handleSelectPlan(
setError(null); plan: PricingPlan,
setIsAttaching(true); interval: BillingInterval = "monthly",
currency: Currency = "INR",
) {
if (plan.id === "free") {
void navigate({ to: redirect ?? "/", replace: true });
return;
}
if (!canManageBilling) {
toast.error("Only the organization owner can change subscription plans.");
return;
}
setError(null);
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;
}
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);
} }
} }
const firstName = session?.user?.name?.split(" ")[0] || "";
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">
<SubscribePageAccountMenu email={session?.user?.email} /> {/* 3D Organic Fluid Corner Ribbons & Aura in Theme Blues */}
<CornerWaveDecoration />
<div className="text-center space-y-3"> {/* Main Content Container */}
<img <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">
src="/transparent-logo.png" {/* Top Navbar */}
alt="CrawlerX" <header className="flex items-center justify-between gap-4 py-2">
className="mx-auto size-10 rounded-lg" {/* 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} />
</div>
</header>
{/* Workspace Usage Notice */}
<WorkspaceUsageBanner
currentPlanId={activePlanId}
siteCount={siteCount}
onUpgradeClick={() => {
const el = document.getElementById("pricing-plans");
el?.scrollIntoView({ behavior: "smooth" });
}}
/> />
<h1 className="text-xl font-semibold">
{isUpgradeFlow
? "Upgrade your plan"
: firstName
? `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 className="rounded-lg border border-base-300 p-5 space-y-4"> {error ? (
<div className="flex items-baseline justify-between gap-4"> <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">
<span className="font-semibold">Base Plan</span> {error}
<span className="text-lg font-semibold tabular-nums">$10/month</span> </div>
</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">
&mdash;
</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">&#8599;</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,46 +337,42 @@ 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 btn-sm"
className="btn btn-ghost btn-circle" aria-label="Open account menu"
aria-label="Open account menu" >
> <User className="h-4 w-4" />
<User className="h-5 w-5" /> </button>
</button> <ul
<ul tabIndex={0}
tabIndex={0} className="dropdown-content z-20 menu mt-3 min-w-56 rounded-2xl border border-slate-200 bg-white p-2 shadow-xl"
className="dropdown-content z-20 menu mt-3 min-w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg" >
> <li className="menu-title max-w-full">
<li className="menu-title max-w-full"> <span className="truncate text-slate-900 text-xs" data-ph-mask>
<span className="truncate text-base-content" data-ph-mask> {email}
{email} </span>
</span> </li>
</li> <li>
<li> <Link to="/settings" className="flex items-center gap-2 text-xs">
<Link to="/settings" className="flex items-center gap-2"> <Settings className="h-3.5 w-3.5" />
<Settings className="h-4 w-4" /> Settings
Settings </Link>
</Link> </li>
</li> <ThemePreferenceMenuItems />
<ThemePreferenceMenuItems /> <li>
<li> <button
<button type="button"
type="button" className="text-rose-600 text-xs"
className="text-error" onClick={() => void signOutAndRedirect()}
onClick={handleSignOut} >
> Sign out
Sign out </button>
</button> </li>
</li> </ul>
</ul>
</div>
</div> </div>
); );
} }

View File

@ -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 />

View 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
View 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>
);
}

View 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",
};
}

View File

@ -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,

View File

@ -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;
} }

View File

@ -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 };
}); });

View File

@ -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,

View 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 };
});