@carrierllc/mcp 0.2.16 → 0.2.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -1
- package/dist/cli.js +1445 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.js +1437 -252
- package/dist/index.js.map +1 -1
- package/package.json +23 -15
- package/plugin/.claude-plugin/marketplace.json +31 -0
- package/plugin/carrier/.claude-plugin/plugin.json +19 -0
- package/plugin/carrier/.mcp.json +8 -0
- package/plugin/carrier/README.md +75 -0
- package/plugin/carrier/agents/carrier-billing-auditor.md +16 -0
- package/plugin/carrier/agents/carrier-fleet-ops.md +15 -0
- package/plugin/carrier/agents/carrier-storefront-builder.md +17 -0
- package/plugin/carrier/commands/billing.md +23 -0
- package/plugin/carrier/commands/churn.md +15 -0
- package/plugin/carrier/commands/credits.md +15 -0
- package/plugin/carrier/commands/esim-status.md +15 -0
- package/plugin/carrier/commands/fleet.md +17 -0
- package/plugin/carrier/commands/greenzone.md +16 -0
- package/plugin/carrier/commands/onboard.md +17 -0
- package/plugin/carrier/commands/packages.md +18 -0
- package/plugin/carrier/commands/provision.md +30 -0
- package/plugin/carrier/commands/sms.md +18 -0
- package/plugin/carrier/commands/status.md +15 -0
- package/plugin/carrier/commands/storefront.md +21 -0
- package/plugin/carrier/commands/subscribers.md +18 -0
- package/plugin/carrier/commands/usage.md +16 -0
- package/plugin/carrier/commands/wallet.md +31 -0
- package/plugin/carrier/skills/carrier-operations/SKILL.md +43 -0
- package/templates/storefront/eslint.config.mjs +15 -0
- package/templates/storefront/next.config.ts +22 -0
- package/templates/storefront/open-next.config.ts +3 -0
- package/templates/storefront/package.json +38 -0
- package/templates/storefront/pnpm-lock.yaml +4020 -0
- package/templates/storefront/postcss.config.mjs +7 -0
- package/templates/storefront/src/app/activate/[orderId]/ActivateClient.tsx +125 -0
- package/templates/storefront/src/app/activate/[orderId]/page.tsx +32 -0
- package/templates/storefront/src/app/api/checkout/claim/route.ts +30 -0
- package/templates/storefront/src/app/api/checkout/guest/route.ts +91 -0
- package/templates/storefront/src/app/api/profile/phone/route.ts +40 -0
- package/templates/storefront/src/app/apple-icon.tsx +29 -0
- package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +110 -0
- package/templates/storefront/src/app/checkout/[templateId]/page.tsx +24 -0
- package/templates/storefront/src/app/checkout/success/CheckoutSuccessClient.tsx +415 -0
- package/templates/storefront/src/app/checkout/success/page.tsx +59 -0
- package/templates/storefront/src/app/contact/page.tsx +35 -0
- package/templates/storefront/src/app/dashboard/page.tsx +26 -0
- package/templates/storefront/src/app/globals.css +99 -0
- package/templates/storefront/src/app/help/page.tsx +25 -0
- package/templates/storefront/src/app/icon.tsx +29 -0
- package/templates/storefront/src/app/layout.tsx +56 -0
- package/templates/storefront/src/app/legal/acceptable-use/page.tsx +21 -0
- package/templates/storefront/src/app/legal/privacy/page.tsx +27 -0
- package/templates/storefront/src/app/legal/terms/page.tsx +25 -0
- package/templates/storefront/src/app/manifest.ts +18 -0
- package/templates/storefront/src/app/page.tsx +31 -0
- package/templates/storefront/src/app/robots.ts +9 -0
- package/templates/storefront/src/app/shop/ShopClient.tsx +27 -0
- package/templates/storefront/src/app/shop/page.tsx +9 -0
- package/templates/storefront/src/app/sign-in/[[...sign-in]]/page.tsx +24 -0
- package/templates/storefront/src/app/sign-up/[[...sign-up]]/StorefrontSignUpClient.tsx +199 -0
- package/templates/storefront/src/app/sign-up/[[...sign-up]]/page.tsx +28 -0
- package/templates/storefront/src/app/sitemap.ts +14 -0
- package/templates/storefront/src/brand.config.ts +28 -0
- package/templates/storefront/src/components/Providers.tsx +16 -0
- package/templates/storefront/src/components/faq/FaqSection.tsx +80 -0
- package/templates/storefront/src/components/footer/StorefrontFooter.tsx +61 -0
- package/templates/storefront/src/components/landing/HeroSection.tsx +52 -0
- package/templates/storefront/src/components/landing/PlanCard.tsx +61 -0
- package/templates/storefront/src/components/nav/StorefrontNav.tsx +69 -0
- package/templates/storefront/src/components/theme/clerk-appearance.ts +51 -0
- package/templates/storefront/src/components/theme/theme-provider.tsx +87 -0
- package/templates/storefront/src/components/theme/theme-script.tsx +24 -0
- package/templates/storefront/src/lib/checkout-order-claim.ts +127 -0
- package/templates/storefront/src/lib/complete-email-sign-up.ts +50 -0
- package/templates/storefront/src/lib/conversion-events.ts +36 -0
- package/templates/storefront/src/lib/sanitize-auth-redirect.ts +16 -0
- package/templates/storefront/src/lib/verify-checkout-session.ts +53 -0
- package/templates/storefront/src/middleware.ts +31 -0
- package/templates/storefront/src/vendor/carrier/client.ts +95 -0
- package/templates/storefront/src/vendor/carrier/index.ts +2 -0
- package/templates/storefront/src/vendor/carrier/types.ts +21 -0
- package/templates/storefront/src/vendor/config/index.ts +2 -0
- package/templates/storefront/src/vendor/geo/index.ts +32 -0
- package/templates/storefront/src/vendor/ui/brand.ts +68 -0
- package/templates/storefront/src/vendor/ui/cn.ts +7 -0
- package/templates/storefront/src/vendor/ui/countryImagery.ts +46 -0
- package/templates/storefront/src/vendor/ui/index.ts +3 -0
- package/templates/storefront/tsconfig.json +23 -0
- package/templates/storefront/wrangler.jsonc +11 -0
- package/dist/.metadata_never_index +0 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
/**
|
|
3
|
+
* ActivateClient
|
|
4
|
+
*
|
|
5
|
+
* MANGO-8/9: Shown after sign-up/sign-in on /activate/[orderId].
|
|
6
|
+
*
|
|
7
|
+
* - Displays the eSIM QR code (once available from the API).
|
|
8
|
+
* - Optionally collects phone number / WhatsApp for eSIM delivery notifications.
|
|
9
|
+
* Phone is NEVER required — user can skip.
|
|
10
|
+
*/
|
|
11
|
+
import { useState, FormEvent } from "react";
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
orderId: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ActivateClient({ orderId }: Props) {
|
|
18
|
+
const [phone, setPhone] = useState("");
|
|
19
|
+
const [submitted, setSubmitted] = useState(false);
|
|
20
|
+
const [saving, setSaving] = useState(false);
|
|
21
|
+
const [saveError, setSaveError] = useState<string | null>(null);
|
|
22
|
+
|
|
23
|
+
async function handlePhoneSubmit(e: FormEvent<HTMLFormElement>) {
|
|
24
|
+
e.preventDefault();
|
|
25
|
+
setSaving(true);
|
|
26
|
+
setSaveError(null);
|
|
27
|
+
try {
|
|
28
|
+
// POST phone to user profile (best-effort — non-blocking)
|
|
29
|
+
const res = await fetch("/api/profile/phone", {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: { "Content-Type": "application/json" },
|
|
32
|
+
body: JSON.stringify({ phone }),
|
|
33
|
+
});
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
const err = (await res.json()) as { error?: string };
|
|
36
|
+
throw new Error(err.error ?? "Failed to save phone number");
|
|
37
|
+
}
|
|
38
|
+
setSubmitted(true);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
const msg = err instanceof Error ? err.message : "Failed to save. Please try again.";
|
|
41
|
+
setSaveError(msg);
|
|
42
|
+
} finally {
|
|
43
|
+
setSaving(false);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="mx-auto max-w-lg px-4 py-16">
|
|
49
|
+
{/* Header */}
|
|
50
|
+
<div className="text-center mb-10">
|
|
51
|
+
<div className="inline-flex items-center justify-center size-14 rounded-full bg-emerald-500/10 border border-emerald-500/30 mb-4">
|
|
52
|
+
<span className="text-2xl">✓</span>
|
|
53
|
+
</div>
|
|
54
|
+
<h1 className="text-2xl font-bold text-white">Your eSIM is ready</h1>
|
|
55
|
+
<p className="mt-2 text-sm text-white/50">Order: {orderId}</p>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
{/* QR Code placeholder */}
|
|
59
|
+
<div className="rounded-2xl border border-white/10 bg-white/5 p-8 text-center mb-8">
|
|
60
|
+
<p className="text-white/60 text-sm mb-2">
|
|
61
|
+
Your QR code will appear here once your order is confirmed.
|
|
62
|
+
</p>
|
|
63
|
+
<p className="text-white/30 text-xs">
|
|
64
|
+
This usually takes less than a minute. Refresh if needed.
|
|
65
|
+
</p>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{/* Optional phone / WhatsApp collection */}
|
|
69
|
+
<div className="rounded-2xl border border-white/10 bg-white/5 p-6">
|
|
70
|
+
<div className="flex items-start gap-3 mb-4">
|
|
71
|
+
<div className="shrink-0 mt-0.5">
|
|
72
|
+
<span className="inline-flex items-center justify-center size-5 rounded-full bg-white/10 text-[10px] text-white/50">
|
|
73
|
+
?
|
|
74
|
+
</span>
|
|
75
|
+
</div>
|
|
76
|
+
<div>
|
|
77
|
+
<p className="text-sm font-medium text-white">
|
|
78
|
+
Get eSIM updates via WhatsApp{" "}
|
|
79
|
+
<span className="text-white/30 font-normal">(optional)</span>
|
|
80
|
+
</p>
|
|
81
|
+
<p className="text-xs text-white/40 mt-0.5">
|
|
82
|
+
We'll send your QR code and activation status. You can skip this.
|
|
83
|
+
</p>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
{submitted ? (
|
|
88
|
+
<p className="text-sm text-emerald-300 text-center py-2">
|
|
89
|
+
✓ Phone number saved. We'll send updates to {phone}.
|
|
90
|
+
</p>
|
|
91
|
+
) : (
|
|
92
|
+
<form onSubmit={handlePhoneSubmit} className="space-y-3">
|
|
93
|
+
<div className="flex gap-2">
|
|
94
|
+
<input
|
|
95
|
+
type="tel"
|
|
96
|
+
autoComplete="tel"
|
|
97
|
+
value={phone}
|
|
98
|
+
onChange={(e) => setPhone(e.target.value)}
|
|
99
|
+
placeholder="+1 555 000 0000"
|
|
100
|
+
className="flex-1 rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white placeholder-white/20 focus:border-[var(--brand-accent)] focus:outline-none"
|
|
101
|
+
/>
|
|
102
|
+
<button
|
|
103
|
+
type="submit"
|
|
104
|
+
disabled={saving || !phone.trim()}
|
|
105
|
+
className="rounded-lg bg-[var(--brand-accent)] px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-50 whitespace-nowrap"
|
|
106
|
+
>
|
|
107
|
+
{saving ? "Saving…" : "Save"}
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
{saveError && (
|
|
111
|
+
<p className="text-xs text-red-300">{saveError}</p>
|
|
112
|
+
)}
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
onClick={() => setSubmitted(true)}
|
|
116
|
+
className="w-full text-center text-xs text-white/30 hover:text-white/50 py-1"
|
|
117
|
+
>
|
|
118
|
+
Skip for now
|
|
119
|
+
</button>
|
|
120
|
+
</form>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const dynamic = "force-dynamic";
|
|
2
|
+
/**
|
|
3
|
+
* /activate/[orderId]
|
|
4
|
+
*
|
|
5
|
+
* MANGO-8/9: Post-purchase eSIM activation page.
|
|
6
|
+
*
|
|
7
|
+
* Phone number / WhatsApp is collected HERE — optionally — after the user
|
|
8
|
+
* has already paid and created their account. It is never required at sign-up.
|
|
9
|
+
*
|
|
10
|
+
* The orderId is the Stripe Checkout session_id (or templateId in dev/mock mode).
|
|
11
|
+
*/
|
|
12
|
+
import { auth } from "@clerk/nextjs/server";
|
|
13
|
+
import { redirect } from "next/navigation";
|
|
14
|
+
import { userOwnsOrder } from "@/lib/checkout-order-claim";
|
|
15
|
+
import { ActivateClient } from "./ActivateClient";
|
|
16
|
+
|
|
17
|
+
export const metadata = { title: "Activate eSIM" };
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
params: Promise<{ orderId: string }>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default async function ActivatePage({ params }: Props) {
|
|
24
|
+
const { userId } = await auth();
|
|
25
|
+
if (!userId) redirect("/sign-in");
|
|
26
|
+
|
|
27
|
+
const { orderId } = await params;
|
|
28
|
+
const ownsOrder = await userOwnsOrder(userId, orderId);
|
|
29
|
+
if (!ownsOrder) redirect("/shop");
|
|
30
|
+
|
|
31
|
+
return <ActivateClient orderId={orderId} />;
|
|
32
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { auth } from "@clerk/nextjs/server";
|
|
2
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
3
|
+
import { claimCheckoutOrder } from "@/lib/checkout-order-claim";
|
|
4
|
+
|
|
5
|
+
export async function POST(req: NextRequest): Promise<NextResponse> {
|
|
6
|
+
const { userId } = await auth();
|
|
7
|
+
if (!userId) {
|
|
8
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let body: { sessionId?: string; templateId?: string };
|
|
12
|
+
try {
|
|
13
|
+
body = (await req.json()) as { sessionId?: string; templateId?: string };
|
|
14
|
+
} catch {
|
|
15
|
+
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const sessionId = body.sessionId ?? "";
|
|
19
|
+
const templateId = body.templateId ?? "";
|
|
20
|
+
if (!sessionId || !templateId) {
|
|
21
|
+
return NextResponse.json({ error: "sessionId and templateId are required" }, { status: 400 });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const result = await claimCheckoutOrder(userId, sessionId, templateId);
|
|
25
|
+
if (!result.ok) {
|
|
26
|
+
return NextResponse.json({ error: result.error }, { status: result.status });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return NextResponse.json({ ok: true, orderId: result.orderId });
|
|
30
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/checkout/guest
|
|
3
|
+
*
|
|
4
|
+
* MANGO-8/9: Purchase-first flow — creates a Stripe Checkout session for
|
|
5
|
+
* unauthenticated users. No Clerk auth required.
|
|
6
|
+
*
|
|
7
|
+
* Body: { templateId: string }
|
|
8
|
+
*
|
|
9
|
+
* Returns: { url: string } — Stripe-hosted checkout URL
|
|
10
|
+
*
|
|
11
|
+
* The Stripe success_url encodes the templateId so the /checkout/success
|
|
12
|
+
* page can display the correct plan and prompt sign-up / sign-in.
|
|
13
|
+
*/
|
|
14
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
15
|
+
import { fetchTemplates } from "@/vendor/carrier/client";
|
|
16
|
+
|
|
17
|
+
export async function POST(req: NextRequest): Promise<NextResponse> {
|
|
18
|
+
let body: { templateId?: string };
|
|
19
|
+
try {
|
|
20
|
+
body = (await req.json()) as { templateId?: string };
|
|
21
|
+
} catch {
|
|
22
|
+
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { templateId } = body;
|
|
26
|
+
if (!templateId) {
|
|
27
|
+
return NextResponse.json({ error: "templateId is required" }, { status: 400 });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Validate the template exists
|
|
31
|
+
const plans = await fetchTemplates();
|
|
32
|
+
const plan = plans.find((p) => p.id === templateId);
|
|
33
|
+
if (!plan) {
|
|
34
|
+
return NextResponse.json({ error: "Plan not found" }, { status: 404 });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
|
38
|
+
if (!stripeKey) {
|
|
39
|
+
// No Stripe configured — return a mock URL so the storefront still builds
|
|
40
|
+
// and renders without credentials (same pattern as fetchTemplates mock).
|
|
41
|
+
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "";
|
|
42
|
+
const successUrl = `${appUrl}/checkout/success?templateId=${encodeURIComponent(templateId)}&session_id=mock`;
|
|
43
|
+
return NextResponse.json({ url: successUrl });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "";
|
|
47
|
+
// success_url carries templateId so the success page knows which plan was purchased
|
|
48
|
+
const successUrl =
|
|
49
|
+
`${appUrl}/checkout/success?templateId=${encodeURIComponent(templateId)}&session_id={CHECKOUT_SESSION_ID}`;
|
|
50
|
+
const cancelUrl = `${appUrl}/shop`;
|
|
51
|
+
|
|
52
|
+
const formBody = new URLSearchParams({
|
|
53
|
+
mode: "payment",
|
|
54
|
+
"line_items[0][price_data][currency]": plan.currency.toLowerCase(),
|
|
55
|
+
"line_items[0][price_data][unit_amount]": String(plan.price_cents),
|
|
56
|
+
"line_items[0][price_data][product_data][name]": plan.name,
|
|
57
|
+
"line_items[0][price_data][product_data][description]": plan.description,
|
|
58
|
+
"line_items[0][quantity]": "1",
|
|
59
|
+
success_url: successUrl,
|
|
60
|
+
cancel_url: cancelUrl,
|
|
61
|
+
// Store templateId in BOTH session and payment_intent metadata so the
|
|
62
|
+
// provisioning webhook can read it. The `checkout.session.completed` event
|
|
63
|
+
// exposes `session.metadata` (NOT `payment_intent.metadata`), so the
|
|
64
|
+
// session-level copy is what fulfilment keys off to provision the eSIM —
|
|
65
|
+
// without it the plan is lost and the order is stuck "activating".
|
|
66
|
+
"metadata[templateId]": templateId,
|
|
67
|
+
"payment_intent_data[metadata][templateId]": templateId,
|
|
68
|
+
// Allow guest checkout (no Stripe account required)
|
|
69
|
+
"payment_method_collection": "always",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const resp = await fetch("https://api.stripe.com/v1/checkout/sessions", {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: {
|
|
75
|
+
Authorization: `Bearer ${stripeKey}`,
|
|
76
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
77
|
+
},
|
|
78
|
+
body: formBody.toString(),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (!resp.ok) {
|
|
82
|
+
const err = (await resp.json()) as { error?: { message?: string } };
|
|
83
|
+
return NextResponse.json(
|
|
84
|
+
{ error: err.error?.message ?? "Failed to create checkout session" },
|
|
85
|
+
{ status: 502 },
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const session = (await resp.json()) as { url: string };
|
|
90
|
+
return NextResponse.json({ url: session.url });
|
|
91
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/profile/phone
|
|
3
|
+
*
|
|
4
|
+
* MANGO-8/9: Saves an optional phone number to the user's Clerk profile.
|
|
5
|
+
* Called from /activate/[orderId] — never required, always skippable.
|
|
6
|
+
*
|
|
7
|
+
* Body: { phone: string }
|
|
8
|
+
*/
|
|
9
|
+
import { auth, clerkClient } from "@clerk/nextjs/server";
|
|
10
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
11
|
+
|
|
12
|
+
export async function POST(req: NextRequest): Promise<NextResponse> {
|
|
13
|
+
const { userId } = await auth();
|
|
14
|
+
if (!userId) {
|
|
15
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let body: { phone?: string };
|
|
19
|
+
try {
|
|
20
|
+
body = (await req.json()) as { phone?: string };
|
|
21
|
+
} catch {
|
|
22
|
+
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const phone = body.phone?.trim();
|
|
26
|
+
if (!phone) {
|
|
27
|
+
return NextResponse.json({ error: "phone is required" }, { status: 400 });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const client = await clerkClient();
|
|
32
|
+
await client.users.updateUserMetadata(userId, {
|
|
33
|
+
publicMetadata: { whatsappPhone: phone },
|
|
34
|
+
});
|
|
35
|
+
return NextResponse.json({ ok: true });
|
|
36
|
+
} catch (err) {
|
|
37
|
+
const msg = err instanceof Error ? err.message : "Failed to update profile";
|
|
38
|
+
return NextResponse.json({ error: msg }, { status: 500 });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { ImageResponse } from "next/og";
|
|
2
|
+
import brand from "@/brand.config";
|
|
3
|
+
|
|
4
|
+
export const size = { width: 180, height: 180 };
|
|
5
|
+
export const contentType = "image/png";
|
|
6
|
+
|
|
7
|
+
export default function AppleIcon() {
|
|
8
|
+
return new ImageResponse(
|
|
9
|
+
(
|
|
10
|
+
<div
|
|
11
|
+
style={{
|
|
12
|
+
width: "100%",
|
|
13
|
+
height: "100%",
|
|
14
|
+
display: "flex",
|
|
15
|
+
alignItems: "center",
|
|
16
|
+
justifyContent: "center",
|
|
17
|
+
background: brand.colors.bg,
|
|
18
|
+
borderRadius: "40px",
|
|
19
|
+
fontSize: "100px",
|
|
20
|
+
fontWeight: 700,
|
|
21
|
+
color: brand.colors.accent,
|
|
22
|
+
}}
|
|
23
|
+
>
|
|
24
|
+
{brand.name[0]}
|
|
25
|
+
</div>
|
|
26
|
+
),
|
|
27
|
+
{ ...size },
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
/**
|
|
3
|
+
* CheckoutClient
|
|
4
|
+
*
|
|
5
|
+
* MANGO-8/9: Purchase-first flow.
|
|
6
|
+
*
|
|
7
|
+
* OLD behaviour: unauthenticated user → redirected to /sign-in (blocking).
|
|
8
|
+
* NEW behaviour:
|
|
9
|
+
* - Unauthenticated → POST /api/checkout/guest → Stripe Checkout directly.
|
|
10
|
+
* - Authenticated → use server-rendered session URL → Stripe Checkout.
|
|
11
|
+
*
|
|
12
|
+
* After Stripe payment success, the user lands on /checkout/success where they
|
|
13
|
+
* are prompted to create an account (email + password, no phone) or sign in.
|
|
14
|
+
* Then they are redirected to /activate/[orderId] for optional WhatsApp/phone setup.
|
|
15
|
+
*/
|
|
16
|
+
import { useEffect, useRef, useState } from "react";
|
|
17
|
+
import { useAuth } from "@clerk/nextjs";
|
|
18
|
+
import { useRouter } from "next/navigation";
|
|
19
|
+
import { conversionEvents } from "@/lib/conversion-events";
|
|
20
|
+
import type { SkuTemplate } from "@/vendor/carrier/types";
|
|
21
|
+
|
|
22
|
+
interface Props {
|
|
23
|
+
plan: SkuTemplate;
|
|
24
|
+
/** Pre-built session URL for authenticated users (server-side). Null for guests. */
|
|
25
|
+
session: { url: string } | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function CheckoutClient({ plan, session }: Props) {
|
|
29
|
+
const { isSignedIn, isLoaded } = useAuth();
|
|
30
|
+
const router = useRouter();
|
|
31
|
+
const [checkoutError, setCheckoutError] = useState<string | null>(null);
|
|
32
|
+
const [loading, setLoading] = useState(false);
|
|
33
|
+
const guestCheckoutPlanIdRef = useRef<string | null>(null);
|
|
34
|
+
const authRefreshAttemptedRef = useRef(false);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!isLoaded) return;
|
|
38
|
+
|
|
39
|
+
conversionEvents.beginCheckout(plan.name, plan.price_cents / 100);
|
|
40
|
+
|
|
41
|
+
if (isSignedIn) {
|
|
42
|
+
// Authenticated path: use the server-rendered session URL.
|
|
43
|
+
if (session?.url) {
|
|
44
|
+
window.location.href = session.url;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (!authRefreshAttemptedRef.current) {
|
|
48
|
+
authRefreshAttemptedRef.current = true;
|
|
49
|
+
router.refresh();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
setCheckoutError(
|
|
53
|
+
"Checkout is unavailable. Configure NEXT_PUBLIC_CARRIER_API_URL and Clerk keys, then try again.",
|
|
54
|
+
);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Guest path (MANGO-8): call the guest checkout API — no auth required.
|
|
59
|
+
if (guestCheckoutPlanIdRef.current === plan.id) return;
|
|
60
|
+
guestCheckoutPlanIdRef.current = plan.id;
|
|
61
|
+
|
|
62
|
+
setLoading(true);
|
|
63
|
+
fetch("/api/checkout/guest", {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: { "Content-Type": "application/json" },
|
|
66
|
+
body: JSON.stringify({ templateId: plan.id }),
|
|
67
|
+
})
|
|
68
|
+
.then(async (res) => {
|
|
69
|
+
if (!res.ok) {
|
|
70
|
+
const err = (await res.json()) as { error?: string };
|
|
71
|
+
throw new Error(err.error ?? "Failed to create checkout session");
|
|
72
|
+
}
|
|
73
|
+
return res.json() as Promise<{ url: string }>;
|
|
74
|
+
})
|
|
75
|
+
.then(({ url }) => {
|
|
76
|
+
window.location.href = url;
|
|
77
|
+
})
|
|
78
|
+
.catch((err: unknown) => {
|
|
79
|
+
guestCheckoutPlanIdRef.current = null;
|
|
80
|
+
const msg = err instanceof Error ? err.message : "Checkout unavailable. Please try again.";
|
|
81
|
+
setCheckoutError(msg);
|
|
82
|
+
setLoading(false);
|
|
83
|
+
});
|
|
84
|
+
}, [isLoaded, isSignedIn, plan, session, router]);
|
|
85
|
+
|
|
86
|
+
if (checkoutError) {
|
|
87
|
+
return (
|
|
88
|
+
<div className="mx-auto max-w-lg px-4 py-32 text-center">
|
|
89
|
+
<p className="text-white/80">{checkoutError}</p>
|
|
90
|
+
<p className="text-sm text-white/30 mt-2">{plan.name}</p>
|
|
91
|
+
<a
|
|
92
|
+
href="/shop"
|
|
93
|
+
className="mt-6 inline-block rounded-full border border-white/20 px-6 py-2 text-sm text-white hover:bg-white/10 transition-colors"
|
|
94
|
+
>
|
|
95
|
+
← Back to shop
|
|
96
|
+
</a>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div className="mx-auto max-w-lg px-4 py-32 text-center">
|
|
103
|
+
<div className="h-8 w-8 rounded-full border-2 border-[var(--brand-accent)] border-t-transparent animate-spin mx-auto mb-4" />
|
|
104
|
+
<p className="text-white/60">
|
|
105
|
+
{loading ? "Preparing your checkout…" : "Redirecting to payment…"}
|
|
106
|
+
</p>
|
|
107
|
+
<p className="text-sm text-white/30 mt-2">{plan.name}</p>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const dynamic = "force-dynamic";
|
|
2
|
+
|
|
3
|
+
import { notFound } from "next/navigation";
|
|
4
|
+
import { auth } from "@clerk/nextjs/server";
|
|
5
|
+
import { fetchTemplates, createCheckoutSession } from "@/vendor/carrier/client";
|
|
6
|
+
import { CheckoutClient } from "./CheckoutClient";
|
|
7
|
+
|
|
8
|
+
export const metadata = { title: "Checkout" };
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
params: Promise<{ templateId: string }>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default async function CheckoutPage({ params }: Props) {
|
|
15
|
+
const { templateId } = await params;
|
|
16
|
+
const { userId } = await auth();
|
|
17
|
+
const plans = await fetchTemplates();
|
|
18
|
+
const plan = plans.find((p) => p.id === templateId);
|
|
19
|
+
if (!plan) notFound();
|
|
20
|
+
|
|
21
|
+
const session = userId ? await createCheckoutSession(templateId, userId) : null;
|
|
22
|
+
|
|
23
|
+
return <CheckoutClient plan={plan} session={session} />;
|
|
24
|
+
}
|