page.tsx171 lines · main
1import Link from 'next/link';
2import { redirect } from 'next/navigation';
3
4import { apiFetch, apiJson } from '../../../../../../lib/api';
5
6export const metadata = {
7 title: 'new project from a template',
8};
9
10interface Org {
11 id: string;
12 slug: string;
13 name: string;
14 personal: boolean;
15}
16
17// Display metadata for the starter templates. The `id`s MUST match the
18// server-side template registry in apps/api (src/templates/index.ts); the
19// api validates the id when apply-template is called.
20const TEMPLATES = [
21 { id: 'contacts-crm', icon: '👥', name: 'Contacts / CRM', blurb: 'people, companies and deals — a simple customer manager.' },
22 { id: 'inventory', icon: '📦', name: 'Inventory / stock', blurb: 'products, stock levels and suppliers.' },
23 { id: 'bookings', icon: '📅', name: 'Bookings / appointments', blurb: 'clients, services and appointments.' },
24 { id: 'tasks', icon: '✅', name: 'Project / tasks', blurb: 'projects, tasks, status and due dates.' },
25] as const;
26
27async function createFromTemplate(formData: FormData) {
28 'use server';
29 const name = String(formData.get('name') ?? '').trim();
30 const region = String(formData.get('region') ?? '').trim() || undefined;
31 const orgId = String(formData.get('orgId') ?? '').trim() || undefined;
32 const templateId = String(formData.get('templateId') ?? '').trim();
33
34 const res = await apiFetch('/v1/projects', {
35 method: 'POST',
36 headers: { 'content-type': 'application/json' },
37 body: JSON.stringify({ name, region, orgId }),
38 });
39 if (!res.ok) {
40 const body = await res.text().catch(() => '');
41 throw new Error(`project create failed (${res.status}): ${body}`);
42 }
43 const data = (await res.json()) as { project: { id: string } };
44
45 // Seed the chosen template. Best-effort: the project already exists, so a
46 // seed failure shouldn't strand the user — they just land on an empty db.
47 if (templateId) {
48 const seed = await apiFetch(`/v1/projects/${data.project.id}/studio/apply-template`, {
49 method: 'POST',
50 headers: { 'content-type': 'application/json' },
51 body: JSON.stringify({ templateId }),
52 });
53 if (!seed.ok) {
54 const body = await seed.text().catch(() => '');
55 console.error(`template seed failed (${seed.status}) for ${data.project.id}: ${body}`);
56 }
57 }
58
59 redirect(`/dashboard/projects/${data.project.id}`);
60}
61
62export default async function NewFromTemplatePage({
63 searchParams,
64}: {
65 searchParams: Promise<{ t?: string }>;
66}) {
67 const { t } = await searchParams;
68 const selected = TEMPLATES.some((x) => x.id === t) ? t : TEMPLATES[0].id;
69
70 // Load the user's orgs so they can pick where the project lives (same as
71 // the blank-create flow).
72 const { orgs } = await apiJson<{ orgs: Org[] }>('/v1/me/orgs');
73 const sorted = [...orgs.filter((o) => o.personal), ...orgs.filter((o) => !o.personal)];
74
75 return (
76 <section className="max-w-2xl">
77 <p className="mb-4 font-mono text-xs text-[var(--color-text-muted)]">
78 <Link href="/dashboard/projects/new" className="hover:text-[var(--color-text)]">
79 ← back
80 </Link>
81 </p>
82 <header className="mb-8">
83 <h1 className="font-mono text-xl tracking-tight">new project · from a template</h1>
84 <p className="mt-1 font-mono text-sm text-[var(--color-text-muted)]">
85 pick what you want to track — briven builds the tables and some example rows so you
86 start with a working database, not a blank screen. you can change anything afterwards.
87 </p>
88 </header>
89
90 <form action={createFromTemplate} className="flex flex-col gap-6">
91 <fieldset className="flex flex-col gap-3">
92 <legend className="mb-1 font-mono text-xs uppercase tracking-wider text-[var(--color-text-subtle)]">
93 template
94 </legend>
95 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
96 {TEMPLATES.map((tpl) => (
97 <label
98 key={tpl.id}
99 className="flex cursor-pointer items-start gap-3 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-4 transition hover:border-[var(--color-border-strong)] has-[:checked]:border-[var(--color-primary)] has-[:checked]:bg-[var(--color-primary-subtle)]"
100 >
101 <input
102 type="radio"
103 name="templateId"
104 value={tpl.id}
105 defaultChecked={tpl.id === selected}
106 className="mt-1"
107 />
108 <span className="flex-1">
109 <span className="flex items-center gap-2 font-mono text-sm text-[var(--color-text)]">
110 <span aria-hidden>{tpl.icon}</span> {tpl.name}
111 </span>
112 <span className="mt-1 block font-mono text-xs text-[var(--color-text-muted)]">
113 {tpl.blurb}
114 </span>
115 </span>
116 </label>
117 ))}
118 </div>
119 </fieldset>
120
121 <label className="flex flex-col gap-2">
122 <span className="font-mono text-xs text-[var(--color-text-muted)]">org</span>
123 <select
124 name="orgId"
125 defaultValue={sorted[0]?.id}
126 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 font-mono text-sm outline-none focus:border-[var(--color-primary)]"
127 >
128 {sorted.map((o) => (
129 <option key={o.id} value={o.id}>
130 {o.name} {o.personal ? '· personal' : '· team'}
131 </option>
132 ))}
133 </select>
134 </label>
135
136 <label className="flex flex-col gap-2">
137 <span className="font-mono text-xs text-[var(--color-text-muted)]">name</span>
138 <input
139 name="name"
140 type="text"
141 required
142 maxLength={80}
143 placeholder="my app"
144 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 font-mono text-sm outline-none focus:border-[var(--color-primary)]"
145 />
146 </label>
147
148 <label className="flex flex-col gap-2">
149 <span className="font-mono text-xs text-[var(--color-text-muted)]">region</span>
150 <select
151 name="region"
152 defaultValue="eu-west-1"
153 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 font-mono text-sm outline-none focus:border-[var(--color-primary)]"
154 >
155 <option value="eu-west-1">eu-west-1 · frankfurt</option>
156 <option value="us-east-1">us-east-1 · virginia</option>
157 </select>
158 </label>
159
160 <div className="flex gap-3">
161 <button
162 type="submit"
163 className="rounded-md bg-[var(--color-primary)] px-4 py-2 font-mono text-sm font-medium text-[var(--color-text-inverse)] transition hover:bg-[var(--color-primary-hover)]"
164 >
165 create with this template
166 </button>
167 </div>
168 </form>
169 </section>
170 );
171}