-
Notifications
You must be signed in to change notification settings - Fork 0
Add subscription product admin #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
56 changes: 56 additions & 0 deletions
56
apps/builder-egghead/src/app/admin/subscriptions/actions.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ) | ||
| } |
277 changes: 277 additions & 0 deletions
277
apps/builder-egghead/src/app/admin/subscriptions/subscription-admin-client.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| <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> | ||
| ) | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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:90Editing 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.currencycontains the Stripe currency preserved by updates; render that currency instead.🚀 Reply "fix it for me" or copy this AI Prompt for your agent: