mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
feat(waitlist): queue position on the customer manage page
A waitlisted customer saw only a bare status — worse, the badge fell back to "Pending" for both waitlisted and cancelled states. The view now carries a 1-based waitlistPosition (earlier-created waitlisted bookings on the session + 1) and the page renders a warning-tone notice with the queue spot and auto-promotion explanation, in CS/EN. Also fixed the badge's nonexistent --danger-soft/--warning-soft tokens. 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
7509e82261
commit
3892e9ce8c
7 files changed
+155
-14
No files matched your search
@@ -108,17 +108,23 @@ func (s *CustomerService) GetBookingByReference(ctx context.Context, reference s
|
|||||||
|
|
||||||
serviceName, locationName := s.descriptors(ctx, booking)
|
serviceName, locationName := s.descriptors(ctx, booking)
|
||||||
|
|
||||||
|
position := 0
|
||||||
|
if booking.Status == "waitlisted" && booking.ClassSessionID != nil {
|
||||||
|
position, _ = s.repo.GetWaitlistPosition(ctx, *booking.ClassSessionID, booking.ID)
|
||||||
|
}
|
||||||
|
|
||||||
return domain.CustomerBookingView{
|
return domain.CustomerBookingView{
|
||||||
Reference: booking.Reference,
|
Reference: booking.Reference,
|
||||||
CustomerName: booking.CustomerName,
|
CustomerName: booking.CustomerName,
|
||||||
CustomerEmail: booking.CustomerEmail,
|
CustomerEmail: booking.CustomerEmail,
|
||||||
Service: serviceName,
|
Service: serviceName,
|
||||||
BusinessName: tenant.Name,
|
BusinessName: tenant.Name,
|
||||||
StartsAt: booking.StartsAt,
|
StartsAt: booking.StartsAt,
|
||||||
EndsAt: booking.EndsAt,
|
EndsAt: booking.EndsAt,
|
||||||
Location: locationName,
|
Location: locationName,
|
||||||
Status: booking.Status,
|
Status: booking.Status,
|
||||||
Notes: booking.Notes,
|
Notes: booking.Notes,
|
||||||
|
WaitlistPosition: position,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -703,3 +703,66 @@ func TestCreateArmsSmsReminderWhenEnabled(t *testing.T) {
|
|||||||
t.Fatalf("expected email+sms channels, got %v", channels)
|
t.Fatalf("expected email+sms channels, got %v", channels)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWaitlistPositionReflectsQueueOrder(t *testing.T) {
|
||||||
|
repo := db.NewMemoryRepository()
|
||||||
|
service := NewService(repo, nil, "http://localhost:3000")
|
||||||
|
customerService := NewCustomerService(repo, nil, "http://localhost:3000")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
session, err := repo.GetClassSessionByID(ctx, "4bf74c12-44dd-45ca-86bb-b104f16f2435")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get session: %v", err)
|
||||||
|
}
|
||||||
|
sessionID := session.ID
|
||||||
|
book := func(email string) domain.CreateBookingResponse {
|
||||||
|
resp, err := service.Create(ctx, domain.CreateBookingRequest{
|
||||||
|
TenantSlug: "studio-atelier",
|
||||||
|
BookingMode: "class",
|
||||||
|
ClassSessionID: &sessionID,
|
||||||
|
CustomerName: "Queue Test",
|
||||||
|
CustomerEmail: email,
|
||||||
|
StartsAt: session.StartsAt.UTC().Format(time.RFC3339),
|
||||||
|
EndsAt: session.EndsAt.UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("book %s: %v", email, err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
var seats []domain.CreateBookingResponse
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
seats = append(seats, book(fmt.Sprintf("qseat%[email protected]", i)))
|
||||||
|
}
|
||||||
|
first := book("[email protected]")
|
||||||
|
second := book("[email protected]")
|
||||||
|
|
||||||
|
positionOf := func(resp domain.CreateBookingResponse) int {
|
||||||
|
manageURL, _ := url.Parse(resp.ManageURL)
|
||||||
|
view, err := customerService.GetBookingByReference(ctx, resp.Reference, manageURL.Query().Get("token"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get booking view: %v", err)
|
||||||
|
}
|
||||||
|
return view.WaitlistPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := positionOf(first); got != 1 {
|
||||||
|
t.Fatalf("expected first waiter at position 1, got %d", got)
|
||||||
|
}
|
||||||
|
if got := positionOf(second); got != 2 {
|
||||||
|
t.Fatalf("expected second waiter at position 2, got %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free a seat: first waiter is promoted, second moves to the front.
|
||||||
|
manageURL, _ := url.Parse(seats[0].ManageURL)
|
||||||
|
if err := customerService.CancelBooking(ctx, seats[0].Reference, manageURL.Query().Get("token")); err != nil {
|
||||||
|
t.Fatalf("cancel: %v", err)
|
||||||
|
}
|
||||||
|
if got := positionOf(second); got != 1 {
|
||||||
|
t.Fatalf("expected second waiter at position 1 after promotion, got %d", got)
|
||||||
|
}
|
||||||
|
if got := positionOf(first); got != 0 {
|
||||||
|
t.Fatalf("expected promoted booking to report no position, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -204,6 +204,18 @@ func (r *PGRepository) GetDashboardMetrics(ctx context.Context, tenantID string,
|
|||||||
return metrics, err
|
return metrics, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) GetWaitlistPosition(ctx context.Context, sessionID, bookingID string) (int, error) {
|
||||||
|
var position int
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) + 1
|
||||||
|
FROM bookings
|
||||||
|
WHERE class_session_id = $1
|
||||||
|
AND status = 'waitlisted'
|
||||||
|
AND created_at < (SELECT created_at FROM bookings WHERE id = $2)
|
||||||
|
`, sessionID, bookingID).Scan(&position)
|
||||||
|
return position, err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *PGRepository) CancelPendingReminderJobsForBooking(ctx context.Context, bookingID string) error {
|
func (r *PGRepository) CancelPendingReminderJobsForBooking(ctx context.Context, bookingID string) error {
|
||||||
_, err := r.pool.Exec(ctx, `
|
_, err := r.pool.Exec(ctx, `
|
||||||
UPDATE reminder_jobs
|
UPDATE reminder_jobs
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ type Repository interface {
|
|||||||
// Customer Booking Management
|
// Customer Booking Management
|
||||||
GetBookingByReference(ctx context.Context, reference string) (BookingRecord, error)
|
GetBookingByReference(ctx context.Context, reference string) (BookingRecord, error)
|
||||||
GetBookingByID(ctx context.Context, bookingID string) (BookingRecord, error)
|
GetBookingByID(ctx context.Context, bookingID string) (BookingRecord, error)
|
||||||
|
GetWaitlistPosition(ctx context.Context, sessionID, bookingID string) (int, error)
|
||||||
UpdateBookingStatus(ctx context.Context, bookingID string, status string) error
|
UpdateBookingStatus(ctx context.Context, bookingID string, status string) error
|
||||||
RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error
|
RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error
|
||||||
|
|
||||||
@@ -1844,6 +1845,27 @@ func (r *MemoryRepository) GetBookingByID(_ context.Context, bookingID string) (
|
|||||||
return BookingRecord{}, pgx.ErrNoRows
|
return BookingRecord{}, pgx.ErrNoRows
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *MemoryRepository) GetWaitlistPosition(_ context.Context, sessionID, bookingID string) (int, error) {
|
||||||
|
var self *BookingRecord
|
||||||
|
for index := range r.bookings {
|
||||||
|
if r.bookings[index].ID == bookingID {
|
||||||
|
self = &r.bookings[index]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self == nil {
|
||||||
|
return 0, pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
position := 1
|
||||||
|
for _, booking := range r.bookings {
|
||||||
|
if booking.ClassSessionID != nil && *booking.ClassSessionID == sessionID &&
|
||||||
|
booking.Status == "waitlisted" && booking.CreatedAt.Before(self.CreatedAt) {
|
||||||
|
position++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return position, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *MemoryRepository) UpdateBookingStatus(_ context.Context, bookingID string, status string) error {
|
func (r *MemoryRepository) UpdateBookingStatus(_ context.Context, bookingID string, status string) error {
|
||||||
for i, b := range r.bookings {
|
for i, b := range r.bookings {
|
||||||
if b.ID == bookingID {
|
if b.ID == bookingID {
|
||||||
|
|||||||
@@ -458,6 +458,9 @@ type CustomerBookingView struct {
|
|||||||
Location string `json:"location"`
|
Location string `json:"location"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Notes string `json:"notes,omitempty"`
|
Notes string `json:"notes,omitempty"`
|
||||||
|
// WaitlistPosition is the 1-based queue spot for waitlisted class
|
||||||
|
// bookings; zero for any other status.
|
||||||
|
WaitlistPosition int `json:"waitlistPosition,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RescheduleBookingRequest struct {
|
type RescheduleBookingRequest struct {
|
||||||
|
|||||||
@@ -195,18 +195,48 @@ export function BookingManageRoute() {
|
|||||||
<CardDescription class="mt-1">{b().businessName}</CardDescription>
|
<CardDescription class="mt-1">{b().businessName}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<span class={`inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold ${
|
<span class={`inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold ${
|
||||||
b().status === 'confirmed'
|
b().status === 'confirmed'
|
||||||
? 'bg-[hsl(var(--success-soft))] text-[hsl(var(--success))]'
|
? 'bg-[hsl(var(--success-soft))] text-[hsl(var(--success))]'
|
||||||
: 'bg-[hsl(var(--warning-soft))] text-[hsl(var(--warning))]'
|
: b().status === 'cancelled'
|
||||||
|
? 'bg-[hsl(var(--error-subtle))] text-[hsl(var(--error))]'
|
||||||
|
: 'bg-[hsl(var(--warning-subtle))] text-[hsl(var(--warning))]'
|
||||||
}`}>
|
}`}>
|
||||||
{b().status === 'confirmed'
|
{b().status === 'confirmed'
|
||||||
? (i18n.locale() === 'cs' ? 'Potvrzeno' : 'Confirmed')
|
? (i18n.locale() === 'cs' ? 'Potvrzeno' : 'Confirmed')
|
||||||
|
: b().status === 'waitlisted'
|
||||||
|
? (i18n.locale() === 'cs' ? 'Čekací listina' : 'Waitlisted')
|
||||||
|
: b().status === 'cancelled'
|
||||||
|
? (i18n.locale() === 'cs' ? 'Zrušeno' : 'Cancelled')
|
||||||
: (i18n.locale() === 'cs' ? 'Čeká se' : 'Pending')
|
: (i18n.locale() === 'cs' ? 'Čeká se' : 'Pending')
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-6">
|
<CardContent class="space-y-6">
|
||||||
|
{/* Waitlist position */}
|
||||||
|
<Show when={b().status === 'waitlisted'}>
|
||||||
|
<div class="flex items-center gap-3 p-4 bg-[hsl(var(--warning-subtle))] rounded-xl border border-[hsl(var(--warning))]/20">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-[hsl(var(--warning))]/10 flex items-center justify-center text-[hsl(var(--warning))]">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" 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"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-ink">
|
||||||
|
{b().waitlistPosition > 0
|
||||||
|
? (i18n.locale() === 'cs' ? `Jste ${b().waitlistPosition}. v pořadí` : `You are #${b().waitlistPosition} in the queue`)
|
||||||
|
: (i18n.locale() === 'cs' ? 'Jste na čekací listině' : 'You are on the waitlist')}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-ink-muted">
|
||||||
|
{i18n.locale() === 'cs'
|
||||||
|
? 'Po uvolnění místa vám rezervaci automaticky potvrdíme e-mailem.'
|
||||||
|
: 'When a seat frees up we will confirm your booking by email automatically.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
{/* Reference */}
|
{/* Reference */}
|
||||||
<div class="flex items-center gap-3 p-3 bg-canvas-subtle rounded-xl">
|
<div class="flex items-center gap-3 p-3 bg-canvas-subtle rounded-xl">
|
||||||
<div class="w-10 h-10 rounded-lg bg-[hsl(var(--info-subtle))] flex items-center justify-center text-[hsl(var(--info))]">
|
<div class="w-10 h-10 rounded-lg bg-[hsl(var(--info-subtle))] flex items-center justify-center text-[hsl(var(--info))]">
|
||||||
|
|||||||
@@ -107,6 +107,11 @@
|
|||||||
- `sms.Service.SendMessage` still enforces enabled/plan/monthly-limit and logs usage, so SMS reminders inherit metering and billing automatically; missing provider config or phone marks the job `failed` with a delivery-log reason
|
- `sms.Service.SendMessage` still enforces enabled/plan/monthly-limit and logs usage, so SMS reminders inherit metering and billing automatically; missing provider config or phone marks the job `failed` with a delivery-log reason
|
||||||
- reminder lifecycle work from milestone 13 applies per-booking, so cancel/reschedule clears and re-arms both channels
|
- reminder lifecycle work from milestone 13 applies per-booking, so cancel/reschedule clears and re-arms both channels
|
||||||
- remaining: live staging validation against a real SMS Manager key
|
- remaining: live staging validation against a real SMS Manager key
|
||||||
|
- `[done/partial]` Milestone 16: Waitlist position display
|
||||||
|
- `GetWaitlistPosition` repository method (pgx + memory) returns the 1-based queue spot: count of earlier-created waitlisted bookings on the same session + 1
|
||||||
|
- `CustomerBookingView` carries `waitlistPosition` for waitlisted class bookings; the manage page now shows a proper status badge for `waitlisted`/`cancelled` (previously both rendered as "Pending") plus a notice with the queue position and the auto-promotion explanation, in CS and EN
|
||||||
|
- fixed invalid token names along the way (`--danger-soft`/`--warning-soft` do not exist; `--error-subtle`/`--warning-subtle` do)
|
||||||
|
- remaining: live staging validation; manual waitlist promotion controls are still not exposed
|
||||||
|
|
||||||
## 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.
|
||||||
|
|||||||
Reference in new issue
Block a user