Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ CourseBuilder's `MerchantProduct` and `MerchantPrice` rows, a `month` or `year`
`billingInterval`, and the database must contain the Stripe `MerchantAccount`
row.

Create and maintain those records from the builder admin at `/admin/subscriptions`. The admin
creates the Stripe product and recurring price together with the CourseBuilder `Product`, `Price`,
`MerchantProduct`, and `MerchantPrice` mappings. Changing amount or interval creates a replacement
Stripe price and retires the previous price. Phase 0 mutations require local Docker, an administrator
session, and a Stripe test-mode secret; beta is read-only and production remains blocked.

Run the app and the Inngest dev server in separate terminals:

```bash
Expand Down
7 changes: 6 additions & 1 deletion apps/builder-egghead/src/app/admin/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
SidebarTrigger,
} from '@/components/ui/sidebar'
import { getServerAuthSession } from '@/server/auth'
import { CalendarIcon, MailIcon, MicIcon, TagIcon } from 'lucide-react'
import { CalendarIcon, CreditCardIcon, MailIcon, MicIcon, TagIcon } from 'lucide-react'

const adminSidebar = [
{
Expand All @@ -33,6 +33,11 @@ const adminSidebar = [
href: '/admin/events',
icon: CalendarIcon,
},
{
label: 'Subscriptions',
href: '/admin/subscriptions',
icon: CreditCardIcon,
},
]

const AdminLayout = async ({ children }: { children: React.ReactNode }) => {
Expand Down
56 changes: 56 additions & 0 deletions apps/builder-egghead/src/app/admin/subscriptions/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
'use server'

import { revalidatePath } from 'next/cache'
import {
createSubscriptionProduct,
updateSubscriptionProduct,
} from '@/lib/subscription-products'
import { parseSubscriptionProductForm } from '@/lib/subscription-products-contracts'
import { z } from 'zod'

export type SubscriptionProductActionState = {
status: 'idle' | 'error' | 'success'
message: string | null
}


function actionErrorMessage(error: unknown) {
if (error instanceof z.ZodError) {
return error.issues.at(0)?.message ?? 'Check the subscription product values.'
}
return error instanceof Error ? error.message : 'Subscription product update failed.'
}

export async function createSubscriptionProductAction(
_previousState: SubscriptionProductActionState,
formData: FormData,
): Promise<SubscriptionProductActionState> {
try {
const input = parseSubscriptionProductForm(formData)
await createSubscriptionProduct(input)
revalidatePath('/admin/subscriptions')
return {
status: 'success',
message: `${input.name} was created in CourseBuilder and Stripe.`,
}
} catch (error) {
return { status: 'error', message: actionErrorMessage(error) }
}
}

export async function updateSubscriptionProductAction(
_previousState: SubscriptionProductActionState,
formData: FormData,
): Promise<SubscriptionProductActionState> {
try {
const input = parseSubscriptionProductForm(formData)
await updateSubscriptionProduct(input)
revalidatePath('/admin/subscriptions')
return {
status: 'success',
message: `${input.name} was updated in CourseBuilder and Stripe.`,
}
} catch (error) {
return { status: 'error', message: actionErrorMessage(error) }
}
}
29 changes: 29 additions & 0 deletions apps/builder-egghead/src/app/admin/subscriptions/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { Metadata } from 'next'
import { SubscriptionAdminClient } from '@/app/admin/subscriptions/subscription-admin-client'
import { getSubscriptionProductAdminData } from '@/lib/subscription-products'

export const metadata: Metadata = {
title: 'Admin - Subscriptions',
description: 'Manage Egghead subscription products and Stripe prices.',
}

export default async function AdminSubscriptionsPage() {
const data = await getSubscriptionProductAdminData()

return (
<div className="container mx-auto grid max-w-5xl gap-8 px-4 py-8">
<header className="grid gap-3">
<p className="text-muted-foreground text-xs font-semibold tracking-[0.18em] uppercase">
Commerce configuration
</p>
<h1 className="text-4xl font-bold tracking-tight">Subscriptions</h1>
<p className="text-muted-foreground max-w-2xl text-base">
One control surface for CourseBuilder products, recurring Stripe prices, and merchant
mappings. Price or interval changes create a new Stripe price and retire the previous
one.
</p>
</header>
<SubscriptionAdminClient data={data} />
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
'use client'

import { useActionState } from 'react'
import {
createSubscriptionProductAction,
updateSubscriptionProductAction,
type SubscriptionProductActionState,
} from '@/app/admin/subscriptions/actions'
import type {
SubscriptionProductAdminData,
SubscriptionProductAdminItem,
} from '@/lib/subscription-products'
import { AlertCircleIcon, CheckCircle2Icon, PlusIcon, SaveIcon } from 'lucide-react'

import { Alert, AlertDescription, AlertTitle } from '@coursebuilder/ui/primitives/alert'
import { Badge } from '@coursebuilder/ui/primitives/badge'
import { Button } from '@coursebuilder/ui/primitives/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@coursebuilder/ui/primitives/card'
import { Input } from '@coursebuilder/ui/primitives/input'
import { Label } from '@coursebuilder/ui/primitives/label'
import { Textarea } from '@coursebuilder/ui/primitives/textarea'

const initialActionState: SubscriptionProductActionState = {
status: 'idle',
message: null,
}

function ActionMessage({ state }: { state: SubscriptionProductActionState }) {
if (!state.message) return null

return (
<p
aria-live="polite"
className={
state.status === 'error'
? 'text-destructive text-sm font-medium'
: 'text-sm font-medium text-emerald-700 dark:text-emerald-400'
}
>
{state.message}
</p>
)
}

function FieldLabel({ children, htmlFor }: { children: React.ReactNode; htmlFor: string }) {
return (
<Label className="text-xs font-semibold tracking-wide uppercase" htmlFor={htmlFor}>
{children}
</Label>
)
}

function ProductFields({
idPrefix,
product,
}: {
idPrefix: string
product?: SubscriptionProductAdminItem
}) {
return (
<div className="grid gap-5">
<div className="grid gap-2">
<FieldLabel htmlFor={`${idPrefix}-name`}>Name</FieldLabel>
<Input
defaultValue={product?.name ?? 'egghead Annual Membership'}
id={`${idPrefix}-name`}
maxLength={90}
name="name"
required
/>
</div>
<div className="grid gap-2">
<FieldLabel htmlFor={`${idPrefix}-description`}>Description</FieldLabel>
<Textarea
defaultValue={product?.description ?? 'Annual access to every egghead course and lesson.'}
id={`${idPrefix}-description`}
maxLength={500}
name="description"
rows={3}
/>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-2">
<FieldLabel htmlFor={`${idPrefix}-price`}>Price (USD)</FieldLabel>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High subscriptions/subscription-admin-client.tsx:90

Editing an existing non-USD product labels its price as USD, so an administrator can enter the wrong amount for products such as EUR subscriptions. The label is hard-coded even though product.currency contains the Stripe currency preserved by updates; render that currency instead.

Suggested change
<FieldLabel htmlFor={`${idPrefix}-price`}>Price (USD)</FieldLabel>
<FieldLabel htmlFor={`${idPrefix}-price`}>Price ({product?.currency?.toUpperCase() ?? 'USD'})</FieldLabel>
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/builder-egghead/src/app/admin/subscriptions/subscription-admin-client.tsx around line 90:

Editing an existing non-USD product labels its price as USD, so an administrator can enter the wrong amount for products such as EUR subscriptions. The label is hard-coded even though `product.currency` contains the Stripe currency preserved by updates; render that currency instead.

<Input
defaultValue={product?.price ?? 150}
id={`${idPrefix}-price`}
min="0.01"
name="price"
required
step="0.01"
type="number"
/>
</div>
<div className="grid gap-2">
<FieldLabel htmlFor={`${idPrefix}-interval`}>Interval</FieldLabel>
<select
className="border-input bg-background ring-offset-background focus-visible:ring-ring h-10 rounded-md border px-3 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
defaultValue={product?.billingInterval ?? 'year'}
id={`${idPrefix}-interval`}
name="billingInterval"
>
<option value="month">Monthly</option>
<option value="year">Yearly</option>
</select>
</div>
<label
className="border-border bg-muted/30 flex min-h-10 cursor-pointer items-center gap-3 rounded-md border px-3 py-2 sm:self-end"
htmlFor={`${idPrefix}-active`}
>
<input
className="border-input accent-primary h-4 w-4 rounded"
defaultChecked={product?.active ?? true}
id={`${idPrefix}-active`}
name="active"
type="checkbox"
/>
<span className="text-sm font-medium">Active</span>
</label>
</div>
</div>
)
}

function CreateSubscriptionProduct({ disabled }: { disabled: boolean }) {
const [state, action, pending] = useActionState(
createSubscriptionProductAction,
initialActionState,
)

return (
<Card className="border-dashed">
<CardHeader>
<div className="flex items-center gap-3">
<div className="bg-primary text-primary-foreground grid size-10 place-items-center rounded-full">
<PlusIcon className="size-5" />
</div>
<div>
<CardTitle>Create subscription product</CardTitle>
<CardDescription>
Creates the Stripe product and price plus every CourseBuilder mapping.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<form action={action} className="grid gap-5">
<fieldset disabled={disabled || pending}>
<ProductFields idPrefix="new-subscription" />
</fieldset>
<div className="flex items-center justify-between gap-4">
<ActionMessage state={state} />
<Button disabled={disabled || pending} type="submit">
<PlusIcon className="mr-2 size-4" />
{pending ? 'Creating…' : 'Create product'}
</Button>
</div>
</form>
</CardContent>
</Card>
)
}

function SubscriptionProductCard({
disabled,
product,
}: {
disabled: boolean
product: SubscriptionProductAdminItem
}) {
const [state, action, pending] = useActionState(
updateSubscriptionProductAction,
initialActionState,
)

return (
<Card>
<CardHeader className="border-border border-b">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="grid gap-2">
<div className="flex flex-wrap items-center gap-2">
<CardTitle>{product.name}</CardTitle>
<Badge variant={product.active ? 'default' : 'secondary'}>
{product.active ? 'Active' : 'Inactive'}
</Badge>
<Badge variant={product.synced ? 'outline' : 'destructive'}>
{product.synced ? 'Stripe synced' : 'Review sync'}
</Badge>
</div>
<CardDescription className="font-mono text-xs">{product.id}</CardDescription>
</div>
<div className="text-muted-foreground grid gap-1 text-right font-mono text-xs">
<span>{product.stripeProductId ?? 'Missing Stripe product'}</span>
<span>{product.stripePriceId ?? 'Missing Stripe price'}</span>
</div>
</div>
</CardHeader>
<CardContent className="pt-6">
<form action={action} className="grid gap-5">
<input name="productId" type="hidden" value={product.id} />
<fieldset disabled={disabled || pending}>
<ProductFields idPrefix={product.id} product={product} />
</fieldset>
<div className="flex items-center justify-between gap-4">
<ActionMessage state={state} />
<Button disabled={disabled || pending} type="submit" variant="outline">
<SaveIcon className="mr-2 size-4" />
{pending ? 'Saving…' : 'Save changes'}
</Button>
</div>
</form>
</CardContent>
</Card>
)
}

export function SubscriptionAdminClient({ data }: { data: SubscriptionProductAdminData }) {
return (
<div className="grid gap-8">
{data.writesAllowed ? (
<Alert className="border-emerald-600/30 bg-emerald-500/5">
<CheckCircle2Icon className="size-4" />
<AlertTitle>Local Stripe test mode</AlertTitle>
<AlertDescription>
Product changes write to local Docker and Stripe test mode only.
</AlertDescription>
</Alert>
) : (
<Alert variant="destructive">
<AlertCircleIcon className="size-4" />
<AlertTitle>Read-only</AlertTitle>
<AlertDescription>
{data.writeRestriction ?? 'Subscription product writes are unavailable.'}
</AlertDescription>
</Alert>
)}

<CreateSubscriptionProduct disabled={!data.writesAllowed} />

<section aria-labelledby="subscription-products-heading" className="grid gap-4">
<div className="flex items-end justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold" id="subscription-products-heading">
Subscription products
</h2>
<p className="text-muted-foreground text-sm">
{data.products.length} configured · Stripe {data.stripeMode} mode
</p>
</div>
</div>
{data.products.length ? (
<div className="grid gap-5">
{data.products.map((product) => (
<SubscriptionProductCard
disabled={!data.writesAllowed}
key={product.id}
product={product}
/>
))}
</div>
) : (
<Card>
<CardContent className="text-muted-foreground py-12 text-center">
No membership products are configured yet.
</CardContent>
</Card>
)}
</section>
</div>
)
}
Loading