From 33b6e2a2a6ead80ce485a9208a20ea086b9307f3 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 18:04:32 +0200 Subject: [PATCH] feat(intake): structured per-service intake questions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- apps/backend/internal/api/server.go | 1 + .../internal/bookings/customer_service.go | 2 + apps/backend/internal/bookings/service.go | 8 ++ apps/backend/internal/catalog/service.go | 24 ++++ apps/backend/internal/db/bookings.go | 11 +- apps/backend/internal/db/repository.go | 23 +++- apps/backend/internal/db/services.go | 17 ++- apps/backend/internal/domain/models.go | 63 +++++---- apps/backend/internal/shared/intake.go | 124 ++++++++++++++++++ apps/backend/internal/shared/intake_test.go | 83 ++++++++++++ .../migrations/00019_intake_fields.sql | 13 ++ apps/backend/openapi/bookra.openapi.yaml | 42 ++++++ apps/frontend/src/providers/i18n-provider.tsx | 20 +++ .../src/routes/booking-manage-route.tsx | 18 +++ .../src/routes/dashboard/dashboard-data.ts | 2 +- apps/frontend/src/routes/dashboard/layout.tsx | 20 ++- .../src/routes/dashboard/services-page.tsx | 92 ++++++++++++- .../src/routes/public-booking-route.tsx | 42 ++++++ packages/api-client/src/generated/types.ts | 24 ++++ project.md | 10 +- 20 files changed, 598 insertions(+), 41 deletions(-) create mode 100644 apps/backend/internal/shared/intake.go create mode 100644 apps/backend/internal/shared/intake_test.go create mode 100644 apps/backend/migrations/00019_intake_fields.sql diff --git a/apps/backend/internal/api/server.go b/apps/backend/internal/api/server.go index 4a1a9f0..a2467e8 100644 --- a/apps/backend/internal/api/server.go +++ b/apps/backend/internal/api/server.go @@ -379,6 +379,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { CustomerEmail string `json:"customerEmail" binding:"required,email"` CustomerPhone string `json:"customerPhone"` Notes string `json:"notes"` + Intake map[string]string `json:"intake,omitempty"` StartsAt string `json:"startsAt" binding:"required"` EndsAt string `json:"endsAt" binding:"required"` } diff --git a/apps/backend/internal/bookings/customer_service.go b/apps/backend/internal/bookings/customer_service.go index 2393b35..d6210f7 100644 --- a/apps/backend/internal/bookings/customer_service.go +++ b/apps/backend/internal/bookings/customer_service.go @@ -11,6 +11,7 @@ import ( "bookra/apps/backend/internal/db" "bookra/apps/backend/internal/domain" "bookra/apps/backend/internal/notifications" + "bookra/apps/backend/internal/shared" ) var ( @@ -134,6 +135,7 @@ func (s *CustomerService) GetBookingByReference(ctx context.Context, reference s Location: locationName, Status: booking.Status, Notes: booking.Notes, + Intake: shared.ParseIntakeAnswers(booking.Intake), WaitlistPosition: position, }, nil } diff --git a/apps/backend/internal/bookings/service.go b/apps/backend/internal/bookings/service.go index ea51ae8..92c05d8 100644 --- a/apps/backend/internal/bookings/service.go +++ b/apps/backend/internal/bookings/service.go @@ -221,6 +221,7 @@ func (s *Service) Catalog(ctx context.Context, tenantSlug string) (domain.Public Name: service.Name, DurationMinutes: service.DurationMinutes, PriceCents: service.PriceCents, + IntakeFields: shared.ParseIntakeFields(service.IntakeFields), }) } for _, staff := range staffRecords { @@ -283,6 +284,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques status := "confirmed" activityLabel := "" + intakeJSON := "{}" switch request.BookingMode { case "appointment": if request.ServiceID == nil || request.ClassSessionID != nil { @@ -295,6 +297,10 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques if !ok { return domain.CreateBookingResponse{}, fmt.Errorf("%w: serviceId is not available for tenant", ErrInvalidBooking) } + intakeJSON, err = shared.NormalizeIntakeAnswers(shared.ParseIntakeFields(service.IntakeFields), request.Intake) + if err != nil { + return domain.CreateBookingResponse{}, fmt.Errorf("%w: %s", ErrInvalidBooking, err.Error()) + } activityLabel = service.Name expectedDuration := time.Duration(service.DurationMinutes) * time.Minute if !startsAt.Add(expectedDuration).Equal(endsAt) { @@ -350,6 +356,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques Reference: db.Reference("BK", time.Now()), ManageToken: generateManageToken(), Notes: notes, + Intake: intakeJSON, }) if err != nil { if db.IsExclusionViolation(err) { @@ -565,6 +572,7 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip CustomerName: booking.CustomerName, CustomerEmail: booking.CustomerEmail, Notes: booking.Notes, + Intake: shared.ParseIntakeAnswers(booking.Intake), StartsAt: booking.StartsAt, EndsAt: booking.EndsAt, Status: booking.Status, diff --git a/apps/backend/internal/catalog/service.go b/apps/backend/internal/catalog/service.go index 312598f..d4848d3 100644 --- a/apps/backend/internal/catalog/service.go +++ b/apps/backend/internal/catalog/service.go @@ -13,6 +13,7 @@ import ( "bookra/apps/backend/internal/db" "bookra/apps/backend/internal/domain" "bookra/apps/backend/internal/notifications" + "bookra/apps/backend/internal/shared" "github.com/jackc/pgx/v5" ) @@ -200,6 +201,16 @@ func (s *Service) DeleteLocation(ctx context.Context, principal domain.Principal // SERVICE CATALOG MANAGEMENT // ============================================ +// normalizeIntakeFields validates declared intake fields via the shared +// normalizer and wraps failures in ErrInvalidInput for the API layer. +func normalizeIntakeFields(fields []domain.IntakeField) (string, error) { + raw, err := shared.NormalizeIntakeFields(fields) + if err != nil { + return "", fmt.Errorf("%w: %s", ErrInvalidInput, err.Error()) + } + return raw, nil +} + func serviceToDomain(rec db.ServiceRecord) domain.Service { return domain.Service{ ID: rec.ID, @@ -209,6 +220,7 @@ func serviceToDomain(rec db.ServiceRecord) domain.Service { BufferBeforeMinutes: rec.BufferBeforeMinutes, BufferAfterMinutes: rec.BufferAfterMinutes, PriceCents: rec.PriceCents, + IntakeFields: shared.ParseIntakeFields(rec.IntakeFields), CreatedAt: rec.CreatedAt, } } @@ -260,6 +272,10 @@ func (s *Service) CreateService(ctx context.Context, principal domain.Principal, if err := validateServiceValues(name, req.DurationMinutes, req.BufferBeforeMinutes, req.BufferAfterMinutes, req.PriceCents); err != nil { return domain.Service{}, err } + intakeJSON, err := normalizeIntakeFields(req.IntakeFields) + if err != nil { + return domain.Service{}, err + } rec, err := s.repo.CreateService(ctx, db.CreateServiceParams{ TenantID: membership.Tenant.ID, @@ -268,6 +284,7 @@ func (s *Service) CreateService(ctx context.Context, principal domain.Principal, BufferBeforeMinutes: req.BufferBeforeMinutes, BufferAfterMinutes: req.BufferAfterMinutes, PriceCents: req.PriceCents, + IntakeFields: intakeJSON, }) if err != nil { return domain.Service{}, err @@ -303,6 +320,13 @@ func (s *Service) UpdateService(ctx context.Context, principal domain.Principal, if req.PriceCents != nil { params.PriceCents = req.PriceCents } + if req.IntakeFields != nil { + intakeJSON, err := normalizeIntakeFields(*req.IntakeFields) + if err != nil { + return domain.Service{}, err + } + params.IntakeFields = &intakeJSON + } // Validate against the merged result so partial updates cannot produce an // invalid record (e.g. setting duration to 0 while keeping the name). diff --git a/apps/backend/internal/db/bookings.go b/apps/backend/internal/db/bookings.go index 9482b6b..9376173 100644 --- a/apps/backend/internal/db/bookings.go +++ b/apps/backend/internal/db/bookings.go @@ -12,7 +12,7 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID rows, err := r.pool.Query(ctx, ` SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id, customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference, - COALESCE(manage_token, ''), COALESCE(notes, ''), created_at + COALESCE(manage_token, ''), COALESCE(notes, ''), COALESCE(intake::text, '{}'), created_at FROM bookings WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2 ORDER BY starts_at ASC @@ -41,6 +41,7 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID &record.Reference, &record.ManageToken, &record.Notes, + &record.Intake, &record.CreatedAt, ); err != nil { return nil, err @@ -56,7 +57,7 @@ func (r *PGRepository) ListBookingsBySession(ctx context.Context, sessionID stri rows, err := r.pool.Query(ctx, ` SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id, customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference, - COALESCE(manage_token, ''), COALESCE(notes, ''), created_at + COALESCE(manage_token, ''), COALESCE(notes, ''), COALESCE(intake::text, '{}'), created_at FROM bookings WHERE class_session_id = $1 ORDER BY created_at ASC @@ -85,6 +86,7 @@ func (r *PGRepository) ListBookingsBySession(ctx context.Context, sessionID stri &record.Reference, &record.ManageToken, &record.Notes, + &record.Intake, &record.CreatedAt, ); err != nil { return nil, err @@ -100,9 +102,9 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa INSERT INTO bookings ( tenant_id, service_id, class_session_id, staff_id, location_id, booking_mode, customer_name, customer_email, customer_phone, starts_at, ends_at, - status, reference, manage_token, notes + status, reference, manage_token, notes, intake ) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16::jsonb) RETURNING id, reference, status, manage_token `, params.TenantID, @@ -120,6 +122,7 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa params.Reference, params.ManageToken, params.Notes, + params.Intake, ).Scan(&created.ID, &created.Reference, &created.Status, &created.ManageToken) return created, err } diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index 4685c80..3972636 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -276,6 +276,9 @@ type ServiceRecord struct { BufferBeforeMinutes int BufferAfterMinutes int PriceCents int + // IntakeFields is the raw JSON list of declared structured intake fields; + // the catalog service parses and validates it into domain.IntakeField. + IntakeFields string CreatedAt time.Time } @@ -286,6 +289,7 @@ type CreateServiceParams struct { BufferBeforeMinutes int BufferAfterMinutes int PriceCents int + IntakeFields string } // Pointer fields distinguish "not sent" (keep current value) from an explicit @@ -296,6 +300,7 @@ type UpdateServiceParams struct { BufferBeforeMinutes *int BufferAfterMinutes *int PriceCents *int + IntakeFields *string } type StaffRecord struct { @@ -447,6 +452,8 @@ type BookingRecord struct { Reference string ManageToken string Notes string + // Intake is the raw JSON object of structured intake answers. + Intake string CreatedAt time.Time } @@ -466,6 +473,7 @@ type CreateBookingParams struct { Reference string ManageToken string Notes string + Intake string } type CreatedBooking struct { @@ -821,11 +829,11 @@ func (r *PGRepository) GetBookingByReference(ctx context.Context, reference stri err := r.pool.QueryRow(ctx, ` SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id, customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference, - COALESCE(manage_token, ''), COALESCE(notes, ''), created_at + COALESCE(manage_token, ''), COALESCE(notes, ''), COALESCE(intake::text, '{}'), created_at FROM bookings WHERE reference = $1 `, reference).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID, - &rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes, &rec.CreatedAt) + &rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes, &rec.Intake, &rec.CreatedAt) return rec, err } @@ -834,11 +842,11 @@ func (r *PGRepository) GetBookingByID(ctx context.Context, bookingID string) (Bo err := r.pool.QueryRow(ctx, ` SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id, customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference, - COALESCE(manage_token, ''), COALESCE(notes, ''), created_at + COALESCE(manage_token, ''), COALESCE(notes, ''), COALESCE(intake::text, '{}'), created_at FROM bookings WHERE id = $1 `, bookingID).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID, - &rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes, &rec.CreatedAt) + &rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes, &rec.Intake, &rec.CreatedAt) return rec, err } @@ -1215,6 +1223,7 @@ func (r *MemoryRepository) CreateService(_ context.Context, params CreateService BufferBeforeMinutes: params.BufferBeforeMinutes, BufferAfterMinutes: params.BufferAfterMinutes, PriceCents: params.PriceCents, + IntakeFields: params.IntakeFields, CreatedAt: time.Now().UTC(), } r.services = append(r.services, service) @@ -1241,6 +1250,9 @@ func (r *MemoryRepository) UpdateService(_ context.Context, serviceID string, pa if params.PriceCents != nil { service.PriceCents = *params.PriceCents } + if params.IntakeFields != nil { + service.IntakeFields = *params.IntakeFields + } r.services[i] = service return service, nil } @@ -1749,6 +1761,7 @@ func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBooking Reference: params.Reference, ManageToken: params.ManageToken, Notes: params.Notes, + Intake: params.Intake, CreatedAt: time.Now().UTC(), }) return created, nil @@ -1764,6 +1777,7 @@ func (r *MemoryRepository) seedDemoBookings() { ClassScheduleRecord{ID: uuid.NewString(), TenantID: r.tenant.ID, TemplateID: templateID, LocationID: &locationID, DayOfWeek: 3, StartsLocal: "10:00:00", Active: true, CreatedAt: now}, ClassScheduleRecord{ID: uuid.NewString(), TenantID: r.tenant.ID, TemplateID: templateID, LocationID: &locationID, DayOfWeek: 6, StartsLocal: "09:30:00", Active: true, CreatedAt: now}, ) + r.services[0].IntakeFields = `[{"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}]` seed := func(name, email, status string, startsAt time.Time) { r.bookings = append(r.bookings, BookingRecord{ ID: uuid.NewString(), @@ -1785,6 +1799,7 @@ func (r *MemoryRepository) seedDemoBookings() { seed("Bob Smith", "bob@example.com", "confirmed", now.Add(-26*time.Hour)) seed("Carol White", "carol@example.com", "confirmed", now.Add(-50*time.Hour)) seed("Alice Johnson", "alice@example.com", "cancelled", now.Add(-74*time.Hour)) + r.bookings[0].Intake = `{"health_notes":"Sensitive skin","first_visit":"yes"}` } func (r *MemoryRepository) AppendWaitlistEntry(_ context.Context, _ WaitlistEntryParams) error { diff --git a/apps/backend/internal/db/services.go b/apps/backend/internal/db/services.go index e395489..fc570a5 100644 --- a/apps/backend/internal/db/services.go +++ b/apps/backend/internal/db/services.go @@ -5,7 +5,7 @@ import ( "time" ) -const serviceColumns = "id, tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents, created_at" +const serviceColumns = "id, tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents, intake_fields::text, created_at" func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) { rows, err := r.pool.Query(ctx, ` @@ -30,6 +30,7 @@ func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string &record.BufferBeforeMinutes, &record.BufferAfterMinutes, &record.PriceCents, + &record.IntakeFields, &record.CreatedAt, ); err != nil { return nil, err @@ -53,6 +54,7 @@ func (r *PGRepository) GetServiceByID(ctx context.Context, serviceID string) (Se &record.BufferBeforeMinutes, &record.BufferAfterMinutes, &record.PriceCents, + &record.IntakeFields, &record.CreatedAt, ) return record, err @@ -61,10 +63,10 @@ func (r *PGRepository) GetServiceByID(ctx context.Context, serviceID string) (Se func (r *PGRepository) CreateService(ctx context.Context, params CreateServiceParams) (ServiceRecord, error) { var record ServiceRecord err := r.pool.QueryRow(ctx, ` - INSERT INTO services (tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO services (tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents, intake_fields) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) RETURNING `+serviceColumns+` - `, params.TenantID, params.Name, params.DurationMinutes, params.BufferBeforeMinutes, params.BufferAfterMinutes, params.PriceCents).Scan( + `, params.TenantID, params.Name, params.DurationMinutes, params.BufferBeforeMinutes, params.BufferAfterMinutes, params.PriceCents, params.IntakeFields).Scan( &record.ID, &record.TenantID, &record.Name, @@ -72,6 +74,7 @@ func (r *PGRepository) CreateService(ctx context.Context, params CreateServicePa &record.BufferBeforeMinutes, &record.BufferAfterMinutes, &record.PriceCents, + &record.IntakeFields, &record.CreatedAt, ) return record, err @@ -85,10 +88,11 @@ func (r *PGRepository) UpdateService(ctx context.Context, serviceID string, para duration_minutes = COALESCE($3, duration_minutes), buffer_before_minutes = COALESCE($4, buffer_before_minutes), buffer_after_minutes = COALESCE($5, buffer_after_minutes), - price_cents = COALESCE($6, price_cents) + price_cents = COALESCE($6, price_cents), + intake_fields = COALESCE($7::jsonb, intake_fields) WHERE id = $1 RETURNING `+serviceColumns+` - `, serviceID, params.Name, params.DurationMinutes, params.BufferBeforeMinutes, params.BufferAfterMinutes, params.PriceCents).Scan( + `, serviceID, params.Name, params.DurationMinutes, params.BufferBeforeMinutes, params.BufferAfterMinutes, params.PriceCents, params.IntakeFields).Scan( &record.ID, &record.TenantID, &record.Name, @@ -96,6 +100,7 @@ func (r *PGRepository) UpdateService(ctx context.Context, serviceID string, para &record.BufferBeforeMinutes, &record.BufferAfterMinutes, &record.PriceCents, + &record.IntakeFields, &record.CreatedAt, ) return record, err diff --git a/apps/backend/internal/domain/models.go b/apps/backend/internal/domain/models.go index fe18f0a..8fccdd4 100644 --- a/apps/backend/internal/domain/models.go +++ b/apps/backend/internal/domain/models.go @@ -26,6 +26,7 @@ type UpcomingBooking struct { Location string `json:"location,omitempty"` StaffName string `json:"staffName,omitempty"` Notes string `json:"notes,omitempty"` + Intake map[string]string `json:"intake,omitempty"` StartsAt time.Time `json:"startsAt"` EndsAt time.Time `json:"endsAt"` Status string `json:"status"` @@ -135,10 +136,11 @@ type PublicAvailability struct { } type PublicService struct { - ID string `json:"id"` - Name string `json:"name"` - DurationMinutes int `json:"durationMinutes"` - PriceCents int `json:"priceCents"` + ID string `json:"id"` + Name string `json:"name"` + DurationMinutes int `json:"durationMinutes"` + PriceCents int `json:"priceCents"` + IntakeFields []IntakeField `json:"intakeFields,omitempty"` } type PublicStaff struct { @@ -163,6 +165,9 @@ type CreateBookingRequest struct { CustomerEmail string `json:"customerEmail"` CustomerPhone string `json:"customerPhone,omitempty"` Notes string `json:"notes"` + // Intake carries structured intake answers keyed by the service's + // declared IntakeField keys; only declared keys are stored. + Intake map[string]string `json:"intake,omitempty"` StartsAt string `json:"startsAt"` EndsAt string `json:"endsAt"` } @@ -295,31 +300,44 @@ type UpdateLocationRequest struct { // SERVICE MODELS // ============================================ +// IntakeField is one structured intake question a service asks at booking +// time. Key is a slug (lowercase alnum + underscore) used as the JSON key in +// booking intake answers; Type is "text" or "yesno". +type IntakeField struct { + Key string `json:"key"` + Label string `json:"label"` + Type string `json:"type"` + Required bool `json:"required,omitempty"` +} + type Service struct { - ID string `json:"id"` - TenantID string `json:"tenantId"` - Name string `json:"name"` - DurationMinutes int `json:"durationMinutes"` - BufferBeforeMinutes int `json:"bufferBeforeMinutes"` - BufferAfterMinutes int `json:"bufferAfterMinutes"` - PriceCents int `json:"priceCents"` - CreatedAt time.Time `json:"createdAt"` + ID string `json:"id"` + TenantID string `json:"tenantId"` + Name string `json:"name"` + DurationMinutes int `json:"durationMinutes"` + BufferBeforeMinutes int `json:"bufferBeforeMinutes"` + BufferAfterMinutes int `json:"bufferAfterMinutes"` + PriceCents int `json:"priceCents"` + IntakeFields []IntakeField `json:"intakeFields,omitempty"` + CreatedAt time.Time `json:"createdAt"` } type CreateServiceRequest struct { - Name string `json:"name" binding:"required"` - DurationMinutes int `json:"durationMinutes" binding:"required"` - BufferBeforeMinutes int `json:"bufferBeforeMinutes,omitempty"` - BufferAfterMinutes int `json:"bufferAfterMinutes,omitempty"` - PriceCents int `json:"priceCents,omitempty"` + Name string `json:"name" binding:"required"` + DurationMinutes int `json:"durationMinutes" binding:"required"` + BufferBeforeMinutes int `json:"bufferBeforeMinutes,omitempty"` + BufferAfterMinutes int `json:"bufferAfterMinutes,omitempty"` + PriceCents int `json:"priceCents,omitempty"` + IntakeFields []IntakeField `json:"intakeFields,omitempty"` } type UpdateServiceRequest struct { - Name *string `json:"name,omitempty"` - DurationMinutes *int `json:"durationMinutes,omitempty"` - BufferBeforeMinutes *int `json:"bufferBeforeMinutes,omitempty"` - BufferAfterMinutes *int `json:"bufferAfterMinutes,omitempty"` - PriceCents *int `json:"priceCents,omitempty"` + Name *string `json:"name,omitempty"` + DurationMinutes *int `json:"durationMinutes,omitempty"` + BufferBeforeMinutes *int `json:"bufferBeforeMinutes,omitempty"` + BufferAfterMinutes *int `json:"bufferAfterMinutes,omitempty"` + PriceCents *int `json:"priceCents,omitempty"` + IntakeFields *[]IntakeField `json:"intakeFields,omitempty"` } // ============================================ @@ -526,6 +544,7 @@ type CustomerBookingView struct { Location string `json:"location"` Status string `json:"status"` Notes string `json:"notes,omitempty"` + Intake map[string]string `json:"intake,omitempty"` // WaitlistPosition is the 1-based queue spot for waitlisted class // bookings; zero for any other status. WaitlistPosition int `json:"waitlistPosition,omitempty"` diff --git a/apps/backend/internal/shared/intake.go b/apps/backend/internal/shared/intake.go new file mode 100644 index 0000000..07e6f16 --- /dev/null +++ b/apps/backend/internal/shared/intake.go @@ -0,0 +1,124 @@ +package shared + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + "bookra/apps/backend/internal/domain" +) + +var intakeKeyRe = regexp.MustCompile(`[^a-z0-9]+`) + +// ParseIntakeFields decodes the stored JSON list of declared intake fields; +// empty or corrupt values decode to nil so a bad row cannot break listing. +func ParseIntakeFields(raw string) []domain.IntakeField { + if raw == "" || raw == "[]" { + return nil + } + var fields []domain.IntakeField + if err := json.Unmarshal([]byte(raw), &fields); err != nil { + return nil + } + return fields +} + +// NormalizeIntakeFields validates a service's declared intake fields and +// returns the canonical JSON string for storage. Keys are slugified from the +// provided key or the label, must be unique, and fields are capped at 8. +// Returns the wrapped error unchanged when it is already an app error. +func NormalizeIntakeFields(fields []domain.IntakeField) (string, error) { + if len(fields) == 0 { + return "[]", nil + } + if len(fields) > 8 { + return "", fmt.Errorf("intakeFields supports at most 8 fields") + } + seen := make(map[string]bool, len(fields)) + out := make([]domain.IntakeField, 0, len(fields)) + for _, f := range fields { + label := strings.TrimSpace(f.Label) + if len(label) < 1 || len(label) > 80 { + return "", fmt.Errorf("intake field label must be 1-80 characters") + } + ftype := strings.ToLower(strings.TrimSpace(f.Type)) + if ftype != "text" && ftype != "yesno" { + return "", fmt.Errorf("intake field type must be text or yesno") + } + key := strings.TrimSpace(f.Key) + if key == "" { + key = label + } + key = strings.Trim(intakeKeyRe.ReplaceAllString(strings.ToLower(key), "_"), "_") + if key == "" || len(key) > 40 { + return "", fmt.Errorf("intake field key must be 1-40 slug characters") + } + if seen[key] { + return "", fmt.Errorf("duplicate intake field key %q", key) + } + seen[key] = true + out = append(out, domain.IntakeField{Key: key, Label: label, Type: ftype, Required: f.Required}) + } + raw, err := json.Marshal(out) + if err != nil { + return "", err + } + return string(raw), nil +} + +// ParseIntakeAnswers decodes a booking's stored intake JSON object; empty or +// corrupt values decode to nil. +func ParseIntakeAnswers(raw string) map[string]string { + if raw == "" || raw == "{}" { + return nil + } + var answers map[string]string + if err := json.Unmarshal([]byte(raw), &answers); err != nil { + return nil + } + return answers +} + +// NormalizeIntakeAnswers validates a customer's intake answers against the +// service's declared fields and returns the canonical JSON object. Required +// fields must be present and non-empty; yesno coerces to "yes"/"no"; text is +// trimmed and capped at 500 chars. Only declared keys are stored — unknown +// keys are dropped rather than persisted. +func NormalizeIntakeAnswers(fields []domain.IntakeField, intake map[string]string) (string, error) { + if len(fields) == 0 { + return "{}", nil + } + out := make(map[string]string, len(fields)) + for _, f := range fields { + raw, ok := intake[f.Key] + value := strings.TrimSpace(raw) + if !ok || value == "" { + if f.Required { + return "", fmt.Errorf("intake field %q is required", f.Label) + } + continue + } + switch f.Type { + case "yesno": + switch strings.ToLower(value) { + case "yes", "ano", "true", "1": + value = "yes" + case "no", "ne", "false", "0": + value = "no" + default: + return "", fmt.Errorf("intake field %q must be yes or no", f.Label) + } + default: + if len(value) > 500 { + return "", fmt.Errorf("intake field %q must be at most 500 characters", f.Label) + } + } + out[f.Key] = value + } + raw, err := json.Marshal(out) + if err != nil { + return "", err + } + return string(raw), nil +} diff --git a/apps/backend/internal/shared/intake_test.go b/apps/backend/internal/shared/intake_test.go new file mode 100644 index 0000000..ae55cc1 --- /dev/null +++ b/apps/backend/internal/shared/intake_test.go @@ -0,0 +1,83 @@ +package shared + +import ( + "strings" + "testing" + + "bookra/apps/backend/internal/domain" +) + +func TestNormalizeIntakeFields(t *testing.T) { + raw, err := NormalizeIntakeFields([]domain.IntakeField{ + {Label: "First visit?", Type: "yesno", Required: true}, + {Key: "injuries", Label: "Injuries or health notes", Type: "text"}, + }) + if err != nil { + t.Fatalf("normalize: %v", err) + } + fields := ParseIntakeFields(raw) + if len(fields) != 2 { + t.Fatalf("expected 2 fields, got %d", len(fields)) + } + if fields[0].Key != "first_visit" || !fields[0].Required { + t.Fatalf("unexpected field normalization: %+v", fields[0]) + } + + if _, err := NormalizeIntakeFields([]domain.IntakeField{ + {Label: "A", Type: "yesno"}, {Label: "A", Type: "text"}, + }); err == nil { + t.Fatal("expected duplicate key error") + } + if _, err := NormalizeIntakeFields([]domain.IntakeField{ + {Label: "A", Type: "radio"}, + }); err == nil { + t.Fatal("expected bad type error") + } + nine := make([]domain.IntakeField, 9) + for i := range nine { + nine[i] = domain.IntakeField{Key: strings.Repeat("k", i+1), Label: "L", Type: "text"} + } + if _, err := NormalizeIntakeFields(nine); err == nil { + t.Fatal("expected max-8 error") + } +} + +func TestNormalizeIntakeAnswers(t *testing.T) { + fields := []domain.IntakeField{ + {Key: "first_visit", Label: "First visit?", Type: "yesno", Required: true}, + {Key: "injuries", Label: "Injuries?", Type: "text"}, + } + + raw, err := NormalizeIntakeAnswers(fields, map[string]string{ + "first_visit": "yes", + "injuries": " sore knee ", + "undeclared": "dropped", + }) + if err != nil { + t.Fatalf("normalize answers: %v", err) + } + answers := ParseIntakeAnswers(raw) + if answers["first_visit"] != "yes" || answers["injuries"] != "sore knee" { + t.Fatalf("unexpected answers: %+v", answers) + } + if _, ok := answers["undeclared"]; ok { + t.Fatal("undeclared key should be dropped") + } + + if _, err := NormalizeIntakeAnswers(fields, map[string]string{}); err == nil { + t.Fatal("expected required-field error") + } + if _, err := NormalizeIntakeAnswers(fields, map[string]string{"first_visit": "maybe"}); err == nil { + t.Fatal("expected yesno coercion error") + } + if _, err := NormalizeIntakeAnswers(fields, map[string]string{ + "first_visit": "no", "injuries": strings.Repeat("x", 501), + }); err == nil { + t.Fatal("expected text cap error") + } + + raw, err = NormalizeIntakeAnswers(nil, map[string]string{"anything": "ignored"}) + if err != nil || raw != "{}" { + t.Fatalf("expected {} for no declared fields, got %q err=%v", raw, err) + } +} diff --git a/apps/backend/migrations/00019_intake_fields.sql b/apps/backend/migrations/00019_intake_fields.sql new file mode 100644 index 0000000..9ec7f59 --- /dev/null +++ b/apps/backend/migrations/00019_intake_fields.sql @@ -0,0 +1,13 @@ +-- +goose Up +-- Schema-light structured intake: services declare a small list of intake +-- fields (key/label/type/required); bookings store the customer's answers as +-- a JSON object keyed by the declared field keys. Both default to empty so +-- existing services/bookings need no backfill. +ALTER TABLE services + ADD COLUMN intake_fields jsonb NOT NULL DEFAULT '[]'::jsonb; +ALTER TABLE bookings + ADD COLUMN intake jsonb NOT NULL DEFAULT '{}'::jsonb; + +-- +goose Down +ALTER TABLE services DROP COLUMN IF EXISTS intake_fields; +ALTER TABLE bookings DROP COLUMN IF EXISTS intake; diff --git a/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml index 6a6e8cd..b6fe6a4 100644 --- a/apps/backend/openapi/bookra.openapi.yaml +++ b/apps/backend/openapi/bookra.openapi.yaml @@ -2044,6 +2044,21 @@ components: type: array items: $ref: "#/components/schemas/PublicStaff" + IntakeField: + type: object + required: [key, label, type] + properties: + key: + type: string + description: Slug key (lowercase alnum + underscore); JSON key in booking intake answers + label: + type: string + description: Customer-facing question text + type: + type: string + enum: [text, yesno] + required: + type: boolean PublicService: type: object required: [id, name, durationMinutes, priceCents] @@ -2057,6 +2072,10 @@ components: type: integer priceCents: type: integer + intakeFields: + type: array + items: + $ref: "#/components/schemas/IntakeField" PublicStaff: type: object required: [id, displayName] @@ -2094,6 +2113,11 @@ components: format: email notes: type: string + intake: + type: object + additionalProperties: + type: string + description: Structured intake answers keyed by the service's declared intakeFields keys startsAt: type: string format: date-time @@ -2190,6 +2214,11 @@ components: type: string notes: type: string + intake: + type: object + additionalProperties: + type: string + description: Structured intake answers keyed by the service's declared intakeFields keys classSessionId: type: string format: uuid @@ -2233,6 +2262,10 @@ components: type: string notes: type: string + intake: + type: object + additionalProperties: + type: string waitlistPosition: type: integer description: 1-based queue spot for waitlisted class bookings; absent otherwise @@ -2619,6 +2652,11 @@ components: priceCents: type: integer description: Price in the tenant's currency minor units + intakeFields: + type: array + items: + $ref: "#/components/schemas/IntakeField" + description: Structured intake questions asked at booking time (max 8) createdAt: type: string format: date-time @@ -2636,6 +2674,10 @@ components: type: integer priceCents: type: integer + intakeFields: + type: array + items: + $ref: "#/components/schemas/IntakeField" StaffMember: type: object required: [id, tenantId, displayName, active] diff --git a/apps/frontend/src/providers/i18n-provider.tsx b/apps/frontend/src/providers/i18n-provider.tsx index 3b29cb6..5be1644 100644 --- a/apps/frontend/src/providers/i18n-provider.tsx +++ b/apps/frontend/src/providers/i18n-provider.tsx @@ -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", diff --git a/apps/frontend/src/routes/booking-manage-route.tsx b/apps/frontend/src/routes/booking-manage-route.tsx index fe48d50..587c569 100644 --- a/apps/frontend/src/routes/booking-manage-route.tsx +++ b/apps/frontend/src/routes/booking-manage-route.tsx @@ -391,6 +391,24 @@ export function BookingManageRoute() {

{b().notes}

+ 0}> +
+

{i18n.t("dashboard.bookingModal.intake")}

+
+ + {([key, raw]) => { + const value = String(raw); + return ( +

+ {key.replace(/_/g, " ")}: + {value === "yes" || value === "no" ? i18n.t(`booking.intake${value === "yes" ? "Yes" : "No"}`) : value} +

+ ); + }} +
+
+
+
{/* Action Buttons */} diff --git a/apps/frontend/src/routes/dashboard/dashboard-data.ts b/apps/frontend/src/routes/dashboard/dashboard-data.ts index e609dc0..b6441d5 100644 --- a/apps/frontend/src/routes/dashboard/dashboard-data.ts +++ b/apps/frontend/src/routes/dashboard/dashboard-data.ts @@ -40,7 +40,7 @@ function createDemoData(i18n: ReturnType) { { 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: "" }, diff --git a/apps/frontend/src/routes/dashboard/layout.tsx b/apps/frontend/src/routes/dashboard/layout.tsx index 1e9aff9..94d4ef7 100644 --- a/apps/frontend/src/routes/dashboard/layout.tsx +++ b/apps/frontend/src/routes/dashboard/layout.tsx @@ -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 }) {

{data.selectedBooking()?.notes}

+ 0}> +
+

{data.i18n.t("dashboard.bookingModal.intake")}

+
+ + {([key, raw]) => { + const value = String(raw); + return ( +
+ {key.replace(/_/g, " ")} + {value === "yes" || value === "no" ? data.i18n.t(`booking.intake${value === "yes" ? "Yes" : "No"}`) : value} +
+ ); + }} +
+
+
+
diff --git a/apps/frontend/src/routes/dashboard/services-page.tsx b/apps/frontend/src/routes/dashboard/services-page.tsx index 148c446..db8de48 100644 --- a/apps/frontend/src/routes/dashboard/services-page.tsx +++ b/apps/frontend/src/routes/dashboard/services-page.tsx @@ -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) => { + 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() { setField({ bufferBeforeMinutes: e.currentTarget.value })} /> setField({ bufferAfterMinutes: e.currentTarget.value })} />
+
+
+

{data.i18n.t("dashboard.service.intake")}

+ + + +
+ 0} fallback={ +

{data.i18n.t("dashboard.service.intakeHint")}

+ }> +
+ + {(field, i) => ( +
+
+ setIntake(i(), { label: e.currentTarget.value })} placeholder={data.i18n.t("dashboard.service.intakeLabel")} /> +
+ setIntake(i(), { required: e.currentTarget.checked })} class="rounded accent-accent" /> + {data.i18n.t("dashboard.service.intakeRequired")} + + +
+ )} +
+
+
+