mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
feat(booking): stepped public booking flow with service/staff selection
The public booking page showed a capped flat preview of slots with no way to pick a service, day, or staff member. Replaces it with a stepped flow: pick service, optional staff preference, pick a day, pick a time. Backend: availability accepts serviceId/staffId/date filters (default response unchanged for the embed widget), a new public catalog endpoint exposes bookable services and active staff, and slots carry staffName. Slot generation and booking creation now honor tenant-wide and per-staff blocked days; appointment requests must fit inside a matching availability rule and reference tenant-owned, active staff and locations. 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
09f780e7a6
commit
d9aa29dbcb
9 files changed
+905
-83
No files matched your search
@@ -323,7 +323,27 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
server.router.GET("/v1/public/tenants/:tenantSlug/availability", func(c *gin.Context) {
|
server.router.GET("/v1/public/tenants/:tenantSlug/availability", func(c *gin.Context) {
|
||||||
response, err := bookingService.Availability(c.Request.Context(), c.Param("tenantSlug"))
|
response, err := bookingService.Availability(c.Request.Context(), c.Param("tenantSlug"), bookings.AvailabilityFilter{
|
||||||
|
ServiceID: c.Query("serviceId"),
|
||||||
|
StaffID: c.Query("staffId"),
|
||||||
|
Date: c.Query("date"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusInternalServerError
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, bookings.ErrTenantNotFound):
|
||||||
|
status = http.StatusNotFound
|
||||||
|
case errors.Is(err, bookings.ErrInvalidBooking):
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
c.JSON(status, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
})
|
||||||
|
|
||||||
|
server.router.GET("/v1/public/tenants/:tenantSlug/catalog", func(c *gin.Context) {
|
||||||
|
response, err := bookingService.Catalog(c.Request.Context(), c.Param("tenantSlug"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
status := http.StatusInternalServerError
|
status := http.StatusInternalServerError
|
||||||
if errors.Is(err, bookings.ErrTenantNotFound) {
|
if errors.Is(err, bookings.ErrTenantNotFound) {
|
||||||
|
|||||||
@@ -58,7 +58,13 @@ func (n *noopNotifier) SendBusinessNotification(ctx context.Context, businessEma
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Availability(ctx context.Context, tenantSlug string) (domain.PublicAvailability, error) {
|
type AvailabilityFilter struct {
|
||||||
|
ServiceID string
|
||||||
|
StaffID string
|
||||||
|
Date string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Availability(ctx context.Context, tenantSlug string, filter AvailabilityFilter) (domain.PublicAvailability, error) {
|
||||||
tenant, err := s.repo.GetTenantBySlug(ctx, tenantSlug)
|
tenant, err := s.repo.GetTenantBySlug(ctx, tenantSlug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
@@ -67,27 +73,101 @@ func (s *Service) Availability(ctx context.Context, tenantSlug string) (domain.P
|
|||||||
return domain.PublicAvailability{}, err
|
return domain.PublicAvailability{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location, err := time.LoadLocation(tenant.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
location = time.UTC
|
||||||
|
}
|
||||||
|
|
||||||
services, err := s.repo.ListServicesByTenant(ctx, tenant.ID)
|
services, err := s.repo.ListServicesByTenant(ctx, tenant.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.PublicAvailability{}, err
|
return domain.PublicAvailability{}, err
|
||||||
}
|
}
|
||||||
|
if filter.ServiceID != "" {
|
||||||
|
found := false
|
||||||
|
for _, service := range services {
|
||||||
|
if service.ID == filter.ServiceID {
|
||||||
|
services = []db.ServiceRecord{service}
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return domain.PublicAvailability{}, fmt.Errorf("%w: serviceId is not available for tenant", ErrInvalidBooking)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
staffRecords, err := s.repo.ListStaffByTenant(ctx, tenant.ID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PublicAvailability{}, err
|
||||||
|
}
|
||||||
|
staffNames := make(map[string]string, len(staffRecords))
|
||||||
|
for _, staff := range staffRecords {
|
||||||
|
staffNames[staff.ID] = staff.DisplayName
|
||||||
|
}
|
||||||
|
var staffFilter *string
|
||||||
|
if filter.StaffID != "" {
|
||||||
|
for _, staff := range staffRecords {
|
||||||
|
if staff.ID == filter.StaffID && staff.Active {
|
||||||
|
staffID := staff.ID
|
||||||
|
staffFilter = &staffID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if staffFilter == nil {
|
||||||
|
return domain.PublicAvailability{}, fmt.Errorf("%w: staffId is not available for tenant", ErrInvalidBooking)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rules, err := s.repo.ListAvailabilityRulesByTenant(ctx, tenant.ID)
|
rules, err := s.repo.ListAvailabilityRulesByTenant(ctx, tenant.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.PublicAvailability{}, err
|
return domain.PublicAvailability{}, err
|
||||||
}
|
}
|
||||||
windowStart := time.Now().UTC()
|
|
||||||
|
now := time.Now()
|
||||||
|
windowStart := now.UTC()
|
||||||
windowEnd := windowStart.AddDate(0, 0, 7)
|
windowEnd := windowStart.AddDate(0, 0, 7)
|
||||||
|
var days []time.Time
|
||||||
|
if filter.Date != "" {
|
||||||
|
day, err := time.ParseInLocation("2006-01-02", filter.Date, location)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PublicAvailability{}, fmt.Errorf("%w: date must be YYYY-MM-DD", ErrInvalidBooking)
|
||||||
|
}
|
||||||
|
days = []time.Time{day}
|
||||||
|
windowStart = day.UTC()
|
||||||
|
windowEnd = day.AddDate(0, 0, 1).UTC()
|
||||||
|
}
|
||||||
|
|
||||||
existingBookings, err := s.repo.ListBookingsByTenantBetween(ctx, tenant.ID, windowStart, windowEnd)
|
existingBookings, err := s.repo.ListBookingsByTenantBetween(ctx, tenant.ID, windowStart, windowEnd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.PublicAvailability{}, err
|
return domain.PublicAvailability{}, err
|
||||||
}
|
}
|
||||||
|
blockedDays, err := s.repo.ListBlockedDaysByTenant(ctx, tenant.ID, windowStart, windowEnd)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PublicAvailability{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if days != nil {
|
||||||
|
slots := appointmentSlotsForDays(tenant, location, now, days, services, rules, blockedDays, staffNames, existingBookings, staffFilter, 0)
|
||||||
|
sort.Slice(slots, func(i, j int) bool { return slots[i].StartsAt < slots[j].StartsAt })
|
||||||
|
return domain.PublicAvailability{
|
||||||
|
TenantSlug: tenant.Slug,
|
||||||
|
Timezone: tenant.Timezone,
|
||||||
|
Locale: tenant.Locale,
|
||||||
|
Slots: slots,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
classSessions, err := s.repo.ListClassSessionsByTenant(ctx, tenant.ID, windowStart, 8)
|
classSessions, err := s.repo.ListClassSessionsByTenant(ctx, tenant.ID, windowStart, 8)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.PublicAvailability{}, err
|
return domain.PublicAvailability{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previewDays := make([]time.Time, 0, 7)
|
||||||
|
for offset := 0; offset < 7; offset++ {
|
||||||
|
previewDays = append(previewDays, now.In(location).AddDate(0, 0, offset))
|
||||||
|
}
|
||||||
slots := make([]domain.TimeSlot, 0, 8)
|
slots := make([]domain.TimeSlot, 0, 8)
|
||||||
slots = append(slots, generateAppointmentSlots(tenant, services, rules, existingBookings)...)
|
slots = append(slots, appointmentSlotsForDays(tenant, location, now, previewDays, services, rules, blockedDays, staffNames, existingBookings, staffFilter, 4)...)
|
||||||
slots = append(slots, generateClassSlots(classSessions, existingBookings)...)
|
slots = append(slots, generateClassSlots(classSessions, existingBookings)...)
|
||||||
sort.Slice(slots, func(i, j int) bool { return slots[i].StartsAt < slots[j].StartsAt })
|
sort.Slice(slots, func(i, j int) bool { return slots[i].StartsAt < slots[j].StartsAt })
|
||||||
if len(slots) > 8 {
|
if len(slots) > 8 {
|
||||||
@@ -102,6 +182,51 @@ func (s *Service) Availability(ctx context.Context, tenantSlug string) (domain.P
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Catalog returns the public-facing service and staff catalog for a tenant's
|
||||||
|
// booking page. Only active staff are listed.
|
||||||
|
func (s *Service) Catalog(ctx context.Context, tenantSlug string) (domain.PublicCatalog, error) {
|
||||||
|
tenant, err := s.repo.GetTenantBySlug(ctx, tenantSlug)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.PublicCatalog{}, ErrTenantNotFound
|
||||||
|
}
|
||||||
|
return domain.PublicCatalog{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
services, err := s.repo.ListServicesByTenant(ctx, tenant.ID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PublicCatalog{}, err
|
||||||
|
}
|
||||||
|
staffRecords, err := s.repo.ListStaffByTenant(ctx, tenant.ID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PublicCatalog{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
catalog := domain.PublicCatalog{
|
||||||
|
TenantSlug: tenant.Slug,
|
||||||
|
Services: make([]domain.PublicService, 0, len(services)),
|
||||||
|
Staff: make([]domain.PublicStaff, 0, len(staffRecords)),
|
||||||
|
}
|
||||||
|
for _, service := range services {
|
||||||
|
catalog.Services = append(catalog.Services, domain.PublicService{
|
||||||
|
ID: service.ID,
|
||||||
|
Name: service.Name,
|
||||||
|
DurationMinutes: service.DurationMinutes,
|
||||||
|
PriceCents: service.PriceCents,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, staff := range staffRecords {
|
||||||
|
if !staff.Active {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
catalog.Staff = append(catalog.Staff, domain.PublicStaff{
|
||||||
|
ID: staff.ID,
|
||||||
|
DisplayName: staff.DisplayName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return catalog, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) Create(ctx context.Context, request domain.CreateBookingRequest) (domain.CreateBookingResponse, error) {
|
func (s *Service) Create(ctx context.Context, request domain.CreateBookingRequest) (domain.CreateBookingResponse, error) {
|
||||||
tenant, err := s.repo.GetTenantBySlug(ctx, request.TenantSlug)
|
tenant, err := s.repo.GetTenantBySlug(ctx, request.TenantSlug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -165,6 +290,9 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
|||||||
if !startsAt.Add(expectedDuration).Equal(endsAt) {
|
if !startsAt.Add(expectedDuration).Equal(endsAt) {
|
||||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: appointment duration must match service duration", ErrInvalidBooking)
|
return domain.CreateBookingResponse{}, fmt.Errorf("%w: appointment duration must match service duration", ErrInvalidBooking)
|
||||||
}
|
}
|
||||||
|
if err := s.validateAppointmentTarget(ctx, tenant, request.StaffID, request.LocationID, startsAt, endsAt); err != nil {
|
||||||
|
return domain.CreateBookingResponse{}, err
|
||||||
|
}
|
||||||
for _, booking := range existing {
|
for _, booking := range existing {
|
||||||
if booking.Status == "cancelled" {
|
if booking.Status == "cancelled" {
|
||||||
continue
|
continue
|
||||||
@@ -251,6 +379,57 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateAppointmentTarget ensures the requested appointment references
|
||||||
|
// resources owned by the tenant and lands inside bookable time: the staff and
|
||||||
|
// location must belong to the tenant, the window must fit an availability rule
|
||||||
|
// the booking could claim, and it must not overlap a blocked day.
|
||||||
|
func (s *Service) validateAppointmentTarget(ctx context.Context, tenant db.TenantRecord, staffID, locationID *string, startsAt, endsAt time.Time) error {
|
||||||
|
if staffID != nil {
|
||||||
|
staff, err := s.repo.GetStaffByID(ctx, *staffID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return fmt.Errorf("%w: staffId is not available for tenant", ErrInvalidBooking)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if staff.TenantID != tenant.ID || !staff.Active {
|
||||||
|
return fmt.Errorf("%w: staffId is not available for tenant", ErrInvalidBooking)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if locationID != nil {
|
||||||
|
location, err := s.repo.GetLocationByID(ctx, *locationID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return fmt.Errorf("%w: locationId is not available for tenant", ErrInvalidBooking)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if location.TenantID != tenant.ID {
|
||||||
|
return fmt.Errorf("%w: locationId is not available for tenant", ErrInvalidBooking)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rules, err := s.repo.ListAvailabilityRulesByTenant(ctx, tenant.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
blockedDays, err := s.repo.ListBlockedDaysByTenant(ctx, tenant.ID, startsAt.UTC(), endsAt.UTC())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
location, err := time.LoadLocation(tenant.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
location = time.UTC
|
||||||
|
}
|
||||||
|
if !fitsAvailabilityWindow(rules, location, staffID, startsAt, endsAt) {
|
||||||
|
return fmt.Errorf("%w: requested time is outside bookable hours", ErrInvalidBooking)
|
||||||
|
}
|
||||||
|
if blockedAt(blockedDays, staffID, startsAt.UTC(), endsAt.UTC()) {
|
||||||
|
return fmt.Errorf("%w: requested time falls on a blocked day", ErrInvalidBooking)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) manageURL(booking db.CreatedBooking) string {
|
func (s *Service) manageURL(booking db.CreatedBooking) string {
|
||||||
return fmt.Sprintf("%s/manage/%s?token=%s", s.frontendURL, booking.Reference, booking.ManageToken)
|
return fmt.Sprintf("%s/manage/%s?token=%s", s.frontendURL, booking.Reference, booking.ManageToken)
|
||||||
}
|
}
|
||||||
@@ -485,68 +664,86 @@ func trackingStatus(repo db.Repository, ctx context.Context, tenant db.TenantRec
|
|||||||
return domain.TrackingStatus{Provider: "umami", Connected: true, SiteID: brand.UmamiSiteID, Message: "Umami tracking is connected."}
|
return domain.TrackingStatus{Provider: "umami", Connected: true, SiteID: brand.UmamiSiteID, Message: "Umami tracking is connected."}
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateAppointmentSlots(
|
// appointmentSlotsForDays generates appointment slots for the given tenant-local
|
||||||
|
// days. When staffFilter is set it acts as the customer's staff preference:
|
||||||
|
// tenant-wide rules (staffID nil) still apply and produce slots assigned to the
|
||||||
|
// preferred staff member, while rules scoped to another staff member are
|
||||||
|
// skipped. capPerService limits total slots per service across all days; a cap
|
||||||
|
// of 0 means unlimited.
|
||||||
|
func appointmentSlotsForDays(
|
||||||
tenant db.TenantRecord,
|
tenant db.TenantRecord,
|
||||||
|
location *time.Location,
|
||||||
|
now time.Time,
|
||||||
|
days []time.Time,
|
||||||
services []db.ServiceRecord,
|
services []db.ServiceRecord,
|
||||||
rules []db.AvailabilityRuleRecord,
|
rules []db.AvailabilityRuleRecord,
|
||||||
|
blockedDays []db.BlockedDayRecord,
|
||||||
|
staffNames map[string]string,
|
||||||
existing []db.BookingRecord,
|
existing []db.BookingRecord,
|
||||||
|
staffFilter *string,
|
||||||
|
capPerService int,
|
||||||
) []domain.TimeSlot {
|
) []domain.TimeSlot {
|
||||||
if len(services) == 0 || len(rules) == 0 {
|
if len(services) == 0 || len(rules) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
location, err := time.LoadLocation(tenant.Timezone)
|
|
||||||
if err != nil {
|
|
||||||
location = time.UTC
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().In(location)
|
|
||||||
var slots []domain.TimeSlot
|
var slots []domain.TimeSlot
|
||||||
|
|
||||||
for _, service := range services {
|
for _, service := range services {
|
||||||
serviceSlots := 0
|
serviceSlots := 0
|
||||||
for dayOffset := 0; dayOffset < 7 && serviceSlots < 4; dayOffset++ {
|
for _, day := range days {
|
||||||
day := now.AddDate(0, 0, dayOffset)
|
if capPerService > 0 && serviceSlots >= capPerService {
|
||||||
|
break
|
||||||
|
}
|
||||||
for _, rule := range rules {
|
for _, rule := range rules {
|
||||||
if int(day.Weekday()) != rule.DayOfWeek {
|
if int(day.Weekday()) != rule.DayOfWeek {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if staffFilter != nil && rule.StaffID != nil && *rule.StaffID != *staffFilter {
|
||||||
startsLocal, err := time.ParseInLocation("15:04:05", rule.StartsLocal, location)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
endsLocal, err := time.ParseInLocation("15:04:05", rule.EndsLocal, location)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
windowStart := time.Date(day.Year(), day.Month(), day.Day(), startsLocal.Hour(), startsLocal.Minute(), 0, 0, location)
|
windowStart, windowEnd, ok := ruleWindow(day, rule, location)
|
||||||
windowEnd := time.Date(day.Year(), day.Month(), day.Day(), endsLocal.Hour(), endsLocal.Minute(), 0, 0, location)
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
slotStaffID := rule.StaffID
|
||||||
|
if staffFilter != nil {
|
||||||
|
slotStaffID = staffFilter
|
||||||
|
}
|
||||||
|
|
||||||
step := time.Duration(service.DurationMinutes+service.BufferAfterMinutes) * time.Minute
|
step := time.Duration(service.DurationMinutes+service.BufferAfterMinutes) * time.Minute
|
||||||
duration := time.Duration(service.DurationMinutes) * time.Minute
|
duration := time.Duration(service.DurationMinutes) * time.Minute
|
||||||
for slotStart := windowStart; slotStart.Add(duration).Before(windowEnd) || slotStart.Add(duration).Equal(windowEnd); slotStart = slotStart.Add(step) {
|
for slotStart := windowStart; !slotStart.Add(duration).After(windowEnd); slotStart = slotStart.Add(step) {
|
||||||
if slotStart.Before(now.Add(2 * time.Hour)) {
|
if slotStart.Before(now.Add(2 * time.Hour)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
slotEnd := slotStart.Add(duration)
|
slotEnd := slotStart.Add(duration)
|
||||||
if collides(existing, rule.StaffID, nil, slotStart.UTC(), slotEnd.UTC()) {
|
if collides(existing, slotStaffID, nil, slotStart.UTC(), slotEnd.UTC()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if blockedAt(blockedDays, slotStaffID, slotStart.UTC(), slotEnd.UTC()) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
serviceID := service.ID
|
serviceID := service.ID
|
||||||
slots = append(slots, domain.TimeSlot{
|
slot := domain.TimeSlot{
|
||||||
ServiceID: &serviceID,
|
ServiceID: &serviceID,
|
||||||
StaffID: rule.StaffID,
|
StaffID: slotStaffID,
|
||||||
StartsAt: slotStart.UTC().Format(time.RFC3339),
|
StartsAt: slotStart.UTC().Format(time.RFC3339),
|
||||||
EndsAt: slotEnd.UTC().Format(time.RFC3339),
|
EndsAt: slotEnd.UTC().Format(time.RFC3339),
|
||||||
Mode: "appointment",
|
Mode: "appointment",
|
||||||
Label: service.Name,
|
Label: service.Name,
|
||||||
})
|
}
|
||||||
|
if slotStaffID != nil {
|
||||||
|
if name, ok := staffNames[*slotStaffID]; ok {
|
||||||
|
slot.StaffName = &name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slots = append(slots, slot)
|
||||||
serviceSlots++
|
serviceSlots++
|
||||||
if serviceSlots >= 4 {
|
if capPerService > 0 && serviceSlots >= capPerService {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -557,6 +754,60 @@ func generateAppointmentSlots(
|
|||||||
return slots
|
return slots
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ruleWindow resolves a weekly availability rule to concrete start/end times on
|
||||||
|
// the given day in the tenant's timezone.
|
||||||
|
func ruleWindow(day time.Time, rule db.AvailabilityRuleRecord, location *time.Location) (time.Time, time.Time, bool) {
|
||||||
|
startsLocal, err := time.ParseInLocation("15:04:05", rule.StartsLocal, location)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, time.Time{}, false
|
||||||
|
}
|
||||||
|
endsLocal, err := time.ParseInLocation("15:04:05", rule.EndsLocal, location)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, time.Time{}, false
|
||||||
|
}
|
||||||
|
windowStart := time.Date(day.Year(), day.Month(), day.Day(), startsLocal.Hour(), startsLocal.Minute(), 0, 0, location)
|
||||||
|
windowEnd := time.Date(day.Year(), day.Month(), day.Day(), endsLocal.Hour(), endsLocal.Minute(), 0, 0, location)
|
||||||
|
return windowStart, windowEnd, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// blockedAt reports whether a tenant-wide blocked day or one scoped to the
|
||||||
|
// given staff member overlaps the requested window.
|
||||||
|
func blockedAt(blockedDays []db.BlockedDayRecord, staffID *string, startsAt, endsAt time.Time) bool {
|
||||||
|
for _, blocked := range blockedDays {
|
||||||
|
if !(blocked.StartsAt.Before(endsAt) && blocked.EndsAt.After(startsAt)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if blocked.StaffID == nil || (staffID != nil && *blocked.StaffID == *staffID) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// fitsAvailabilityWindow reports whether the requested window falls inside an
|
||||||
|
// availability rule the booking could claim: a tenant-wide rule, or one scoped
|
||||||
|
// to the requested staff member.
|
||||||
|
func fitsAvailabilityWindow(rules []db.AvailabilityRuleRecord, location *time.Location, staffID *string, startsAt, endsAt time.Time) bool {
|
||||||
|
day := startsAt.In(location)
|
||||||
|
weekday := int(day.Weekday())
|
||||||
|
for _, rule := range rules {
|
||||||
|
if rule.DayOfWeek != weekday {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rule.StaffID != nil && (staffID == nil || *rule.StaffID != *staffID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
windowStart, windowEnd, ok := ruleWindow(day, rule, location)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !day.Before(windowStart) && !endsAt.In(location).After(windowEnd) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func generateClassSlots(classSessions []db.ClassSessionRecord, existing []db.BookingRecord) []domain.TimeSlot {
|
func generateClassSlots(classSessions []db.ClassSessionRecord, existing []db.BookingRecord) []domain.TimeSlot {
|
||||||
slots := make([]domain.TimeSlot, 0, len(classSessions))
|
slots := make([]domain.TimeSlot, 0, len(classSessions))
|
||||||
for _, session := range classSessions {
|
for _, session := range classSessions {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ func TestCreateAppointmentRejectsConflict(t *testing.T) {
|
|||||||
repo := db.NewMemoryRepository()
|
repo := db.NewMemoryRepository()
|
||||||
service := NewService(repo, nil, "http://localhost:3000")
|
service := NewService(repo, nil, "http://localhost:3000")
|
||||||
|
|
||||||
availability, err := service.Availability(context.Background(), "studio-atelier")
|
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("availability: %v", err)
|
t.Fatalf("availability: %v", err)
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,7 @@ func TestCreateClassFallsBackToWaitlistWhenCapacityReached(t *testing.T) {
|
|||||||
repo := db.NewMemoryRepository()
|
repo := db.NewMemoryRepository()
|
||||||
service := NewService(repo, nil, "http://localhost:3000")
|
service := NewService(repo, nil, "http://localhost:3000")
|
||||||
|
|
||||||
availability, err := service.Availability(context.Background(), "studio-atelier")
|
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("availability: %v", err)
|
t.Fatalf("availability: %v", err)
|
||||||
}
|
}
|
||||||
@@ -164,7 +164,7 @@ func TestAvailabilityGeneratesUpcomingSlots(t *testing.T) {
|
|||||||
repo := db.NewMemoryRepository()
|
repo := db.NewMemoryRepository()
|
||||||
service := NewService(repo, nil, "http://localhost:3000")
|
service := NewService(repo, nil, "http://localhost:3000")
|
||||||
|
|
||||||
availability, err := service.Availability(context.Background(), "studio-atelier")
|
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("availability: %v", err)
|
t.Fatalf("availability: %v", err)
|
||||||
}
|
}
|
||||||
@@ -187,7 +187,7 @@ func TestCreateSchedulesReminderJobForUpcomingAppointment(t *testing.T) {
|
|||||||
repo := db.NewMemoryRepository()
|
repo := db.NewMemoryRepository()
|
||||||
service := NewService(repo, nil, "http://localhost:3000")
|
service := NewService(repo, nil, "http://localhost:3000")
|
||||||
|
|
||||||
availability, err := service.Availability(context.Background(), "studio-atelier")
|
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{Date: nextRuleDay(t, 2)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("availability: %v", err)
|
t.Fatalf("availability: %v", err)
|
||||||
}
|
}
|
||||||
@@ -211,8 +211,8 @@ func TestCreateSchedulesReminderJobForUpcomingAppointment(t *testing.T) {
|
|||||||
LocationID: appointment.LocationID,
|
LocationID: appointment.LocationID,
|
||||||
CustomerName: "Reminder",
|
CustomerName: "Reminder",
|
||||||
CustomerEmail: "[email protected]",
|
CustomerEmail: "[email protected]",
|
||||||
StartsAt: time.Now().UTC().Add(30 * time.Hour).Format(time.RFC3339),
|
StartsAt: appointment.StartsAt,
|
||||||
EndsAt: time.Now().UTC().Add(31 * time.Hour).Format(time.RFC3339),
|
EndsAt: appointment.EndsAt,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create: %v", err)
|
t.Fatalf("create: %v", err)
|
||||||
@@ -226,3 +226,176 @@ func TestCreateSchedulesReminderJobForUpcomingAppointment(t *testing.T) {
|
|||||||
t.Fatal("expected reminder job to be scheduled")
|
t.Fatal("expected reminder job to be scheduled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nextRuleDay returns the first day at least minOffset days out whose weekday
|
||||||
|
// has a seeded availability rule (Mon–Fri), formatted YYYY-MM-DD.
|
||||||
|
func nextRuleDay(t *testing.T, minOffset int) string {
|
||||||
|
t.Helper()
|
||||||
|
for offset := minOffset; offset < minOffset+7; offset++ {
|
||||||
|
day := time.Now().AddDate(0, 0, offset)
|
||||||
|
if day.Weekday() >= time.Monday && day.Weekday() <= time.Friday {
|
||||||
|
return day.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("no rule day found")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAvailabilityDateFilterReturnsFullDaySlots(t *testing.T) {
|
||||||
|
repo := db.NewMemoryRepository()
|
||||||
|
service := NewService(repo, nil, "http://localhost:3000")
|
||||||
|
|
||||||
|
day := nextRuleDay(t, 2)
|
||||||
|
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{Date: day})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("availability: %v", err)
|
||||||
|
}
|
||||||
|
if len(availability.Slots) == 0 {
|
||||||
|
t.Fatal("expected day slots")
|
||||||
|
}
|
||||||
|
tenantLocation, err := time.LoadLocation(availability.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load timezone: %v", err)
|
||||||
|
}
|
||||||
|
for _, slot := range availability.Slots {
|
||||||
|
if slot.Mode != "appointment" {
|
||||||
|
t.Fatalf("expected only appointment slots, got %s", slot.Mode)
|
||||||
|
}
|
||||||
|
startsAt, err := time.Parse(time.RFC3339, slot.StartsAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse startsAt: %v", err)
|
||||||
|
}
|
||||||
|
if startsAt.In(tenantLocation).Format("2006-01-02") != day {
|
||||||
|
t.Fatalf("slot %s outside requested day %s", slot.StartsAt, day)
|
||||||
|
}
|
||||||
|
if slot.StaffName == nil || *slot.StaffName == "" {
|
||||||
|
t.Fatal("expected staffName on slot")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAvailabilityHonorsTenantWideBlockedDay(t *testing.T) {
|
||||||
|
repo := db.NewMemoryRepository()
|
||||||
|
service := NewService(repo, nil, "http://localhost:3000")
|
||||||
|
|
||||||
|
day := nextRuleDay(t, 2)
|
||||||
|
dayStart, err := time.Parse("2006-01-02", day)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse day: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.CreateBlockedDay(context.Background(), db.CreateBlockedDayParams{
|
||||||
|
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
|
||||||
|
StartsAt: dayStart.UTC(),
|
||||||
|
EndsAt: dayStart.Add(26 * time.Hour).UTC(),
|
||||||
|
Kind: "blocked",
|
||||||
|
Reason: "holiday",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create blocked day: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{Date: day})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("availability: %v", err)
|
||||||
|
}
|
||||||
|
if len(availability.Slots) != 0 {
|
||||||
|
t.Fatalf("expected no slots on blocked day, got %d", len(availability.Slots))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAvailabilityStaffFilterValidatesTenantStaff(t *testing.T) {
|
||||||
|
repo := db.NewMemoryRepository()
|
||||||
|
service := NewService(repo, nil, "http://localhost:3000")
|
||||||
|
foreignStaff := "22222222-2222-2222-2222-222222222222"
|
||||||
|
|
||||||
|
_, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{StaffID: foreignStaff})
|
||||||
|
if !errors.Is(err, ErrInvalidBooking) {
|
||||||
|
t.Fatalf("expected ErrInvalidBooking, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogReturnsServicesAndActiveStaff(t *testing.T) {
|
||||||
|
repo := db.NewMemoryRepository()
|
||||||
|
service := NewService(repo, nil, "http://localhost:3000")
|
||||||
|
|
||||||
|
catalog, err := service.Catalog(context.Background(), "studio-atelier")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("catalog: %v", err)
|
||||||
|
}
|
||||||
|
if len(catalog.Services) == 0 {
|
||||||
|
t.Fatal("expected services")
|
||||||
|
}
|
||||||
|
if len(catalog.Staff) == 0 {
|
||||||
|
t.Fatal("expected staff")
|
||||||
|
}
|
||||||
|
for _, staff := range catalog.Staff {
|
||||||
|
if staff.DisplayName == "" {
|
||||||
|
t.Fatal("expected display name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateAppointmentRejectsTimeOutsideAvailabilityWindow(t *testing.T) {
|
||||||
|
repo := db.NewMemoryRepository()
|
||||||
|
service := NewService(repo, nil, "http://localhost:3000")
|
||||||
|
|
||||||
|
catalog, err := service.Catalog(context.Background(), "studio-atelier")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("catalog: %v", err)
|
||||||
|
}
|
||||||
|
serviceID := catalog.Services[0].ID
|
||||||
|
staffID := catalog.Staff[0].ID
|
||||||
|
|
||||||
|
// 03:00 on a rule day is outside the seeded 09:00–17:00 window.
|
||||||
|
day := nextRuleDay(t, 2)
|
||||||
|
startsAt, err := time.Parse(time.RFC3339, day+"T03:00:00Z")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse startsAt: %v", err)
|
||||||
|
}
|
||||||
|
_, err = service.Create(context.Background(), domain.CreateBookingRequest{
|
||||||
|
TenantSlug: "studio-atelier",
|
||||||
|
BookingMode: "appointment",
|
||||||
|
ServiceID: &serviceID,
|
||||||
|
StaffID: &staffID,
|
||||||
|
CustomerName: "Night Owl",
|
||||||
|
CustomerEmail: "[email protected]",
|
||||||
|
StartsAt: startsAt.Format(time.RFC3339),
|
||||||
|
EndsAt: startsAt.Add(time.Hour).Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrInvalidBooking) {
|
||||||
|
t.Fatalf("expected ErrInvalidBooking, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateAppointmentRejectsForeignStaff(t *testing.T) {
|
||||||
|
repo := db.NewMemoryRepository()
|
||||||
|
service := NewService(repo, nil, "http://localhost:3000")
|
||||||
|
|
||||||
|
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("availability: %v", err)
|
||||||
|
}
|
||||||
|
var appointment domain.TimeSlot
|
||||||
|
for _, slot := range availability.Slots {
|
||||||
|
if slot.Mode == "appointment" {
|
||||||
|
appointment = slot
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if appointment.StartsAt == "" {
|
||||||
|
t.Fatal("expected appointment slot")
|
||||||
|
}
|
||||||
|
foreignStaff := "22222222-2222-2222-2222-222222222222"
|
||||||
|
_, err = service.Create(context.Background(), domain.CreateBookingRequest{
|
||||||
|
TenantSlug: "studio-atelier",
|
||||||
|
BookingMode: "appointment",
|
||||||
|
ServiceID: appointment.ServiceID,
|
||||||
|
StaffID: &foreignStaff,
|
||||||
|
CustomerName: "Impersonator",
|
||||||
|
CustomerEmail: "[email protected]",
|
||||||
|
StartsAt: appointment.StartsAt,
|
||||||
|
EndsAt: appointment.EndsAt,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrInvalidBooking) {
|
||||||
|
t.Fatalf("expected ErrInvalidBooking, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,6 +114,7 @@ type TimeSlot struct {
|
|||||||
ServiceID *string `json:"serviceId,omitempty"`
|
ServiceID *string `json:"serviceId,omitempty"`
|
||||||
ClassSessionID *string `json:"classSessionId,omitempty"`
|
ClassSessionID *string `json:"classSessionId,omitempty"`
|
||||||
StaffID *string `json:"staffId,omitempty"`
|
StaffID *string `json:"staffId,omitempty"`
|
||||||
|
StaffName *string `json:"staffName,omitempty"`
|
||||||
LocationID *string `json:"locationId,omitempty"`
|
LocationID *string `json:"locationId,omitempty"`
|
||||||
StartsAt string `json:"startsAt"`
|
StartsAt string `json:"startsAt"`
|
||||||
EndsAt string `json:"endsAt"`
|
EndsAt string `json:"endsAt"`
|
||||||
@@ -129,6 +130,24 @@ type PublicAvailability struct {
|
|||||||
Slots []TimeSlot `json:"slots"`
|
Slots []TimeSlot `json:"slots"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PublicService struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DurationMinutes int `json:"durationMinutes"`
|
||||||
|
PriceCents int `json:"priceCents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicStaff struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicCatalog struct {
|
||||||
|
TenantSlug string `json:"tenantSlug"`
|
||||||
|
Services []PublicService `json:"services"`
|
||||||
|
Staff []PublicStaff `json:"staff"`
|
||||||
|
}
|
||||||
|
|
||||||
type CreateBookingRequest struct {
|
type CreateBookingRequest struct {
|
||||||
TenantSlug string `json:"tenantSlug"`
|
TenantSlug string `json:"tenantSlug"`
|
||||||
BookingMode string `json:"bookingMode"`
|
BookingMode string `json:"bookingMode"`
|
||||||
|
|||||||
@@ -263,6 +263,27 @@ paths:
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
- in: query
|
||||||
|
name: serviceId
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Restrict appointment slots to a single service
|
||||||
|
- in: query
|
||||||
|
name: staffId
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Restrict appointment slots to a single staff member
|
||||||
|
- in: query
|
||||||
|
name: date
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
description: Return all slots for a single tenant-local day (YYYY-MM-DD) instead of the capped preview
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Availability for a tenant booking page
|
description: Availability for a tenant booking page
|
||||||
@@ -270,6 +291,23 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/PublicAvailabilityResponse"
|
$ref: "#/components/schemas/PublicAvailabilityResponse"
|
||||||
|
/v1/public/tenants/{tenantSlug}/catalog:
|
||||||
|
get:
|
||||||
|
tags: [Public Booking]
|
||||||
|
operationId: getPublicCatalog
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: tenantSlug
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Bookable services and active staff for a tenant booking page
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/PublicCatalogResponse"
|
||||||
/v1/public/bookings:
|
/v1/public/bookings:
|
||||||
post:
|
post:
|
||||||
tags: [Public Booking]
|
tags: [Public Booking]
|
||||||
@@ -1270,6 +1308,9 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
nullable: true
|
nullable: true
|
||||||
|
staffName:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
locationId:
|
locationId:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
@@ -1303,6 +1344,42 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/TimeSlot"
|
$ref: "#/components/schemas/TimeSlot"
|
||||||
|
PublicCatalogResponse:
|
||||||
|
type: object
|
||||||
|
required: [tenantSlug, services, staff]
|
||||||
|
properties:
|
||||||
|
tenantSlug:
|
||||||
|
type: string
|
||||||
|
services:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/PublicService"
|
||||||
|
staff:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/PublicStaff"
|
||||||
|
PublicService:
|
||||||
|
type: object
|
||||||
|
required: [id, name, durationMinutes, priceCents]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
durationMinutes:
|
||||||
|
type: integer
|
||||||
|
priceCents:
|
||||||
|
type: integer
|
||||||
|
PublicStaff:
|
||||||
|
type: object
|
||||||
|
required: [id, displayName]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
displayName:
|
||||||
|
type: string
|
||||||
CreateBookingRequest:
|
CreateBookingRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [tenantSlug, bookingMode, customerName, customerEmail, startsAt, endsAt]
|
required: [tenantSlug, bookingMode, customerName, customerEmail, startsAt, endsAt]
|
||||||
|
|||||||
@@ -475,6 +475,14 @@ const dictionaries = {
|
|||||||
"booking.selectTimeBody": "Vyberte si z dostupných termínů pro služby a lekce.",
|
"booking.selectTimeBody": "Vyberte si z dostupných termínů pro služby a lekce.",
|
||||||
"booking.empty": "Momentálně nejsou k dispozici žádné termíny.",
|
"booking.empty": "Momentálně nejsou k dispozici žádné termíny.",
|
||||||
"booking.emptyHint": "Zkuste to prosím později.",
|
"booking.emptyHint": "Zkuste to prosím později.",
|
||||||
|
"booking.emptyDay": "V tento den není nic volného.",
|
||||||
|
"booking.step.service": "Vyberte službu",
|
||||||
|
"booking.step.staff": "S kým?",
|
||||||
|
"booking.step.date": "Vyberte den",
|
||||||
|
"booking.step.time": "Vyberte čas",
|
||||||
|
"booking.staff.any": "Bez preference",
|
||||||
|
"booking.service.free": "Zdarma",
|
||||||
|
"booking.classes": "Skupinové lekce",
|
||||||
"booking.only": "Zbývají",
|
"booking.only": "Zbývají",
|
||||||
"booking.left": "místa",
|
"booking.left": "místa",
|
||||||
"booking.spotsAvailable": "volná místa",
|
"booking.spotsAvailable": "volná místa",
|
||||||
@@ -1019,6 +1027,14 @@ const dictionaries = {
|
|||||||
"booking.selectTimeBody": "Choose from available appointment and class times.",
|
"booking.selectTimeBody": "Choose from available appointment and class times.",
|
||||||
"booking.empty": "No bookable times are available right now.",
|
"booking.empty": "No bookable times are available right now.",
|
||||||
"booking.emptyHint": "Please check again later.",
|
"booking.emptyHint": "Please check again later.",
|
||||||
|
"booking.emptyDay": "No times available on this day.",
|
||||||
|
"booking.step.service": "Choose a service",
|
||||||
|
"booking.step.staff": "With whom?",
|
||||||
|
"booking.step.date": "Pick a day",
|
||||||
|
"booking.step.time": "Pick a time",
|
||||||
|
"booking.staff.any": "No preference",
|
||||||
|
"booking.service.free": "Free",
|
||||||
|
"booking.classes": "Group classes",
|
||||||
"booking.only": "Only",
|
"booking.only": "Only",
|
||||||
"booking.left": "left",
|
"booking.left": "left",
|
||||||
"booking.spotsAvailable": "spots available",
|
"booking.spotsAvailable": "spots available",
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { A, useParams } from "@solidjs/router";
|
import { A, useParams } from "@solidjs/router";
|
||||||
import { For, Show, createResource, createSignal } from "solid-js";
|
import { For, Show, createMemo, createResource, createSignal } from "solid-js";
|
||||||
import { apiClient } from "../lib/api-client";
|
import { apiClient } from "../lib/api-client";
|
||||||
import { useI18n } from "../providers/i18n-provider";
|
import { useI18n } from "../providers/i18n-provider";
|
||||||
import type { components } from "@bookra/api-client/generated/types";
|
import type { components } from "@bookra/api-client/generated/types";
|
||||||
import { BookraCharacter } from "../components/bookra-character";
|
import { BookraCharacter } from "../components/bookra-character";
|
||||||
import { Button, Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Input, SkeletonCard, Textarea, Tooltip } from "../components/ui";
|
import { Button, Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Input, SkeletonCard, Textarea } from "../components/ui";
|
||||||
|
|
||||||
|
type PublicService = components["schemas"]["PublicService"];
|
||||||
|
type PublicStaff = components["schemas"]["PublicStaff"];
|
||||||
|
|
||||||
export function PublicBookingRoute() {
|
export function PublicBookingRoute() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -18,7 +21,19 @@ export function PublicBookingRoute() {
|
|||||||
const [customerPhone, setCustomerPhone] = createSignal("");
|
const [customerPhone, setCustomerPhone] = createSignal("");
|
||||||
const [notes, setNotes] = createSignal("");
|
const [notes, setNotes] = createSignal("");
|
||||||
const [highlightContact, setHighlightContact] = createSignal(false);
|
const [highlightContact, setHighlightContact] = createSignal(false);
|
||||||
|
const [selectedServiceId, setSelectedServiceId] = createSignal<string | null>(null);
|
||||||
|
const [selectedStaffId, setSelectedStaffId] = createSignal<string | null>(null);
|
||||||
|
const [selectedDate, setSelectedDate] = createSignal<string | null>(null);
|
||||||
let contactFormRef: HTMLDivElement | undefined;
|
let contactFormRef: HTMLDivElement | undefined;
|
||||||
|
|
||||||
|
const [catalog] = createResource(() => {
|
||||||
|
const slug = tenantSlug();
|
||||||
|
if (!slug) return null;
|
||||||
|
return apiClient.GET("/v1/public/tenants/{tenantSlug}/catalog", {
|
||||||
|
params: { path: { tenantSlug: slug } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const [availability, { refetch }] = createResource(() => {
|
const [availability, { refetch }] = createResource(() => {
|
||||||
const slug = tenantSlug();
|
const slug = tenantSlug();
|
||||||
if (!slug) return null;
|
if (!slug) return null;
|
||||||
@@ -31,6 +46,72 @@ export function PublicBookingRoute() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dayParams = createMemo(() => {
|
||||||
|
const slug = tenantSlug();
|
||||||
|
const serviceId = selectedServiceId();
|
||||||
|
const date = selectedDate();
|
||||||
|
if (!slug || !serviceId || !date) return null;
|
||||||
|
return { slug, serviceId, date, staffId: selectedStaffId() };
|
||||||
|
});
|
||||||
|
const [dayAvailability, { refetch: refetchDay }] = createResource(dayParams, (source) =>
|
||||||
|
apiClient.GET("/v1/public/tenants/{tenantSlug}/availability", {
|
||||||
|
params: {
|
||||||
|
path: { tenantSlug: source.slug },
|
||||||
|
query: {
|
||||||
|
serviceId: source.serviceId,
|
||||||
|
date: source.date,
|
||||||
|
...(source.staffId ? { staffId: source.staffId } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const services = createMemo<PublicService[]>(() => catalog.latest?.data?.services ?? []);
|
||||||
|
const staff = createMemo<PublicStaff[]>(() => catalog.latest?.data?.staff ?? []);
|
||||||
|
const classSlots = createMemo(() => (availability.latest?.data?.slots ?? []).filter((slot) => slot.mode === "class"));
|
||||||
|
const daySlots = createMemo(() => dayAvailability.latest?.data?.slots ?? []);
|
||||||
|
const hasCatalog = createMemo(() => services().length > 0);
|
||||||
|
|
||||||
|
const tenantTimezone = createMemo(
|
||||||
|
() => availability.latest?.data?.timezone ?? dayAvailability.latest?.data?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
);
|
||||||
|
|
||||||
|
const days = createMemo(() => {
|
||||||
|
const tz = tenantTimezone();
|
||||||
|
const locale = i18n.locale();
|
||||||
|
const items: { value: string; weekday: string; day: string; month: string }[] = [];
|
||||||
|
const now = Date.now();
|
||||||
|
for (let offset = 0; offset < 14; offset++) {
|
||||||
|
const date = new Date(now + offset * 86400000);
|
||||||
|
items.push({
|
||||||
|
value: new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit" }).format(date),
|
||||||
|
weekday: new Intl.DateTimeFormat(locale, { timeZone: tz, weekday: "short" }).format(date),
|
||||||
|
day: new Intl.DateTimeFormat(locale, { timeZone: tz, day: "numeric" }).format(date),
|
||||||
|
month: new Intl.DateTimeFormat(locale, { timeZone: tz, month: "short" }).format(date),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatPrice = (priceCents: number) => {
|
||||||
|
if (!priceCents) return i18n.t("booking.service.free");
|
||||||
|
return new Intl.NumberFormat(i18n.locale() === "cs" ? "cs-CZ" : "en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "CZK",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(priceCents / 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickService = (serviceId: string) => {
|
||||||
|
setSelectedServiceId(serviceId);
|
||||||
|
setSelectedDate(null);
|
||||||
|
setBookingError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickStaff = (staffId: string | null) => {
|
||||||
|
setSelectedStaffId(staffId);
|
||||||
|
};
|
||||||
|
|
||||||
const bookSlot = async (slot: components["schemas"]["TimeSlot"]) => {
|
const bookSlot = async (slot: components["schemas"]["TimeSlot"]) => {
|
||||||
if (!customerName().trim() || !customerEmail().trim()) {
|
if (!customerName().trim() || !customerEmail().trim()) {
|
||||||
setBookingError(i18n.t("booking.customerRequired"));
|
setBookingError(i18n.t("booking.customerRequired"));
|
||||||
@@ -77,6 +158,7 @@ export function PublicBookingRoute() {
|
|||||||
|
|
||||||
setSubmittingSlot(null);
|
setSubmittingSlot(null);
|
||||||
void refetch();
|
void refetch();
|
||||||
|
void refetchDay();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -273,7 +355,7 @@ export function PublicBookingRoute() {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Loading state */}
|
{/* Loading state */}
|
||||||
<Show when={availability.loading}>
|
<Show when={availability.loading || catalog.loading}>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<SkeletonCard />
|
<SkeletonCard />
|
||||||
<SkeletonCard />
|
<SkeletonCard />
|
||||||
@@ -281,35 +363,155 @@ export function PublicBookingRoute() {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Slots list */}
|
<Show when={!availability.loading && !catalog.loading}>
|
||||||
<div class="space-y-4">
|
{/* Stepped appointment flow */}
|
||||||
<Show
|
<Show when={hasCatalog()}>
|
||||||
when={!availability.loading && availability.latest?.data?.slots?.length}
|
<div class="space-y-6">
|
||||||
fallback={
|
{/* Service picker */}
|
||||||
<Show when={!availability.loading}>
|
<div class="space-y-3">
|
||||||
<div class="rounded-card border border-dashed border-border bg-canvas-subtle/50 p-8 text-center">
|
<p class="text-xs font-semibold uppercase tracking-wider text-ink-subtle">{i18n.t("booking.step.service")}</p>
|
||||||
<BookraCharacter pose="sleep" size="md" animate={true} class="mx-auto mb-4" />
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
<p class="text-sm text-ink-muted font-medium">{i18n.t("booking.empty")}</p>
|
<For each={services()}>
|
||||||
<p class="text-xs text-ink-subtle mt-2">{i18n.t("booking.emptyHint")}</p>
|
{(service) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => pickService(service.id)}
|
||||||
|
class={`rounded-card border p-4 text-left transition-all duration-200 ${
|
||||||
|
selectedServiceId() === service.id
|
||||||
|
? "border-accent bg-accent/5 ring-2 ring-accent/20 shadow-sm"
|
||||||
|
: "border-border bg-canvas-subtle/50 hover:border-accent/40 hover:bg-canvas-subtle"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p class="font-display font-semibold text-ink">{service.name}</p>
|
||||||
|
<p class="mt-1 text-sm text-ink-muted">
|
||||||
|
{service.durationMinutes} min
|
||||||
|
<span class="mx-1.5 text-ink-subtle">·</span>
|
||||||
|
{formatPrice(service.priceCents)}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={selectedServiceId()}>
|
||||||
|
{/* Staff preference */}
|
||||||
|
<Show when={staff().length > 1}>
|
||||||
|
<div class="space-y-3 animate-fade-in">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wider text-ink-subtle">{i18n.t("booking.step.staff")}</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => pickStaff(null)}
|
||||||
|
class={`rounded-full border px-4 py-2 text-sm font-medium transition-all duration-200 ${
|
||||||
|
selectedStaffId() === null
|
||||||
|
? "border-accent bg-accent text-canvas shadow-sm"
|
||||||
|
: "border-border bg-canvas-subtle/50 text-ink-muted hover:border-accent/40 hover:text-ink"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{i18n.t("booking.staff.any")}
|
||||||
|
</button>
|
||||||
|
<For each={staff()}>
|
||||||
|
{(member) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => pickStaff(member.id)}
|
||||||
|
class={`rounded-full border px-4 py-2 text-sm font-medium transition-all duration-200 ${
|
||||||
|
selectedStaffId() === member.id
|
||||||
|
? "border-accent bg-accent text-canvas shadow-sm"
|
||||||
|
: "border-border bg-canvas-subtle/50 text-ink-muted hover:border-accent/40 hover:text-ink"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{member.displayName}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
}
|
|
||||||
|
{/* Day picker */}
|
||||||
|
<div class="space-y-3 animate-fade-in">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wider text-ink-subtle">{i18n.t("booking.step.date")}</p>
|
||||||
|
<div class="flex gap-2 overflow-x-auto pb-1 -mx-1 px-1">
|
||||||
|
<For each={days()}>
|
||||||
|
{(item) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedDate(item.value)}
|
||||||
|
class={`flex min-w-[64px] shrink-0 flex-col items-center rounded-card border px-3 py-2.5 transition-all duration-200 ${
|
||||||
|
selectedDate() === item.value
|
||||||
|
? "border-accent bg-accent text-canvas shadow-sm"
|
||||||
|
: "border-border bg-canvas-subtle/50 text-ink-muted hover:border-accent/40 hover:text-ink"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<For each={availability.latest?.data?.slots ?? []}>
|
<span class="text-[11px] font-medium uppercase tracking-wide opacity-80">{item.weekday}</span>
|
||||||
{(slot, index) => (
|
<span class="font-display text-lg font-semibold leading-tight">{item.day}</span>
|
||||||
<Card
|
<span class="text-[11px] opacity-70">{item.month}</span>
|
||||||
variant="default"
|
</button>
|
||||||
class="group surface-card hover:border-accent/30 transition-all duration-300"
|
)}
|
||||||
style={{ "animation-delay": `${0.1 * (index() + 3)}s` }}
|
</For>
|
||||||
padding="lg"
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time grid */}
|
||||||
|
<Show when={selectedDate()}>
|
||||||
|
<div class="space-y-3 animate-fade-in">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wider text-ink-subtle">{i18n.t("booking.step.time")}</p>
|
||||||
|
<Show when={dayAvailability.loading}>
|
||||||
|
<div class="grid grid-cols-3 gap-2 sm:grid-cols-4">
|
||||||
|
<div class="h-11 animate-pulse rounded-card bg-canvas-subtle" />
|
||||||
|
<div class="h-11 animate-pulse rounded-card bg-canvas-subtle" />
|
||||||
|
<div class="h-11 animate-pulse rounded-card bg-canvas-subtle" />
|
||||||
|
<div class="h-11 animate-pulse rounded-card bg-canvas-subtle" />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={!dayAvailability.loading && daySlots().length === 0}>
|
||||||
|
<div class="rounded-card border border-dashed border-border bg-canvas-subtle/50 p-6 text-center">
|
||||||
|
<BookraCharacter pose="sleep" size="sm" animate={false} class="mx-auto mb-3" />
|
||||||
|
<p class="text-sm text-ink-muted font-medium">{i18n.t("booking.emptyDay")}</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div class="grid grid-cols-3 gap-2 sm:grid-cols-4">
|
||||||
|
<For each={daySlots()}>
|
||||||
|
{(slot) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={submittingSlot() === slot.startsAt}
|
||||||
|
onClick={() => void bookSlot(slot)}
|
||||||
|
class={`rounded-card border px-2 py-2.5 text-center transition-all duration-200 disabled:opacity-60 ${
|
||||||
|
submittingSlot() === slot.startsAt
|
||||||
|
? "border-accent bg-accent/10"
|
||||||
|
: "border-border bg-canvas-subtle/50 hover:border-accent hover:bg-accent/5 hover:shadow-sm"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
|
<span class="block font-display text-sm font-semibold text-ink">
|
||||||
|
{new Date(slot.startsAt).toLocaleTimeString(i18n.locale(), { hour: "2-digit", minute: "2-digit", timeZone: tenantTimezone() })}
|
||||||
|
</span>
|
||||||
|
<Show when={slot.staffName}>
|
||||||
|
<span class="block truncate text-[11px] text-ink-subtle">{slot.staffName}</span>
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Class sessions */}
|
||||||
|
<Show when={classSlots().length > 0}>
|
||||||
|
<div class="space-y-4" classList={{ "mt-8": hasCatalog() }}>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wider text-ink-subtle">{i18n.t("booking.classes")}</p>
|
||||||
|
<For each={classSlots()}>
|
||||||
|
{(slot) => (
|
||||||
|
<Card variant="default" class="group surface-card hover:border-accent/30 transition-all duration-300" padding="lg">
|
||||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Badge
|
<Badge variant="default" class="font-display text-xs uppercase tracking-wider">
|
||||||
variant={slot.mode === "appointment" ? "secondary" : "default"}
|
|
||||||
class="font-display text-xs uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
{slot.mode}
|
{slot.mode}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Show when={typeof slot.remainingCapacity === "number" && slot.remainingCapacity <= 3}>
|
<Show when={typeof slot.remainingCapacity === "number" && slot.remainingCapacity <= 3}>
|
||||||
@@ -322,16 +524,16 @@ export function PublicBookingRoute() {
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
||||||
</svg>
|
</svg>
|
||||||
{new Date(slot.startsAt).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })}
|
{new Date(slot.startsAt).toLocaleDateString(i18n.locale(), { weekday: 'short', month: 'short', day: 'numeric' })}
|
||||||
</span>
|
</span>
|
||||||
<span class="flex items-center gap-1.5">
|
<span class="flex items-center gap-1.5">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
||||||
</svg>
|
</svg>
|
||||||
{new Date(slot.startsAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })} - {new Date(slot.endsAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
|
{new Date(slot.startsAt).toLocaleTimeString(i18n.locale(), { hour: '2-digit', minute: '2-digit' })} - {new Date(slot.endsAt).toLocaleTimeString(i18n.locale(), { hour: '2-digit', minute: '2-digit' })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Show when={typeof slot.remainingCapacity === "number" && slot.mode === "class"}>
|
<Show when={typeof slot.remainingCapacity === "number"}>
|
||||||
<p class="text-sm text-success font-medium flex items-center gap-1.5">
|
<p class="text-sm text-success font-medium flex items-center gap-1.5">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||||
@@ -340,37 +542,30 @@ export function PublicBookingRoute() {
|
|||||||
</p>
|
</p>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Tooltip content="Book this slot now">
|
|
||||||
<Button
|
<Button
|
||||||
isLoading={submittingSlot() === slot.startsAt}
|
isLoading={submittingSlot() === slot.startsAt}
|
||||||
onClick={() => void bookSlot(slot)}
|
onClick={() => void bookSlot(slot)}
|
||||||
size="md"
|
size="md"
|
||||||
class="shadow-md hover:shadow-lg transition-shadow whitespace-nowrap"
|
class="shadow-md hover:shadow-lg transition-shadow whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{submittingSlot() === slot.startsAt ? (
|
|
||||||
<>
|
|
||||||
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
|
|
||||||
</svg>
|
|
||||||
{i18n.t("booking.submitting")}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="mr-2">
|
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
|
|
||||||
</svg>
|
|
||||||
{i18n.t("booking.submit")}
|
{i18n.t("booking.submit")}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
<Show when={!hasCatalog() && classSlots().length === 0}>
|
||||||
|
<div class="rounded-card border border-dashed border-border bg-canvas-subtle/50 p-8 text-center">
|
||||||
|
<BookraCharacter pose="sleep" size="md" animate={true} class="mx-auto mb-4" />
|
||||||
|
<p class="text-sm text-ink-muted font-medium">{i18n.t("booking.empty")}</p>
|
||||||
|
<p class="text-xs text-ink-subtle mt-2">{i18n.t("booking.emptyHint")}</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -607,6 +607,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/v1/public/tenants/{tenantSlug}/catalog": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getPublicCatalog"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/v1/public/widget.js": {
|
"/v1/public/widget.js": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1033,6 +1049,11 @@ export interface components {
|
|||||||
tenantSlug: string;
|
tenantSlug: string;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
};
|
};
|
||||||
|
PublicCatalogResponse: {
|
||||||
|
services: components["schemas"]["PublicService"][];
|
||||||
|
staff: components["schemas"]["PublicStaff"][];
|
||||||
|
tenantSlug: string;
|
||||||
|
};
|
||||||
PublicConfig: {
|
PublicConfig: {
|
||||||
/** Format: uri */
|
/** Format: uri */
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
@@ -1040,6 +1061,18 @@ export interface components {
|
|||||||
environment: string;
|
environment: string;
|
||||||
neonAuthEnabled: boolean;
|
neonAuthEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
PublicService: {
|
||||||
|
durationMinutes: number;
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
priceCents: number;
|
||||||
|
};
|
||||||
|
PublicStaff: {
|
||||||
|
displayName: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
Service: {
|
Service: {
|
||||||
/** @description Blocked time after the appointment (0-480) */
|
/** @description Blocked time after the appointment (0-480) */
|
||||||
bufferAfterMinutes: number;
|
bufferAfterMinutes: number;
|
||||||
@@ -1136,6 +1169,7 @@ export interface components {
|
|||||||
serviceId?: string | null;
|
serviceId?: string | null;
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
staffId?: string | null;
|
staffId?: string | null;
|
||||||
|
staffName?: string | null;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
startsAt: string;
|
startsAt: string;
|
||||||
};
|
};
|
||||||
@@ -2674,7 +2708,14 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
getPublicAvailability: {
|
getPublicAvailability: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: {
|
||||||
|
/** @description Return all slots for a single tenant-local day (YYYY-MM-DD) instead of the capped preview */
|
||||||
|
date?: string;
|
||||||
|
/** @description Restrict appointment slots to a single service */
|
||||||
|
serviceId?: string;
|
||||||
|
/** @description Restrict appointment slots to a single staff member */
|
||||||
|
staffId?: string;
|
||||||
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path: {
|
path: {
|
||||||
tenantSlug: string;
|
tenantSlug: string;
|
||||||
@@ -2694,6 +2735,28 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getPublicCatalog: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tenantSlug: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Bookable services and active staff for a tenant booking page */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PublicCatalogResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
getWidgetScript: {
|
getWidgetScript: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
+9
-1
@@ -59,6 +59,13 @@
|
|||||||
- booking modal service field is a select bound to the tenant's real services; blocked-day creation supports optional per-staff scoping
|
- booking modal service field is a select bound to the tenant's real services; blocked-day creation supports optional per-staff scoping
|
||||||
- deleting a service or staff member preserves bookings (FK `SET NULL`) and removes the member's availability rules (`CASCADE`), mirrored in the in-memory repository
|
- deleting a service or staff member preserves bookings (FK `SET NULL`) and removes the member's availability rules (`CASCADE`), mirrored in the in-memory repository
|
||||||
- live staging validation, real Neon Auth/Stripe/SMS provider checks, and operational hardening are still open
|
- live staging validation, real Neon Auth/Stripe/SMS provider checks, and operational hardening are still open
|
||||||
|
- `[done/partial]` Milestone 9: Public booking flow with real service/staff selection
|
||||||
|
- public booking page is now a stepped flow: pick service → optional staff preference → pick day → pick time; class sessions still listed below
|
||||||
|
- `GET /v1/public/tenants/{slug}/catalog` exposes bookable services and active staff for the booking page
|
||||||
|
- availability endpoint accepts `serviceId`, `staffId`, and `date` filters; default response is unchanged for the embeddable widget
|
||||||
|
- slots now carry `staffName`, respect tenant-wide and per-staff blocked days, and are generated per-day without the preview cap when `date` is given
|
||||||
|
- booking creation hardened: appointment requests must fit inside a matching availability rule, not overlap a blocked day, and staff/location IDs must belong to the tenant with staff active
|
||||||
|
- remaining: per-staff availability UI, class/session template management, 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.
|
||||||
@@ -287,6 +294,8 @@ Key entities:
|
|||||||
- `/dashboard` now includes first-run onboarding instead of only a passive empty state for unassigned authenticated users.
|
- `/dashboard` now includes first-run onboarding instead of only a passive empty state for unassigned authenticated users.
|
||||||
- Catalog management now covers services and staff end-to-end; staff creation enforces `MaxStaff` entitlements and staff location assignment validates tenant ownership.
|
- Catalog management now covers services and staff end-to-end; staff creation enforces `MaxStaff` entitlements and staff location assignment validates tenant ownership.
|
||||||
- Dashboard summary computes `setupCompletion` from real configured state (brand, location, service, staff, availability rules) rather than a static value.
|
- Dashboard summary computes `setupCompletion` from real configured state (brand, location, service, staff, availability rules) rather than a static value.
|
||||||
|
- Public booking flow offers real service and staff selection via a stepped picker backed by `GET /v1/public/tenants/{slug}/catalog` and filtered availability (`serviceId`, `staffId`, `date`).
|
||||||
|
- Appointment creation validates that requested times fit inside availability rules, skip blocked days, and reference tenant-owned staff and locations.
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
- Neon Auth token extraction is wired structurally, but still needs live browser validation against a real Neon Auth environment.
|
- Neon Auth token extraction is wired structurally, but still needs live browser validation against a real Neon Auth environment.
|
||||||
@@ -302,7 +311,6 @@ Key entities:
|
|||||||
- validate Stripe checkout/webhooks/portal and reminder dispatch against live staging infrastructure
|
- validate Stripe checkout/webhooks/portal and reminder dispatch against live staging infrastructure
|
||||||
- replace noop notification providers with the first production email transport and a gated SMS transport
|
- replace noop notification providers with the first production email transport and a gated SMS transport
|
||||||
- add management UI for class/session templates and per-staff availability rules so service duration, capacity, and staff schedules are editable after onboarding
|
- add management UI for class/session templates and per-staff availability rules so service duration, capacity, and staff schedules are editable after onboarding
|
||||||
- surface service selection and staff assignment on the public booking flow (services currently auto-resolve to the first match)
|
|
||||||
|
|
||||||
## Product Principles
|
## Product Principles
|
||||||
- calm, premium, structured UX
|
- calm, premium, structured UX
|
||||||
|
|||||||
Reference in new issue
Block a user