mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
feat(intake): structured per-service intake questions
Services declare up to 8 intake fields (text/yesno, required) stored as JSONB; shared/intake.go is the single normalization point — slug keys, label/dup/unknown-key rules, yes/no canonicalization. Booking create validates answers against the service declaration (missing required -> 400), unknown keys are dropped before persist. Answers surface on UpcomingBooking and CustomerBookingView; the services page gains a compact editor and the public form renders declared questions under contact details. Verified live end-to-end. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
ad60167a31
commit
33b6e2a2a6
20 files changed
+598
-41
No files matched your search
@@ -468,6 +468,13 @@ const dictionaries = {
|
||||
"dashboard.service.edit": "Upravit službu",
|
||||
"dashboard.service.name": "Název služby",
|
||||
"dashboard.service.namePlaceholder": "např. Dámský střih",
|
||||
"dashboard.service.intake": "Dotazník před návštěvou",
|
||||
"dashboard.service.intakeAdd": "Přidat otázku",
|
||||
"dashboard.service.intakeLabel": "Otázka pro zákazníka",
|
||||
"dashboard.service.intakeText": "Text",
|
||||
"dashboard.service.intakeYesNo": "Ano/Ne",
|
||||
"dashboard.service.intakeRequired": "Povinná",
|
||||
"dashboard.service.intakeHint": "Zákazníci odpoví při rezervaci. Odpovědi uvidíte v detailu rezervace.",
|
||||
"dashboard.service.duration": "Délka (min)",
|
||||
"dashboard.service.bufferBefore": "Přestávka před",
|
||||
"dashboard.service.bufferAfter": "Přestávka po",
|
||||
@@ -519,6 +526,7 @@ const dictionaries = {
|
||||
"dashboard.bookingModal.status": "Stav",
|
||||
"dashboard.bookingModal.reference": "Reference",
|
||||
"dashboard.bookingModal.notes": "Poznámky",
|
||||
"dashboard.bookingModal.intake": "Odpovědi z dotazníku",
|
||||
"dashboard.bookingModal.reschedule": "Přeplánovat",
|
||||
"dashboard.bookingModal.confirmCancel": "Opravdu chcete zrušit tuto rezervaci?",
|
||||
"dashboard.bookingModal.createdAt": "Vytvořeno",
|
||||
@@ -563,6 +571,8 @@ const dictionaries = {
|
||||
"booking.customer.email": "E-mail",
|
||||
"booking.customer.phone": "Telefon",
|
||||
"booking.customer.notes": "Poznámka",
|
||||
"booking.intakeYes": "Ano",
|
||||
"booking.intakeNo": "Ne",
|
||||
"booking.customerRequired": "Před rezervací vyplňte jméno a e-mail.",
|
||||
"booking.failed": "Rezervaci se nepodařilo vytvořit",
|
||||
"booking.created": "Rezervace vytvořena",
|
||||
@@ -1087,6 +1097,13 @@ const dictionaries = {
|
||||
"dashboard.service.edit": "Edit service",
|
||||
"dashboard.service.name": "Service name",
|
||||
"dashboard.service.namePlaceholder": "e.g. Women's haircut",
|
||||
"dashboard.service.intake": "Pre-visit questions",
|
||||
"dashboard.service.intakeAdd": "Add question",
|
||||
"dashboard.service.intakeLabel": "Question for the customer",
|
||||
"dashboard.service.intakeText": "Text",
|
||||
"dashboard.service.intakeYesNo": "Yes/No",
|
||||
"dashboard.service.intakeRequired": "Required",
|
||||
"dashboard.service.intakeHint": "Customers answer these when booking. Answers appear in the booking detail.",
|
||||
"dashboard.service.duration": "Duration (min)",
|
||||
"dashboard.service.bufferBefore": "Buffer before",
|
||||
"dashboard.service.bufferAfter": "Buffer after",
|
||||
@@ -1138,6 +1155,7 @@ const dictionaries = {
|
||||
"dashboard.bookingModal.status": "Status",
|
||||
"dashboard.bookingModal.reference": "Reference",
|
||||
"dashboard.bookingModal.notes": "Notes",
|
||||
"dashboard.bookingModal.intake": "Intake answers",
|
||||
"dashboard.bookingModal.reschedule": "Reschedule",
|
||||
"dashboard.bookingModal.confirmCancel": "Are you sure you want to cancel this booking?",
|
||||
"dashboard.bookingModal.createdAt": "Created",
|
||||
@@ -1182,6 +1200,8 @@ const dictionaries = {
|
||||
"booking.customer.email": "Email",
|
||||
"booking.customer.phone": "Phone",
|
||||
"booking.customer.notes": "Note",
|
||||
"booking.intakeYes": "Yes",
|
||||
"booking.intakeNo": "No",
|
||||
"booking.customerRequired": "Add your name and email before booking.",
|
||||
"booking.failed": "Booking failed",
|
||||
"booking.created": "Booking created",
|
||||
|
||||
@@ -391,6 +391,24 @@ export function BookingManageRoute() {
|
||||
<p class="text-sm text-ink mt-1">{b().notes}</p>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={b().intake && Object.keys(b().intake).length > 0}>
|
||||
<div class="mt-3 pt-3 border-t border-border">
|
||||
<p class="text-sm text-ink-muted">{i18n.t("dashboard.bookingModal.intake")}</p>
|
||||
<div class="space-y-1 mt-1">
|
||||
<For each={Object.entries(b().intake)}>
|
||||
{([key, raw]) => {
|
||||
const value = String(raw);
|
||||
return (
|
||||
<p class="text-sm text-ink">
|
||||
<span class="text-ink-muted">{key.replace(/_/g, " ")}: </span>
|
||||
{value === "yes" || value === "no" ? i18n.t(`booking.intake${value === "yes" ? "Yes" : "No"}`) : value}
|
||||
</p>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
|
||||
@@ -40,7 +40,7 @@ function createDemoData(i18n: ReturnType<typeof useI18n>) {
|
||||
{ id: "b4", customerName: customers[3].name, customerEmail: customers[3].email, service: services[3], status: "cancelled", startsAt: fmt(at(-1, 11)), endsAt: fmt(at(-1, 12)), reference: "BOK-004", location: salonA, staffName: staff1, notes: "" },
|
||||
{ id: "b11", customerName: customers[2].name, customerEmail: customers[2].email, service: services[0], status: "completed", startsAt: fmt(at(-1, 15, 30)), endsAt: fmt(at(-1, 16, 30)), reference: "BOK-011", location: salonA, staffName: staff2, notes: "" },
|
||||
// today
|
||||
{ id: "b1", customerName: customers[0].name, customerEmail: customers[0].email, service: services[0], status: "confirmed", startsAt: fmt(at(0, 10)), endsAt: fmt(at(0, 11)), reference: "BOK-001", location: salonA, staffName: staff1, notes: "" },
|
||||
{ id: "b1", customerName: customers[0].name, customerEmail: customers[0].email, service: services[0], status: "confirmed", startsAt: fmt(at(0, 10)), endsAt: fmt(at(0, 11)), reference: "BOK-001", location: salonA, staffName: staff1, notes: "", intake: cs ? { "zdravotni_poznamky": "Citlivá pokožka", "prvni_navsteva": "yes" } : { "health_notes": "Sensitive skin", "first_visit": "yes" } },
|
||||
{ id: "b7", customerName: customers[1].name, customerEmail: customers[1].email, service: services[2], status: "confirmed", startsAt: fmt(at(0, 16)), endsAt: fmt(at(0, 17)), reference: "BOK-007", location: salonB, staffName: "", notes: "" },
|
||||
// upcoming
|
||||
{ id: "b2", customerName: customers[1].name, customerEmail: customers[1].email, service: services[1], status: "pending", startsAt: fmt(at(1, 14)), endsAt: fmt(at(1, 15)), reference: "BOK-002", location: salonA, staffName: staff2, notes: "" },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Show, createEffect, createSignal, type JSX, type ParentComponent } from "solid-js";
|
||||
import { For, Show, createEffect, createSignal, type JSX, type ParentComponent } from "solid-js";
|
||||
import { A, useLocation } from "@solidjs/router";
|
||||
import { useTheme } from "../../providers/theme-provider";
|
||||
import { BookraCharacter } from "../../components/bookra-character";
|
||||
@@ -329,6 +329,24 @@ function DashboardShell(props: { children: JSX.Element }) {
|
||||
<p class="text-ink text-sm">{data.selectedBooking()?.notes}</p>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={data.selectedBooking()?.intake && Object.keys(data.selectedBooking()!.intake!).length > 0}>
|
||||
<div class="p-4 bg-canvas-subtle rounded-xl">
|
||||
<p class="text-xs text-ink-muted uppercase tracking-wider mb-2">{data.i18n.t("dashboard.bookingModal.intake")}</p>
|
||||
<div class="space-y-1.5">
|
||||
<For each={Object.entries(data.selectedBooking()!.intake!)}>
|
||||
{([key, raw]) => {
|
||||
const value = String(raw);
|
||||
return (
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<span class="text-xs text-ink-muted">{key.replace(/_/g, " ")}</span>
|
||||
<span class="text-sm text-ink">{value === "yes" || value === "no" ? data.i18n.t(`booking.intake${value === "yes" ? "Yes" : "No"}`) : value}</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={data.selectedBooking()?.status !== "cancelled"}>
|
||||
<div class="p-6 border-t border-border space-y-3">
|
||||
|
||||
@@ -1,19 +1,38 @@
|
||||
import { For, Show, createResource, createSignal } from "solid-js";
|
||||
import { apiClient } from "../../lib/api-client";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Select } from "../../components/ui/select";
|
||||
import { PlusIcon, ScissorsIcon, Trash2Icon, EditIcon, ClockIcon } from "../../components/dashboard/icons";
|
||||
import { DashboardLayout, useDashboardData } from "./layout";
|
||||
import { ExportCsvButton } from "../../components/dashboard/export-csv-button";
|
||||
|
||||
interface IntakeDraft {
|
||||
key: string;
|
||||
label: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
const intakeKey = (label: string) =>
|
||||
label
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.slice(0, 40);
|
||||
|
||||
interface ServiceForm {
|
||||
name: string;
|
||||
durationMinutes: string;
|
||||
bufferBeforeMinutes: string;
|
||||
bufferAfterMinutes: string;
|
||||
price: string;
|
||||
intakeFields: IntakeDraft[];
|
||||
}
|
||||
|
||||
const emptyForm: ServiceForm = { name: "", durationMinutes: "60", bufferBeforeMinutes: "0", bufferAfterMinutes: "0", price: "0" };
|
||||
const emptyForm: ServiceForm = { name: "", durationMinutes: "60", bufferBeforeMinutes: "0", bufferAfterMinutes: "0", price: "0", intakeFields: [] };
|
||||
|
||||
function ServicesPage() {
|
||||
const data = useDashboardData();
|
||||
@@ -32,7 +51,18 @@ function ServicesPage() {
|
||||
if (!bearer) return [];
|
||||
if (bearer.startsWith("demo.")) {
|
||||
return [
|
||||
{ id: "sv1", name: cs() ? "Dámský střih" : "Women's haircut", durationMinutes: 60, bufferBeforeMinutes: 0, bufferAfterMinutes: 15, priceCents: 89000 },
|
||||
{
|
||||
id: "sv1", name: cs() ? "Dámský střih" : "Women's haircut", durationMinutes: 60, bufferBeforeMinutes: 0, bufferAfterMinutes: 15, priceCents: 89000,
|
||||
intakeFields: cs()
|
||||
? [
|
||||
{ key: "zdravotni_poznamky", label: "Zdravotní poznámky nebo alergie", type: "text", required: false },
|
||||
{ key: "prvni_navsteva", label: "Je to vaše první návštěva?", type: "yesno", required: true },
|
||||
]
|
||||
: [
|
||||
{ key: "health_notes", label: "Health notes or allergies", type: "text", required: false },
|
||||
{ key: "first_visit", label: "Is this your first visit?", type: "yesno", required: true },
|
||||
],
|
||||
},
|
||||
{ id: "sv2", name: cs() ? "Pánský střih" : "Men's haircut", durationMinutes: 45, bufferBeforeMinutes: 0, bufferAfterMinutes: 10, priceCents: 59000 },
|
||||
{ id: "sv3", name: cs() ? "Barvení" : "Coloring", durationMinutes: 120, bufferBeforeMinutes: 10, bufferAfterMinutes: 15, priceCents: 199000 },
|
||||
{ id: "sv4", name: cs() ? "Konzultace" : "Consultation", durationMinutes: 30, bufferBeforeMinutes: 0, bufferAfterMinutes: 0, priceCents: 0 },
|
||||
@@ -64,10 +94,24 @@ function ServicesPage() {
|
||||
bufferBeforeMinutes: String(service.bufferBeforeMinutes ?? 0),
|
||||
bufferAfterMinutes: String(service.bufferAfterMinutes ?? 0),
|
||||
price: String((service.priceCents ?? 0) / 100),
|
||||
intakeFields: (service.intakeFields ?? []).map((f: any) => ({ key: f.key ?? "", label: f.label ?? "", type: f.type ?? "text", required: !!f.required })),
|
||||
});
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const setIntake = (i: number, patch: Partial<IntakeDraft>) => {
|
||||
const fields = form().intakeFields.slice();
|
||||
fields[i] = { ...fields[i], ...patch };
|
||||
setField({ intakeFields: fields });
|
||||
};
|
||||
const addIntake = () => {
|
||||
if (form().intakeFields.length >= 8) return;
|
||||
setField({ intakeFields: [...form().intakeFields, { key: "", label: "", type: "text", required: false }] });
|
||||
};
|
||||
const removeIntake = (i: number) => {
|
||||
setField({ intakeFields: form().intakeFields.filter((_, j) => j !== i) });
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const bearer = data.token();
|
||||
const name = form().name.trim();
|
||||
@@ -85,6 +129,14 @@ function ServicesPage() {
|
||||
bufferBeforeMinutes: Math.max(0, parseInt(form().bufferBeforeMinutes, 10) || 0),
|
||||
bufferAfterMinutes: Math.max(0, parseInt(form().bufferAfterMinutes, 10) || 0),
|
||||
priceCents: Math.max(0, Math.round((parseFloat(form().price) || 0) * 100)),
|
||||
intakeFields: form().intakeFields
|
||||
.filter((f) => f.label.trim())
|
||||
.map((f) => ({
|
||||
key: f.key || intakeKey(f.label),
|
||||
label: f.label.trim(),
|
||||
type: (f.type === "yesno" ? "yesno" : "text") as "text" | "yesno",
|
||||
required: f.required,
|
||||
})),
|
||||
};
|
||||
const id = editingId();
|
||||
const response = id
|
||||
@@ -142,6 +194,42 @@ function ServicesPage() {
|
||||
<Input type="number" min="0" label={data.i18n.t("dashboard.service.bufferBefore")} value={form().bufferBeforeMinutes} onInput={(e) => setField({ bufferBeforeMinutes: e.currentTarget.value })} />
|
||||
<Input type="number" min="0" label={data.i18n.t("dashboard.service.bufferAfter")} value={form().bufferAfterMinutes} onInput={(e) => setField({ bufferAfterMinutes: e.currentTarget.value })} />
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-border/60">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-sm font-medium text-ink">{data.i18n.t("dashboard.service.intake")}</p>
|
||||
<Show when={form().intakeFields.length < 8}>
|
||||
<button onClick={addIntake} class="text-xs text-accent hover:underline flex items-center gap-1"><PlusIcon /> {data.i18n.t("dashboard.service.intakeAdd")}</button>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={form().intakeFields.length > 0} fallback={
|
||||
<p class="text-xs text-ink-subtle">{data.i18n.t("dashboard.service.intakeHint")}</p>
|
||||
}>
|
||||
<div class="space-y-2">
|
||||
<For each={form().intakeFields}>
|
||||
{(field, i) => (
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1">
|
||||
<Input type="text" value={field.label} onInput={(e) => setIntake(i(), { label: e.currentTarget.value })} placeholder={data.i18n.t("dashboard.service.intakeLabel")} />
|
||||
</div>
|
||||
<Select
|
||||
value={field.type}
|
||||
onChange={(v) => setIntake(i(), { type: v })}
|
||||
options={[
|
||||
{ value: "text", label: data.i18n.t("dashboard.service.intakeText") },
|
||||
{ value: "yesno", label: data.i18n.t("dashboard.service.intakeYesNo") },
|
||||
]}
|
||||
/>
|
||||
<label class="flex items-center gap-1.5 text-xs text-ink-muted whitespace-nowrap cursor-pointer">
|
||||
<input type="checkbox" checked={field.required} onChange={(e) => setIntake(i(), { required: e.currentTarget.checked })} class="rounded accent-accent" />
|
||||
{data.i18n.t("dashboard.service.intakeRequired")}
|
||||
</label>
|
||||
<button onClick={() => removeIntake(i())} class="p-1.5 text-ink-subtle hover:text-red-600 rounded-lg" aria-label={cs() ? "Smazat" : "Delete"}><Trash2Icon /></button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-4">
|
||||
<button onClick={() => void handleSave()} disabled={saving() || !form().name.trim()} class="btn-primary text-sm disabled:opacity-60">
|
||||
{saving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.settings.save")}
|
||||
|
||||
@@ -20,6 +20,7 @@ export function PublicBookingRoute() {
|
||||
const [customerEmail, setCustomerEmail] = createSignal("");
|
||||
const [customerPhone, setCustomerPhone] = createSignal("");
|
||||
const [notes, setNotes] = createSignal("");
|
||||
const [intakeAnswers, setIntakeAnswers] = createSignal<Record<string, string>>({});
|
||||
const [highlightContact, setHighlightContact] = createSignal(false);
|
||||
const [selectedServiceId, setSelectedServiceId] = createSignal<string | null>(null);
|
||||
const [selectedStaffId, setSelectedStaffId] = createSignal<string | null>(null);
|
||||
@@ -68,6 +69,9 @@ export function PublicBookingRoute() {
|
||||
|
||||
const services = createMemo<PublicService[]>(() => catalog.latest?.data?.services ?? []);
|
||||
const staff = createMemo<PublicStaff[]>(() => catalog.latest?.data?.staff ?? []);
|
||||
const intakeFields = createMemo(
|
||||
() => services().find((s) => s.id === selectedServiceId())?.intakeFields ?? [],
|
||||
);
|
||||
const classSlots = createMemo(() => (availability.latest?.data?.slots ?? []).filter((slot) => slot.mode === "class"));
|
||||
const classDays = createMemo(() => {
|
||||
const groups = new Map<string, any[]>();
|
||||
@@ -155,6 +159,7 @@ export function PublicBookingRoute() {
|
||||
customerEmail: customerEmail().trim(),
|
||||
customerPhone: customerPhone().trim() || undefined,
|
||||
notes: notes().trim(),
|
||||
intake: intakeFields().length > 0 ? intakeAnswers() : undefined,
|
||||
startsAt: slot.startsAt,
|
||||
endsAt: slot.endsAt,
|
||||
},
|
||||
@@ -241,6 +246,43 @@ export function PublicBookingRoute() {
|
||||
rows={3}
|
||||
value={notes()}
|
||||
/>
|
||||
<Show when={intakeFields().length > 0}>
|
||||
<div class="flex flex-col gap-3 border-t border-border/60 pt-3">
|
||||
<For each={intakeFields()}>
|
||||
{(field) => (
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-xs font-medium text-ink-muted">
|
||||
{field.label}
|
||||
<Show when={field.required}>
|
||||
<span class="text-red-500"> *</span>
|
||||
</Show>
|
||||
</label>
|
||||
<Show
|
||||
when={field.type === "yesno"}
|
||||
fallback={
|
||||
<input
|
||||
class="form-input"
|
||||
type="text"
|
||||
value={intakeAnswers()[field.key] ?? ""}
|
||||
onInput={(e) => setIntakeAnswers({ ...intakeAnswers(), [field.key]: e.currentTarget.value })}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<select
|
||||
class="form-input"
|
||||
value={intakeAnswers()[field.key] ?? ""}
|
||||
onChange={(e) => setIntakeAnswers({ ...intakeAnswers(), [field.key]: e.currentTarget.value })}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="yes">{i18n.t("booking.intakeYes")}</option>
|
||||
<option value="no">{i18n.t("booking.intakeNo")}</option>
|
||||
</select>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
Reference in new issue
Block a user