feat(catalog): class template editing

Templates were delete-and-recreate only. Adds PUT
/v1/catalog/class-templates/{id} across repository, service (sharing
the create-time validation), route, spec, and client; the classes tab
gets a pencil action that loads the row into the form with a
save/cancel affordance.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-18 13:13:37 +02:00
1 parent 3892e9ce8c
commit 5884203e9d
11 files changed
+249 -21

No files matched your search

+20
View File
@@ -998,6 +998,26 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
} }
c.JSON(http.StatusCreated, response) c.JSON(http.StatusCreated, response)
}) })
protected.PUT("/catalog/class-templates/:templateID", func(c *gin.Context) {
var request domain.UpsertClassTemplateRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.UpdateClassTemplate(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("templateID"), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrClassTemplateNotFound):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidInput):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.DELETE("/catalog/class-templates/:templateID", func(c *gin.Context) { protected.DELETE("/catalog/class-templates/:templateID", func(c *gin.Context) {
err := catalogService.DeleteClassTemplate(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("templateID")) err := catalogService.DeleteClassTemplate(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("templateID"))
if err != nil { if err != nil {
+48 -9
View File
@@ -1050,15 +1050,9 @@ func (s *Service) CreateClassTemplate(ctx context.Context, principal domain.Prin
return domain.ClassTemplate{}, ErrTenantMembership return domain.ClassTemplate{}, ErrTenantMembership
} }
title := strings.TrimSpace(req.Title) title, err := validateClassTemplateInput(req)
if len(title) < 1 || len(title) > 120 { if err != nil {
return domain.ClassTemplate{}, fmt.Errorf("%w: title must be between 1 and 120 characters", ErrInvalidInput) return domain.ClassTemplate{}, err
}
if req.DurationMinutes < 1 || req.DurationMinutes > 1440 {
return domain.ClassTemplate{}, fmt.Errorf("%w: durationMinutes must be between 1 and 1440", ErrInvalidInput)
}
if req.Capacity < 1 || req.Capacity > 500 {
return domain.ClassTemplate{}, fmt.Errorf("%w: capacity must be between 1 and 500", ErrInvalidInput)
} }
rec, err := s.repo.CreateClassTemplate(ctx, db.CreateClassTemplateParams{ rec, err := s.repo.CreateClassTemplate(ctx, db.CreateClassTemplateParams{
@@ -1372,3 +1366,48 @@ func (s *Service) sendBookingEmail(booking db.BookingRecord, promoted bool) {
s.notificationService.SendBookingCancellation(ctx, data) s.notificationService.SendBookingCancellation(ctx, data)
} }
} }
func (s *Service) UpdateClassTemplate(ctx context.Context, principal domain.Principal, templateID string, req domain.UpsertClassTemplateRequest) (domain.ClassTemplate, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.ClassTemplate{}, ErrTenantMembership
}
if err := s.requireTenantClassTemplate(ctx, membership.Tenant.ID, templateID); err != nil {
return domain.ClassTemplate{}, err
}
title, err := validateClassTemplateInput(req)
if err != nil {
return domain.ClassTemplate{}, err
}
rec, err := s.repo.UpdateClassTemplate(ctx, templateID, db.CreateClassTemplateParams{
TenantID: membership.Tenant.ID,
Title: title,
DurationMinutes: req.DurationMinutes,
Capacity: req.Capacity,
})
if err != nil {
return domain.ClassTemplate{}, err
}
return domain.ClassTemplate{
ID: rec.ID,
TenantID: rec.TenantID,
Title: rec.Title,
DurationMinutes: rec.DurationMinutes,
Capacity: rec.Capacity,
CreatedAt: rec.CreatedAt,
}, nil
}
func validateClassTemplateInput(req domain.UpsertClassTemplateRequest) (string, error) {
title := strings.TrimSpace(req.Title)
if len(title) < 1 || len(title) > 120 {
return "", fmt.Errorf("%w: title must be between 1 and 120 characters", ErrInvalidInput)
}
if req.DurationMinutes < 1 || req.DurationMinutes > 1440 {
return "", fmt.Errorf("%w: durationMinutes must be between 1 and 1440", ErrInvalidInput)
}
if req.Capacity < 1 || req.Capacity > 500 {
return "", fmt.Errorf("%w: capacity must be between 1 and 500", ErrInvalidInput)
}
return title, nil
}
@@ -640,3 +640,43 @@ func TestCancelBookingPromotesWaitlist(t *testing.T) {
t.Fatalf("expected second waiter promoted, got %q", booking.Status) t.Fatalf("expected second waiter promoted, got %q", booking.Status)
} }
} }
func TestUpdateClassTemplate(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
created, err := svc.CreateClassTemplate(ctx, owner, domain.UpsertClassTemplateRequest{
Title: "Yin Yoga", DurationMinutes: 75, Capacity: 10,
})
if err != nil {
t.Fatalf("create template: %v", err)
}
updated, err := svc.UpdateClassTemplate(ctx, owner, created.ID, domain.UpsertClassTemplateRequest{
Title: "Yin Yoga (večer)", DurationMinutes: 90, Capacity: 12,
})
if err != nil {
t.Fatalf("update template: %v", err)
}
if updated.Title != "Yin Yoga (večer)" || updated.DurationMinutes != 90 || updated.Capacity != 12 {
t.Fatalf("unexpected updated template: %+v", updated)
}
if _, err := svc.UpdateClassTemplate(ctx, owner, created.ID, domain.UpsertClassTemplateRequest{
Title: "", DurationMinutes: 90, Capacity: 12,
}); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput, got %v", err)
}
stranger := domain.Principal{Subject: "someone-else", Role: "staff"}
if _, err := svc.UpdateClassTemplate(ctx, stranger, created.ID, domain.UpsertClassTemplateRequest{
Title: "X", DurationMinutes: 60, Capacity: 5,
}); !errors.Is(err, ErrTenantMembership) {
t.Fatalf("expected ErrTenantMembership, got %v", err)
}
if _, err := svc.UpdateClassTemplate(ctx, owner, "00000000-0000-0000-0000-000000000099", domain.UpsertClassTemplateRequest{
Title: "X", DurationMinutes: 60, Capacity: 5,
}); !errors.Is(err, ErrClassTemplateNotFound) {
t.Fatalf("expected ErrClassTemplateNotFound, got %v", err)
}
}
+18
View File
@@ -71,6 +71,24 @@ func (r *PGRepository) CreateClassTemplate(ctx context.Context, params CreateCla
return record, err return record, err
} }
func (r *PGRepository) UpdateClassTemplate(ctx context.Context, templateID string, params CreateClassTemplateParams) (ClassTemplateRecord, error) {
var record ClassTemplateRecord
err := r.pool.QueryRow(ctx, `
UPDATE class_templates
SET title = $2, duration_minutes = $3, capacity = $4
WHERE id = $1
RETURNING `+classTemplateColumns+`
`, templateID, params.Title, params.DurationMinutes, params.Capacity).Scan(
&record.ID,
&record.TenantID,
&record.Title,
&record.DurationMinutes,
&record.Capacity,
&record.CreatedAt,
)
return record, err
}
func (r *PGRepository) DeleteClassTemplate(ctx context.Context, templateID string) error { func (r *PGRepository) DeleteClassTemplate(ctx context.Context, templateID string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM class_templates WHERE id = $1`, templateID) _, err := r.pool.Exec(ctx, `DELETE FROM class_templates WHERE id = $1`, templateID)
return err return err
+13
View File
@@ -32,6 +32,7 @@ type Repository interface {
ListClassTemplatesByTenant(ctx context.Context, tenantID string) ([]ClassTemplateRecord, error) ListClassTemplatesByTenant(ctx context.Context, tenantID string) ([]ClassTemplateRecord, error)
GetClassTemplateByID(ctx context.Context, templateID string) (ClassTemplateRecord, error) GetClassTemplateByID(ctx context.Context, templateID string) (ClassTemplateRecord, error)
CreateClassTemplate(ctx context.Context, params CreateClassTemplateParams) (ClassTemplateRecord, error) CreateClassTemplate(ctx context.Context, params CreateClassTemplateParams) (ClassTemplateRecord, error)
UpdateClassTemplate(ctx context.Context, templateID string, params CreateClassTemplateParams) (ClassTemplateRecord, error)
DeleteClassTemplate(ctx context.Context, templateID string) error DeleteClassTemplate(ctx context.Context, templateID string) error
ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error)
CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error) CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error)
@@ -1311,6 +1312,18 @@ func (r *MemoryRepository) CreateClassTemplate(_ context.Context, params CreateC
return record, nil return record, nil
} }
func (r *MemoryRepository) UpdateClassTemplate(_ context.Context, templateID string, params CreateClassTemplateParams) (ClassTemplateRecord, error) {
for index := range r.classTemplates {
if r.classTemplates[index].ID == templateID {
r.classTemplates[index].Title = params.Title
r.classTemplates[index].DurationMinutes = params.DurationMinutes
r.classTemplates[index].Capacity = params.Capacity
return r.classTemplates[index], nil
}
}
return ClassTemplateRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) DeleteClassTemplate(_ context.Context, templateID string) error { func (r *MemoryRepository) DeleteClassTemplate(_ context.Context, templateID string) error {
for i, template := range r.classTemplates { for i, template := range r.classTemplates {
if template.ID == templateID { if template.ID == templateID {
+25
View File
@@ -1099,6 +1099,31 @@ paths:
"400": "400":
description: Invalid request description: Invalid request
/v1/catalog/class-templates/{templateID}: /v1/catalog/class-templates/{templateID}:
put:
tags: [Catalog]
operationId: updateClassTemplate
security:
- bearerAuth: []
parameters:
- in: path
name: templateID
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpsertClassTemplateRequest"
responses:
"200":
description: Updated class template
"400":
description: Invalid input
"404":
description: Template not found
delete: delete:
tags: [Catalog] tags: [Catalog]
operationId: deleteClassTemplate operationId: deleteClassTemplate
@@ -114,6 +114,12 @@ export const PlusIcon = () => (
</svg> </svg>
); );
export const PencilIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5"/>
</svg>
);
export const ScissorsIcon = () => ( export const ScissorsIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="6" cy="6" r="3"/><path d="M8.12 8.12 12 12"/><path d="M20 4 8.12 15.88"/><circle cx="6" cy="18" r="3"/><path d="M14.8 14.8 20 20"/> <circle cx="6" cy="6" r="3"/><path d="M8.12 8.12 12 12"/><path d="M20 4 8.12 15.88"/><circle cx="6" cy="18" r="3"/><path d="M14.8 14.8 20 20"/>
@@ -411,6 +411,7 @@ const dictionaries = {
"dashboard.class.templates": "Šablony lekcí", "dashboard.class.templates": "Šablony lekcí",
"dashboard.class.sessions": "Naplánované lekce", "dashboard.class.sessions": "Naplánované lekce",
"dashboard.class.addTemplate": "Nová šablona", "dashboard.class.addTemplate": "Nová šablona",
"dashboard.class.saveChanges": "Uložit změny",
"dashboard.class.addSession": "Naplánovat lekci", "dashboard.class.addSession": "Naplánovat lekci",
"dashboard.class.title": "Název", "dashboard.class.title": "Název",
"dashboard.class.titlePlaceholder": "např. Jóga pro začátečníky", "dashboard.class.titlePlaceholder": "např. Jóga pro začátečníky",
@@ -986,6 +987,7 @@ const dictionaries = {
"dashboard.class.templates": "Class templates", "dashboard.class.templates": "Class templates",
"dashboard.class.sessions": "Scheduled sessions", "dashboard.class.sessions": "Scheduled sessions",
"dashboard.class.addTemplate": "New template", "dashboard.class.addTemplate": "New template",
"dashboard.class.saveChanges": "Save changes",
"dashboard.class.addSession": "Schedule session", "dashboard.class.addSession": "Schedule session",
"dashboard.class.title": "Title", "dashboard.class.title": "Title",
"dashboard.class.titlePlaceholder": "e.g. Beginner yoga", "dashboard.class.titlePlaceholder": "e.g. Beginner yoga",
@@ -2,7 +2,7 @@ import { For, Show, createResource, createSignal } from "solid-js";
import { apiClient } from "../../lib/api-client"; import { apiClient } from "../../lib/api-client";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { Select } from "../../components/ui/select"; import { Select } from "../../components/ui/select";
import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon } from "../../components/dashboard/icons"; import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon, PencilIcon } from "../../components/dashboard/icons";
import { DashboardLayout, useDashboardData } from "./layout"; import { DashboardLayout, useDashboardData } from "./layout";
function ZonesPage() { function ZonesPage() {
@@ -24,6 +24,7 @@ function ZonesPage() {
const [newTemplateDuration, setNewTemplateDuration] = createSignal(60); const [newTemplateDuration, setNewTemplateDuration] = createSignal(60);
const [newTemplateCapacity, setNewTemplateCapacity] = createSignal(8); const [newTemplateCapacity, setNewTemplateCapacity] = createSignal(8);
const [templateSaving, setTemplateSaving] = createSignal(false); const [templateSaving, setTemplateSaving] = createSignal(false);
const [editingTemplateId, setEditingTemplateId] = createSignal<string | null>(null);
const [newSessionTemplateId, setNewSessionTemplateId] = createSignal(""); const [newSessionTemplateId, setNewSessionTemplateId] = createSignal("");
const [newSessionLocationId, setNewSessionLocationId] = createSignal(""); const [newSessionLocationId, setNewSessionLocationId] = createSignal("");
const [newSessionStartsAt, setNewSessionStartsAt] = createSignal(""); const [newSessionStartsAt, setNewSessionStartsAt] = createSignal("");
@@ -211,18 +212,25 @@ function ZonesPage() {
} }
}; };
const handleAddTemplate = async () => { const resetTemplateForm = () => {
setNewTemplateTitle(""); setNewTemplateDuration(60); setNewTemplateCapacity(8); setEditingTemplateId(null);
};
const handleSaveTemplate = async () => {
const bearer = data.token(); const bearer = data.token();
if (!newTemplateTitle().trim()) return; if (!newTemplateTitle().trim()) return;
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; } if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
setTemplateSaving(true); setTemplateSaving(true);
try { try {
await (apiClient as any).POST("/v1/catalog/class-templates", { const body = { title: newTemplateTitle().trim(), durationMinutes: newTemplateDuration(), capacity: newTemplateCapacity() };
...authHeaders(), const editing = editingTemplateId();
body: { title: newTemplateTitle().trim(), durationMinutes: newTemplateDuration(), capacity: newTemplateCapacity() }, if (editing) {
}); await (apiClient as any).PUT(`/v1/catalog/class-templates/${editing}`, { ...authHeaders(), body });
setNewTemplateTitle(""); setNewTemplateDuration(60); setNewTemplateCapacity(8); } else {
void refetchTemplates(); await (apiClient as any).POST("/v1/catalog/class-templates", { ...authHeaders(), body });
}
resetTemplateForm();
void refetchTemplates(); void refetchSessions();
} catch { } catch {
data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed."); data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed.");
} finally { } finally {
@@ -230,6 +238,13 @@ function ZonesPage() {
} }
}; };
const handleEditTemplate = (tpl: any) => {
setNewTemplateTitle(tpl.title);
setNewTemplateDuration(tpl.durationMinutes);
setNewTemplateCapacity(tpl.capacity);
setEditingTemplateId(tpl.id);
};
const handleDeleteTemplate = async (id: string) => { const handleDeleteTemplate = async (id: string) => {
const bearer = data.token(); const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode."); return; } if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode."); return; }
@@ -487,9 +502,14 @@ function ZonesPage() {
<Input type="number" label={data.i18n.t("dashboard.class.durationMin")} value={newTemplateDuration()} onInput={(e) => setNewTemplateDuration(parseInt(e.currentTarget.value) || 60)} /> <Input type="number" label={data.i18n.t("dashboard.class.durationMin")} value={newTemplateDuration()} onInput={(e) => setNewTemplateDuration(parseInt(e.currentTarget.value) || 60)} />
<Input type="number" label={data.i18n.t("dashboard.zone.capacity")} value={newTemplateCapacity()} onInput={(e) => setNewTemplateCapacity(parseInt(e.currentTarget.value) || 8)} /> <Input type="number" label={data.i18n.t("dashboard.zone.capacity")} value={newTemplateCapacity()} onInput={(e) => setNewTemplateCapacity(parseInt(e.currentTarget.value) || 8)} />
<div class="flex items-end"> <div class="flex items-end">
<button onClick={() => void handleAddTemplate()} disabled={templateSaving() || !newTemplateTitle().trim()} class="btn-primary text-sm w-full sm:w-auto disabled:opacity-60"> <button onClick={() => void handleSaveTemplate()} disabled={templateSaving() || !newTemplateTitle().trim()} class="btn-primary text-sm w-full sm:w-auto disabled:opacity-60">
{templateSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.class.addTemplate")} {templateSaving() ? data.i18n.t("dashboard.saving") : editingTemplateId() ? data.i18n.t("dashboard.class.saveChanges") : data.i18n.t("dashboard.class.addTemplate")}
</button> </button>
<Show when={editingTemplateId()}>
<button onClick={resetTemplateForm} class="text-sm text-ink-muted hover:text-ink px-3 py-2">
{data.i18n.t("dashboard.cancel")}
</button>
</Show>
</div> </div>
</div> </div>
</div> </div>
@@ -509,7 +529,10 @@ function ZonesPage() {
<p class="text-sm text-ink-muted">{tpl.durationMinutes} {data.i18n.t("dashboard.service.min")} &bull; {data.i18n.t("dashboard.zone.capacity")}: {tpl.capacity}</p> <p class="text-sm text-ink-muted">{tpl.durationMinutes} {data.i18n.t("dashboard.service.min")} &bull; {data.i18n.t("dashboard.zone.capacity")}: {tpl.capacity}</p>
</div> </div>
</div> </div>
<button onClick={() => void handleDeleteTemplate(tpl.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2Icon /></button> <div class="flex items-center gap-1">
<button onClick={() => handleEditTemplate(tpl)} class="p-2 text-ink-subtle hover:text-accent hover:bg-accent-subtle rounded-lg transition-colors"><PencilIcon /></button>
<button onClick={() => void handleDeleteTemplate(tpl.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2Icon /></button>
</div>
</div> </div>
)} )}
</For> </For>
+39 -1
View File
@@ -359,7 +359,7 @@ export interface paths {
cookie?: never; cookie?: never;
}; };
get?: never; get?: never;
put?: never; put: operations["updateClassTemplate"];
post?: never; post?: never;
delete: operations["deleteClassTemplate"]; delete: operations["deleteClassTemplate"];
options?: never; options?: never;
@@ -2220,6 +2220,44 @@ export interface operations {
}; };
}; };
}; };
updateClassTemplate: {
parameters: {
query?: never;
header?: never;
path: {
templateID: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpsertClassTemplateRequest"];
};
};
responses: {
/** @description Updated class template */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Template not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
deleteClassTemplate: { deleteClassTemplate: {
parameters: { parameters: {
query?: never; query?: never;
+4
View File
@@ -112,6 +112,10 @@
- `CustomerBookingView` carries `waitlistPosition` for waitlisted class bookings; the manage page now shows a proper status badge for `waitlisted`/`cancelled` (previously both rendered as "Pending") plus a notice with the queue position and the auto-promotion explanation, in CS and EN - `CustomerBookingView` carries `waitlistPosition` for waitlisted class bookings; the manage page now shows a proper status badge for `waitlisted`/`cancelled` (previously both rendered as "Pending") plus a notice with the queue position and the auto-promotion explanation, in CS and EN
- fixed invalid token names along the way (`--danger-soft`/`--warning-soft` do not exist; `--error-subtle`/`--warning-subtle` do) - fixed invalid token names along the way (`--danger-soft`/`--warning-soft` do not exist; `--error-subtle`/`--warning-subtle` do)
- remaining: live staging validation; manual waitlist promotion controls are still not exposed - remaining: live staging validation; manual waitlist promotion controls are still not exposed
- `[done/partial]` Milestone 17: Class template editing
- `PUT /v1/catalog/class-templates/{id}` end-to-end: `UpdateClassTemplate` in both repositories, catalog service method sharing `validateClassTemplateInput` with create, route, OpenAPI path, regenerated client
- classes tab template rows gained a pencil edit action that loads the template into the form; the submit button switches to save-changes with a cancel affordance
- remaining: live staging validation
## Overview ## Overview
Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP. Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP.