@fayz-ai/plugin-agenda 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-DRZF6CV2.js +421 -0
- package/dist/chunk-DRZF6CV2.js.map +1 -0
- package/dist/chunk-LFUELUIT.cjs +423 -0
- package/dist/chunk-LFUELUIT.cjs.map +1 -0
- package/dist/index.cjs +741 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +741 -10
- package/dist/index.js.map +1 -1
- package/dist/migrations/index.d.ts +2 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/public/BookingWidget.d.ts +4 -2
- package/dist/public/BookingWidget.d.ts.map +1 -1
- package/dist/public/components/BrandHeader.d.ts +6 -0
- package/dist/public/components/BrandHeader.d.ts.map +1 -0
- package/dist/public/components/ServiceCard.d.ts +5 -0
- package/dist/public/components/ServiceCard.d.ts.map +1 -0
- package/dist/public/components/SidebarExtras.d.ts +4 -0
- package/dist/public/components/SidebarExtras.d.ts.map +1 -0
- package/dist/public/components/StepRailItem.d.ts +4 -0
- package/dist/public/components/StepRailItem.d.ts.map +1 -0
- package/dist/public/components/SuccessPanel.d.ts +4 -0
- package/dist/public/components/SuccessPanel.d.ts.map +1 -0
- package/dist/public/components/SummaryRail.d.ts +27 -0
- package/dist/public/components/SummaryRail.d.ts.map +1 -0
- package/dist/public/components/defaults.d.ts +3 -0
- package/dist/public/components/defaults.d.ts.map +1 -0
- package/dist/public/context.d.ts +22 -8
- package/dist/public/context.d.ts.map +1 -1
- package/dist/public/createPublicBookingPlugin.d.ts +27 -10
- package/dist/public/createPublicBookingPlugin.d.ts.map +1 -1
- package/dist/public/data.d.ts +37 -0
- package/dist/public/data.d.ts.map +1 -0
- package/dist/public/data.supabase.d.ts +7 -0
- package/dist/public/data.supabase.d.ts.map +1 -0
- package/dist/public/format.d.ts +18 -0
- package/dist/public/format.d.ts.map +1 -0
- package/dist/public/hooks.d.ts +13 -11
- package/dist/public/hooks.d.ts.map +1 -1
- package/dist/public/index.cjs +1016 -613
- package/dist/public/index.cjs.map +1 -1
- package/dist/public/index.d.ts +15 -4
- package/dist/public/index.d.ts.map +1 -1
- package/dist/public/index.js +1006 -616
- package/dist/public/index.js.map +1 -1
- package/dist/public/steps/ContactStep.d.ts +30 -0
- package/dist/public/steps/ContactStep.d.ts.map +1 -0
- package/dist/public/steps/DateTimeStep.d.ts +23 -0
- package/dist/public/steps/DateTimeStep.d.ts.map +1 -0
- package/dist/public/steps/OverviewStep.d.ts +16 -0
- package/dist/public/steps/OverviewStep.d.ts.map +1 -0
- package/dist/public/steps/PaymentStep.d.ts +20 -0
- package/dist/public/steps/PaymentStep.d.ts.map +1 -0
- package/dist/public/types.d.ts +134 -0
- package/dist/public/types.d.ts.map +1 -1
- package/package.json +12 -12
- package/src/index.ts +11 -0
- package/src/migrations/001_public_booking.sql +278 -0
- package/src/migrations/index.ts +282 -0
- package/src/public/BookingWidget.tsx +136 -713
- package/src/public/components/BrandHeader.tsx +27 -0
- package/src/public/components/ServiceCard.tsx +39 -0
- package/src/public/components/SidebarExtras.tsx +30 -0
- package/src/public/components/StepRailItem.tsx +27 -0
- package/src/public/components/SuccessPanel.tsx +27 -0
- package/src/public/components/SummaryRail.tsx +90 -0
- package/src/public/components/defaults.ts +14 -0
- package/src/public/context.tsx +64 -7
- package/src/public/createPublicBookingPlugin.ts +131 -21
- package/src/public/data.supabase.ts +94 -0
- package/src/public/data.ts +99 -0
- package/src/public/format.ts +61 -0
- package/src/public/hooks.ts +22 -34
- package/src/public/index.ts +57 -4
- package/src/public/steps/ContactStep.tsx +179 -0
- package/src/public/steps/DateTimeStep.tsx +138 -0
- package/src/public/steps/OverviewStep.tsx +81 -0
- package/src/public/steps/PaymentStep.tsx +180 -0
- package/src/public/types.ts +169 -3
- package/dist/chunk-GAJVIEXM.cjs +0 -870
- package/dist/chunk-GAJVIEXM.cjs.map +0 -1
- package/dist/chunk-U6VTLOHN.js +0 -867
- package/dist/chunk-U6VTLOHN.js.map +0 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Supabase implementation of PublicBookingDataProvider — anon-safe.
|
|
3
|
+
//
|
|
4
|
+
// Public booking pages run with the publishable (anon) key and NO user session,
|
|
5
|
+
// so the canonical tenant RLS (`tenant_id IN user_tenant_ids()`) yields nothing.
|
|
6
|
+
// This provider therefore only touches objects designed for anon access:
|
|
7
|
+
// - public.v_public_services (column-whitelisted view of active services)
|
|
8
|
+
// - public.get_available_slots (SECURITY DEFINER RPC, mirrors the mock algo)
|
|
9
|
+
// - public.create_public_booking (SECURITY DEFINER RPC: validates the slot,
|
|
10
|
+
// upserts the customer person, writes order+booking+order_item atomically)
|
|
11
|
+
// Shipped by plugins/plugin-agenda/src/migrations/001_public_booking.sql.
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
import { getSupabaseClientOptional } from '@fayz-ai/core'
|
|
15
|
+
import type { TimeSlot } from '../types'
|
|
16
|
+
import type { PublicService } from './types'
|
|
17
|
+
import type { PublicBookingDataProvider } from './data'
|
|
18
|
+
|
|
19
|
+
export interface SupabasePublicBookingOptions {
|
|
20
|
+
/** The tenant this public page books against (RPC arg — required). */
|
|
21
|
+
tenantId: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface SupabaseLikeClient {
|
|
25
|
+
from(table: string): any
|
|
26
|
+
rpc(fn: string, args: Record<string, unknown>): PromiseLike<{ data: unknown; error: { message: string } | null }>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getClient(): SupabaseLikeClient {
|
|
30
|
+
const client = getSupabaseClientOptional() as SupabaseLikeClient | null
|
|
31
|
+
if (!client) throw new Error('[plugin-agenda/public] Supabase client not initialized')
|
|
32
|
+
return client
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Digits-only phone (RPC normalizes again server-side; keep the dial code). */
|
|
36
|
+
function toPhoneDigits(phone: string | undefined): string {
|
|
37
|
+
return (phone ?? '').replace(/\D/g, '')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createSupabasePublicBookingProvider(options: SupabasePublicBookingOptions): PublicBookingDataProvider {
|
|
41
|
+
const { tenantId } = options
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
async getServices(): Promise<PublicService[]> {
|
|
45
|
+
const { data, error } = await getClient()
|
|
46
|
+
.from('v_public_services')
|
|
47
|
+
.select('id, name, description, price, currency, duration_minutes, sort_order')
|
|
48
|
+
.eq('tenant_id', tenantId)
|
|
49
|
+
.order('sort_order', { ascending: true })
|
|
50
|
+
if (error) throw new Error(`[plugin-agenda/public] getServices: ${error.message}`)
|
|
51
|
+
return ((data ?? []) as Array<Record<string, unknown>>).map((row) => ({
|
|
52
|
+
id: String(row.id),
|
|
53
|
+
name: String(row.name),
|
|
54
|
+
description: (row.description as string | null) ?? undefined,
|
|
55
|
+
price: Number(row.price ?? 0),
|
|
56
|
+
durationMinutes: Number(row.duration_minutes ?? 30),
|
|
57
|
+
}))
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
async getAvailableSlots(query): Promise<TimeSlot[]> {
|
|
61
|
+
const { data, error } = await getClient().rpc('get_available_slots', {
|
|
62
|
+
p_tenant_id: tenantId,
|
|
63
|
+
p_assignee_id: query.assigneeId,
|
|
64
|
+
p_date: query.date,
|
|
65
|
+
p_duration_minutes: query.durationMinutes,
|
|
66
|
+
p_slot_interval: query.slotInterval,
|
|
67
|
+
})
|
|
68
|
+
if (error) throw new Error(`[plugin-agenda/public] getAvailableSlots: ${error.message}`)
|
|
69
|
+
return ((data ?? []) as Array<{ slot_start: string; slot_end: string }>).map((row) => ({
|
|
70
|
+
start: row.slot_start,
|
|
71
|
+
end: row.slot_end,
|
|
72
|
+
}))
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
async createBooking(input) {
|
|
76
|
+
const phone = toPhoneDigits(input.contact.phone)
|
|
77
|
+
const { data, error } = await getClient().rpc('create_public_booking', {
|
|
78
|
+
p_tenant_id: tenantId,
|
|
79
|
+
p_service_id: input.serviceId,
|
|
80
|
+
p_starts_at: input.startsAt,
|
|
81
|
+
p_name: input.contact.name.trim(),
|
|
82
|
+
p_phone: phone,
|
|
83
|
+
p_email: input.contact.email?.trim() || null,
|
|
84
|
+
p_notes: input.contact.notes?.trim() || null,
|
|
85
|
+
})
|
|
86
|
+
if (error) throw new Error(`[plugin-agenda/public] createBooking: ${error.message}`)
|
|
87
|
+
const row = (Array.isArray(data) ? data[0] : data) as
|
|
88
|
+
| { booking_id: string; starts_at: string; ends_at: string }
|
|
89
|
+
| undefined
|
|
90
|
+
if (!row) throw new Error('[plugin-agenda/public] createBooking: empty RPC response')
|
|
91
|
+
return { bookingId: row.booking_id, startsAt: row.starts_at, endsAt: row.ends_at }
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// PublicBookingDataProvider — the narrow data seam the public booking surface
|
|
3
|
+
// depends on. The admin calendar's 15-method AgendaDataProvider is deliberately
|
|
4
|
+
// NOT used here: a public page needs exactly three capabilities, and the
|
|
5
|
+
// Supabase implementation must be anon-safe (SECURITY DEFINER RPCs + whitelisted
|
|
6
|
+
// views) rather than the authenticated table access the admin provider does.
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
import type { TimeSlot } from '../types'
|
|
10
|
+
import { createMockAgendaProvider, type MockAgendaSeed } from '../data/mock'
|
|
11
|
+
import type { PublicService, ContactInfo } from './types'
|
|
12
|
+
|
|
13
|
+
export interface PublicSlotQuery {
|
|
14
|
+
/** Staff person id; null → any active professional for the tenant. */
|
|
15
|
+
assigneeId: string | null
|
|
16
|
+
/** 'YYYY-MM-DD' local calendar day. */
|
|
17
|
+
date: string
|
|
18
|
+
durationMinutes: number
|
|
19
|
+
slotInterval: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PublicCreateBookingInput {
|
|
23
|
+
serviceId: string
|
|
24
|
+
/** ISO datetime of the chosen slot start. */
|
|
25
|
+
startsAt: string
|
|
26
|
+
/** name required; phone should include the country dial for real backends. */
|
|
27
|
+
contact: ContactInfo
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PublicBookingResult {
|
|
31
|
+
bookingId: string
|
|
32
|
+
startsAt: string
|
|
33
|
+
endsAt: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PublicBookingDataProvider {
|
|
37
|
+
/** Bookable catalog. Used when the host doesn't pass static `services`. */
|
|
38
|
+
getServices(): Promise<PublicService[]>
|
|
39
|
+
getAvailableSlots(query: PublicSlotQuery): Promise<TimeSlot[]>
|
|
40
|
+
createBooking(input: PublicCreateBookingInput): Promise<PublicBookingResult>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Mock implementation — adapter over the admin mock provider (reuses its slot
|
|
45
|
+
// algorithm + seed machinery, so mock and live behave identically).
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
export interface MockPublicBookingOptions {
|
|
49
|
+
seed: MockAgendaSeed
|
|
50
|
+
services: PublicService[]
|
|
51
|
+
/** The seed professional bookings are created against. */
|
|
52
|
+
professionalId: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createMockPublicBookingProvider(options: MockPublicBookingOptions): PublicBookingDataProvider {
|
|
56
|
+
const inner = createMockAgendaProvider({ seed: options.seed })
|
|
57
|
+
const { services, professionalId } = options
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
async getServices() {
|
|
61
|
+
return services
|
|
62
|
+
},
|
|
63
|
+
async getAvailableSlots(query) {
|
|
64
|
+
return inner.getAvailableSlots({
|
|
65
|
+
assigneeId: query.assigneeId ?? professionalId,
|
|
66
|
+
date: query.date,
|
|
67
|
+
durationMinutes: query.durationMinutes,
|
|
68
|
+
slotInterval: query.slotInterval,
|
|
69
|
+
})
|
|
70
|
+
},
|
|
71
|
+
async createBooking(input) {
|
|
72
|
+
const service = services.find((s) => s.id === input.serviceId)
|
|
73
|
+
if (!service) throw new Error('Serviço inválido')
|
|
74
|
+
const booking = await inner.createBooking({
|
|
75
|
+
kind: 'appointment',
|
|
76
|
+
// Mock: the customer is captured as the booking title; the Supabase
|
|
77
|
+
// path upserts a person record server-side instead.
|
|
78
|
+
clientId: 'public-guest',
|
|
79
|
+
professionalId,
|
|
80
|
+
startsAt: input.startsAt,
|
|
81
|
+
title: input.contact.name,
|
|
82
|
+
notes:
|
|
83
|
+
[input.contact.phone, input.contact.email, input.contact.notes].filter(Boolean).join(' · ') || undefined,
|
|
84
|
+
services: [
|
|
85
|
+
{
|
|
86
|
+
serviceId: service.id,
|
|
87
|
+
name: service.name,
|
|
88
|
+
durationMinutes: service.durationMinutes,
|
|
89
|
+
price: service.price,
|
|
90
|
+
assigneeId: professionalId,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
})
|
|
94
|
+
const endsAt =
|
|
95
|
+
booking.endsAt ?? new Date(new Date(input.startsAt).getTime() + service.durationMinutes * 60_000).toISOString()
|
|
96
|
+
return { bookingId: booking.id, startsAt: booking.startsAt, endsAt }
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Date/price formatting + calendar helpers shared by the booking widget and
|
|
3
|
+
// its (host-overridable) subcomponents.
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
|
|
6
|
+
import { useMemo } from 'react'
|
|
7
|
+
import type { BookingFormatters } from './types'
|
|
8
|
+
|
|
9
|
+
export const DAY_NAMES = ['Domingo', 'Segunda-feira', 'Terça-feira', 'Quarta-feira', 'Quinta-feira', 'Sexta-feira', 'Sábado']
|
|
10
|
+
export const WEEK_ORDER = [1, 2, 3, 4, 5, 6, 0] // Monday-first, like the reference
|
|
11
|
+
|
|
12
|
+
export function useFormatters(locale: string, currency: string): BookingFormatters {
|
|
13
|
+
return useMemo(
|
|
14
|
+
() => ({
|
|
15
|
+
price: new Intl.NumberFormat(locale, { style: 'currency', currency }),
|
|
16
|
+
time: new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }),
|
|
17
|
+
weekday: new Intl.DateTimeFormat(locale, { weekday: 'short' }),
|
|
18
|
+
dayNum: new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit' }),
|
|
19
|
+
longDay: new Intl.DateTimeFormat(locale, { weekday: 'long', day: '2-digit', month: 'long' }),
|
|
20
|
+
dateShort: new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit', year: 'numeric' }),
|
|
21
|
+
month: new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }),
|
|
22
|
+
}),
|
|
23
|
+
[locale, currency],
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Local 'YYYY-MM-DD' for a Date. */
|
|
28
|
+
export function toIso(d: Date): string {
|
|
29
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Read a preselected service id from the URL (?service=…), router-agnostic. */
|
|
33
|
+
export function readServiceParam(): string | null {
|
|
34
|
+
if (typeof window === 'undefined') return null
|
|
35
|
+
return new URLSearchParams(window.location.search).get('service')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DayCell {
|
|
39
|
+
iso: string
|
|
40
|
+
date: Date
|
|
41
|
+
isToday: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 7 consecutive days starting `weekOffset*7` days from today. */
|
|
45
|
+
export function buildWeek(weekOffset: number): DayCell[] {
|
|
46
|
+
const base = new Date()
|
|
47
|
+
base.setHours(0, 0, 0, 0)
|
|
48
|
+
const cells: DayCell[] = []
|
|
49
|
+
for (let i = 0; i < 7; i++) {
|
|
50
|
+
const d = new Date(base.getFullYear(), base.getMonth(), base.getDate() + weekOffset * 7 + i)
|
|
51
|
+
cells.push({ iso: toIso(d), date: d, isToday: weekOffset === 0 && i === 0 })
|
|
52
|
+
}
|
|
53
|
+
return cells
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** "13/07/2026 – 21:30 até 22:00 (30min)" */
|
|
57
|
+
export function formatSlotRange(startIso: string, durationMin: number, fmt: BookingFormatters): string {
|
|
58
|
+
const start = new Date(startIso)
|
|
59
|
+
const end = new Date(start.getTime() + durationMin * 60_000)
|
|
60
|
+
return `${fmt.dateShort.format(start)} – ${fmt.time.format(start)} até ${fmt.time.format(end)} (${durationMin}min)`
|
|
61
|
+
}
|
package/src/public/hooks.ts
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
2
|
import { useBooking } from './context'
|
|
3
|
-
import type { PublicService
|
|
3
|
+
import type { PublicService } from './types'
|
|
4
|
+
import type { PublicCreateBookingInput, PublicBookingResult } from './data'
|
|
4
5
|
import type { TimeSlot } from '../types'
|
|
5
6
|
|
|
6
|
-
/** The
|
|
7
|
+
/** The resolved bookable services (static option or fetched from the provider). */
|
|
7
8
|
export function useServices(): PublicService[] {
|
|
8
9
|
return useBooking().services
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
export interface UsePublicServicesResult {
|
|
13
|
+
services: PublicService[]
|
|
14
|
+
loading: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Like useServices, but exposes the fetch state for skeletons. */
|
|
18
|
+
export function usePublicServices(): UsePublicServicesResult {
|
|
19
|
+
const { services, servicesLoading } = useBooking()
|
|
20
|
+
return { services, loading: servicesLoading }
|
|
21
|
+
}
|
|
22
|
+
|
|
11
23
|
export interface UseAvailableSlotsResult {
|
|
12
24
|
slots: TimeSlot[]
|
|
13
25
|
loading: boolean
|
|
@@ -16,7 +28,7 @@ export interface UseAvailableSlotsResult {
|
|
|
16
28
|
|
|
17
29
|
/**
|
|
18
30
|
* Available time slots for a service on a given date. Delegates to
|
|
19
|
-
* provider.getAvailableSlots — mock now,
|
|
31
|
+
* provider.getAvailableSlots — mock now, Supabase RPC when configured, same call.
|
|
20
32
|
*/
|
|
21
33
|
export function useAvailableSlots(params: { serviceId: string | null; date: string | null }): UseAvailableSlotsResult {
|
|
22
34
|
const { provider, professionalId, services, window } = useBooking()
|
|
@@ -57,53 +69,29 @@ export function useAvailableSlots(params: { serviceId: string | null; date: stri
|
|
|
57
69
|
return { slots, loading, error }
|
|
58
70
|
}
|
|
59
71
|
|
|
60
|
-
export
|
|
61
|
-
serviceId: string
|
|
62
|
-
/** ISO datetime of the chosen slot start. */
|
|
63
|
-
startsAt: string
|
|
64
|
-
contact: ContactInfo
|
|
65
|
-
}
|
|
72
|
+
export type CreatePublicBookingInput = PublicCreateBookingInput
|
|
66
73
|
|
|
67
74
|
export interface UseCreateBookingResult {
|
|
68
|
-
submit: (input: CreatePublicBookingInput) => Promise<
|
|
75
|
+
submit: (input: CreatePublicBookingInput) => Promise<PublicBookingResult>
|
|
69
76
|
submitting: boolean
|
|
70
77
|
error: Error | null
|
|
71
78
|
done: boolean
|
|
72
79
|
}
|
|
73
80
|
|
|
74
|
-
/** Create a booking via the provider
|
|
81
|
+
/** Create a booking via the provider (mock append / Supabase RPC). */
|
|
75
82
|
export function useCreateBooking(): UseCreateBookingResult {
|
|
76
|
-
const { provider
|
|
83
|
+
const { provider } = useBooking()
|
|
77
84
|
const [submitting, setSubmitting] = useState(false)
|
|
78
85
|
const [error, setError] = useState<Error | null>(null)
|
|
79
86
|
const [done, setDone] = useState(false)
|
|
80
87
|
|
|
81
|
-
async function submit(input: CreatePublicBookingInput): Promise<
|
|
82
|
-
const service = services.find((s) => s.id === input.serviceId)
|
|
83
|
-
if (!service) throw new Error('Serviço inválido')
|
|
88
|
+
async function submit(input: CreatePublicBookingInput): Promise<PublicBookingResult> {
|
|
84
89
|
setSubmitting(true)
|
|
85
90
|
setError(null)
|
|
86
91
|
try {
|
|
87
|
-
await provider.createBooking(
|
|
88
|
-
kind: 'appointment',
|
|
89
|
-
// POC: the customer is captured as the booking title; a real flow would
|
|
90
|
-
// upsert a client/person record and pass its id here.
|
|
91
|
-
clientId: 'public-guest',
|
|
92
|
-
professionalId,
|
|
93
|
-
startsAt: input.startsAt,
|
|
94
|
-
title: input.contact.name,
|
|
95
|
-
notes: [input.contact.phone, input.contact.email, input.contact.notes].filter(Boolean).join(' · ') || undefined,
|
|
96
|
-
services: [
|
|
97
|
-
{
|
|
98
|
-
serviceId: service.id,
|
|
99
|
-
name: service.name,
|
|
100
|
-
durationMinutes: service.durationMinutes,
|
|
101
|
-
price: service.price,
|
|
102
|
-
assigneeId: professionalId,
|
|
103
|
-
},
|
|
104
|
-
],
|
|
105
|
-
})
|
|
92
|
+
const result = await provider.createBooking(input)
|
|
106
93
|
setDone(true)
|
|
94
|
+
return result
|
|
107
95
|
} catch (err) {
|
|
108
96
|
const e = err instanceof Error ? err : new Error(String(err))
|
|
109
97
|
setError(e)
|
package/src/public/index.ts
CHANGED
|
@@ -3,13 +3,66 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Separate entry from the admin plugin so a website host imports only the lean
|
|
5
5
|
// booking widget + data seam (no @fayz-ai/saas / calendar admin graph).
|
|
6
|
+
//
|
|
7
|
+
// Customization levers (cheapest first): `labels` → `brand` → `components`
|
|
8
|
+
// (swap subcomponents; defaults exported below for wrapping/composition).
|
|
9
|
+
// Data: PublicBookingDataProvider — mock seed by default, Supabase RPCs when a
|
|
10
|
+
// global client is configured (requires `tenantId`).
|
|
6
11
|
// ---------------------------------------------------------------------------
|
|
7
12
|
|
|
8
13
|
export { createPublicBookingPlugin } from './createPublicBookingPlugin'
|
|
9
14
|
export type { PublicBookingOptions, PublicBookingPlugin } from './createPublicBookingPlugin'
|
|
10
15
|
export { BookingWidget } from './BookingWidget'
|
|
11
16
|
export { BookingProvider, useBooking } from './context'
|
|
12
|
-
export type { PublicBookingContextValue } from './context'
|
|
13
|
-
export { useServices, useAvailableSlots, useCreateBooking } from './hooks'
|
|
14
|
-
export type {
|
|
15
|
-
|
|
17
|
+
export type { PublicBookingContextValue, OnIdentityVerified } from './context'
|
|
18
|
+
export { useServices, usePublicServices, useAvailableSlots, useCreateBooking } from './hooks'
|
|
19
|
+
export type {
|
|
20
|
+
UsePublicServicesResult,
|
|
21
|
+
UseAvailableSlotsResult,
|
|
22
|
+
UseCreateBookingResult,
|
|
23
|
+
CreatePublicBookingInput,
|
|
24
|
+
} from './hooks'
|
|
25
|
+
|
|
26
|
+
// Data seam
|
|
27
|
+
export type {
|
|
28
|
+
PublicBookingDataProvider,
|
|
29
|
+
PublicSlotQuery,
|
|
30
|
+
PublicCreateBookingInput,
|
|
31
|
+
PublicBookingResult,
|
|
32
|
+
MockPublicBookingOptions,
|
|
33
|
+
} from './data'
|
|
34
|
+
export { createMockPublicBookingProvider } from './data'
|
|
35
|
+
export { createSupabasePublicBookingProvider } from './data.supabase'
|
|
36
|
+
export type { SupabasePublicBookingOptions } from './data.supabase'
|
|
37
|
+
|
|
38
|
+
// Customization seam — default components (wrap or replace via `components`)
|
|
39
|
+
export { DEFAULT_BOOKING_COMPONENTS } from './components/defaults'
|
|
40
|
+
export { DefaultBrandHeader } from './components/BrandHeader'
|
|
41
|
+
export { DefaultServiceCard } from './components/ServiceCard'
|
|
42
|
+
export { DefaultSidebarExtras } from './components/SidebarExtras'
|
|
43
|
+
export { DefaultStepRailItem } from './components/StepRailItem'
|
|
44
|
+
export { DefaultSuccessPanel } from './components/SuccessPanel'
|
|
45
|
+
|
|
46
|
+
// Formatting helpers (useful inside custom components)
|
|
47
|
+
export { useFormatters, formatSlotRange, DAY_NAMES, WEEK_ORDER } from './format'
|
|
48
|
+
|
|
49
|
+
export type {
|
|
50
|
+
PublicService,
|
|
51
|
+
ContactInfo,
|
|
52
|
+
BookingWindow,
|
|
53
|
+
PublicBookingLabels,
|
|
54
|
+
WorkingHours,
|
|
55
|
+
PaymentConfig,
|
|
56
|
+
ResolvedPayment,
|
|
57
|
+
BookingBrand,
|
|
58
|
+
ResolvedBookingBrand,
|
|
59
|
+
BookingHighlight,
|
|
60
|
+
BookingFormatters,
|
|
61
|
+
BookingComponents,
|
|
62
|
+
BrandHeaderProps,
|
|
63
|
+
ServiceCardProps,
|
|
64
|
+
SidebarExtrasProps,
|
|
65
|
+
SuccessPanelProps,
|
|
66
|
+
StepRailItemProps,
|
|
67
|
+
StepState,
|
|
68
|
+
} from './types'
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { Check, ChevronDown, CreditCard, Loader2, ShieldCheck } from 'lucide-react'
|
|
2
|
+
import { COUNTRIES, maskPhone, unmaskPhone, type CountryDef } from '@fayz-ai/core'
|
|
3
|
+
import type { PublicBookingLabels, ResolvedPayment } from '../types'
|
|
4
|
+
|
|
5
|
+
export interface ContactDraft {
|
|
6
|
+
name: string
|
|
7
|
+
phone: string
|
|
8
|
+
email: string
|
|
9
|
+
notes: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ContactStepProps {
|
|
13
|
+
labels: PublicBookingLabels
|
|
14
|
+
contact: ContactDraft
|
|
15
|
+
onContactChange: (contact: ContactDraft) => void
|
|
16
|
+
contactStep: 'phone' | 'code' | 'details'
|
|
17
|
+
country: CountryDef
|
|
18
|
+
onCountryChange: (iso2: string) => void
|
|
19
|
+
code: string
|
|
20
|
+
onCodeChange: (code: string) => void
|
|
21
|
+
codeError: boolean
|
|
22
|
+
onSendCode: () => void
|
|
23
|
+
onConfirmCode: () => void
|
|
24
|
+
onBackToPhone: () => void
|
|
25
|
+
onSubmitDetails: () => void
|
|
26
|
+
payment: ResolvedPayment | null
|
|
27
|
+
submitting: boolean
|
|
28
|
+
error: Error | null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Contact step — phone verification first (Quaddro-style), then details. */
|
|
32
|
+
export function ContactStep({
|
|
33
|
+
labels, contact, onContactChange, contactStep, country, onCountryChange,
|
|
34
|
+
code, onCodeChange, codeError, onSendCode, onConfirmCode, onBackToPhone,
|
|
35
|
+
onSubmitDetails, payment, submitting, error,
|
|
36
|
+
}: ContactStepProps) {
|
|
37
|
+
return (
|
|
38
|
+
<div>
|
|
39
|
+
<h3 className="font-heading text-xl font-bold text-foreground">{labels.contactStep}</h3>
|
|
40
|
+
|
|
41
|
+
{/* Sub-step: phone */}
|
|
42
|
+
{contactStep === 'phone' ? (
|
|
43
|
+
<form onSubmit={(e) => { e.preventDefault(); onSendCode() }}>
|
|
44
|
+
<p className="mt-1 text-sm text-muted-foreground">{labels.contactIntro}</p>
|
|
45
|
+
<div className="mt-5">
|
|
46
|
+
<label htmlFor="bk-phone" className="mb-1 block text-sm font-medium text-foreground">{labels.phoneLabel}</label>
|
|
47
|
+
<div className="flex">
|
|
48
|
+
<div className="relative">
|
|
49
|
+
<select
|
|
50
|
+
aria-label="País"
|
|
51
|
+
value={country.iso2}
|
|
52
|
+
onChange={(e) => onCountryChange(e.target.value)}
|
|
53
|
+
className="h-full appearance-none rounded-l-xl border border-r-0 border-border bg-muted py-3 pl-3 pr-8 text-sm font-medium text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
54
|
+
>
|
|
55
|
+
{COUNTRIES.map((c) => (
|
|
56
|
+
<option key={c.iso2} value={c.iso2}>{c.flag} {c.dial}</option>
|
|
57
|
+
))}
|
|
58
|
+
</select>
|
|
59
|
+
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
60
|
+
</div>
|
|
61
|
+
<input
|
|
62
|
+
id="bk-phone" type="tel" autoComplete="tel" inputMode="tel" value={contact.phone}
|
|
63
|
+
onChange={(e) => onContactChange({ ...contact, phone: maskPhone(e.target.value, country.mask) })}
|
|
64
|
+
className="w-full rounded-r-xl border border-border bg-background px-4 py-3 text-foreground outline-none transition-colors focus:border-primary focus-visible:ring-2 focus-visible:ring-ring"
|
|
65
|
+
placeholder={`Ex.: ${maskPhone('11987654321', country.mask)}`}
|
|
66
|
+
/>
|
|
67
|
+
</div>
|
|
68
|
+
<p className="mt-2 text-xs text-muted-foreground">{labels.phoneHint}</p>
|
|
69
|
+
</div>
|
|
70
|
+
<button
|
|
71
|
+
type="submit"
|
|
72
|
+
disabled={unmaskPhone(contact.phone).length < 8}
|
|
73
|
+
className="mt-5 w-full rounded-xl bg-primary py-3 font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
74
|
+
>
|
|
75
|
+
{labels.sendCodeCta}
|
|
76
|
+
</button>
|
|
77
|
+
</form>
|
|
78
|
+
) : null}
|
|
79
|
+
|
|
80
|
+
{/* Sub-step: details (after verification) */}
|
|
81
|
+
{contactStep === 'details' ? (
|
|
82
|
+
<form onSubmit={(e) => { e.preventDefault(); onSubmitDetails() }}>
|
|
83
|
+
<p className="mt-1 text-sm text-muted-foreground">{labels.contactIntro}</p>
|
|
84
|
+
<div className="mt-4 flex items-center justify-between rounded-xl border border-border bg-muted/40 px-4 py-3">
|
|
85
|
+
<span className="flex items-center gap-2 text-sm text-foreground">
|
|
86
|
+
<ShieldCheck className="h-4 w-4 text-primary" /> {labels.phoneVerified} · {country.flag} {country.dial} {contact.phone}
|
|
87
|
+
</span>
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
onClick={onBackToPhone}
|
|
91
|
+
className="rounded text-sm font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
92
|
+
>
|
|
93
|
+
{labels.changePhone}
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
<div className="mt-4 space-y-3">
|
|
97
|
+
<div>
|
|
98
|
+
<label htmlFor="bk-name" className="mb-1 block text-sm font-medium text-foreground">{labels.nameLabel}</label>
|
|
99
|
+
<input
|
|
100
|
+
id="bk-name" type="text" autoComplete="name" value={contact.name}
|
|
101
|
+
onChange={(e) => onContactChange({ ...contact, name: e.target.value })}
|
|
102
|
+
className="w-full rounded-xl border border-border bg-background px-4 py-3 text-foreground outline-none transition-colors focus:border-primary focus-visible:ring-2 focus-visible:ring-ring"
|
|
103
|
+
placeholder={labels.namePlaceholder}
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
<div>
|
|
107
|
+
<label htmlFor="bk-email" className="mb-1 block text-sm font-medium text-foreground">{labels.emailLabel}</label>
|
|
108
|
+
<input
|
|
109
|
+
id="bk-email" type="email" autoComplete="email" inputMode="email" value={contact.email}
|
|
110
|
+
onChange={(e) => onContactChange({ ...contact, email: e.target.value })}
|
|
111
|
+
className="w-full rounded-xl border border-border bg-background px-4 py-3 text-foreground outline-none transition-colors focus:border-primary focus-visible:ring-2 focus-visible:ring-ring"
|
|
112
|
+
placeholder={labels.emailPlaceholder}
|
|
113
|
+
/>
|
|
114
|
+
<p className="mt-1 text-xs text-muted-foreground">{labels.emailHint}</p>
|
|
115
|
+
</div>
|
|
116
|
+
<div>
|
|
117
|
+
<label htmlFor="bk-notes" className="mb-1 block text-sm font-medium text-foreground">{labels.notesLabel}</label>
|
|
118
|
+
<textarea
|
|
119
|
+
id="bk-notes" rows={3} value={contact.notes}
|
|
120
|
+
onChange={(e) => onContactChange({ ...contact, notes: e.target.value })}
|
|
121
|
+
className="w-full resize-none rounded-xl border border-border bg-background px-4 py-3 text-foreground outline-none transition-colors focus:border-primary focus-visible:ring-2 focus-visible:ring-ring"
|
|
122
|
+
placeholder={labels.notesPlaceholder}
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
{error ? (
|
|
127
|
+
<p role="alert" className="mt-3 text-sm text-destructive">{labels.submitError}</p>
|
|
128
|
+
) : null}
|
|
129
|
+
<button
|
|
130
|
+
type="submit"
|
|
131
|
+
disabled={!contact.name.trim() || submitting}
|
|
132
|
+
className="mt-5 flex w-full items-center justify-center gap-2 rounded-xl bg-primary py-3 font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
133
|
+
>
|
|
134
|
+
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
|
135
|
+
{payment ? labels.saveAndContinueCta : labels.confirmCta}
|
|
136
|
+
</button>
|
|
137
|
+
{!payment ? (
|
|
138
|
+
<p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
|
|
139
|
+
<CreditCard className="h-3.5 w-3.5" /> {labels.noChargeNote}
|
|
140
|
+
</p>
|
|
141
|
+
) : null}
|
|
142
|
+
</form>
|
|
143
|
+
) : null}
|
|
144
|
+
|
|
145
|
+
{/* Code verification modal */}
|
|
146
|
+
{contactStep === 'code' ? (
|
|
147
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-foreground/40 p-4" role="dialog" aria-modal="true" aria-label={labels.codeTitle}>
|
|
148
|
+
<div className="w-full max-w-md rounded-2xl border border-border bg-card p-6 shadow-xl">
|
|
149
|
+
<h4 className="font-heading text-lg font-bold text-foreground">{labels.codeTitle}</h4>
|
|
150
|
+
<p className="mt-1 text-sm text-muted-foreground">{labels.codeBody}</p>
|
|
151
|
+
<form onSubmit={(e) => { e.preventDefault(); onConfirmCode() }}>
|
|
152
|
+
<input
|
|
153
|
+
type="text" inputMode="numeric" value={code} autoFocus
|
|
154
|
+
onChange={(e) => onCodeChange(e.target.value)}
|
|
155
|
+
className="mt-4 w-full rounded-xl border border-border bg-background px-4 py-3 text-center text-lg tracking-[0.4em] text-foreground outline-none transition-colors focus:border-primary focus-visible:ring-2 focus-visible:ring-ring placeholder:text-sm placeholder:tracking-normal"
|
|
156
|
+
placeholder="Digite o código"
|
|
157
|
+
/>
|
|
158
|
+
{codeError ? (
|
|
159
|
+
<p role="alert" className="mt-2 text-sm text-destructive">{labels.codeInvalid}</p>
|
|
160
|
+
) : null}
|
|
161
|
+
<button
|
|
162
|
+
type="submit"
|
|
163
|
+
className="mt-4 w-full rounded-xl bg-primary py-3 font-semibold text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
164
|
+
>
|
|
165
|
+
{labels.codeConfirmCta}
|
|
166
|
+
</button>
|
|
167
|
+
</form>
|
|
168
|
+
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
|
|
169
|
+
<span>{labels.codeResendHint}</span>
|
|
170
|
+
<button type="button" onClick={onBackToPhone} className="font-medium text-primary hover:underline">
|
|
171
|
+
{labels.back}
|
|
172
|
+
</button>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
) : null}
|
|
177
|
+
</div>
|
|
178
|
+
)
|
|
179
|
+
}
|