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