eny.space Landingpage
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(billing): WIP connect stripe plans with ui

+264 -69
+7 -1
.env.local.example
··· 20 20 SUPABASE_SERVICE_ROLE_KEY=somekey 21 21 22 22 # https://dashboard.stripe.com/ 23 - NEXT_PUBLIC_STRIPE_PRICE_ID=price_randomhash 23 + NEXT_PUBLIC_STRIPE_PRICE_ID=price_randomhash 24 + 25 + # Optional: plan-specific Stripe Price IDs (recommended). Keys match package names. 26 + # If provided, the UI will show amounts from Stripe and checkout will use the correct Price per plan. 27 + NEXT_PUBLIC_STRIPE_PRICE_PERSONAL_ID=price_randomhash_personal 28 + NEXT_PUBLIC_STRIPE_PRICE_COMMUNITY_ID=price_randomhash_community 29 + NEXT_PUBLIC_STRIPE_PRICE_BUSINESS_ID=price_randomhash_business
+7 -1
README.md
··· 64 64 STRIPE_WEBHOOK_SECRET=your_webhook_secret 65 65 NEXT_PUBLIC_STRIPE_PRICE_ID=your_stripe_price_id 66 66 67 + # Optional (recommended): plan-specific Price IDs for checkout + pricing display. 68 + # If set, the UI will show the real Stripe amounts for each plan. 69 + NEXT_PUBLIC_STRIPE_PRICE_PERSONAL_ID=your_stripe_price_id_personal 70 + NEXT_PUBLIC_STRIPE_PRICE_COMMUNITY_ID=your_stripe_price_id_community 71 + NEXT_PUBLIC_STRIPE_PRICE_BUSINESS_ID=your_stripe_price_id_business 72 + 67 73 # App URL (for redirects) 68 74 NEXT_PUBLIC_APP_URL=http://localhost:3000 69 75 ``` 70 76 71 77 Get your Supabase keys from your project settings → API. 72 78 Get your Stripe keys from your Stripe dashboard. 73 - Create a subscription product and price in Stripe, then use the price ID for `NEXT_PUBLIC_STRIPE_PRICE_ID`. 79 + Create 3 subscription prices in Stripe (Personal / Community / Business). Set the resulting Price IDs in `NEXT_PUBLIC_STRIPE_PRICE_PERSONAL_ID`, `NEXT_PUBLIC_STRIPE_PRICE_COMMUNITY_ID`, and `NEXT_PUBLIC_STRIPE_PRICE_BUSINESS_ID`. 74 80 75 81 3. Start the development server: 76 82
+4 -13
app/components/hero/hero-left.tsx
··· 10 10 as="h1" 11 11 className="font-heading text-4xl leading-tight tracking-tight text-white sm:text-5xl md:text-6xl" 12 12 > 13 - Managed PDS hosting with a real UI. 13 + Managed PDS hosting, launching soon. 14 14 </Heading> 15 15 <Paragraph className="text-lg text-white/90 sm:text-xl"> 16 - eny.space lets you run your own Personal Data Server without touching 17 - Kubernetes, Docker or cloud consoles. Spin up a PDS in one click, bring 18 - your own domain, and manage users, access and resources from a clear, AT 19 - Protocol-native dashboard. 16 + eny.space will let you run your own Personal Data Server with a real UI. 17 + For now, sign up and we&apos;ll notify you when packages go live. 20 18 </Paragraph> 21 19 <div className="flex flex-wrap gap-3"> 22 20 <ButtonLink ··· 24 22 className="border border-white/80 bg-transparent uppercase tracking-wide text-white hover:bg-white/10 hover:border-white focus-visible:ring-white/50" 25 23 endIcon={<ArrowUpRightIcon className="size-4" aria-hidden />} 26 24 > 27 - Launch your PDS 28 - </ButtonLink> 29 - <ButtonLink 30 - href="/dashboard" 31 - className="border border-white/80 bg-transparent uppercase tracking-wide text-white hover:bg-white/10 hover:border-white focus-visible:ring-white/50" 32 - endIcon={<ArrowUpRightIcon className="size-4" aria-hidden />} 33 - > 34 - View PDS dashboard 25 + Get started 35 26 </ButtonLink> 36 27 </div> 37 28 </div>
+45 -8
app/components/pricing/pricing-section.tsx
··· 8 8 import { Heading } from "@/components/heading"; 9 9 import { Paragraph } from "@/components/paragraph"; 10 10 import { prelaunch } from "@/lib/prelaunch"; 11 + import { getStripePlanAmounts, type PlanKey } from "@/lib/stripe-plans"; 11 12 12 13 type PricingPlan = { 13 14 key: string; ··· 24 25 25 26 const PLANS: PricingPlan[] = [ 26 27 { 27 - key: "starter", 28 - name: "Starter plan", 28 + key: "personal", 29 + name: "Personal", 29 30 price: "$19", 30 31 period: "per month", 31 32 description: "Perfect for small projects.", ··· 38 39 ], 39 40 }, 40 41 { 41 - key: "growth", 42 - name: "Growth plan", 42 + key: "community", 43 + name: "Community", 43 44 price: "$49", 44 45 period: "per month", 45 46 badge: "Popular", ··· 54 55 ], 55 56 }, 56 57 { 57 - key: "pro", 58 - name: "Pro plan", 58 + key: "business", 59 + name: "Business", 59 60 price: "$99", 60 61 period: "per month", 61 62 description: "Enterprise‑level performance.", ··· 71 72 }, 72 73 ]; 73 74 74 - export function PricingSection() { 75 + function formatStripePrice(unitAmount: number | null, currency: string | null) { 76 + if (!unitAmount || !currency) return null; 77 + 78 + const normalized = currency.toLowerCase(); 79 + const zeroDecimalCurrencies = new Set([ 80 + "jpy", 81 + "krw", 82 + "clp", 83 + "vnd", 84 + "idr", 85 + "huf", 86 + "pyg", 87 + ]); 88 + const decimals = zeroDecimalCurrencies.has(normalized) ? 0 : 2; 89 + const major = unitAmount / 10 ** decimals; 90 + 91 + try { 92 + return new Intl.NumberFormat(undefined, { 93 + style: "currency", 94 + currency: normalized.toUpperCase(), 95 + maximumFractionDigits: decimals, 96 + }).format(major); 97 + } catch { 98 + return null; 99 + } 100 + } 101 + 102 + export async function PricingSection() { 75 103 // Hide the pricing block entirely during prelaunch mode. 76 104 // This keeps the "prelaunch vs launch" behavior controlled by one global flag. 77 105 if (prelaunch) return null; 106 + 107 + const stripeAmounts = await getStripePlanAmounts(); 78 108 79 109 return ( 80 110 <section ··· 129 159 </div> 130 160 <div className="mt-4 flex items-baseline gap-2"> 131 161 <span className="text-4xl font-semibold sm:text-5xl text-white"> 132 - {plan.price} 162 + {(() => { 163 + const key = plan.key as PlanKey; 164 + const fromStripe = formatStripePrice( 165 + stripeAmounts[key]?.unitAmount ?? null, 166 + stripeAmounts[key]?.currency ?? null, 167 + ); 168 + return fromStripe ?? plan.price; 169 + })()} 133 170 </span> 134 171 <span className="text-sm font-medium opacity-80 text-white"> 135 172 {plan.period}
+1 -11
app/components/site-header/mobile-menu.tsx
··· 18 18 return ( 19 19 <div className="fixed right-3 top-14 z-40 max-h-[calc(100vh-3.5rem)] w-1/3 overflow-y-auto rounded-xl border border-fuchsia-300/30 bg-slate-900/75 px-4 pb-4 pt-3 shadow-[0_0_30px_rgba(232,121,249,0.2)] backdrop-blur-md md:hidden"> 20 20 <nav className="flex flex-col gap-2"> 21 - <Link href="/" className={mobileLinkClass} onClick={onClose}> 22 - Home 23 - </Link> 24 - {user && ( 25 - <Link href="/dashboard" className={mobileLinkClass} onClick={onClose}> 26 - Dashboard 27 - </Link> 28 - )} 29 - <Link href="/#about" className={mobileLinkClass} onClick={onClose}> 30 - About 31 - </Link> 21 + {/* navigation intentionally hidden; CTA-only header */} 32 22 </nav> 33 23 34 24 <div className="mt-3 flex flex-col gap-2">
-24
app/components/site-header/site-header.tsx
··· 109 109 </div> 110 110 </Link> 111 111 112 - {/* ... (rest of your nav and buttons) ... */} 113 - <nav className="hidden flex-1 justify-center gap-1 md:flex"> 114 - <Link 115 - href="/" 116 - className="inline-flex h-9 items-center justify-center rounded-full bg-transparent px-4 py-2 text-xs font-medium uppercase tracking-wide text-white/90 transition-colors hover:bg-white/5" 117 - > 118 - Home 119 - </Link> 120 - {user && ( 121 - <Link 122 - href="/dashboard" 123 - className="inline-flex h-9 items-center justify-center rounded-full bg-transparent px-4 py-2 text-xs font-medium uppercase tracking-wide text-white/90 transition-colors hover:bg-white/5" 124 - > 125 - Dashboard 126 - </Link> 127 - )} 128 - <Link 129 - href="/#about" 130 - className="inline-flex h-9 items-center justify-center rounded-full bg-transparent px-4 py-2 text-xs font-medium uppercase tracking-wide text-white/90 transition-colors hover:bg-white/5" 131 - > 132 - About 133 - </Link> 134 - </nav> 135 - 136 112 <div className="hidden items-center gap-2 md:flex"> 137 113 {!user ? ( 138 114 <Button
+5 -4
app/dashboard/dashboard-client.tsx
··· 41 41 return Number(pdsDisksizeGb); 42 42 } 43 43 44 - if (pdsPlan === "growth") return 50; 45 - if (pdsPlan === "pro") return 200; 44 + const p = (pdsPlan || "").toLowerCase(); 45 + if (p === "community" || p === "growth") return 50; 46 + if (p === "business" || p === "pro") return 200; 46 47 return 10; 47 48 }, [pdsDisksizeGb, pdsPlan]); 48 49 ··· 52 53 const handleSubscribe = async () => { 53 54 if (!priceId) { 54 55 alert( 55 - "Stripe price ID not configured. Please set NEXT_PUBLIC_STRIPE_PRICE_ID in your environment variables.", 56 + "Stripe price ID not configured. Set NEXT_PUBLIC_STRIPE_PRICE_PERSONAL_ID (and COMMUNITY/BUSINESS) or NEXT_PUBLIC_STRIPE_PRICE_ID as fallback.", 56 57 ); 57 58 return; 58 59 } ··· 123 124 </Paragraph> 124 125 {(pdsPlan || selectedHostname || selectedUsername) && ( 125 126 <Paragraph className="text-xs text-white/70"> 126 - Selected plan settings: plan={pdsPlan || "starter"}, disksize= 127 + Selected plan settings: plan={pdsPlan || "personal"}, disksize= 127 128 {planBasedDisksize}GiB 128 129 {selectedHostname ? `, hostname=${selectedHostname}` : ""} 129 130 {selectedUsername ? `, username=${selectedUsername}` : ""}
+9 -7
app/dashboard/page.tsx
··· 14 14 import DashboardClient from "./dashboard-client"; 15 15 import { ServiceDetailsClient } from "./service-details-client"; 16 16 import { AtprotoTestClient } from "./atproto-test-client"; 17 + import { prelaunch } from "@/lib/prelaunch"; 18 + import { getPriceIdForPlan } from "@/lib/stripe-plans"; 17 19 18 20 type DashboardPageProps = { 19 21 searchParams?: { ··· 36 38 } 37 39 38 40 const { subscribed, subscription } = await getSubscriptionStatus(); 41 + 42 + // During prelaunch we only collect signups and notify them on launch. 43 + // Prevent non-subscribed users from reaching the dashboard subscribe UI. 44 + if (prelaunch && !subscribed) { 45 + redirect("/welcome"); 46 + } 39 47 40 48 // Simple stubbed PDS status derived from subscription state 41 49 const pdsStatus = subscribed ? "active" : "provisioning"; ··· 90 98 91 99 <div className="mt-4 flex flex-wrap gap-3"> 92 100 <ButtonLink 93 - href="/dashboard/manage" 94 - className="border border-white/80 bg-transparent uppercase tracking-wide text-white hover:bg-white/10 hover:border-white focus-visible:ring-white/50" 95 - > 96 - Manage 97 - </ButtonLink> 98 - <ButtonLink 99 101 href={pdsDashboardUrl} 100 102 className="border border-white/80 bg-transparent uppercase tracking-wide text-white hover:bg-white/10 hover:border-white focus-visible:ring-white/50" 101 103 > ··· 119 121 <DashboardClient 120 122 subscribed={subscribed} 121 123 subscription={subscription} 122 - priceId={process.env.NEXT_PUBLIC_STRIPE_PRICE_ID || ""} 124 + priceId={getPriceIdForPlan(searchParams?.pds_plan)} 123 125 autoCheckoutFromPlan={searchParams?.auto_checkout === "1"} 124 126 pdsPlan={searchParams?.pds_plan} 125 127 pdsUsername={searchParams?.pds_username}
+102
app/welcome/page.tsx
··· 1 + import { redirect } from "next/navigation"; 2 + import { createClient } from "@/lib/supabase/server"; 3 + import { getSubscriptionStatus } from "@/actions/subscription"; 4 + import { prelaunch } from "@/lib/prelaunch"; 5 + import { 6 + Card, 7 + CardContent, 8 + CardDescription, 9 + CardHeader, 10 + CardTitle, 11 + } from "@/actions/components/ui/card"; 12 + import { ButtonLink } from "@/components/button-link"; 13 + import { Heading } from "@/components/heading"; 14 + import { Paragraph } from "@/components/paragraph"; 15 + import DashboardClient from "../dashboard/dashboard-client"; 16 + import { getPriceIdForPlan } from "@/lib/stripe-plans"; 17 + 18 + type WelcomePageProps = { 19 + searchParams?: { 20 + auto_checkout?: string; 21 + pds_plan?: string; 22 + pds_username?: string; 23 + pds_hostname?: string; 24 + pds_disksize_gb?: string; 25 + }; 26 + }; 27 + 28 + export default async function WelcomePage({ searchParams }: WelcomePageProps) { 29 + const supabase = await createClient(); 30 + const { 31 + data: { user }, 32 + } = await supabase.auth.getUser(); 33 + 34 + if (!user) { 35 + redirect("/login"); 36 + } 37 + 38 + const { subscribed, subscription } = await getSubscriptionStatus(); 39 + 40 + // If they already have access, skip the onboarding. 41 + if (subscribed) { 42 + redirect("/dashboard"); 43 + } 44 + 45 + return ( 46 + <main className="flex min-h-[60vh] items-center justify-center px-4 py-8"> 47 + <Card className="w-full max-w-2xl bg-white/5"> 48 + <CardHeader> 49 + <CardTitle>Welcome</CardTitle> 50 + <CardDescription> 51 + {prelaunch 52 + ? "Thanks for registering. We'll notify you when we're live." 53 + : "Almost there — pick a plan to activate your access."} 54 + </CardDescription> 55 + </CardHeader> 56 + 57 + <CardContent className="space-y-6"> 58 + {prelaunch ? ( 59 + <div className="space-y-3 text-white"> 60 + <Heading as="h2" className="text-base font-semibold text-white"> 61 + You're on the launch list 62 + </Heading> 63 + <Paragraph className="text-sm text-white/80"> 64 + We don't offer PDS hosting yet. Once we launch and start 65 + offering packages, we'll email you and unlock your dashboard. 66 + </Paragraph> 67 + <Paragraph className="text-xs text-white/60"> 68 + Registered as:{" "} 69 + <span className="font-mono text-white">{user.email}</span> 70 + </Paragraph> 71 + <div className="flex flex-wrap gap-3 pt-2"> 72 + <ButtonLink 73 + href="/" 74 + className="border border-white/80 bg-transparent uppercase tracking-wide text-white hover:bg-white/10 hover:border-white focus-visible:ring-white/50" 75 + > 76 + Back to home 77 + </ButtonLink> 78 + </div> 79 + </div> 80 + ) : ( 81 + <div className="space-y-4 text-white"> 82 + <Heading as="h2" className="text-base font-semibold"> 83 + Subscribe to Access 84 + </Heading> 85 + <DashboardClient 86 + subscribed={subscribed} 87 + subscription={subscription} 88 + priceId={getPriceIdForPlan(searchParams?.pds_plan)} 89 + autoCheckoutFromPlan={searchParams?.auto_checkout === "1"} 90 + pdsPlan={searchParams?.pds_plan} 91 + pdsUsername={searchParams?.pds_username} 92 + pdsHostname={searchParams?.pds_hostname} 93 + pdsDisksizeGb={searchParams?.pds_disksize_gb} 94 + /> 95 + </div> 96 + )} 97 + </CardContent> 98 + </Card> 99 + </main> 100 + ); 101 + } 102 +
+84
lib/stripe-plans.ts
··· 1 + import { stripe } from "@/lib/stripe"; 2 + 3 + /** Matches Stripe product names: Personal, Community, Business */ 4 + export const PLAN_KEYS = ["personal", "community", "business"] as const; 5 + export type PlanKey = (typeof PLAN_KEYS)[number]; 6 + 7 + /** Old query-param keys → current plan keys (backwards compatibility). */ 8 + const LEGACY_PLAN_KEYS: Record<string, PlanKey> = { 9 + starter: "personal", 10 + growth: "community", 11 + pro: "business", 12 + }; 13 + 14 + function getDefaultFallbackPriceId(): string | null { 15 + const fallback = process.env.NEXT_PUBLIC_STRIPE_PRICE_ID; 16 + return fallback && fallback.trim().length ? fallback : null; 17 + } 18 + 19 + function getEnvPriceId(planKey: PlanKey): string | null { 20 + const fallback = getDefaultFallbackPriceId(); 21 + 22 + const map: Record<PlanKey, string | undefined> = { 23 + personal: process.env.NEXT_PUBLIC_STRIPE_PRICE_PERSONAL_ID, 24 + community: process.env.NEXT_PUBLIC_STRIPE_PRICE_COMMUNITY_ID, 25 + business: process.env.NEXT_PUBLIC_STRIPE_PRICE_BUSINESS_ID, 26 + }; 27 + 28 + return (map[planKey] && map[planKey]!.trim().length ? map[planKey]! : fallback) ?? null; 29 + } 30 + 31 + function normalizePlanKey(planKey: string | undefined | null): PlanKey { 32 + const raw = (planKey || "personal").toLowerCase(); 33 + const fromLegacy = LEGACY_PLAN_KEYS[raw]; 34 + const candidate = fromLegacy ?? raw; 35 + return (PLAN_KEYS as readonly string[]).includes(candidate) 36 + ? (candidate as PlanKey) 37 + : "personal"; 38 + } 39 + 40 + export function getPriceIdForPlan(planKey: string | undefined | null): string { 41 + const key = normalizePlanKey(planKey); 42 + return getEnvPriceId(key) || ""; 43 + } 44 + 45 + type StripePriceAmount = { 46 + priceId: string; 47 + unitAmount: number | null; 48 + currency: string | null; 49 + }; 50 + 51 + async function retrievePriceById(priceId: string): Promise<StripePriceAmount> { 52 + const price = await stripe.prices.retrieve(priceId); 53 + return { 54 + priceId, 55 + unitAmount: price.unit_amount ?? null, 56 + currency: price.currency ?? null, 57 + }; 58 + } 59 + 60 + /** 61 + * Fetch amounts for each plan from Stripe (using Price IDs from env). 62 + * 63 + * Note: this is server-side only; it never exposes Stripe secrets to the client. 64 + */ 65 + export async function getStripePlanAmounts(): Promise< 66 + Record<PlanKey, StripePriceAmount> 67 + > { 68 + const results = {} as Record<PlanKey, StripePriceAmount>; 69 + 70 + await Promise.all( 71 + PLAN_KEYS.map(async (planKey) => { 72 + const priceId = getEnvPriceId(planKey); 73 + 74 + if (!priceId) { 75 + results[planKey] = { priceId: "", unitAmount: null, currency: null }; 76 + return; 77 + } 78 + 79 + results[planKey] = await retrievePriceById(priceId); 80 + }), 81 + ); 82 + 83 + return results; 84 + }