From 98ff4014cc5920af329b63b969c67e2c2a49c53f Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Wed, 28 Jan 2026 18:33:30 +0000 Subject: [PATCH] stripe frontend --- packages/backend/src/db/queries/users.ts | 6 + packages/frontend/src/components/account.tsx | 16 +- .../frontend/src/components/pricing-card.tsx | 120 +++++++++ packages/frontend/src/components/top-bar.tsx | 8 +- .../subscription/createCheckoutSession.ts | 38 +++ .../subscription/createPortalSession.ts | 31 +++ .../server/subscription/getSubscription.ts | 27 +++ packages/frontend/src/main.tsx | 18 ++ packages/frontend/src/pages/Landing.tsx | 120 +-------- packages/frontend/src/pages/Plans.tsx | 229 ++++++++++++++++++ 10 files changed, 504 insertions(+), 109 deletions(-) create mode 100644 packages/frontend/src/components/pricing-card.tsx create mode 100644 packages/frontend/src/lib/server/subscription/createCheckoutSession.ts create mode 100644 packages/frontend/src/lib/server/subscription/createPortalSession.ts create mode 100644 packages/frontend/src/lib/server/subscription/getSubscription.ts create mode 100644 packages/frontend/src/pages/Plans.tsx diff --git a/packages/backend/src/db/queries/users.ts b/packages/backend/src/db/queries/users.ts index bd034c6..83f1fc6 100644 --- a/packages/backend/src/db/queries/users.ts +++ b/packages/backend/src/db/queries/users.ts @@ -29,8 +29,14 @@ export async function updateById( passwordHash?: string; avatarURL?: string | null; iconPreference?: IconStyle; + plan?: string; }, ): Promise { const [user] = await db.update(User).set(updates).where(eq(User.id, id)).returning(); return user; } + +export async function updateUser(id: number, updates: { plan?: string }) { + const [user] = await db.update(User).set(updates).where(eq(User.id, id)).returning(); + return user; +} diff --git a/packages/frontend/src/components/account.tsx b/packages/frontend/src/components/account.tsx index 5ebe4f1..192e5a1 100644 --- a/packages/frontend/src/components/account.tsx +++ b/packages/frontend/src/components/account.tsx @@ -1,6 +1,7 @@ import type { IconStyle } from "@sprint/shared"; import type { ReactNode } from "react"; import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; import { toast } from "sonner"; import { useAuthenticatedSession } from "@/components/session-provider"; import ThemeToggle from "@/components/theme-toggle"; @@ -161,7 +162,20 @@ function Account({ trigger }: { trigger?: ReactNode }) { {error !== "" && } -
+ {/* Show subscription management link */} +
+ {currentUser.plan === "pro" ? ( + + ) : ( + + )} +
+ +
diff --git a/packages/frontend/src/components/pricing-card.tsx b/packages/frontend/src/components/pricing-card.tsx new file mode 100644 index 0000000..0fe2c22 --- /dev/null +++ b/packages/frontend/src/components/pricing-card.tsx @@ -0,0 +1,120 @@ +import { Button } from "@/components/ui/button"; +import Icon from "@/components/ui/icon"; +import { cn } from "@/lib/utils"; + +export interface PricingTier { + name: string; + price: string; + priceAnnual: string; + period: string; + periodAnnual: string; + description: string; + tagline: string; + features: string[]; + cta: string; + highlighted: boolean; +} + +export function PricingCard({ + tier, + billingPeriod, + onCtaClick, + disabled = false, + loading = false, +}: { + tier: PricingTier; + billingPeriod: "monthly" | "annual"; + onCtaClick: () => void; + disabled?: boolean; + loading?: boolean; +}) { + return ( +
+ {tier.highlighted && ( +
+ {tier.tagline} +
+ )} + +
+

{tier.name}

+
+ + {billingPeriod === "annual" ? tier.priceAnnual : tier.price} + + + {billingPeriod === "annual" ? tier.periodAnnual : tier.period} + +
+

{tier.description}

+
+ +
    + {tier.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ + +
+ ); +} + +export const pricingTiers: PricingTier[] = [ + { + name: "Starter", + price: "£0", + priceAnnual: "£0", + period: "Free forever", + periodAnnual: "Free forever", + description: "Perfect for side projects and solo developers", + tagline: "For solo devs and small projects", + features: [ + "1 organisation (owned or joined)", + "1 project", + "100 issues", + "Up to 5 team members", + "Email support", + ], + cta: "Get started free", + highlighted: false, + }, + { + name: "Pro", + price: "£11.99", + priceAnnual: "£9.99", + period: "per user/month", + periodAnnual: "per user/month", + description: "For growing teams and professionals", + tagline: "Most Popular", + features: [ + "Everything in starter", + "Unlimited organisations", + "Unlimited projects", + "Unlimited issues", + "Advanced time tracking & reports", + "Custom issue statuses", + "Priority email support", + ], + cta: "Upgrade to Pro", + highlighted: true, + }, +]; diff --git a/packages/frontend/src/components/top-bar.tsx b/packages/frontend/src/components/top-bar.tsx index fabc10e..a47e8e3 100644 --- a/packages/frontend/src/components/top-bar.tsx +++ b/packages/frontend/src/components/top-bar.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; +import { Link, useLocation, useNavigate } from "react-router-dom"; import Account from "@/components/account"; import { IssueForm } from "@/components/issue-form"; import LogOutButton from "@/components/log-out-button"; @@ -11,6 +11,7 @@ import { useSelection } from "@/components/selection-provider"; import { useAuthenticatedSession } from "@/components/session-provider"; import SmallUserDisplay from "@/components/small-user-display"; import { SprintForm } from "@/components/sprint-form"; +import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, @@ -122,6 +123,11 @@ export default function TopBar({ showIssueForm = true }: { showIssueForm?: boole )}
+ {user.plan !== "pro" && ( + + )} diff --git a/packages/frontend/src/lib/server/subscription/createCheckoutSession.ts b/packages/frontend/src/lib/server/subscription/createCheckoutSession.ts new file mode 100644 index 0000000..a6e88eb --- /dev/null +++ b/packages/frontend/src/lib/server/subscription/createCheckoutSession.ts @@ -0,0 +1,38 @@ +import { getServerURL } from "@/lib/utils"; + +interface CreateCheckoutParams { + billingPeriod: "monthly" | "annual"; + csrfToken: string; + onSuccess?: (url: string) => void; + onError?: (error: string) => void; +} + +export async function createCheckoutSession({ + billingPeriod, + csrfToken, + onSuccess, + onError, +}: CreateCheckoutParams) { + try { + const response = await fetch(`${getServerURL()}/subscription/create-checkout-session`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken, + }, + credentials: "include", + body: JSON.stringify({ billingPeriod }), + }); + + const data = await response.json(); + + if (!response.ok) { + onError?.(data.error || "Failed to create checkout session"); + return; + } + + onSuccess?.(data.url); + } catch (error) { + onError?.("Network error"); + } +} diff --git a/packages/frontend/src/lib/server/subscription/createPortalSession.ts b/packages/frontend/src/lib/server/subscription/createPortalSession.ts new file mode 100644 index 0000000..9395f19 --- /dev/null +++ b/packages/frontend/src/lib/server/subscription/createPortalSession.ts @@ -0,0 +1,31 @@ +import { getServerURL } from "@/lib/utils"; + +interface CreatePortalParams { + csrfToken: string; + onSuccess?: (url: string) => void; + onError?: (error: string) => void; +} + +export async function createPortalSession({ csrfToken, onSuccess, onError }: CreatePortalParams) { + try { + const response = await fetch(`${getServerURL()}/subscription/create-portal-session`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken, + }, + credentials: "include", + }); + + const data = await response.json(); + + if (!response.ok) { + onError?.(data.error || "Failed to create portal session"); + return; + } + + onSuccess?.(data.url); + } catch (error) { + onError?.("Network error"); + } +} diff --git a/packages/frontend/src/lib/server/subscription/getSubscription.ts b/packages/frontend/src/lib/server/subscription/getSubscription.ts new file mode 100644 index 0000000..e23d3c3 --- /dev/null +++ b/packages/frontend/src/lib/server/subscription/getSubscription.ts @@ -0,0 +1,27 @@ +import type { SubscriptionRecord } from "@sprint/shared"; +import { getServerURL } from "@/lib/utils"; + +interface GetSubscriptionParams { + onSuccess?: (subscription: SubscriptionRecord | null) => void; + onError?: (error: string) => void; +} + +export async function getSubscription({ onSuccess, onError }: GetSubscriptionParams) { + try { + const response = await fetch(`${getServerURL()}/subscription/get`, { + method: "GET", + credentials: "include", + }); + + const data = await response.json(); + + if (!response.ok) { + onError?.(data.error || "Failed to fetch subscription"); + return; + } + + onSuccess?.(data.subscription); + } catch (error) { + onError?.("Network error"); + } +} diff --git a/packages/frontend/src/main.tsx b/packages/frontend/src/main.tsx index 8a7761d..e0d83d3 100644 --- a/packages/frontend/src/main.tsx +++ b/packages/frontend/src/main.tsx @@ -12,6 +12,8 @@ import Font from "@/pages/Font"; import Issues from "@/pages/Issues"; import Landing from "@/pages/Landing"; import NotFound from "@/pages/NotFound"; +import Plans from "@/pages/Plans"; +import StripeTest from "@/pages/StripeTest"; import Test from "@/pages/Test"; import Timeline from "@/pages/Timeline"; @@ -28,6 +30,14 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( } /> {/* authed routes */} + + + + } + /> } /> + + + + } + /> ("monthly"); + const [billingPeriod, setBillingPeriod] = useState<"monthly" | "annual">("annual"); const [loginModalOpen, setLoginModalOpen] = useState(false); return ( @@ -163,7 +114,7 @@ export default function Landing() { ) : ( <>
-

No credit card required · Full access for 14 days

+

Free forever · Upgrade when you need more

{/* problem section */} @@ -340,57 +291,12 @@ export default function Landing() {
{pricingTiers.map((tier) => ( -
- {tier.highlighted && ( -
- {tier.tagline} -
- )} - -
-

{tier.name}

-
- - {billingPeriod === "annual" ? tier.priceAnnual : tier.price} - - - {billingPeriod === "annual" ? tier.periodAnnual : tier.period} - -
-

{tier.description}

-
- -
    - {tier.features.map((feature) => ( -
  • - - {feature} -
  • - ))} -
- - -
+ tier={tier} + billingPeriod={billingPeriod} + onCtaClick={() => setLoginModalOpen(true)} + /> ))}
@@ -408,8 +314,8 @@ export default function Landing() { className="size-8" color="var(--personality)" /> -

No Card Required

-

Start your trial instantly

+

Free Starter Plan

+

Get started instantly

@@ -473,12 +379,12 @@ export default function Landing() { ) : ( )}

- No credit card required · 14-day free trial · Cancel anytime + Free forever · Upgrade when you need more · Cancel anytime

diff --git a/packages/frontend/src/pages/Plans.tsx b/packages/frontend/src/pages/Plans.tsx new file mode 100644 index 0000000..62d146a --- /dev/null +++ b/packages/frontend/src/pages/Plans.tsx @@ -0,0 +1,229 @@ +import { useEffect, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { LoginModal } from "@/components/login-modal"; +import { PricingCard, pricingTiers } from "@/components/pricing-card"; +import { useSession } from "@/components/session-provider"; +import { Button } from "@/components/ui/button"; +import Icon from "@/components/ui/icon"; +import { Switch } from "@/components/ui/switch"; +import { createCheckoutSession } from "@/lib/server/subscription/createCheckoutSession"; +import { createPortalSession } from "@/lib/server/subscription/createPortalSession"; +import { getSubscription } from "@/lib/server/subscription/getSubscription"; +import { cn, getCsrfToken } from "@/lib/utils"; + +interface SubscriptionData { + status: string; + currentPeriodEnd: Date | null; + quantity: number; +} + +export default function Plans() { + const { user, isLoading } = useSession(); + const navigate = useNavigate(); + const [billingPeriod, setBillingPeriod] = useState<"monthly" | "annual">("annual"); + const [loginModalOpen, setLoginModalOpen] = useState(false); + const [subscription, setSubscription] = useState(null); + const [loadingSubscription, setLoadingSubscription] = useState(false); + const [processingTier, setProcessingTier] = useState(null); + + // fetch subscription if user is logged in + useEffect(() => { + if (user) { + setLoadingSubscription(true); + getSubscription({ + onSuccess: (data) => { + setSubscription(data); + setLoadingSubscription(false); + }, + onError: () => { + setSubscription(null); + setLoadingSubscription(false); + }, + }); + } + }, [user]); + + const hasProSubscription = subscription?.status === "active"; + const csrfToken = getCsrfToken() || ""; + + const handleTierAction = (tierName: string) => { + if (!user) { + setLoginModalOpen(true); + return; + } + + if (tierName === "Pro") { + if (hasProSubscription) { + // open customer portal + setProcessingTier(tierName); + createPortalSession({ + csrfToken, + onSuccess: (url) => { + window.location.href = url; + }, + onError: () => { + setProcessingTier(null); + }, + }); + } else { + // start checkout + setProcessingTier(tierName); + createCheckoutSession({ + billingPeriod, + csrfToken, + onSuccess: (url) => { + window.location.href = url; + }, + onError: () => { + setProcessingTier(null); + }, + }); + } + } + // starter tier - just go to issues if not already there + if (tierName === "Starter") { + navigate("/issues"); + } + }; + + // modify pricing tiers based on user's current plan + const modifiedTiers = pricingTiers.map((tier) => { + const isCurrentPlan = tier.name === "Pro" && hasProSubscription; + const isStarterCurrent = tier.name === "Starter" && !hasProSubscription; + + return { + ...tier, + highlighted: isCurrentPlan || (!hasProSubscription && tier.name === "Pro"), + cta: isCurrentPlan + ? "Manage subscription" + : isStarterCurrent + ? "Current plan" + : tier.name === "Pro" + ? "Upgrade to Pro" + : tier.cta, + }; + }); + + return ( +
+
+
+
+ Sprint + Sprint +
+ +
+
+ +
+
+
+

+ {user ? "Choose your plan" : "Simple, transparent pricing"} +

+

+ {user + ? hasProSubscription + ? "You are currently on the Pro plan. Manage your subscription or switch plans below." + : "You are currently on the Starter plan. Upgrade to Pro for unlimited access." + : "Choose the plan that fits your team. Scale as you grow."} +

+ + {/* billing toggle */} +
+ + setBillingPeriod(checked ? "annual" : "monthly")} + className="bg-border data-[state=checked]:bg-border! data-[state=unchecked]:bg-border!" + thumbClassName="bg-personality dark:bg-personality data-[state=checked]:bg-personality! data-[state=unchecked]:bg-personality!" + aria-label="toggle billing period" + /> + + + Save 17% + +
+
+ +
+ {modifiedTiers.map((tier) => ( + handleTierAction(tier.name)} + disabled={processingTier !== null || tier.name === "Starter"} + loading={processingTier === tier.name} + /> + ))} +
+ + {/* trust signals */} +
+
+ +

Secure & Encrypted

+

Your data is safe with us

+
+
+ +

Free Starter Plan

+

Get started instantly

+
+
+ +

Money Back Guarantee

+

30-day no-risk policy

+
+
+
+
+ + +
+ ); +}