diff --git a/examples/webhooks/.dockerignore b/examples/webhooks/.dockerignore new file mode 100644 index 00000000..760fd8cb --- /dev/null +++ b/examples/webhooks/.dockerignore @@ -0,0 +1,9 @@ +node_modules +.next +.env +.env.local +.git +.gitignore +README.md +drizzle +dist diff --git a/examples/webhooks/.env.example b/examples/webhooks/.env.example new file mode 100644 index 00000000..1933a3ad --- /dev/null +++ b/examples/webhooks/.env.example @@ -0,0 +1,22 @@ +# Database +POSTGRES_URL=postgresql://postgres:postgres@localhost:54328/webhooks_saas + +# Better Auth +BETTER_AUTH_SECRET=your-secret-key-min-32-chars-long +BETTER_AUTH_URL=http://localhost:3008 +NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3008 + +# Commet +COMMET_API_KEY=your_api_key_here + +# Webhook signing secret. +# Local dev: printed by `commet listen localhost:3008/api/webhooks/commet`. +# Production: shown when you create the webhook endpoint in the Commet dashboard. +COMMET_WEBHOOK_SECRET=your_webhook_secret_here + +# Welcome email (Resend). Optional in local dev: without it the welcome email is skipped. +RESEND_API_KEY= +EMAIL_FROM=Acme + +# App +NEXT_PUBLIC_APP_URL=http://localhost:3008 diff --git a/examples/webhooks/.gitignore b/examples/webhooks/.gitignore new file mode 100644 index 00000000..8740b603 --- /dev/null +++ b/examples/webhooks/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz +pnpm-lock.yaml + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local +.env + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# Database +drizzle/ +.commet/ diff --git a/examples/webhooks/actions/auth.ts b/examples/webhooks/actions/auth.ts new file mode 100644 index 00000000..0b01fff4 --- /dev/null +++ b/examples/webhooks/actions/auth.ts @@ -0,0 +1,150 @@ +"use server"; + +import { eq } from "drizzle-orm"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { auth } from "@/lib/auth/auth"; +import { getUser } from "@/lib/auth/session"; +import { hasUsableSubscription } from "@/lib/billing/entitlements"; +import { db } from "@/lib/db/drizzle"; +import { getBillingStateForUser } from "@/lib/db/queries"; +import { + ActivityType, + activityLogs, + type NewActivityLog, + user, +} from "@/lib/db/schema"; +import { safeRedirectPath } from "@/lib/redirects"; +import { + updateAccountSchema, + updatePasswordSchema, +} from "@/lib/validations/auth"; + +type ActionState = { + error?: string; + success?: string; + fieldErrors?: Record; + [key: string]: + | string + | string[] + | Record + | undefined; +}; + +async function logActivity( + userId: string, + type: ActivityType, + ipAddress?: string, +) { + const newActivity: NewActivityLog = { + userId, + action: type, + ipAddress, + }; + await db.insert(activityLogs).values(newActivity); +} + +export async function getPostAuthRedirect(fallback = "/dashboard") { + const redirectPath = safeRedirectPath(fallback); + const currentUser = await getUser(); + if (!currentUser) { + return "/sign-in"; + } + + if (redirectPath !== "/dashboard") { + return redirectPath; + } + + const billing = await getBillingStateForUser(currentUser.id); + return hasUsableSubscription(billing?.subscriptionStatus) + ? "/dashboard" + : "/pricing"; +} + +export async function signOut() { + const currentUser = await getUser(); + if (currentUser) { + await logActivity(currentUser.id, ActivityType.SIGN_OUT); + } + redirect("/sign-in"); +} + +export async function updateAccount( + _prevState: ActionState, + formData: FormData, +): Promise { + const currentUser = await getUser(); + if (!currentUser) { + return { error: "Not authenticated" }; + } + + const rawData = { + name: formData.get("name") as string, + email: formData.get("email") as string, + }; + + const result = updateAccountSchema.safeParse(rawData); + if (!result.success) { + return { + error: "Validation failed", + fieldErrors: result.error.flatten().fieldErrors, + }; + } + + const { name, email } = result.data; + + await db + .update(user) + .set({ name, email, updatedAt: new Date() }) + .where(eq(user.id, currentUser.id)); + + await logActivity(currentUser.id, ActivityType.UPDATE_ACCOUNT); + + return { success: "Account updated successfully." }; +} + +export async function updatePassword( + _prevState: ActionState, + formData: FormData, +): Promise { + const currentUser = await getUser(); + if (!currentUser) { + return { error: "Not authenticated" }; + } + + const rawData = { + currentPassword: formData.get("currentPassword") as string, + newPassword: formData.get("newPassword") as string, + confirmPassword: formData.get("confirmPassword") as string, + }; + + const result = updatePasswordSchema.safeParse(rawData); + if (!result.success) { + return { + error: "Validation failed", + fieldErrors: result.error.flatten().fieldErrors, + }; + } + + const { currentPassword, newPassword } = result.data; + + try { + await auth.api.changePassword({ + body: { + currentPassword, + newPassword, + revokeOtherSessions: false, + }, + headers: await headers(), + }); + + await logActivity(currentUser.id, ActivityType.UPDATE_PASSWORD); + + return { success: "Password updated successfully." }; + } catch (error: unknown) { + if (error instanceof Error) { + return { error: error.message || "Failed to update password" }; + } + return { error: "Failed to update password" }; + } +} diff --git a/examples/webhooks/actions/plans.ts b/examples/webhooks/actions/plans.ts new file mode 100644 index 00000000..6429d6c8 --- /dev/null +++ b/examples/webhooks/actions/plans.ts @@ -0,0 +1,34 @@ +"use server"; + +import type { Plan } from "@commet/node"; +import { unstable_cache } from "next/cache"; +import { commet } from "@/lib/commet"; + +const getCachedPlans = unstable_cache( + async () => { + const result = await commet.plans.list(); + if (!result.success || !result.data) { + throw new Error("Unable to load plans"); + } + return result.data; + }, + ["plans"], + { revalidate: 3600, tags: ["plans"] }, +); + +export async function getPlansAction(): Promise<{ + success: boolean; + data?: Plan[]; + error?: string; +}> { + try { + const plans = await getCachedPlans(); + return { + success: true, + data: plans, + }; + } catch (error) { + console.error("Error fetching plans:", error); + return { success: false, error: "Unable to load plans. Please try again." }; + } +} diff --git a/examples/webhooks/app/(auth)/layout.tsx b/examples/webhooks/app/(auth)/layout.tsx new file mode 100644 index 00000000..4a9cd3d5 --- /dev/null +++ b/examples/webhooks/app/(auth)/layout.tsx @@ -0,0 +1,11 @@ +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
{children}
+
+ ); +} diff --git a/examples/webhooks/app/(auth)/sign-in/page.tsx b/examples/webhooks/app/(auth)/sign-in/page.tsx new file mode 100644 index 00000000..055a9268 --- /dev/null +++ b/examples/webhooks/app/(auth)/sign-in/page.tsx @@ -0,0 +1,122 @@ +"use client"; + +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Suspense, useState } from "react"; +import { getPostAuthRedirect } from "@/actions/auth"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { signIn } from "@/lib/auth/auth-client"; +import { handlePostSignupCheckout } from "@/lib/payments/actions"; +import { normalizePlanCode } from "@/lib/plans"; +import { buildInternalHref, safeRedirectPath } from "@/lib/redirects"; + +function SignInContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const redirect = safeRedirectPath(searchParams.get("redirect")); + const planCode = normalizePlanCode(searchParams.get("planCode")); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setLoading(true); + setError(""); + + const formData = new FormData(event.currentTarget); + const email = formData.get("email") as string; + const password = formData.get("password") as string; + + const result = await signIn.email({ email, password }); + + if (result.error) { + setError(result.error.message || "Invalid credentials"); + setLoading(false); + return; + } + + if (planCode) { + const checkoutResult = await handlePostSignupCheckout(planCode); + if (checkoutResult.success && checkoutResult.checkoutUrl) { + window.location.href = checkoutResult.checkoutUrl; + return; + } + setError( + checkoutResult.error || + "We couldn't continue with checkout. Please try again.", + ); + setLoading(false); + return; + } + + router.push(await getPostAuthRedirect(redirect)); + } + + const signUpHref = planCode + ? buildInternalHref("/sign-up", { + planCode, + redirect: redirect !== "/dashboard" ? redirect : null, + }) + : "/sign-up"; + + return ( + + + Sign in + Enter your credentials to continue. + + + {planCode && ( +
+ Selected plan + + Sign in to continue with {planCode}. + +
+ )} +
+
+ + +
+
+ + +
+ {error && ( +

{error}

+ )} + +

+ Don't have an account?{" "} + + Sign up + +

+
+
+
+ ); +} + +export default function SignInPage() { + return ( + + + + ); +} diff --git a/examples/webhooks/app/(auth)/sign-up/page.tsx b/examples/webhooks/app/(auth)/sign-up/page.tsx new file mode 100644 index 00000000..0b98397b --- /dev/null +++ b/examples/webhooks/app/(auth)/sign-up/page.tsx @@ -0,0 +1,133 @@ +"use client"; + +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Suspense, useState } from "react"; +import { getPostAuthRedirect } from "@/actions/auth"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { signUp } from "@/lib/auth/auth-client"; +import { handlePostSignupCheckout } from "@/lib/payments/actions"; +import { normalizePlanCode } from "@/lib/plans"; +import { buildInternalHref, safeRedirectPath } from "@/lib/redirects"; + +function SignUpContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const redirect = safeRedirectPath(searchParams.get("redirect")); + const planCode = normalizePlanCode(searchParams.get("planCode")); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setLoading(true); + setError(""); + + const formData = new FormData(event.currentTarget); + const name = formData.get("name") as string; + const email = formData.get("email") as string; + const password = formData.get("password") as string; + + const result = await signUp.email({ name, email, password }); + + if (result.error) { + setError(result.error.message || "Something went wrong"); + setLoading(false); + return; + } + + if (planCode) { + const checkoutResult = await handlePostSignupCheckout(planCode); + if (checkoutResult.success && checkoutResult.checkoutUrl) { + window.location.href = checkoutResult.checkoutUrl; + return; + } + setError( + checkoutResult.error || + "We couldn't continue with checkout. Please try again.", + ); + setLoading(false); + return; + } + + router.push(await getPostAuthRedirect(redirect)); + } + + const signInHref = planCode + ? buildInternalHref("/sign-in", { + planCode, + redirect: redirect !== "/dashboard" ? redirect : null, + }) + : "/sign-in"; + + return ( + + + Create an account + Get started with your subscription. + + + {planCode && ( +
+ Selected plan + + Complete your signup to continue with {planCode}. + +
+ )} +
+
+ + +
+
+ + +
+
+ + +
+ {error && ( +

{error}

+ )} + +

+ Already have an account?{" "} + + Sign in + +

+
+
+
+ ); +} + +export default function SignUpPage() { + return ( + + + + ); +} diff --git a/examples/webhooks/app/(dashboard)/dashboard/billing/page.tsx b/examples/webhooks/app/(dashboard)/dashboard/billing/page.tsx new file mode 100644 index 00000000..b594e074 --- /dev/null +++ b/examples/webhooks/app/(dashboard)/dashboard/billing/page.tsx @@ -0,0 +1,94 @@ +import { ExternalLink } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { getUser } from "@/lib/auth/session"; +import { resolveAccessForBillingState } from "@/lib/billing/entitlements"; +import { getBillingStateForUser } from "@/lib/db/queries"; + +function formatStatus(status: string): string { + if (status === "none") return "No subscription"; + if (status === "past_due") return "Past due"; + return status.charAt(0).toUpperCase() + status.slice(1); +} + +export default async function BillingPage() { + const user = await getUser(); + const billing = await getBillingStateForUser(user!.id); + const access = resolveAccessForBillingState(billing); + const hasSubscription = access.status !== "none"; + + return ( +
+
+

Billing

+

+ Your subscription and payment details — synced from webhooks, no + Commet API call. +

+
+ + + +
+ Subscription + Your current plan and status. +
+ {hasSubscription && ( +
+ ); +} diff --git a/examples/webhooks/app/(dashboard)/dashboard/events/page.tsx b/examples/webhooks/app/(dashboard)/dashboard/events/page.tsx new file mode 100644 index 00000000..2a85bfcc --- /dev/null +++ b/examples/webhooks/app/(dashboard)/dashboard/events/page.tsx @@ -0,0 +1,96 @@ +import { AutoRefresh } from "@/components/auto-refresh"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { getRecentWebhookEvents } from "@/lib/db/queries"; +import { cn } from "@/lib/utils"; + +function eventBadgeClasses(event: string): string { + if (event.startsWith("payment.")) { + return "border-amber-600/30 bg-amber-600/10 text-amber-700 dark:text-amber-500"; + } + if (event.startsWith("invoice.")) { + return "border-border bg-muted text-muted-foreground"; + } + return "border-primary/30 bg-primary/10 text-primary"; +} + +function formatRelativeTime(receivedAt: Date): string { + const elapsedSeconds = Math.round((Date.now() - receivedAt.getTime()) / 1000); + if (elapsedSeconds < 60) return `${elapsedSeconds}s ago`; + if (elapsedSeconds < 3600) return `${Math.floor(elapsedSeconds / 60)}m ago`; + if (elapsedSeconds < 86400) + return `${Math.floor(elapsedSeconds / 3600)}h ago`; + return receivedAt.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); +} + +export default async function EventsPage() { + const events = await getRecentWebhookEvents(); + + return ( +
+ +
+

Events

+

+ Webhooks received from Commet for your account, newest first. + Refreshes every few seconds. +

+
+ + + + Webhook feed + + Last {events.length} events recorded by /api/webhooks/commet. + + + + {events.length === 0 ? ( +

+ No events yet. Run{" "} + + commet listen localhost:3008/api/webhooks/commet + {" "} + and subscribe to a plan to see webhooks arrive here. +

+ ) : ( + events.map((webhookEvent) => ( +
+ + + {webhookEvent.event} + + + {webhookEvent.commetCustomerId ?? "—"} + + + {formatRelativeTime(webhookEvent.receivedAt)} + + +
+                  {JSON.stringify(webhookEvent.payload, null, 2)}
+                
+
+ )) + )} +
+
+
+ ); +} diff --git a/examples/webhooks/app/(dashboard)/dashboard/layout.tsx b/examples/webhooks/app/(dashboard)/dashboard/layout.tsx new file mode 100644 index 00000000..b3c97426 --- /dev/null +++ b/examples/webhooks/app/(dashboard)/dashboard/layout.tsx @@ -0,0 +1,78 @@ +import { CreditCard, Home, Lock, Tags, Webhook } from "lucide-react"; +import Link from "next/link"; +import { PastDueBanner } from "@/components/past-due-banner"; +import { SignOutButton } from "@/components/shared/sign-out-button"; +import { Separator } from "@/components/ui/separator"; +import { getUser } from "@/lib/auth/session"; +import { hasUsableSubscription } from "@/lib/billing/entitlements"; +import { getBillingStateForUser } from "@/lib/db/queries"; +import { cn } from "@/lib/utils"; + +function NavLink({ + href, + children, + className, +}: { + href: string; + children: React.ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} + +export default async function DashboardInnerLayout({ + children, +}: { + children: React.ReactNode; +}) { + const user = await getUser(); + const billing = await getBillingStateForUser(user!.id); + const hasPlan = hasUsableSubscription(billing?.subscriptionStatus); + + return ( +
+ {billing?.subscriptionStatus === "past_due" && } +
+ +
{children}
+
+
+ ); +} diff --git a/examples/webhooks/app/(dashboard)/dashboard/page.tsx b/examples/webhooks/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 00000000..d0bc1f95 --- /dev/null +++ b/examples/webhooks/app/(dashboard)/dashboard/page.tsx @@ -0,0 +1,126 @@ +import { redirect } from "next/navigation"; +import { AutoRefresh } from "@/components/auto-refresh"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { getUser } from "@/lib/auth/session"; +import { + hasUsableSubscription, + resolveAccessForBillingState, +} from "@/lib/billing/entitlements"; +import { getBillingStateForUser } from "@/lib/db/queries"; +import type { BillingFeature } from "@/lib/db/schema"; + +function formatPeriodEnd(periodEnd: Date | null): string { + if (!periodEnd) return "—"; + return periodEnd.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +function formatStatus(status: string): string { + if (status === "none") return "No subscription"; + if (status === "past_due") return "Past due"; + return status.charAt(0).toUpperCase() + status.slice(1); +} + +function formatFeatureValue(feature: BillingFeature): string { + if (feature.unlimited) return "Unlimited"; + if (feature.type === "boolean") return feature.enabled ? "Enabled" : "Off"; + if (feature.type === "usage" && typeof feature.remaining === "number") { + return `${feature.remaining.toLocaleString()} remaining`; + } + if (typeof feature.included === "number") { + return feature.included.toLocaleString(); + } + if (typeof feature.current === "number") { + return feature.current.toLocaleString(); + } + return feature.allowed ? "Included" : "Not included"; +} + +export default async function DashboardPage() { + const user = await getUser(); + const billing = await getBillingStateForUser(user!.id); + + if (!hasUsableSubscription(billing?.subscriptionStatus)) { + redirect("/pricing"); + } + + const access = resolveAccessForBillingState(billing); + + return ( +
+ +
+

Dashboard

+

+ Your subscription, renewal date, and included limits. +

+
+ +
+ + + Current plan + Your current subscription. + + +
+ Plan + {access.planLabel} +
+
+ Status + + {formatStatus(access.status)} + +
+
+ + Current period ends + + + {formatPeriodEnd(billing?.currentPeriodEnd ?? null)} + +
+
+
+ + + + Features + Included limits and access. + + + {access.features.length === 0 ? ( +

+ No included features for this plan. +

+ ) : ( + access.features.map((feature) => ( +
+ + {feature.name} + + + {formatFeatureValue(feature)} + +
+ )) + )} +
+
+
+
+ ); +} diff --git a/examples/webhooks/app/(dashboard)/dashboard/security/page.tsx b/examples/webhooks/app/(dashboard)/dashboard/security/page.tsx new file mode 100644 index 00000000..00dbbfc2 --- /dev/null +++ b/examples/webhooks/app/(dashboard)/dashboard/security/page.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useActionState } from "react"; +import { updatePassword } from "@/actions/auth"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useFormToast } from "@/hooks/use-form-toast"; + +type PasswordState = { + currentPassword?: string; + newPassword?: string; + confirmPassword?: string; + error?: string; + success?: string; + fieldErrors?: Record; +}; + +export default function SecurityPage() { + const [passwordState, passwordAction, isPasswordPending] = useActionState< + PasswordState, + FormData + >(updatePassword, {}); + + useFormToast(passwordState); + + return ( +
+
+

Security

+

+ Manage your password and account security. +

+
+ + + + Change Password + Update your account password. + + +
+
+ + + {passwordState.fieldErrors?.currentPassword?.[0] && ( +

+ {passwordState.fieldErrors.currentPassword[0]} +

+ )} +
+
+ + + {passwordState.fieldErrors?.newPassword?.[0] && ( +

+ {passwordState.fieldErrors.newPassword[0]} +

+ )} +
+
+ + + {passwordState.fieldErrors?.confirmPassword?.[0] && ( +

+ {passwordState.fieldErrors.confirmPassword[0]} +

+ )} +
+ +
+
+
+
+ ); +} diff --git a/examples/webhooks/app/(dashboard)/layout.tsx b/examples/webhooks/app/(dashboard)/layout.tsx new file mode 100644 index 00000000..4e68f562 --- /dev/null +++ b/examples/webhooks/app/(dashboard)/layout.tsx @@ -0,0 +1,16 @@ +import { redirect } from "next/navigation"; +import { getUser } from "@/lib/auth/session"; + +export default async function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + const user = await getUser(); + + if (!user) { + redirect("/sign-in"); + } + + return <>{children}; +} diff --git a/examples/webhooks/app/api/auth/[...all]/route.ts b/examples/webhooks/app/api/auth/[...all]/route.ts new file mode 100644 index 00000000..57d84926 --- /dev/null +++ b/examples/webhooks/app/api/auth/[...all]/route.ts @@ -0,0 +1,6 @@ +import { toNextJsHandler } from "better-auth/next-js"; +import { auth } from "@/lib/auth/auth"; + +const { GET, POST } = toNextJsHandler(auth); + +export { GET, POST }; diff --git a/examples/webhooks/app/api/commet/portal/route.ts b/examples/webhooks/app/api/commet/portal/route.ts new file mode 100644 index 00000000..d3db0a03 --- /dev/null +++ b/examples/webhooks/app/api/commet/portal/route.ts @@ -0,0 +1,11 @@ +import { CustomerPortal } from "@commet/next"; +import { auth } from "@/lib/auth/auth"; +import { env } from "@/lib/env"; + +export const GET = CustomerPortal({ + apiKey: env.COMMET_API_KEY, + getCustomerId: async (req) => { + const session = await auth.api.getSession({ headers: req.headers }); + return session?.user.id ?? null; + }, +}); diff --git a/examples/webhooks/app/api/webhooks/commet/route.ts b/examples/webhooks/app/api/webhooks/commet/route.ts new file mode 100644 index 00000000..77b78e2d --- /dev/null +++ b/examples/webhooks/app/api/webhooks/commet/route.ts @@ -0,0 +1,109 @@ +import { createHash } from "node:crypto"; +import { type WebhookPayload, Webhooks } from "@commet/node"; +import { type NextRequest, NextResponse } from "next/server"; +import { + recordCurrentPeriod, + recordUsageFromEvent, + resolveActivatedUserForWelcome, + restoreAccessAfterPayment, + syncBillingStateFromSnapshot, +} from "@/lib/billing/sync"; +import { + markWebhookEventCompleted, + markWebhookEventFailed, + recordWebhookEvent, +} from "@/lib/billing/webhook-events"; +import { env } from "@/lib/env"; +import { sendWelcomeEmail } from "@/lib/notifications/email"; +import { notifyTeamOfNewSubscription } from "@/lib/notifications/team"; + +export async function POST(request: NextRequest) { + const webhooks = new Webhooks(); + const rawBody = await request.text(); + const signature = request.headers.get("x-commet-signature"); + + const isValid = webhooks.verify({ + payload: rawBody, + signature, + secret: env.COMMET_WEBHOOK_SECRET, + }); + + if (!isValid) { + return NextResponse.json( + { received: false, error: "Invalid signature" }, + { status: 403 }, + ); + } + + let payload: WebhookPayload; + try { + payload = JSON.parse(rawBody) as WebhookPayload; + } catch (error) { + console.error("[commet-webhook] invalid payload", { error }); + return NextResponse.json( + { received: false, error: "Invalid payload" }, + { status: 400 }, + ); + } + + const eventId = createHash("sha256").update(rawBody).digest("hex"); + const insertResult = await recordWebhookEvent({ eventId, payload }); + if (insertResult === "completed") { + return NextResponse.json({ received: true, duplicate: true }); + } + if (insertResult === "processing") { + return NextResponse.json( + { received: false, error: "Event is already processing" }, + { status: 400 }, + ); + } + + const criticalHandlers: Promise[] = []; + const notificationHandlers: Promise[] = []; + + switch (payload.event) { + case "customer.state_changed": + criticalHandlers.push(syncBillingStateFromSnapshot(payload.data)); + if (payload.data.trigger === "subscription_activated") { + notificationHandlers.push( + resolveActivatedUserForWelcome(payload.data).then((activatedUser) => + Promise.all([ + sendWelcomeEmail(activatedUser), + notifyTeamOfNewSubscription(activatedUser), + ]).then(() => undefined), + ), + ); + } + break; + + case "subscription.activated": + case "subscription.reactivated": + criticalHandlers.push(recordCurrentPeriod(payload.data)); + break; + + case "payment.received": + case "payment.recovered": + criticalHandlers.push(restoreAccessAfterPayment(payload.data)); + break; + + case "usage.recorded": + criticalHandlers.push(recordUsageFromEvent(payload.data)); + break; + } + + try { + await Promise.all(criticalHandlers); + } catch (error) { + await markWebhookEventFailed(eventId); + console.error("[commet-webhook] handler failed", { error, payload }); + return NextResponse.json( + { received: false, error: "Handler failed" }, + { status: 500 }, + ); + } + + await markWebhookEventCompleted(eventId); + await Promise.allSettled(notificationHandlers); + + return NextResponse.json({ received: true }, { status: 200 }); +} diff --git a/examples/webhooks/app/checkout/page.tsx b/examples/webhooks/app/checkout/page.tsx new file mode 100644 index 00000000..aa3e2367 --- /dev/null +++ b/examples/webhooks/app/checkout/page.tsx @@ -0,0 +1,33 @@ +import { redirect } from "next/navigation"; +import { env } from "@/lib/env"; +import { createCheckoutSession } from "@/lib/payments/commet"; +import { normalizePlanCode } from "@/lib/plans"; + +type CheckoutPageProps = { + searchParams: Promise<{ + planCode?: string | string[]; + }>; +}; + +function readPlanCode(planCode: string | string[] | undefined): string | null { + if (!planCode) return null; + if (Array.isArray(planCode)) { + return normalizePlanCode(planCode[0] ?? null); + } + return normalizePlanCode(planCode); +} + +export default async function CheckoutPage({ + searchParams, +}: CheckoutPageProps) { + const params = await searchParams; + const planCode = readPlanCode(params?.planCode); + + if (!planCode) { + redirect("/pricing?error=missing_plan"); + } + + const successUrl = `${env.NEXT_PUBLIC_APP_URL}/dashboard`; + + await createCheckoutSession({ planCode, successUrl }); +} diff --git a/examples/webhooks/app/globals.css b/examples/webhooks/app/globals.css new file mode 100644 index 00000000..e37ed8d9 --- /dev/null +++ b/examples/webhooks/app/globals.css @@ -0,0 +1,132 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(0.98 0.012 65); + --foreground: oklch(0.17 0.018 52); + --card: oklch(0.98 0.012 65); + --card-foreground: oklch(0.17 0.018 52); + --popover: oklch(0.98 0.012 65); + --popover-foreground: oklch(0.17 0.018 52); + --primary: oklch(0.2 0.018 52); + --primary-foreground: oklch(0.97 0.008 65); + --secondary: oklch(0.95 0.014 65); + --secondary-foreground: oklch(0.28 0.028 52); + --muted: oklch(0.95 0.014 65); + --muted-foreground: oklch(0.52 0.022 57); + --accent: oklch(0.95 0.014 65); + --accent-foreground: oklch(0.28 0.028 52); + --destructive: oklch(0.55 0.22 27); + --destructive-foreground: oklch(0.98 0.01 27); + --border: oklch(0.89 0.017 62); + --input: oklch(0.89 0.017 62); + --ring: oklch(0.2 0.018 52); + --chart-1: oklch(0.6 0.16 45); + --chart-2: oklch(0.6 0.1 175); + --chart-3: oklch(0.75 0.15 80); + --chart-4: oklch(0.45 0.12 35); + --chart-5: oklch(0.65 0.12 15); + --radius: 0px; + --sidebar-background: oklch(0.97 0.014 65); + --sidebar-foreground: oklch(0.17 0.018 52); + --sidebar-primary: oklch(0.2 0.018 52); + --sidebar-primary-foreground: oklch(0.97 0.008 65); + --sidebar-accent: oklch(0.95 0.014 65); + --sidebar-accent-foreground: oklch(0.28 0.028 52); + --sidebar-border: oklch(0.89 0.017 62); + --sidebar-ring: oklch(0.2 0.018 52); +} + +.dark { + --background: oklch(0.155 0.015 55); + --foreground: oklch(0.93 0.01 75); + --card: oklch(0.155 0.015 55); + --card-foreground: oklch(0.93 0.01 75); + --popover: oklch(0.155 0.015 55); + --popover-foreground: oklch(0.93 0.01 75); + --primary: oklch(0.93 0.015 60); + --primary-foreground: oklch(0.155 0.015 55); + --secondary: oklch(0.24 0.015 55); + --secondary-foreground: oklch(0.93 0.01 75); + --muted: oklch(0.24 0.015 55); + --muted-foreground: oklch(0.65 0.015 60); + --accent: oklch(0.24 0.015 55); + --accent-foreground: oklch(0.93 0.01 75); + --destructive: oklch(0.4 0.14 27); + --destructive-foreground: oklch(0.65 0.22 27); + --border: oklch(0.28 0.015 55); + --input: oklch(0.28 0.015 55); + --ring: oklch(0.6 0.12 50); + --chart-1: oklch(0.65 0.16 45); + --chart-2: oklch(0.65 0.1 175); + --chart-3: oklch(0.72 0.14 80); + --chart-4: oklch(0.55 0.12 35); + --chart-5: oklch(0.62 0.14 15); + --sidebar-background: oklch(0.185 0.015 55); + --sidebar-foreground: oklch(0.93 0.01 75); + --sidebar-primary: oklch(0.93 0.015 60); + --sidebar-primary-foreground: oklch(0.155 0.015 55); + --sidebar-accent: oklch(0.24 0.015 55); + --sidebar-accent-foreground: oklch(0.93 0.01 75); + --sidebar-border: oklch(0.28 0.015 55); + --sidebar-ring: oklch(0.5 0.1 50); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar-background); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --font-heading: var(--font-sans); + --font-sans: var(--font-sans); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} diff --git a/examples/webhooks/app/layout.tsx b/examples/webhooks/app/layout.tsx new file mode 100644 index 00000000..445e30df --- /dev/null +++ b/examples/webhooks/app/layout.tsx @@ -0,0 +1,50 @@ +import { Analytics } from "@vercel/analytics/next"; +import "./globals.css"; +import type { Metadata, Viewport } from "next"; +import { IBM_Plex_Sans } from "next/font/google"; +import { Toaster } from "sonner"; +import { TemplateHeader } from "@/components/template-header"; + +export const metadata: Metadata = { + title: "Webhooks SaaS", + description: + "Webhook-driven SaaS template with subscription billing via Commet.", + icons: { + icon: [ + { + media: "(prefers-color-scheme: light)", + url: "/favicon-light.svg", + }, + { + media: "(prefers-color-scheme: dark)", + url: "/favicon-dark.svg", + }, + ], + }, +}; + +export const viewport: Viewport = { + maximumScale: 1, +}; + +const ibmPlexSans = IBM_Plex_Sans({ + subsets: ["latin"], + weight: ["300", "400", "500", "600"], +}); + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + {children} + + + + + ); +} diff --git a/examples/webhooks/app/not-found.tsx b/examples/webhooks/app/not-found.tsx new file mode 100644 index 00000000..c7822c97 --- /dev/null +++ b/examples/webhooks/app/not-found.tsx @@ -0,0 +1,14 @@ +import Link from "next/link"; +import { Button } from "@/components/ui/button"; + +export default function NotFound() { + return ( +
+

404

+

Page not found.

+ +
+ ); +} diff --git a/examples/webhooks/app/page.tsx b/examples/webhooks/app/page.tsx new file mode 100644 index 00000000..1b41a6bd --- /dev/null +++ b/examples/webhooks/app/page.tsx @@ -0,0 +1,82 @@ +import { ArrowRight, Bell, Mail, Webhook } from "lucide-react"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { getUser } from "@/lib/auth/session"; + +function FeatureCard({ + icon: Icon, + title, + description, +}: { + icon: React.ComponentType<{ className?: string }>; + title: string; + description: string; +}) { + return ( +
+ +

{title}

+

{description}

+
+ ); +} + +export default async function HomePage() { + const user = await getUser(); + + if (user) { + redirect("/dashboard"); + } + + return ( +
+
+
+

+ Webhooks Template +

+

+ Commet notifies, your app reacts. Provisioning, dunning UX, and + local entitlements kept in sync by webhook events. +

+
+ + +
+
+ +
+ + + +
+
+ +
+ Built with Commet, Better Auth, and Next.js. +
+
+ ); +} diff --git a/examples/webhooks/app/pricing.md/route.ts b/examples/webhooks/app/pricing.md/route.ts new file mode 100644 index 00000000..119500f8 --- /dev/null +++ b/examples/webhooks/app/pricing.md/route.ts @@ -0,0 +1,9 @@ +import { PricingMarkdown } from "@commet/next"; +import { env } from "@/lib/env"; + +export const GET = PricingMarkdown({ + apiKey: env.COMMET_API_KEY, + title: "Webhooks SaaS Pricing", + description: + "Simple subscription pricing. Choose the plan that works for you. No hidden fees, no surprises.", +}); diff --git a/examples/webhooks/app/pricing/page.tsx b/examples/webhooks/app/pricing/page.tsx new file mode 100644 index 00000000..6f57eac1 --- /dev/null +++ b/examples/webhooks/app/pricing/page.tsx @@ -0,0 +1,146 @@ +import type { BillingInterval, Plan } from "@commet/node"; + +type PlanFeatureItem = NonNullable[number]; + +import { Check } from "lucide-react"; +import { getPlansAction } from "@/actions/plans"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { getUser } from "@/lib/auth/session"; +import { getBillingStateForUser } from "@/lib/db/queries"; +import { checkoutAction } from "@/lib/payments/actions"; + +function formatPrice(cents: number) { + return `$${(cents / 100).toFixed(0)}`; +} + +function formatBillingInterval(interval: BillingInterval): string { + if (interval === "weekly") return "wk"; + if (interval === "monthly") return "mo"; + if (interval === "yearly") return "yr"; + if (interval === "quarterly") return "qtr"; + return interval; +} + +function formatFeature(feature: PlanFeatureItem): string { + if (feature.type === "boolean" && feature.enabled) { + return feature.name; + } + if (feature.type === "usage" && feature.includedAmount !== null) { + return `${feature.includedAmount.toLocaleString()} ${feature.name} included`; + } + if (feature.type === "seats" && feature.includedAmount !== null) { + return `${feature.includedAmount} ${feature.name}`; + } + return feature.name; +} + +export default async function PricingPage() { + const user = await getUser(); + const billing = user ? await getBillingStateForUser(user.id) : null; + const hasLiveSubscription = + billing?.subscriptionStatus === "active" || + billing?.subscriptionStatus === "trialing"; + + const plansResult = await getPlansAction(); + const plans = plansResult.data || []; + + return ( +
+
+

Pricing

+

+ Simple, fixed pricing. Choose the plan that works for you. No hidden + fees, no surprises. +

+
+ + {plans.length === 0 ? ( +

+ No plans available yet. Configure plans in your Commet dashboard. +

+ ) : ( +
+ {plans.map((plan) => { + const defaultPrice = + plan.prices?.find((p) => p.isDefault) || plan.prices?.[0]; + const isCurrentPlan = + hasLiveSubscription && + billing?.planName?.toLowerCase() === plan.name.toLowerCase(); + + return ( + + + {plan.name} + {plan.description && ( + {plan.description} + )} + + +
+ + {defaultPrice ? formatPrice(defaultPrice.price) : "Free"} + + {defaultPrice && ( + + / + {formatBillingInterval( + defaultPrice.billingInterval || "monthly", + )} + + )} +
+
    + {plan.features?.map((feature) => ( +
  • + + {formatFeature(feature)} +
  • + ))} +
+
+ + {isCurrentPlan ? ( + + ) : hasLiveSubscription ? ( + + + )} + +
+ ); + })} +
+ )} +
+ ); +} diff --git a/examples/webhooks/components.json b/examples/webhooks/components.json new file mode 100644 index 00000000..f382eb79 --- /dev/null +++ b/examples/webhooks/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/examples/webhooks/components/auto-refresh.tsx b/examples/webhooks/components/auto-refresh.tsx new file mode 100644 index 00000000..744334cf --- /dev/null +++ b/examples/webhooks/components/auto-refresh.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export function AutoRefresh({ seconds }: { seconds: number }) { + const router = useRouter(); + + useEffect(() => { + const interval = setInterval(() => router.refresh(), seconds * 1000); + return () => clearInterval(interval); + }, [router, seconds]); + + return null; +} diff --git a/examples/webhooks/components/past-due-banner.tsx b/examples/webhooks/components/past-due-banner.tsx new file mode 100644 index 00000000..3887140f --- /dev/null +++ b/examples/webhooks/components/past-due-banner.tsx @@ -0,0 +1,33 @@ +import { AlertTriangle } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +export function PastDueBanner() { + return ( +
+
+ +

+ Your last payment failed.{" "} + + Premium features are paused until payment succeeds. + +

+
+
+ ); +} diff --git a/examples/webhooks/components/shared/sign-out-button.tsx b/examples/webhooks/components/shared/sign-out-button.tsx new file mode 100644 index 00000000..7bb3e788 --- /dev/null +++ b/examples/webhooks/components/shared/sign-out-button.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { LogOut } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { signOut } from "@/lib/auth/auth-client"; + +export function SignOutButton() { + const router = useRouter(); + + return ( + + ); +} diff --git a/examples/webhooks/components/shared/submit-button.tsx b/examples/webhooks/components/shared/submit-button.tsx new file mode 100644 index 00000000..312f05e5 --- /dev/null +++ b/examples/webhooks/components/shared/submit-button.tsx @@ -0,0 +1,40 @@ +"use client"; + +import type { Button as ButtonPrimitive } from "@base-ui/react/button"; +import type { VariantProps } from "class-variance-authority"; +import { Loader2 } from "lucide-react"; +import type { ReactNode } from "react"; +import type { buttonVariants } from "@/components/ui/button"; +import { Button } from "@/components/ui/button"; + +interface SubmitButtonProps + extends ButtonPrimitive.Props, + VariantProps { + isPending: boolean; + pendingText?: string; + icon?: ReactNode; +} + +export function SubmitButton({ + children, + isPending, + pendingText = "Saving...", + icon, + ...props +}: SubmitButtonProps) { + return ( + + ); +} diff --git a/examples/webhooks/components/template-header.tsx b/examples/webhooks/components/template-header.tsx new file mode 100644 index 00000000..e3a520b0 --- /dev/null +++ b/examples/webhooks/components/template-header.tsx @@ -0,0 +1,69 @@ +import { ExternalLink, Github } from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; + +function CommetLogo() { + return ( + + + + + + ); +} + +export function TemplateHeader({ templateName }: { templateName: string }) { + return ( +
+
+ + + + + + {templateName} + +
+
+ + +
+
+ ); +} diff --git a/examples/webhooks/components/ui/avatar.tsx b/examples/webhooks/components/ui/avatar.tsx new file mode 100644 index 00000000..173070bb --- /dev/null +++ b/examples/webhooks/components/ui/avatar.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"; + +import { cn } from "@/lib/utils"; + +function Avatar({ + className, + size = "default", + ...props +}: AvatarPrimitive.Root.Props & { + size?: "default" | "sm" | "lg"; +}) { + return ( + + ); +} + +function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) { + return ( + + ); +} + +function AvatarFallback({ + className, + ...props +}: AvatarPrimitive.Fallback.Props) { + return ( + + ); +} + +export { Avatar, AvatarFallback, AvatarImage }; diff --git a/examples/webhooks/components/ui/button.tsx b/examples/webhooks/components/ui/button.tsx new file mode 100644 index 00000000..75f141da --- /dev/null +++ b/examples/webhooks/components/ui/button.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { Button as ButtonPrimitive } from "@base-ui/react/button"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", + icon: "size-8", + "icon-xs": + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + "icon-sm": + "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +function Button({ + className, + variant = "default", + size = "default", + ...props +}: ButtonPrimitive.Props & VariantProps) { + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/examples/webhooks/components/ui/card.tsx b/examples/webhooks/components/ui/card.tsx new file mode 100644 index 00000000..29fa9097 --- /dev/null +++ b/examples/webhooks/components/ui/card.tsx @@ -0,0 +1,103 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className, + )} + {...props} + /> + ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +}; diff --git a/examples/webhooks/components/ui/form-field.tsx b/examples/webhooks/components/ui/form-field.tsx new file mode 100644 index 00000000..00285884 --- /dev/null +++ b/examples/webhooks/components/ui/form-field.tsx @@ -0,0 +1,41 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; + +interface FormFieldProps extends ComponentProps<"input"> { + label: string; + error?: string; + description?: string; +} + +export function FormField({ + label, + error, + description, + id, + className, + ...props +}: FormFieldProps) { + return ( +
+ + + {description && !error && ( +

{description}

+ )} + {error &&

{error}

} +
+ ); +} diff --git a/examples/webhooks/components/ui/input.tsx b/examples/webhooks/components/ui/input.tsx new file mode 100644 index 00000000..3f9abd53 --- /dev/null +++ b/examples/webhooks/components/ui/input.tsx @@ -0,0 +1,20 @@ +import { Input as InputPrimitive } from "@base-ui/react/input"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/examples/webhooks/components/ui/label.tsx b/examples/webhooks/components/ui/label.tsx new file mode 100644 index 00000000..bad18134 --- /dev/null +++ b/examples/webhooks/components/ui/label.tsx @@ -0,0 +1,21 @@ +"use client"; + +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Label({ className, ...props }: React.ComponentProps<"label">) { + return ( + // biome-ignore lint/a11y/noLabelWithoutControl: htmlFor passed via props +