diff --git a/README.md b/README.md index c5400f0..000b42d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/builder-egghead/src/app/admin/layout.tsx b/apps/builder-egghead/src/app/admin/layout.tsx index 4bb7457..20817fe 100644 --- a/apps/builder-egghead/src/app/admin/layout.tsx +++ b/apps/builder-egghead/src/app/admin/layout.tsx @@ -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 = [ { @@ -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 }) => { diff --git a/apps/builder-egghead/src/app/admin/subscriptions/actions.ts b/apps/builder-egghead/src/app/admin/subscriptions/actions.ts new file mode 100644 index 0000000..f0c884c --- /dev/null +++ b/apps/builder-egghead/src/app/admin/subscriptions/actions.ts @@ -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 { + 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 { + 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) } + } +} diff --git a/apps/builder-egghead/src/app/admin/subscriptions/page.tsx b/apps/builder-egghead/src/app/admin/subscriptions/page.tsx new file mode 100644 index 0000000..f0d8911 --- /dev/null +++ b/apps/builder-egghead/src/app/admin/subscriptions/page.tsx @@ -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 ( +
+
+

+ Commerce configuration +

+

Subscriptions

+

+ 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. +

+
+ +
+ ) +} diff --git a/apps/builder-egghead/src/app/admin/subscriptions/subscription-admin-client.tsx b/apps/builder-egghead/src/app/admin/subscriptions/subscription-admin-client.tsx new file mode 100644 index 0000000..369e1b2 --- /dev/null +++ b/apps/builder-egghead/src/app/admin/subscriptions/subscription-admin-client.tsx @@ -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 ( +

+ {state.message} +

+ ) +} + +function FieldLabel({ children, htmlFor }: { children: React.ReactNode; htmlFor: string }) { + return ( + + ) +} + +function ProductFields({ + idPrefix, + product, +}: { + idPrefix: string + product?: SubscriptionProductAdminItem +}) { + return ( +
+
+ Name + +
+
+ Description +