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:
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;
|
||||
}
|
||||
Reference in new issue
Block a user