Skip to content

Commit 6cbf876

Browse files
committed
admin services and backend validation
1 parent c38085b commit 6cbf876

3 files changed

Lines changed: 330 additions & 9 deletions

File tree

app/dashboard/services/actions.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const statusSchema = z.enum(["active", "disabled", "archived", "deleted"]);
2727

2828
const baseFields = z.object({
2929
title: z.string().min(1, "Title is required").max(500),
30-
description: z.string().max(5000).default(""),
30+
description: z.string().min(1, "Description is required").max(5000),
3131
type: serviceTypeSchema,
3232
duration_minutes: z.coerce.number().int().min(1).max(24 * 60),
3333
scheduled_at: z.string().optional(),
@@ -40,7 +40,7 @@ const ALLOWED_TRANSITIONS: Record<
4040
> = {
4141
active: ["disabled", "archived"],
4242
disabled: ["active", "archived"],
43-
archived: ["active", "deleted"],
43+
archived: ["deleted"],
4444
deleted: [],
4545
};
4646

@@ -81,7 +81,10 @@ export async function createService(
8181
}
8282

8383
let scheduledAtValue: unknown = null;
84-
if (type === "booking" && scheduled_at) {
84+
if (type === "booking") {
85+
if (!scheduled_at || !scheduled_at.trim()) {
86+
return { errors: { scheduled_at: ["Bookings require a JSON array of dates"] } };
87+
}
8588
const result = parseScheduledAt(scheduled_at);
8689
if (!result.ok) {
8790
return { errors: { scheduled_at: [result.error] } };
@@ -135,7 +138,7 @@ export async function createService(
135138
const updateFields = z.object({
136139
service_id: z.string().uuid(),
137140
title: z.string().min(1, "Title cannot be empty").max(500).optional(),
138-
description: z.string().max(5000).optional(),
141+
description: z.string().min(1, "Description cannot be empty").max(5000).optional(),
139142
duration_minutes: z.coerce.number().int().min(1).max(24 * 60).optional(),
140143
scheduled_at: z.string().optional(),
141144
price_cad: z.string().min(1, "Price cannot be empty").optional(),
@@ -162,11 +165,11 @@ export async function updateService(
162165

163166
const parsed = updateFields.safeParse({
164167
service_id: field(formData, "service_id"),
165-
title: field(formData, "title"),
166-
description: field(formData, "description"),
167-
duration_minutes: field(formData, "duration_minutes"),
168-
scheduled_at: field(formData, "scheduled_at"),
169-
price_cad: field(formData, "price_cad"),
168+
title: field(formData, "title") || undefined,
169+
description: field(formData, "description") || undefined,
170+
duration_minutes: field(formData, "duration_minutes") || undefined,
171+
scheduled_at: field(formData, "scheduled_at") || undefined,
172+
price_cad: field(formData, "price_cad") || undefined,
170173
});
171174
if (!parsed.success) {
172175
return { errors: parsed.error.flatten().fieldErrors };

app/dashboard/services/page.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { Suspense } from "react";
2+
import { ServicesPlayground } from "./service-forms";
3+
import { listServices } from "./queries";
4+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
5+
6+
async function ServicesList() {
7+
const services = await listServices();
8+
9+
return (
10+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full max-w-5xl mb-8">
11+
{services.length === 0 && (
12+
<div className="col-span-full p-4 border rounded text-muted-foreground text-center">No services found.</div>
13+
)}
14+
{services.map(svc => (
15+
<Card key={svc.id} className="shadow-sm">
16+
<CardHeader>
17+
<CardTitle>{svc.title || "Untitled"}</CardTitle>
18+
<CardDescription className="uppercase text-xs tracking-wider font-semibold flex justify-between items-center">
19+
<span>{svc.type.replace("_", " ")}</span>
20+
<span className={`px-2 py-0.5 rounded-full text-[10px] ${svc.status === 'active' ? 'bg-green-100 text-green-800' : svc.status === 'disabled' ? 'bg-amber-100 text-amber-800' : 'bg-gray-200 text-gray-800'}`}>
21+
{svc.status}
22+
</span>
23+
</CardDescription>
24+
</CardHeader>
25+
<CardContent>
26+
<p className="text-sm mb-4 text-muted-foreground line-clamp-2">{svc.description || "No description."}</p>
27+
<div className="text-sm font-medium flex items-center">
28+
{svc.priceCents ? `$${(svc.priceCents / 100).toFixed(2)} CAD` : "Free"}
29+
<span className="text-muted-foreground font-normal bg-muted px-1.5 py-0.5 rounded ml-2">duration: {svc.durationMinutes} min</span>
30+
</div>
31+
{Array.isArray(svc.scheduledAt) && svc.scheduledAt.length > 0 && (
32+
<div className="mt-2 text-xs text-muted-foreground">
33+
<strong>Dates:</strong> {svc.scheduledAt.length} preset dates
34+
</div>
35+
)}
36+
<div className="text-[10px] text-muted-foreground mt-4 break-all bg-muted/50 p-1.5 rounded">
37+
ID: <span className="font-mono">{svc.id}</span>
38+
</div>
39+
</CardContent>
40+
</Card>
41+
))}
42+
</div>
43+
);
44+
}
45+
46+
export default function ServicesPage() {
47+
return (
48+
<main className="flex min-h-screen flex-col items-center p-8 bg-muted/20">
49+
<div className="w-full max-w-5xl mb-6">
50+
<h1 className="text-3xl font-bold mb-2">Services Dashboard</h1>
51+
<p className="text-muted-foreground">Manage your coaching and booking services.</p>
52+
</div>
53+
54+
<Suspense fallback={<div className="w-full max-w-5xl mb-8 p-8 text-center text-muted-foreground border border-dashed rounded-lg">Loading services from cache...</div>}>
55+
<ServicesList />
56+
</Suspense>
57+
58+
<div className="w-full max-w-5xl mt-4 mb-8">
59+
<h2 className="text-xl font-bold mb-4">Playground Actions</h2>
60+
<ServicesPlayground />
61+
</div>
62+
</main>
63+
);
64+
}
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
"use client";
2+
3+
import { useActionState } from "react";
4+
import { createService, updateService, setServiceStatus } from "./actions";
5+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
6+
import { Button } from "@/components/ui/button";
7+
import { Input } from "@/components/ui/input";
8+
import { Label } from "@/components/ui/label";
9+
10+
function CreateServiceCard() {
11+
const [state, action, isPending] = useActionState(createService, null);
12+
13+
return (
14+
<Card className="w-full shadow-sm">
15+
<CardHeader>
16+
<CardTitle className="text-xl">Create Service</CardTitle>
17+
<CardDescription>Test creating a new service in DB and Stripe.</CardDescription>
18+
</CardHeader>
19+
<CardContent>
20+
{state?.message && (
21+
<div className="bg-green-50 text-green-900 border border-green-200 p-3 rounded-md mb-4 text-sm">
22+
{state.message}
23+
</div>
24+
)}
25+
{state?.errors?._form && (
26+
<div className="bg-destructive/15 text-destructive border border-destructive/20 p-3 rounded-md mb-4 text-sm">
27+
{state.errors._form[0]}
28+
</div>
29+
)}
30+
31+
<form action={action} className="space-y-4" noValidate>
32+
<div className="space-y-2">
33+
<Label htmlFor="title">Service Title</Label>
34+
<Input id="title" name="title" placeholder="e.g., Parent Coaching" />
35+
{state?.errors?.title && (
36+
<p className="text-xs text-destructive">{state.errors.title[0]}</p>
37+
)}
38+
</div>
39+
40+
<div className="space-y-2">
41+
<Label htmlFor="description">Description (Required)</Label>
42+
<Input id="description" name="description" placeholder="A short explanation" />
43+
{state?.errors?.description && (
44+
<p className="text-xs text-destructive">{state.errors.description[0]}</p>
45+
)}
46+
</div>
47+
48+
<div className="grid grid-cols-2 gap-4">
49+
<div className="space-y-2">
50+
<Label htmlFor="price_cad">Price (CAD)</Label>
51+
<div className="relative">
52+
<span className="absolute left-3 top-2.5 text-muted-foreground text-sm">$</span>
53+
<Input id="price_cad" name="price_cad" placeholder="99.00" className="pl-6" />
54+
</div>
55+
{state?.errors?.price_cad && (
56+
<p className="text-xs text-destructive">{state.errors.price_cad[0]}</p>
57+
)}
58+
</div>
59+
60+
<div className="space-y-2">
61+
<Label htmlFor="duration_minutes">Duration (Min)</Label>
62+
<Input id="duration_minutes" name="duration_minutes" defaultValue="60" />
63+
{state?.errors?.duration_minutes && (
64+
<p className="text-xs text-destructive">{state.errors.duration_minutes[0]}</p>
65+
)}
66+
</div>
67+
</div>
68+
69+
<div className="space-y-2">
70+
<Label htmlFor="scheduled_at">Scheduled Dates (JSON Array)</Label>
71+
<Input id="scheduled_at" name="scheduled_at" placeholder='["2026-05-01T10:00:00Z"]' />
72+
{state?.errors?.scheduled_at && (
73+
<p className="text-xs text-destructive">{state.errors.scheduled_at[0]}</p>
74+
)}
75+
<p className="text-xs text-muted-foreground mt-1">Required for Bookings. Ignore for Coaching.</p>
76+
</div>
77+
78+
<div className="space-y-2">
79+
<Label htmlFor="type">Service Type</Label>
80+
<select
81+
id="type"
82+
name="type"
83+
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background disabled:cursor-not-allowed disabled:opacity-50"
84+
>
85+
<option value="coaching_session">Coaching Session</option>
86+
<option value="booking">Booking</option>
87+
</select>
88+
</div>
89+
90+
<div className="pt-2">
91+
<Button type="submit" className="w-full" disabled={isPending}>
92+
{isPending ? "Connecting to Stripe..." : "Create Service"}
93+
</Button>
94+
</div>
95+
</form>
96+
</CardContent>
97+
</Card>
98+
);
99+
}
100+
101+
function EditServiceCard() {
102+
const [state, action, isPending] = useActionState(updateService, null);
103+
104+
return (
105+
<Card className="w-full shadow-sm">
106+
<CardHeader>
107+
<CardTitle className="text-xl">Edit Service</CardTitle>
108+
<CardDescription>Paste a Service ID (UUID) to update details.</CardDescription>
109+
</CardHeader>
110+
<CardContent>
111+
{state?.message && (
112+
<div className="bg-green-50 text-green-900 border border-green-200 p-3 rounded-md mb-4 text-sm">
113+
{state.message}
114+
</div>
115+
)}
116+
{state?.errors?._form && (
117+
<div className="bg-destructive/15 text-destructive border border-destructive/20 p-3 rounded-md mb-4 text-sm">
118+
{state.errors._form[0]}
119+
</div>
120+
)}
121+
122+
<form action={action} className="space-y-4" noValidate>
123+
<div className="space-y-2">
124+
<Label htmlFor="service_id">Service ID (UUID) *</Label>
125+
<Input id="service_id" name="service_id" placeholder="Copy from Supabase" />
126+
{state?.errors?.service_id && (
127+
<p className="text-xs text-destructive">{state.errors.service_id[0]}</p>
128+
)}
129+
</div>
130+
131+
<div className="space-y-2">
132+
<Label htmlFor="edit-title">New Title</Label>
133+
<Input id="edit-title" name="title" placeholder="Optional" />
134+
{state?.errors?.title && (
135+
<p className="text-xs text-destructive">{state.errors.title[0]}</p>
136+
)}
137+
</div>
138+
139+
<div className="space-y-2">
140+
<Label htmlFor="edit-description">New Description</Label>
141+
<Input id="edit-description" name="description" placeholder="Optional" />
142+
{state?.errors?.description && (
143+
<p className="text-xs text-destructive">{state.errors.description[0]}</p>
144+
)}
145+
</div>
146+
147+
<div className="grid grid-cols-2 gap-4">
148+
<div className="space-y-2">
149+
<Label htmlFor="edit-price_cad">New Price (CAD)</Label>
150+
<div className="relative">
151+
<span className="absolute left-3 top-2.5 text-muted-foreground text-sm">$</span>
152+
<Input id="edit-price_cad" name="price_cad" placeholder="Optional" className="pl-6" />
153+
</div>
154+
{state?.errors?.price_cad && (
155+
<p className="text-xs text-destructive">{state.errors.price_cad[0]}</p>
156+
)}
157+
</div>
158+
159+
<div className="space-y-2">
160+
<Label htmlFor="edit-duration_minutes">Duration (Min)</Label>
161+
<Input id="edit-duration_minutes" name="duration_minutes" placeholder="Optional" />
162+
{state?.errors?.duration_minutes && (
163+
<p className="text-xs text-destructive">{state.errors.duration_minutes[0]}</p>
164+
)}
165+
</div>
166+
</div>
167+
168+
<div className="space-y-2">
169+
<Label htmlFor="edit-scheduled_at">New Scheduled Dates (JSON Array)</Label>
170+
<Input id="edit-scheduled_at" name="scheduled_at" placeholder='Optional' />
171+
{state?.errors?.scheduled_at && (
172+
<p className="text-xs text-destructive">{state.errors.scheduled_at[0]}</p>
173+
)}
174+
</div>
175+
176+
<p className="text-xs text-muted-foreground">Note: Service Type is immutable.</p>
177+
178+
<div className="pt-2">
179+
<Button type="submit" variant="secondary" className="w-full" disabled={isPending}>
180+
{isPending ? "Connecting to Stripe..." : "Update Service"}
181+
</Button>
182+
</div>
183+
</form>
184+
</CardContent>
185+
</Card>
186+
);
187+
}
188+
189+
function UpdateStatusCard() {
190+
const [state, action, isPending] = useActionState(setServiceStatus, null);
191+
192+
return (
193+
<Card className="w-full shadow-sm border-amber-200">
194+
<CardHeader>
195+
<CardTitle className="text-xl">Set Status</CardTitle>
196+
<CardDescription>Archive or disable an existing service.</CardDescription>
197+
</CardHeader>
198+
<CardContent>
199+
{state?.message && (
200+
<div className="bg-green-50 text-green-900 border border-green-200 p-3 rounded-md mb-4 text-sm">
201+
{state.message}
202+
</div>
203+
)}
204+
{state?.errors?._form && (
205+
<div className="bg-destructive/15 text-destructive border border-destructive/20 p-3 rounded-md mb-4 text-sm">
206+
{state.errors._form[0]}
207+
</div>
208+
)}
209+
210+
<form action={action} className="space-y-4" noValidate>
211+
<div className="space-y-2">
212+
<Label htmlFor="status_service_id">Service ID (UUID) *</Label>
213+
<Input id="status_service_id" name="service_id" placeholder="Copy from Supabase" />
214+
{state?.errors?.service_id && (
215+
<p className="text-xs text-destructive">{state.errors.service_id[0]}</p>
216+
)}
217+
</div>
218+
219+
<div className="space-y-2">
220+
<Label htmlFor="status">New Status</Label>
221+
<select
222+
id="status"
223+
name="status"
224+
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background disabled:cursor-not-allowed disabled:opacity-50"
225+
>
226+
<option value="active">Active</option>
227+
<option value="disabled">Disabled</option>
228+
<option value="archived">Archived</option>
229+
</select>
230+
{state?.errors?.status && (
231+
<p className="text-xs text-destructive">{state.errors.status[0]}</p>
232+
)}
233+
</div>
234+
235+
<div className="pt-2">
236+
<Button type="submit" variant="destructive" className="w-full" disabled={isPending}>
237+
{isPending ? "Connecting to Stripe..." : "Change Status"}
238+
</Button>
239+
</div>
240+
</form>
241+
</CardContent>
242+
</Card>
243+
);
244+
}
245+
246+
export function ServicesPlayground() {
247+
return (
248+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 w-full max-w-5xl items-start">
249+
<CreateServiceCard />
250+
<EditServiceCard />
251+
<UpdateStatusCard />
252+
</div>
253+
);
254+
}

0 commit comments

Comments
 (0)