website-next: port the pricing API flows (session, Stripe checkout, credits)

Restores the Vite pricing table's live behaviour on the Next site without
pulling the Nx @storyteller/api lib into this standalone app:

- lib/api.ts: minimal browser client for the endpoints pricing needs
  (session, active subscriptions, signup checkout, subscription checkout,
  switch plan, manage-plan portal, credits pack checkout, web referral
  log). Same paths, bodies, and envelopes as the shared lib; cookies plus
  the signed-session header fallback. Host via NEXT_PUBLIC_API_HOST.
- lib/referral.ts: landing-context capture (?u=, ?r=, landing URL,
  referrer) to apex-domain cookies + localStorage, mounted site-wide by
  components/landing-context.tsx, which also logs the referral once per
  browser session (the old main.tsx bootstrap).
- lib/use-account.ts: one shared session + ArtCraft plan fetch per page
  load for every consumer.
- Plan grid: logged out -> Stripe signup checkout with attribution; logged
  in without a plan -> subscription checkout; with a plan -> portal plan
  switch. "Your plan" tab and disabled CTA on the current tier, "Switch
  to X" labels, Manage plan portal link, inline error line.
- Credits cell: logged-in visitors get the one-time credit pack modal
  (Stripe checkout); logged-out visitors are sent to the app to sign in.

Server HTML still renders the logged-out state, so static output, SEO,
and every other page are unchanged.
This commit is contained in:
bflatastic committed 2026-09-15 09:00:55 +07:00
1 parent b6b24eef6c
commit a1b4c4f362
9 files changed
+702 -49

No files matched your search

@@ -8,6 +8,7 @@ import {
import SiteNav from "@/components/site-nav";
import SiteFooter from "@/components/site-footer";
import IntroConductor from "@/components/intro-conductor";
import LandingContext from "@/components/landing-context";
import MotionProvider from "@/components/motion-provider";
import ScrollRuler from "@/components/ruler/scroll-ruler";
import TunerPanel from "@/components/dev/tuner-panel";
@@ -137,6 +138,7 @@ export default function RootLayout({
<ScrollRuler />
</MotionProvider>
<TunerPanel />
<LandingContext />
</body>
</html>
);
@@ -3,9 +3,8 @@ import { TagIcon } from "lucide-react";
import RevealManager from "@/components/reveal-manager";
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
import { Accent, PageHeader } from "@/components/page/page-header";
import CreditsCta from "@/components/pricing/credits-cta";
import PricingTable from "@/components/pricing/pricing-table";
import { Button } from "@/components/ui";
import { webappUrl } from "@/lib/links";
import { PROMO_PCT } from "@/lib/pricing-data";
export const metadata: Metadata = {
@@ -29,9 +28,7 @@ export default async function PricingPage({
searchParams: SearchParams;
}) {
const params = await searchParams;
const ref = typeof params.ref === "string" ? params.ref : "";
const isSeedanceRef = ref === SEEDANCE_REF;
const checkoutQuery = ref ? `ref=${encodeURIComponent(ref)}` : "";
const isSeedanceRef = params.ref === SEEDANCE_REF;
return (
<>
@@ -45,27 +42,13 @@ export default async function PricingPage({
label="Choose your plan"
annotation="Every paid plan includes video credits"
/>
<PricingTable showSeedanceFeatures checkoutQuery={checkoutQuery} />
<PricingTable showSeedanceFeatures />
</SectionShell>
<SectionShell id="credits">
<div className="grid gap-px bg-line md:grid-cols-2">
<div className="bg-bg p-6 md:p-10">
<p className="hud-label text-faint">Already have an account?</p>
<h2 className="mt-4 font-display text-2xl font-medium tracking-[-0.02em] text-ink-strong">
One-time credit packs
</h2>
<p className="mt-2 max-w-md leading-relaxed text-muted">
Top up without changing your plan. Credit packs are purchased
inside the app and never expire.
</p>
<Button
href={webappUrl("/pricing")}
variant="secondary"
className="mt-6"
>
Buy credits in the app
</Button>
<CreditsCta />
</div>
<div className="bg-bg p-6 md:p-10">
<p className="hud-label text-faint">† Footnote</p>
@@ -0,0 +1,25 @@
"use client";
import { useEffect } from "react";
import { logWebReferral } from "@/lib/api";
import { captureLandingContext, getReferrer } from "@/lib/referral";
const REFERRAL_LOGGED_KEY = "referral_logged";
// Site-wide attribution bootstrap (ported from the Vite site's main.tsx):
// persist the landing context on every page load, and log the referral to
// the API once per browser session.
export default function LandingContext() {
useEffect(() => {
captureLandingContext();
try {
if (sessionStorage.getItem(REFERRAL_LOGGED_KEY)) return;
sessionStorage.setItem(REFERRAL_LOGGED_KEY, "1");
} catch {
return;
}
logWebReferral(getReferrer()).catch(() => {});
}, []);
return null;
}
@@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import { CoinsIcon } from "lucide-react";
import { Button } from "@/components/ui";
import { webappUrl } from "@/lib/links";
import { useAccount } from "@/lib/use-account";
import CreditsModal from "./credits-modal";
// Credit-pack cell on the pricing page. Logged-in visitors buy directly via
// Stripe (the modal); everyone else is sent to the app to sign in first.
export default function CreditsCta() {
const { user, loading } = useAccount();
const [open, setOpen] = useState(false);
return (
<>
<p className="hud-label text-faint">
{user ? `Signed in as ${user.display_name}` : "Already have an account?"}
</p>
<h2 className="mt-4 font-display text-2xl font-medium tracking-[-0.02em] text-ink-strong">
One-time credit packs
</h2>
<p className="mt-2 max-w-md leading-relaxed text-muted">
Top up without changing your plan. Credit packs never expire.
</p>
{user ? (
<Button
type="button"
variant="secondary"
onClick={() => setOpen(true)}
className="mt-6"
>
<CoinsIcon aria-hidden className="h-3.5 w-3.5 text-accent-ink" />
Buy credits
</Button>
) : (
<Button
href={webappUrl("/pricing")}
variant="secondary"
loading={loading}
className="mt-6"
>
Sign in to buy credits
</Button>
)}
{user && <CreditsModal isOpen={open} onClose={() => setOpen(false)} />}
</>
);
}
@@ -0,0 +1,117 @@
"use client";
import { useState } from "react";
import { CoinsIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { Button, Modal } from "@/components/ui";
import { creditsPackCheckout } from "@/lib/api";
type CreditPack = {
id: string;
total: number;
priceUsd: number;
badge?: string;
};
// One-time packs, ported from the Vite site's credits modal. Ids are the
// Stripe product slugs the API expects.
const CREDIT_PACKS: CreditPack[] = [
{ id: "artcraft_1000", total: 1000, priceUsd: 10 },
{ id: "artcraft_2500", total: 2500, priceUsd: 25 },
{ id: "artcraft_5000", total: 5000, priceUsd: 50, badge: "Popular" },
{ id: "artcraft_10000", total: 10000, priceUsd: 100 },
];
export default function CreditsModal({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) {
const [purchasingId, setPurchasingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const purchase = async (pack: CreditPack) => {
setPurchasingId(pack.id);
setError(null);
const result = await creditsPackCheckout(pack.id);
if (result.success) {
window.location.href = result.data.checkoutUrl;
return;
}
setError(result.errorMessage);
setPurchasingId(null);
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
childPadding={false}
className="max-w-2xl"
accessibleTitle="Buy credits"
>
<div className="border-b border-line px-6 py-5 md:px-8">
<p className="hud-label text-faint">Credit packs</p>
<h2 className="mt-3 font-display text-2xl font-medium tracking-[-0.02em] text-ink-strong">
Buy credits
</h2>
<p className="mt-1 text-sm text-muted">
One-time packs. No subscription required, never expire.
</p>
</div>
<ul className="grid gap-px bg-line sm:grid-cols-2">
{CREDIT_PACKS.map((pack) => {
const busy = purchasingId !== null;
return (
<li key={pack.id} className="bg-bg-raised">
<button
type="button"
onClick={() => purchase(pack)}
disabled={busy}
className={twMerge(
"group flex h-full w-full flex-col p-6 text-left transition-colors hover:bg-bg disabled:cursor-not-allowed disabled:opacity-60 md:p-8",
)}
>
<div className="flex items-center justify-between gap-3">
<span className="flex items-center gap-2 font-display text-3xl font-medium tracking-[-0.02em] text-ink-strong">
<CoinsIcon aria-hidden className="h-5 w-5 text-accent-ink" />
{pack.total.toLocaleString()}
</span>
{pack.badge && (
<span className="hud-label bg-accent px-2 py-1 font-bold text-white">
{pack.badge}
</span>
)}
</div>
<p className="hud-label mt-2 text-faint">credits</p>
<div className="mt-6 flex items-center justify-between gap-3">
<span className="font-display text-xl font-medium text-ink">
${pack.priceUsd}
</span>
<Button
variant="secondary"
size="sm"
loading={purchasingId === pack.id}
className="pointer-events-none"
tabIndex={-1}
>
Purchase
</Button>
</div>
</button>
</li>
);
})}
</ul>
{error && (
<p className="border-t border-line px-6 py-3 font-mono text-[10px] uppercase tracking-[0.1em] text-danger md:px-8">
{error}
</p>
)}
</Modal>
);
}
@@ -11,7 +11,12 @@ import {
} from "lucide-react";
import { twMerge } from "tailwind-merge";
import { Badge, Button, TabSelector } from "@/components/ui";
import { webappUrl } from "@/lib/links";
import {
getPortalUrl,
subscriptionCheckout,
switchPlan,
userSignupSubscriptionCheckout,
} from "@/lib/api";
import {
CONTACT_EMAIL,
ENTERPRISE_FEATURES,
@@ -22,6 +27,13 @@ import {
type BillingCadence,
type SubscriptionPlan,
} from "@/lib/pricing-data";
import {
getLandingUrl,
getReferralCode,
getReferralUsername,
getReferrer,
} from "@/lib/referral";
import { useAccount } from "@/lib/use-account";
const BILLING_TABS: { id: BillingCadence; label: string }[] = [
{ id: "yearly", label: "Yearly" },
@@ -40,21 +52,61 @@ const PLAN_BUTTON_CLASSES =
// Plan grid in the landing's hairline-cell language, color-coded per plan
// (the original table's green / purple / orange / blue via `.plan-*`
// tokens): a solid color index tab, a wash pooling beneath it, plan-colored
// checks and CTA, and a 2px frame on the highlighted tiers. Checkout
// happens in the webapp — the CTAs hand off there, carrying the referral
// query through.
// checks and CTA, and a 2px frame on the highlighted tiers.
//
// Checkout is the Vite table's flow, ported: logged out → Stripe signup
// checkout (with referral attribution); logged in without a plan → Stripe
// subscription checkout; logged in with a plan → Stripe portal plan switch.
// The server HTML renders the logged-out state; hydration upgrades it.
export default function PricingTable({
showSeedanceFeatures = false,
checkoutQuery = "",
}: {
showSeedanceFeatures?: boolean;
/** Query string (without "?") forwarded to the webapp's pricing page. */
checkoutQuery?: string;
}) {
const [cadence, setCadence] = useState<BillingCadence>("yearly");
const checkoutHref = webappUrl(
`/pricing${checkoutQuery ? `?${checkoutQuery}` : ""}`,
);
const { user, activePlanSlug, loading } = useAccount();
const [processingPlan, setProcessingPlan] = useState<string | null>(null);
const [managing, setManaging] = useState(false);
const [error, setError] = useState<string | null>(null);
const hasActivePlan = !!activePlanSlug && activePlanSlug !== "free";
const redirect = (result: { success: true; data: { checkoutUrl: string } | { portalUrl: string } } | { success: false; errorMessage: string }) => {
if (!result.success) {
setError(result.errorMessage);
return false;
}
window.location.href =
"checkoutUrl" in result.data ? result.data.checkoutUrl : result.data.portalUrl;
return true;
};
const choosePlan = async (plan: SubscriptionPlan) => {
if (plan.slug === activePlanSlug) return;
setProcessingPlan(plan.slug);
setError(null);
const body = { plan: plan.slug, cadence };
const redirected = redirect(
!user
? await userSignupSubscriptionCheckout({
...body,
maybeReferralUrl: getReferrer(),
maybeLandingUrl: getLandingUrl(),
maybeReferralUsername: getReferralUsername(),
maybeReferralCode: getReferralCode(),
})
: hasActivePlan
? await switchPlan(body)
: await subscriptionCheckout(body),
);
if (!redirected) setProcessingPlan(null);
};
const managePlan = async () => {
setManaging(true);
setError(null);
if (!redirect(await getPortalUrl())) setManaging(false);
};
return (
<>
@@ -74,9 +126,20 @@ export default function PricingTable({
{cadence === "yearly" ? "2 months free" : "Switch to yearly, save 20%"}
</p>
</div>
<p className="hud-label hidden text-faint sm:block">Prices in USD</p>
<p className="hud-label hidden text-faint sm:block">
{user ? `Signed in as ${user.display_name}` : "Prices in USD"}
</p>
</div>
{error && (
<p
role="alert"
className="border-b border-line px-6 py-3 font-mono text-[10px] uppercase tracking-[0.1em] text-danger md:px-10"
>
{error}
</p>
)}
<div
data-reveal-group
className="grid gap-px bg-line md:grid-cols-2 xl:grid-cols-4"
@@ -88,20 +151,42 @@ export default function PricingTable({
index={String(i + 1).padStart(2, "0")}
cadence={cadence}
showSeedanceFeatures={showSeedanceFeatures}
href={checkoutHref}
isCurrent={plan.slug === activePlanSlug}
ctaLabel={
plan.slug === activePlanSlug
? "Current plan"
: hasActivePlan
? `Switch to ${plan.name}`
: `Get ${plan.name}`
}
disabled={loading || processingPlan !== null}
processing={processingPlan === plan.slug}
onChoose={() => choosePlan(plan)}
/>
))}
<EnterpriseCell index={String(SUBSCRIPTION_PLANS.length + 1).padStart(2, "0")} />
</div>
<ul className="flex flex-wrap items-center justify-center gap-x-8 gap-y-2 border-t border-line px-6 py-4 md:px-10">
{TRUST_POINTS.map((point) => (
<li key={point} className="hud-label flex items-center gap-2 text-muted">
<CheckIcon aria-hidden className="h-3.5 w-3.5 text-accent-ink" />
{point}
</li>
))}
</ul>
<div className="flex flex-wrap items-center justify-between gap-x-8 gap-y-2 border-t border-line px-6 py-4 md:px-10">
<ul className="flex flex-wrap items-center gap-x-8 gap-y-2">
{TRUST_POINTS.map((point) => (
<li key={point} className="hud-label flex items-center gap-2 text-muted">
<CheckIcon aria-hidden className="h-3.5 w-3.5 text-accent-ink" />
{point}
</li>
))}
</ul>
{hasActivePlan && (
<button
type="button"
onClick={managePlan}
disabled={managing}
className="hud-label text-muted underline underline-offset-4 hover:text-ink disabled:opacity-50"
>
{managing ? "Opening portal…" : "Manage plan"}
</button>
)}
</div>
</>
);
}
@@ -111,13 +196,21 @@ function PlanCell({
index,
cadence,
showSeedanceFeatures,
href,
isCurrent,
ctaLabel,
disabled,
processing,
onChoose,
}: {
plan: SubscriptionPlan;
index: string;
cadence: BillingCadence;
showSeedanceFeatures: boolean;
href: string;
isCurrent: boolean;
ctaLabel: string;
disabled: boolean;
processing: boolean;
onChoose: () => void;
}) {
const { current, basePrice, yearlySavings } = planPricing(plan, cadence);
const HighlightIcon = plan.highlight ? HIGHLIGHT_ICONS[plan.highlight] : null;
@@ -128,12 +221,21 @@ function PlanCell({
return (
<article
data-reveal
data-highlight={plan.highlight ? "" : undefined}
data-highlight={plan.highlight || isCurrent ? "" : undefined}
className={`plan-card plan-${plan.color} flex flex-col`}
>
<CellIndexRow index={index}>
{HighlightIcon && <HighlightIcon aria-hidden className="h-3 w-3" />}
{plan.highlight ?? "Plan"}
{isCurrent ? (
<>
<CheckIcon aria-hidden className="h-3 w-3" />
Your plan
</>
) : (
<>
{HighlightIcon && <HighlightIcon aria-hidden className="h-3 w-3" />}
{plan.highlight ?? "Plan"}
</>
)}
</CellIndexRow>
<div className="flex flex-1 flex-col p-6 md:p-8">
@@ -167,9 +269,16 @@ function PlanCell({
)}
</p>
<Button href={href} size="lg" className={twMerge(PLAN_BUTTON_CLASSES, "mt-6")}>
Get {plan.name}
<ArrowRightIcon aria-hidden className="h-3.5 w-3.5" />
<Button
type="button"
size="lg"
onClick={onChoose}
disabled={disabled || isCurrent}
loading={processing}
className={twMerge(PLAN_BUTTON_CLASSES, "mt-6", isCurrent && "opacity-60")}
>
{ctaLabel}
{!isCurrent && <ArrowRightIcon aria-hidden className="h-3.5 w-3.5" />}
</Button>
<p className="hud-label mt-8 text-faint">What you get</p>
@@ -0,0 +1,200 @@
// Minimal Storyteller API client, ported from the @storyteller/api lib's
// ApiManager / UsersApi / BillingApi for the pieces the marketing site
// needs (session detection and Stripe billing flows). BROWSER ONLY: every
// call sends cookies (`credentials: include`) plus the signed-session
// header fallback for browsers that block the third-party cookie.
//
// Endpoints, request bodies, and response envelopes match the lib exactly
// so the same backend handlers serve both sites.
export const API_HOST =
process.env.NEXT_PUBLIC_API_HOST ?? "https://api.storyteller.ai";
// Same key the shared lib uses, so a session stored by the webapp's login
// on this origin (dev) is honored here too.
const SESSION_STORAGE_KEY = "artcraft_signed_session";
export type ApiResult<T> =
| { success: true; data: T }
| { success: false; errorMessage: string };
export type SessionUser = {
user_token: string;
username: string;
display_name: string;
email_address?: string;
};
export type Session = {
loggedIn: boolean;
user?: SessionUser;
};
export type ActiveSubscription = {
namespace: string;
product_slug: string;
};
export type BillingCadence = "monthly" | "yearly";
export function getSession(): Promise<ApiResult<Session>> {
return request<{ logged_in: boolean; user?: SessionUser }>("/v1/session").then(
(r) =>
r.success
? { success: true, data: { loggedIn: r.data.logged_in, user: r.data.user } }
: r,
);
}
export function listActiveSubscriptions(): Promise<
ApiResult<ActiveSubscription[]>
> {
return request<{ active_subscriptions?: ActiveSubscription[] }>(
"/v1/billing/active_subscriptions",
).then((r) =>
r.success ? { success: true, data: r.data.active_subscriptions ?? [] } : r,
);
}
// Not logged in: Stripe Checkout creates the account and the subscription
// together. Referral attribution rides along.
export function userSignupSubscriptionCheckout(body: {
plan: string;
cadence: BillingCadence;
maybeReferralUrl?: string;
maybeLandingUrl?: string;
maybeReferralUsername?: string;
maybeReferralCode?: string;
}): Promise<ApiResult<{ checkoutUrl: string }>> {
return checkoutRequest("/v1/stripe_artcraft/user_signup_subscription_checkout", {
plan: body.plan,
cadence: body.cadence,
...(body.maybeReferralUrl && { maybe_referral_url: body.maybeReferralUrl }),
...(body.maybeLandingUrl && { maybe_landing_url: body.maybeLandingUrl }),
...(body.maybeReferralUsername && {
maybe_referral_username: body.maybeReferralUsername,
}),
...(body.maybeReferralCode && { maybe_referral_code: body.maybeReferralCode }),
});
}
// Logged in, no active plan: attach a subscription to the existing account.
export function subscriptionCheckout(body: {
plan: string;
cadence: BillingCadence;
}): Promise<ApiResult<{ checkoutUrl: string }>> {
return checkoutRequest("/v1/stripe_artcraft/checkout/subscription", body);
}
// Logged in with an active plan: Stripe portal flow to change plans.
export function switchPlan(body: {
plan: string;
cadence: BillingCadence;
}): Promise<ApiResult<{ portalUrl: string }>> {
return portalRequest("/v1/stripe_artcraft/portal/switch_plan", body);
}
export function getPortalUrl(): Promise<ApiResult<{ portalUrl: string }>> {
return portalRequest("/v1/stripe_artcraft/portal/manage_plan", {});
}
export function creditsPackCheckout(
creditsPack: string,
): Promise<ApiResult<{ checkoutUrl: string }>> {
return checkoutRequest("/v1/stripe_artcraft/checkout/credits_pack", {
credits_pack: creditsPack,
});
}
// Fire-and-forget referral log, once per browser session (mirrors the Vite
// site's main.tsx bootstrap).
export function logWebReferral(maybeReferralUrl?: string): Promise<void> {
return request("/v1/web_referrals/record", {
method: "POST",
body: { maybe_referral_url: maybeReferralUrl ?? null },
}).then(() => undefined);
}
function checkoutRequest(
path: string,
body: unknown,
): Promise<ApiResult<{ checkoutUrl: string }>> {
return request<{ stripe_checkout_redirect_url?: string }>(path, {
method: "POST",
body,
}).then((r) => {
if (!r.success) return r;
return r.data.stripe_checkout_redirect_url
? { success: true, data: { checkoutUrl: r.data.stripe_checkout_redirect_url } }
: { success: false, errorMessage: "Failed to initiate checkout" };
});
}
function portalRequest(
path: string,
body: unknown,
): Promise<ApiResult<{ portalUrl: string }>> {
return request<{ stripe_portal_url?: string }>(path, {
method: "POST",
body,
}).then((r) => {
if (!r.success) return r;
return r.data.stripe_portal_url
? { success: true, data: { portalUrl: r.data.stripe_portal_url } }
: { success: false, errorMessage: "Failed to open the billing portal" };
});
}
type Envelope = { success: boolean; error_message?: string; message?: string };
async function request<T extends object>(
path: string,
init: { method?: "GET" | "POST"; body?: unknown } = {},
): Promise<ApiResult<T>> {
try {
const headers: Record<string, string> = {
Accept: "application/json",
"Content-Type": "application/json",
};
const session = readSignedSession();
if (session) headers.session = session;
const response = await fetch(`${API_HOST}${path}`, {
method: init.method ?? "GET",
headers,
credentials: "include",
body: init.body === undefined ? undefined : JSON.stringify(init.body),
});
const payload = (await response.json().catch(() => null)) as
| (Envelope & T)
| null;
if (!response.ok) {
return {
success: false,
errorMessage:
payload?.message ?? payload?.error_message ?? `Request failed (${response.status})`,
};
}
if (!payload?.success) {
return {
success: false,
errorMessage: payload?.error_message ?? "Request failed",
};
}
return { success: true, data: payload };
} catch (err) {
return {
success: false,
errorMessage: err instanceof Error ? err.message : "Network error",
};
}
}
function readSignedSession(): string | null {
try {
return localStorage.getItem(SESSION_STORAGE_KEY);
} catch {
return null;
}
}
@@ -0,0 +1,105 @@
// Landing-context tracking, ported from @storyteller/common. Persists the
// referral username (?u=), referral code (?r=), landing URL, and referrer
// to apex-domain cookies (shared across getartcraft.com subdomains) plus
// localStorage as a fallback, so signup attribution survives the hop from
// the marketing site to app.getartcraft.com. BROWSER ONLY.
//
// First visit wins for landing URL / referrer; latest visit wins for
// referral attribution so a fresh share link gets credit.
const REFERRAL_USERNAME_KEY = "referral_username";
const REFERRAL_CODE_KEY = "referral_code";
const LANDING_URL_KEY = "referral_landing_url";
const REFERRER_KEY = "referral_referrer";
const URL_MAX_LENGTH = 1024;
const REFERRAL_CODE_MAX_LENGTH = 30;
const COOKIE_MAX_AGE_DAYS = 90;
const APEX_DOMAINS = ["getartcraft.com"];
export function captureLandingContext(): void {
try {
const params = new URLSearchParams(window.location.search);
const referralUsername = sanitizeReferralUsername(params.get("u") ?? "");
if (referralUsername) persist(REFERRAL_USERNAME_KEY, referralUsername);
const referralCode = sanitizeReferralCode(params.get("r") ?? "");
if (referralCode) persist(REFERRAL_CODE_KEY, referralCode);
const landingUrl = sanitizeUrl(window.location.href);
if (landingUrl && !getLandingUrl()) persist(LANDING_URL_KEY, landingUrl);
const referrer = sanitizeUrl(document.referrer);
if (referrer && !getReferrer()) persist(REFERRER_KEY, referrer);
} catch {
// Storage or URL APIs unavailable; attribution is best-effort.
}
}
export function getReferralUsername(): string | undefined {
const stored = read(REFERRAL_USERNAME_KEY);
return stored ? sanitizeReferralUsername(stored) || undefined : undefined;
}
export function getReferralCode(): string | undefined {
const stored = read(REFERRAL_CODE_KEY);
return stored ? sanitizeReferralCode(stored) || undefined : undefined;
}
export function getLandingUrl(): string | undefined {
return read(LANDING_URL_KEY);
}
export function getReferrer(): string | undefined {
return read(REFERRER_KEY);
}
function sanitizeReferralUsername(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9_]/g, "")
.slice(0, 20);
}
function sanitizeReferralCode(value: string): string {
return value
.trim()
.replace(/[^A-Za-z0-9._-]/g, "")
.slice(0, REFERRAL_CODE_MAX_LENGTH);
}
function sanitizeUrl(value: string | null | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed.slice(0, URL_MAX_LENGTH) : undefined;
}
function persist(key: string, value: string): void {
const maxAge = COOKIE_MAX_AGE_DAYS * 24 * 60 * 60;
const secure = window.location.protocol === "https:" ? "; Secure" : "";
const hostname = window.location.hostname;
const apex = APEX_DOMAINS.find(
(d) => hostname === d || hostname.endsWith(`.${d}`),
);
const domain = apex ? `; Domain=.${apex}` : "";
document.cookie = `${key}=${encodeURIComponent(value)}; Max-Age=${maxAge}; Path=/; SameSite=Lax${secure}${domain}`;
try {
localStorage.setItem(key, value);
} catch {
// Private mode; the cookie still covers us.
}
}
function read(key: string): string | undefined {
const prefix = `${key}=`;
const cookie = document.cookie
.split("; ")
.find((entry) => entry.startsWith(prefix));
if (cookie) return decodeURIComponent(cookie.slice(prefix.length)) || undefined;
try {
return localStorage.getItem(key) ?? undefined;
} catch {
return undefined;
}
}
@@ -0,0 +1,62 @@
"use client";
import { useEffect, useState } from "react";
import {
getSession,
listActiveSubscriptions,
type SessionUser,
} from "./api";
// Session + ArtCraft subscription, fetched once per page load and shared by
// every consumer (the plan grid and the credits cell both need it). Server
// HTML renders the logged-out state; hydration upgrades it.
export type Account = {
/** null until the request resolves. */
user: SessionUser | null;
/** Active ArtCraft plan slug, or null when none / logged out. */
activePlanSlug: string | null;
loading: boolean;
};
const LOGGED_OUT: Account = { user: null, activePlanSlug: null, loading: false };
let pending: Promise<Account> | null = null;
function loadAccount(): Promise<Account> {
pending ??= (async () => {
const session = await getSession();
if (!session.success || !session.data.loggedIn || !session.data.user) {
return LOGGED_OUT;
}
const subs = await listActiveSubscriptions();
const artcraft = subs.success
? subs.data.find((s) => s.namespace === "artcraft")
: undefined;
return {
user: session.data.user,
activePlanSlug: artcraft?.product_slug ?? null,
loading: false,
};
})();
return pending;
}
export function useAccount(): Account {
const [account, setAccount] = useState<Account>({
user: null,
activePlanSlug: null,
loading: true,
});
useEffect(() => {
let live = true;
loadAccount().then((a) => {
if (live) setAccount(a);
});
return () => {
live = false;
};
}, []);
return account;
}