@fayz-ai/plugin-agenda 0.2.0 → 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.
Files changed (82) hide show
  1. package/dist/chunk-DRZF6CV2.js +421 -0
  2. package/dist/chunk-DRZF6CV2.js.map +1 -0
  3. package/dist/chunk-LFUELUIT.cjs +423 -0
  4. package/dist/chunk-LFUELUIT.cjs.map +1 -0
  5. package/dist/data/mock.d.ts.map +1 -1
  6. package/dist/index.cjs +300 -421
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +297 -420
  10. package/dist/index.js.map +1 -1
  11. package/dist/migrations/index.d.ts +2 -0
  12. package/dist/migrations/index.d.ts.map +1 -0
  13. package/dist/public/BookingWidget.d.ts +11 -0
  14. package/dist/public/BookingWidget.d.ts.map +1 -0
  15. package/dist/public/components/BrandHeader.d.ts +6 -0
  16. package/dist/public/components/BrandHeader.d.ts.map +1 -0
  17. package/dist/public/components/ServiceCard.d.ts +5 -0
  18. package/dist/public/components/ServiceCard.d.ts.map +1 -0
  19. package/dist/public/components/SidebarExtras.d.ts +4 -0
  20. package/dist/public/components/SidebarExtras.d.ts.map +1 -0
  21. package/dist/public/components/StepRailItem.d.ts +4 -0
  22. package/dist/public/components/StepRailItem.d.ts.map +1 -0
  23. package/dist/public/components/SuccessPanel.d.ts +4 -0
  24. package/dist/public/components/SuccessPanel.d.ts.map +1 -0
  25. package/dist/public/components/SummaryRail.d.ts +27 -0
  26. package/dist/public/components/SummaryRail.d.ts.map +1 -0
  27. package/dist/public/components/defaults.d.ts +3 -0
  28. package/dist/public/components/defaults.d.ts.map +1 -0
  29. package/dist/public/context.d.ts +51 -0
  30. package/dist/public/context.d.ts.map +1 -0
  31. package/dist/public/createPublicBookingPlugin.d.ts +62 -0
  32. package/dist/public/createPublicBookingPlugin.d.ts.map +1 -0
  33. package/dist/public/data.d.ts +37 -0
  34. package/dist/public/data.d.ts.map +1 -0
  35. package/dist/public/data.supabase.d.ts +7 -0
  36. package/dist/public/data.supabase.d.ts.map +1 -0
  37. package/dist/public/format.d.ts +18 -0
  38. package/dist/public/format.d.ts.map +1 -0
  39. package/dist/public/hooks.d.ts +34 -0
  40. package/dist/public/hooks.d.ts.map +1 -0
  41. package/dist/public/index.cjs +1423 -0
  42. package/dist/public/index.cjs.map +1 -0
  43. package/dist/public/index.d.ts +20 -0
  44. package/dist/public/index.d.ts.map +1 -0
  45. package/dist/public/index.js +1402 -0
  46. package/dist/public/index.js.map +1 -0
  47. package/dist/public/steps/ContactStep.d.ts +30 -0
  48. package/dist/public/steps/ContactStep.d.ts.map +1 -0
  49. package/dist/public/steps/DateTimeStep.d.ts +23 -0
  50. package/dist/public/steps/DateTimeStep.d.ts.map +1 -0
  51. package/dist/public/steps/OverviewStep.d.ts +16 -0
  52. package/dist/public/steps/OverviewStep.d.ts.map +1 -0
  53. package/dist/public/steps/PaymentStep.d.ts +20 -0
  54. package/dist/public/steps/PaymentStep.d.ts.map +1 -0
  55. package/dist/public/types.d.ts +185 -0
  56. package/dist/public/types.d.ts.map +1 -0
  57. package/package.json +20 -14
  58. package/src/data/mock.ts +4 -1
  59. package/src/index.ts +14 -0
  60. package/src/integrations/google-calendar/integration.test.ts +1 -1
  61. package/src/migrations/001_public_booking.sql +278 -0
  62. package/src/migrations/index.ts +282 -0
  63. package/src/public/BookingWidget.tsx +315 -0
  64. package/src/public/components/BrandHeader.tsx +27 -0
  65. package/src/public/components/ServiceCard.tsx +39 -0
  66. package/src/public/components/SidebarExtras.tsx +30 -0
  67. package/src/public/components/StepRailItem.tsx +27 -0
  68. package/src/public/components/SuccessPanel.tsx +27 -0
  69. package/src/public/components/SummaryRail.tsx +90 -0
  70. package/src/public/components/defaults.ts +14 -0
  71. package/src/public/context.tsx +100 -0
  72. package/src/public/createPublicBookingPlugin.ts +265 -0
  73. package/src/public/data.supabase.ts +94 -0
  74. package/src/public/data.ts +99 -0
  75. package/src/public/format.ts +61 -0
  76. package/src/public/hooks.ts +105 -0
  77. package/src/public/index.ts +68 -0
  78. package/src/public/steps/ContactStep.tsx +179 -0
  79. package/src/public/steps/DateTimeStep.tsx +138 -0
  80. package/src/public/steps/OverviewStep.tsx +81 -0
  81. package/src/public/steps/PaymentStep.tsx +180 -0
  82. package/src/public/types.ts +229 -0
@@ -0,0 +1,282 @@
1
+ // AUTO-GENERATED from 001_public_booking.sql — regenerate with scripts/embed-migrations.mjs
2
+ // (kept in sync so the manifest can declare migrations as data, plugin-tasks pattern)
3
+
4
+ export const MIGRATION_001_PUBLIC_BOOKING = `-- ============================================================================
5
+ -- plugin-agenda 001: booking read model + public (anon) booking surface
6
+ --
7
+ -- Ships the objects the agenda providers reference but the spine (packages/db
8
+ -- migrations 001–008) does not define:
9
+ -- §1 order_items columns the admin provider writes (duration/assignee)
10
+ -- §2 public.v_bookings — admin read model (authenticated, RLS applies)
11
+ -- §3 public.v_public_services — anon-readable service catalog
12
+ -- §4 public.get_available_slots — anon RPC (mirrors the mock algorithm)
13
+ -- §5 public.create_public_booking — anon RPC (validated, race-safe write)
14
+ --
15
+ -- Anon-access design: public booking pages run with the publishable key and no
16
+ -- session, so the canonical tenant RLS yields nothing. Anon access is granted
17
+ -- ONLY via the column-whitelisted view (§3) and SECURITY DEFINER RPCs (§4, §5)
18
+ -- with tight input validation and hardcoded statuses.
19
+ -- ============================================================================
20
+
21
+ -- §1 — columns the agenda provider writes but 004_archetypes lacks
22
+ ALTER TABLE saas_core.order_items
23
+ ADD COLUMN IF NOT EXISTS duration_minutes integer,
24
+ ADD COLUMN IF NOT EXISTS assignee_id uuid REFERENCES saas_core.persons(id) ON DELETE SET NULL;
25
+
26
+ -- Upsert key for public customers (per tenant, by normalized phone)
27
+ CREATE UNIQUE INDEX IF NOT EXISTS persons_tenant_customer_phone
28
+ ON saas_core.persons(tenant_id, phone)
29
+ WHERE kind = 'customer' AND phone IS NOT NULL;
30
+
31
+ -- Slot-conflict scan
32
+ CREATE INDEX IF NOT EXISTS bookings_assignee_time
33
+ ON saas_core.bookings(assignee_id, starts_at, ends_at)
34
+ WHERE status NOT IN ('cancelled', 'no_show');
35
+
36
+ -- §2 — admin read model (security_invoker → underlying RLS applies; anon sees 0 rows)
37
+ CREATE OR REPLACE VIEW public.v_bookings
38
+ WITH (security_invoker = true) AS
39
+ SELECT
40
+ b.id, b.tenant_id, b.kind, b.starts_at, b.ends_at, b.status, b.notes,
41
+ b.order_id, b.location_id, b.metadata, b.created_at, b.updated_at,
42
+ b.party_id AS client_id,
43
+ COALESCE(pc.name, b.metadata->>'title') AS client_name,
44
+ pc.phone AS client_phone,
45
+ pc.email AS client_email,
46
+ pc.avatar_url AS client_avatar_url,
47
+ b.assignee_id AS professional_id,
48
+ ps.name AS professional_name,
49
+ ps.avatar_url AS professional_avatar_url,
50
+ l.name AS location_name,
51
+ o.total AS order_total,
52
+ o.status AS order_status,
53
+ (EXTRACT(EPOCH FROM (COALESCE(b.ends_at, b.starts_at) - b.starts_at))::int / 60)
54
+ AS total_duration_minutes
55
+ FROM saas_core.bookings b
56
+ LEFT JOIN saas_core.persons pc ON pc.id = b.party_id
57
+ LEFT JOIN saas_core.persons ps ON ps.id = b.assignee_id
58
+ LEFT JOIN saas_core.locations l ON l.id = b.location_id
59
+ LEFT JOIN saas_core.orders o ON o.id = b.order_id;
60
+
61
+ GRANT SELECT ON public.v_bookings TO authenticated, service_role;
62
+ REVOKE SELECT ON public.v_bookings FROM anon;
63
+
64
+ -- §3 — anon-readable public catalog (owner-rights view = deliberate, whitelisted)
65
+ CREATE OR REPLACE VIEW public.v_public_services AS
66
+ SELECT
67
+ s.id, s.tenant_id, s.name, s.description, s.price, s.currency,
68
+ COALESCE(s.duration_minutes, 30) AS duration_minutes,
69
+ s.image_url,
70
+ COALESCE((s.metadata->>'sort_order')::int, 0) AS sort_order
71
+ FROM saas_core.services s
72
+ WHERE s.is_active AND s.status = 'active';
73
+
74
+ GRANT SELECT ON public.v_public_services TO anon, authenticated, service_role;
75
+
76
+ -- §4 — availability RPC (mirrors plugin-agenda/src/data/mock.ts getAvailableSlots)
77
+ CREATE OR REPLACE FUNCTION public.get_available_slots(
78
+ p_tenant_id uuid,
79
+ p_date date,
80
+ p_duration_minutes integer,
81
+ p_assignee_id uuid DEFAULT NULL,
82
+ p_slot_interval integer DEFAULT 30
83
+ ) RETURNS TABLE (slot_start timestamptz, slot_end timestamptz)
84
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
85
+ SET search_path = saas_core, public
86
+ AS $$
87
+ DECLARE
88
+ v_tz text;
89
+ BEGIN
90
+ -- tight validation: this function is anon-callable
91
+ IF p_duration_minutes IS NULL OR p_duration_minutes NOT BETWEEN 5 AND 480 THEN
92
+ RAISE EXCEPTION 'invalid duration';
93
+ END IF;
94
+ IF p_slot_interval IS NULL OR p_slot_interval NOT BETWEEN 5 AND 240 THEN
95
+ RAISE EXCEPTION 'invalid interval';
96
+ END IF;
97
+ IF p_date IS NULL OR p_date < current_date OR p_date > current_date + 90 THEN
98
+ RETURN;
99
+ END IF;
100
+
101
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
102
+ FROM tenants t WHERE t.id = p_tenant_id;
103
+ IF v_tz IS NULL THEN RETURN; END IF; -- unknown tenant → empty, not an error
104
+
105
+ RETURN QUERY
106
+ WITH day_schedules AS (
107
+ SELECT sc.assignee_id, sc.starts_at AS win_start, sc.ends_at AS win_end
108
+ FROM schedules sc
109
+ WHERE sc.tenant_id = p_tenant_id
110
+ AND sc.kind = 'working_hours'
111
+ AND sc.is_active
112
+ AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)
113
+ AND (
114
+ sc.specific_date = p_date
115
+ OR (sc.specific_date IS NULL AND sc.day_of_week = EXTRACT(dow FROM p_date)::smallint)
116
+ )
117
+ ),
118
+ candidate AS (
119
+ SELECT
120
+ ds.assignee_id,
121
+ gs AS s_start,
122
+ gs + make_interval(mins => p_duration_minutes) AS s_end
123
+ FROM day_schedules ds
124
+ CROSS JOIN LATERAL generate_series(
125
+ (p_date + ds.win_start) AT TIME ZONE v_tz,
126
+ ((p_date + ds.win_end) AT TIME ZONE v_tz) - make_interval(mins => p_duration_minutes),
127
+ make_interval(mins => p_slot_interval)
128
+ ) AS gs
129
+ )
130
+ SELECT DISTINCT c.s_start, c.s_end
131
+ FROM candidate c
132
+ WHERE NOT EXISTS (
133
+ SELECT 1 FROM bookings b
134
+ WHERE b.tenant_id = p_tenant_id
135
+ AND b.assignee_id = c.assignee_id
136
+ AND b.status NOT IN ('cancelled', 'no_show')
137
+ AND b.starts_at < c.s_end
138
+ AND COALESCE(b.ends_at, b.starts_at) > c.s_start
139
+ )
140
+ ORDER BY 1;
141
+ END;
142
+ $$;
143
+
144
+ REVOKE ALL ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer) FROM public;
145
+ GRANT EXECUTE ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer)
146
+ TO anon, authenticated, service_role;
147
+
148
+ -- §5 — public booking write RPC (validated, race-safe, hardcoded statuses)
149
+ CREATE OR REPLACE FUNCTION public.create_public_booking(
150
+ p_tenant_id uuid,
151
+ p_service_id uuid,
152
+ p_starts_at timestamptz,
153
+ p_name text,
154
+ p_phone text,
155
+ p_email text DEFAULT NULL,
156
+ p_notes text DEFAULT NULL
157
+ ) RETURNS TABLE (booking_id uuid, starts_at timestamptz, ends_at timestamptz)
158
+ LANGUAGE plpgsql SECURITY DEFINER
159
+ SET search_path = saas_core, public
160
+ AS $$
161
+ DECLARE
162
+ v_service record;
163
+ v_person_id uuid;
164
+ v_assignee uuid;
165
+ v_ends timestamptz;
166
+ v_order uuid;
167
+ v_booking uuid;
168
+ v_phone text;
169
+ v_tz text;
170
+ v_notes text;
171
+ BEGIN
172
+ -- validation: anon-callable, everything tight
173
+ IF p_name IS NULL OR length(trim(p_name)) NOT BETWEEN 2 AND 120 THEN
174
+ RAISE EXCEPTION 'invalid name';
175
+ END IF;
176
+ v_phone := regexp_replace(COALESCE(p_phone, ''), '\\D', '', 'g');
177
+ IF length(v_phone) NOT BETWEEN 8 AND 15 THEN
178
+ RAISE EXCEPTION 'invalid phone';
179
+ END IF;
180
+ IF p_email IS NOT NULL AND (length(p_email) > 254 OR p_email !~ '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$') THEN
181
+ RAISE EXCEPTION 'invalid email';
182
+ END IF;
183
+ v_notes := left(p_notes, 500);
184
+ IF p_starts_at IS NULL OR p_starts_at <= now() OR p_starts_at > now() + interval '90 days' THEN
185
+ RAISE EXCEPTION 'invalid start';
186
+ END IF;
187
+
188
+ SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
189
+ INTO v_service
190
+ FROM services s
191
+ WHERE s.id = p_service_id AND s.tenant_id = p_tenant_id
192
+ AND s.is_active AND s.status = 'active';
193
+ IF NOT FOUND THEN RAISE EXCEPTION 'unknown service'; END IF;
194
+
195
+ v_ends := p_starts_at + make_interval(mins => v_service.duration_minutes);
196
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
197
+ FROM tenants t WHERE t.id = p_tenant_id;
198
+ IF v_tz IS NULL THEN RAISE EXCEPTION 'unknown tenant'; END IF;
199
+
200
+ -- pick the assignee whose working hours cover the slot
201
+ SELECT sc.assignee_id INTO v_assignee
202
+ FROM schedules sc
203
+ WHERE sc.tenant_id = p_tenant_id
204
+ AND sc.kind = 'working_hours'
205
+ AND sc.is_active
206
+ AND (
207
+ sc.specific_date = (p_starts_at AT TIME ZONE v_tz)::date
208
+ OR (sc.specific_date IS NULL
209
+ AND sc.day_of_week = EXTRACT(dow FROM p_starts_at AT TIME ZONE v_tz)::smallint)
210
+ )
211
+ AND (p_starts_at AT TIME ZONE v_tz)::time >= sc.starts_at
212
+ AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at
213
+ ORDER BY sc.assignee_id
214
+ LIMIT 1;
215
+ IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; END IF;
216
+
217
+ -- serialize per (tenant, assignee) to kill the double-book race
218
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_assignee::text, 0));
219
+
220
+ IF EXISTS (
221
+ SELECT 1 FROM bookings b
222
+ WHERE b.tenant_id = p_tenant_id
223
+ AND b.assignee_id = v_assignee
224
+ AND b.status NOT IN ('cancelled', 'no_show')
225
+ AND b.starts_at < v_ends
226
+ AND COALESCE(b.ends_at, b.starts_at) > p_starts_at
227
+ ) THEN
228
+ RAISE EXCEPTION 'slot no longer available';
229
+ END IF;
230
+
231
+ -- anti-spam: cap open future public bookings per phone per tenant
232
+ IF (
233
+ SELECT count(*) FROM bookings b
234
+ JOIN persons p ON p.id = b.party_id
235
+ WHERE b.tenant_id = p_tenant_id
236
+ AND p.phone = v_phone
237
+ AND b.starts_at > now()
238
+ AND b.status = 'scheduled'
239
+ ) >= 3 THEN
240
+ RAISE EXCEPTION 'too many open bookings for this phone';
241
+ END IF;
242
+
243
+ -- upsert the customer by (tenant, phone) — matches persons_tenant_customer_phone
244
+ INSERT INTO persons (tenant_id, kind, name, phone, email, metadata)
245
+ VALUES (p_tenant_id, 'customer', trim(p_name), v_phone, p_email,
246
+ jsonb_build_object('source', 'public_booking'))
247
+ ON CONFLICT (tenant_id, phone) WHERE kind = 'customer' AND phone IS NOT NULL
248
+ DO UPDATE SET
249
+ name = EXCLUDED.name,
250
+ email = COALESCE(EXCLUDED.email, persons.email),
251
+ updated_at = now()
252
+ RETURNING id INTO v_person_id;
253
+
254
+ -- order + booking + order_item (statuses hardcoded — caller cannot set)
255
+ INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
256
+ VALUES (p_tenant_id, 'appointment', 'scheduled', v_person_id, v_assignee,
257
+ v_service.price, v_service.price, v_notes,
258
+ jsonb_build_object('source', 'public_booking',
259
+ 'serviceNames', v_service.name,
260
+ 'contactName', trim(p_name)))
261
+ RETURNING id INTO v_order;
262
+
263
+ INSERT INTO bookings (tenant_id, kind, party_id, assignee_id, order_id,
264
+ starts_at, ends_at, status, notes, metadata)
265
+ VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order,
266
+ p_starts_at, v_ends, 'scheduled', v_notes,
267
+ jsonb_build_object('source', 'public_booking', 'serviceNames', v_service.name))
268
+ RETURNING id INTO v_booking;
269
+
270
+ INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
271
+ sort_order, duration_minutes, assignee_id)
272
+ VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
273
+ 0, v_service.duration_minutes, v_assignee);
274
+
275
+ RETURN QUERY SELECT v_booking, p_starts_at, v_ends;
276
+ END;
277
+ $$;
278
+
279
+ REVOKE ALL ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text) FROM public;
280
+ GRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text)
281
+ TO anon, authenticated, service_role;
282
+ `
@@ -0,0 +1,315 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { ChevronLeft } from 'lucide-react'
3
+ import { DEFAULT_COUNTRY, getCountry, maskPhone, unmaskPhone } from '@fayz-ai/core'
4
+ import type { PixCharge } from '@fayz-ai/core'
5
+ import { useBooking } from './context'
6
+ import { useAvailableSlots, useCreateBooking } from './hooks'
7
+ import { buildWeek, toIso, readServiceParam, useFormatters } from './format'
8
+ import type { StepState } from './types'
9
+ import { OverviewStep } from './steps/OverviewStep'
10
+ import { DateTimeStep } from './steps/DateTimeStep'
11
+ import { ContactStep, type ContactDraft } from './steps/ContactStep'
12
+ import { PaymentStep, PIX_PAYLOAD } from './steps/PaymentStep'
13
+ import { SummaryRail, type RailState } from './components/SummaryRail'
14
+
15
+ type Step = 'overview' | 'datetime' | 'contact' | 'payment' | 'success'
16
+
17
+ /**
18
+ * Customer-facing booking surface. Default view is an overview (services +
19
+ * opening hours + CTA) like Quaddro; picking a service — or landing with
20
+ * ?service=… — enters a stepped flow (date → time → contact → [payment])
21
+ * with a live progress + summary rail. Token-only, keyboard-accessible, 44px
22
+ * targets. Brand-visible parts render via the host-overridable
23
+ * `components`/`brand` seams; data flows through PublicBookingDataProvider
24
+ * (mock seed or Supabase RPCs — same calls).
25
+ */
26
+ export function BookingWidget() {
27
+ const {
28
+ services, servicesLoading, professionalName, businessHours, payment, paymentProvider,
29
+ onIdentityVerified, labels, brand, components: C, window: bookingWindow, locale, currency,
30
+ } = useBooking()
31
+ const fmt = useFormatters(locale, currency)
32
+
33
+ const preselected = useMemo(() => {
34
+ const q = readServiceParam()
35
+ if (q && services.some((s) => s.id === q)) return q
36
+ return null
37
+ }, [services])
38
+ const todayIso = useMemo(() => toIso(new Date()), [])
39
+
40
+ const [step, setStep] = useState<Step>(preselected ? 'datetime' : 'overview')
41
+ const [serviceId, setServiceId] = useState<string | null>(preselected)
42
+ const [weekOffset, setWeekOffset] = useState(0)
43
+ const [date, setDate] = useState<string | null>(todayIso)
44
+ const [slotStart, setSlotStart] = useState<string | null>(null)
45
+ const [contact, setContact] = useState<ContactDraft>({ name: '', phone: '', email: '', notes: '' })
46
+ // Contact step is phone-verification-first (like Quaddro): phone → code → details.
47
+ const [contactStep, setContactStep] = useState<'phone' | 'code' | 'details'>('phone')
48
+ const [code, setCode] = useState('')
49
+ const [codeError, setCodeError] = useState(false)
50
+ // Payment sub-flow: choose method → Pix QR ("aguardando pagamento").
51
+ const [paymentStep, setPaymentStep] = useState<'choose' | 'pix'>('choose')
52
+ const [copied, setCopied] = useState(false)
53
+ const [countryIso, setCountryIso] = useState(DEFAULT_COUNTRY)
54
+ const country = getCountry(countryIso)
55
+ // Pix charge from the injected payment provider (null → self-mocked fallback).
56
+ const [charge, setCharge] = useState<PixCharge | null>(null)
57
+ const [charging, setCharging] = useState(false)
58
+ const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
59
+
60
+ // ?service=… deep link: enter the flow once the (possibly async) catalog resolves.
61
+ useEffect(() => {
62
+ if (preselected && step === 'overview') {
63
+ setServiceId(preselected)
64
+ setStep('datetime')
65
+ }
66
+ // eslint-disable-next-line react-hooks/exhaustive-deps
67
+ }, [preselected])
68
+
69
+ // Poll the charge status while on the Pix screen; settle → success.
70
+ useEffect(() => {
71
+ if (!(step === 'payment' && paymentStep === 'pix' && charge && paymentProvider)) return
72
+ let active = true
73
+ const clear = () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null } }
74
+ const tick = () => {
75
+ paymentProvider.getChargeStatus(charge.chargeId).then((s) => {
76
+ if (!active) return
77
+ if (s === 'paid') { clear(); setStep('success') }
78
+ else if (s === 'expired' || s === 'failed') clear()
79
+ }).catch(() => { /* keep polling on transient errors */ })
80
+ }
81
+ tick()
82
+ pollRef.current = setInterval(tick, 2500)
83
+ return () => { active = false; clear() }
84
+ }, [step, paymentStep, charge, paymentProvider])
85
+
86
+ const week = useMemo(() => buildWeek(weekOffset), [weekOffset])
87
+ const { slots, loading: slotsLoading } = useAvailableSlots({ serviceId, date })
88
+ const { submit, submitting, error } = useCreateBooking()
89
+
90
+ const minTime = Date.now() + bookingWindow.minAdvanceHours * 3600_000
91
+ const bookableSlots = slots.filter((s) => new Date(s.start).getTime() >= minTime)
92
+ const service = services.find((s) => s.id === serviceId) ?? null
93
+ const maxWeekOffset = Math.floor(bookingWindow.maxAdvanceDays / 7)
94
+
95
+ /** Contact with the full international phone, as real backends expect. */
96
+ function contactForSubmit(): ContactDraft {
97
+ return { ...contact, phone: `${country.dial} ${contact.phone}` }
98
+ }
99
+
100
+ function startBooking(id?: string) {
101
+ setServiceId(id ?? serviceId ?? services[0]?.id ?? null)
102
+ setDate(todayIso)
103
+ setSlotStart(null)
104
+ setStep('datetime')
105
+ }
106
+
107
+ async function handleConfirm() {
108
+ if (!serviceId || !slotStart || !contact.name.trim()) return
109
+ try {
110
+ await submit({ serviceId, startsAt: slotStart, contact: contactForSubmit() })
111
+ setStep('success')
112
+ } catch {
113
+ /* error surfaced inline */
114
+ }
115
+ }
116
+
117
+ function emitIdentity() {
118
+ onIdentityVerified?.({
119
+ name: contact.name,
120
+ phone: `${country.dial} ${contact.phone}`,
121
+ email: contact.email || undefined,
122
+ })
123
+ }
124
+
125
+ // Contact sub-step submit handlers (also used by Enter via <form onSubmit>).
126
+ function sendCode() {
127
+ if (unmaskPhone(contact.phone).length < 8) return
128
+ setCode(''); setCodeError(false); setContactStep('code')
129
+ }
130
+ function confirmCode() {
131
+ if (code.trim() === '0000') {
132
+ setCodeError(false)
133
+ emitIdentity() // phone verified → host signs the user in
134
+ setContactStep('details')
135
+ } else {
136
+ setCodeError(true)
137
+ }
138
+ }
139
+ function submitDetails() {
140
+ if (!contact.name.trim() || submitting) return
141
+ emitIdentity() // re-fire with the full name to enrich the account
142
+ if (payment) { setPaymentStep('choose'); setStep('payment') } else void handleConfirm()
143
+ }
144
+ async function payWithPix(fee: number) {
145
+ if (charging || !service || !slotStart) return
146
+ setCharging(true)
147
+ try {
148
+ await submit({ serviceId: service.id, startsAt: slotStart, contact: contactForSubmit() })
149
+ if (paymentProvider) {
150
+ const c = await paymentProvider.createCharge({
151
+ amount: fee, currency, method: 'pix', description: service.name,
152
+ customer: { name: contact.name, email: contact.email || undefined, phone: contact.phone || undefined },
153
+ })
154
+ setCharge(c)
155
+ }
156
+ setPaymentStep('pix')
157
+ } catch {
158
+ /* error surfaced inline */
159
+ } finally {
160
+ setCharging(false)
161
+ }
162
+ }
163
+ function copyPixPayload() {
164
+ const payload = charge?.pixCopyPaste ?? PIX_PAYLOAD
165
+ try { void navigator.clipboard?.writeText(payload); setCopied(true) } catch { setCopied(true) }
166
+ }
167
+
168
+ // ---- OVERVIEW (default view, no service chosen) ----
169
+ if (step === 'overview') {
170
+ return (
171
+ <div className="mx-auto max-w-4xl">
172
+ <C.BrandHeader brand={brand} />
173
+ <OverviewStep
174
+ services={services}
175
+ servicesLoading={servicesLoading}
176
+ businessHours={businessHours}
177
+ professionalName={professionalName}
178
+ labels={labels}
179
+ brand={brand}
180
+ fmt={fmt}
181
+ components={C}
182
+ onStartBooking={startBooking}
183
+ />
184
+ </div>
185
+ )
186
+ }
187
+
188
+ // ---- WIZARD (datetime → contact → [payment] → success) ----
189
+ const railState: RailState = {
190
+ service: 'done',
191
+ professional: 'done',
192
+ datetime: (slotStart ? 'done' : step === 'datetime' ? 'active' : 'todo') as StepState,
193
+ contact: (step === 'payment' || step === 'success' ? 'done' : step === 'contact' ? 'active' : 'todo') as StepState,
194
+ payment: (step === 'success' ? 'done' : step === 'payment' ? 'active' : 'todo') as StepState,
195
+ }
196
+ const railTitle = step === 'payment' && paymentStep === 'pix' ? labels.railWaitingPayment : labels.railTitle
197
+ const contactSummary =
198
+ (contact.name || contact.phone) && (step === 'payment' || step === 'success' || contactStep === 'details')
199
+ ? `${contact.name || 'Contato'}${contact.phone ? ` · ${country.dial} ${contact.phone}` : ''}`
200
+ : null
201
+
202
+ return (
203
+ <div className="mx-auto max-w-4xl">
204
+ <C.BrandHeader brand={brand} />
205
+ <div className="grid gap-6 lg:grid-cols-[1fr_18rem]">
206
+ {/* LEFT — step panel */}
207
+ <div className="rounded-2xl border border-border bg-card p-6 md:p-7 shadow-sm">
208
+ <div className="mb-5 flex items-center gap-1.5 lg:hidden">
209
+ {(['datetime', 'contact'] as const).map((s) => (
210
+ <span key={s} className={`h-1.5 flex-1 rounded-full ${railState[s] !== 'todo' ? 'bg-primary' : 'bg-muted'}`} />
211
+ ))}
212
+ </div>
213
+
214
+ {step !== 'success' ? (
215
+ <button
216
+ type="button"
217
+ onClick={() => setStep(step === 'payment' ? 'contact' : step === 'contact' ? 'datetime' : 'overview')}
218
+ className="mb-4 inline-flex items-center gap-1 rounded text-sm font-medium text-muted-foreground hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
219
+ >
220
+ <ChevronLeft className="h-4 w-4" /> {step === 'datetime' ? labels.backToServices : labels.back}
221
+ </button>
222
+ ) : null}
223
+
224
+ {step === 'datetime' ? (
225
+ <DateTimeStep
226
+ labels={labels}
227
+ fmt={fmt}
228
+ week={week}
229
+ weekOffset={weekOffset}
230
+ maxWeekOffset={maxWeekOffset}
231
+ onWeekOffset={setWeekOffset}
232
+ businessHours={businessHours}
233
+ date={date}
234
+ onSelectDate={(iso) => { setDate(iso); setSlotStart(null) }}
235
+ slots={bookableSlots}
236
+ slotsLoading={slotsLoading}
237
+ slotStart={slotStart}
238
+ onSelectSlot={setSlotStart}
239
+ onContinue={() => setStep('contact')}
240
+ />
241
+ ) : null}
242
+
243
+ {step === 'contact' ? (
244
+ <ContactStep
245
+ labels={labels}
246
+ contact={contact}
247
+ onContactChange={setContact}
248
+ contactStep={contactStep}
249
+ country={country}
250
+ onCountryChange={(iso2) => {
251
+ const c = getCountry(iso2)
252
+ setCountryIso(c.iso2)
253
+ setContact((prev) => ({ ...prev, phone: maskPhone(unmaskPhone(prev.phone), c.mask) }))
254
+ }}
255
+ code={code}
256
+ onCodeChange={(v) => { setCode(v); setCodeError(false) }}
257
+ codeError={codeError}
258
+ onSendCode={sendCode}
259
+ onConfirmCode={confirmCode}
260
+ onBackToPhone={() => setContactStep('phone')}
261
+ onSubmitDetails={submitDetails}
262
+ payment={payment}
263
+ submitting={submitting}
264
+ error={error}
265
+ />
266
+ ) : null}
267
+
268
+ {step === 'payment' && service && payment ? (
269
+ <PaymentStep
270
+ service={service}
271
+ payment={payment}
272
+ paymentStep={paymentStep}
273
+ charge={charge}
274
+ busy={submitting || charging}
275
+ error={error}
276
+ copied={copied}
277
+ fmt={fmt}
278
+ onPayWithPix={(fee) => void payWithPix(fee)}
279
+ onCopy={copyPixPayload}
280
+ onSimulatePaid={() => setStep('success')}
281
+ />
282
+ ) : null}
283
+
284
+ {step === 'success' ? (
285
+ <C.SuccessPanel
286
+ service={service}
287
+ slotStart={slotStart}
288
+ professionalName={professionalName}
289
+ labels={labels}
290
+ fmt={fmt}
291
+ />
292
+ ) : null}
293
+ </div>
294
+
295
+ {/* RIGHT — progress + summary rail */}
296
+ <aside className="hidden lg:block">
297
+ <SummaryRail
298
+ title={railTitle}
299
+ railState={railState}
300
+ service={service}
301
+ professionalName={professionalName}
302
+ slotStart={slotStart}
303
+ contactSummary={contactSummary}
304
+ payment={payment}
305
+ paymentReached={step === 'payment' || step === 'success'}
306
+ labels={labels}
307
+ fmt={fmt}
308
+ components={C}
309
+ serviceMeta={brand.serviceMeta}
310
+ />
311
+ </aside>
312
+ </div>
313
+ </div>
314
+ )
315
+ }
@@ -0,0 +1,27 @@
1
+ import { ShieldCheck } from 'lucide-react'
2
+ import type { BrandHeaderProps } from '../types'
3
+
4
+ /** Default brand header shown above every booking view. Override via
5
+ * `components.BrandHeader` when the structure must change; text/logo-only
6
+ * changes need just the `brand` option. */
7
+ export function DefaultBrandHeader({ brand }: BrandHeaderProps) {
8
+ return (
9
+ <div className="mb-5 flex items-center gap-3 rounded-2xl border border-border bg-card px-5 py-4 shadow-sm">
10
+ {brand.logoUrl ? (
11
+ <img src={brand.logoUrl} alt={brand.name} className="h-11 w-11 rounded-xl object-cover" />
12
+ ) : (
13
+ <span className="flex h-11 w-11 items-center justify-center rounded-xl bg-primary text-lg font-bold text-primary-foreground">
14
+ {brand.initials}
15
+ </span>
16
+ )}
17
+ <div>
18
+ <p className="font-heading text-base font-bold text-foreground">{brand.name}</p>
19
+ {brand.tagline ? (
20
+ <p className="flex items-center gap-1.5 text-xs text-muted-foreground">
21
+ <ShieldCheck className="h-3.5 w-3.5 text-primary" /> {brand.tagline}
22
+ </p>
23
+ ) : null}
24
+ </div>
25
+ </div>
26
+ )
27
+ }
@@ -0,0 +1,39 @@
1
+ import { ChevronRight, Clock } from 'lucide-react'
2
+ import type { ServiceCardProps } from '../types'
3
+
4
+ /** Default service row (overview list). Icon/badge/meta come from `brand`;
5
+ * swap the whole card via `components.ServiceCard`. */
6
+ export function DefaultServiceCard({ service, featured, onSelect, fmt, brand }: ServiceCardProps) {
7
+ const Icon = brand.serviceIcon
8
+ return (
9
+ <button
10
+ type="button"
11
+ onClick={onSelect}
12
+ className="group flex w-full items-center gap-4 rounded-2xl border border-border p-4 text-left transition-colors hover:border-primary hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
13
+ >
14
+ <span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-accent text-primary">
15
+ <Icon className="h-5 w-5" />
16
+ </span>
17
+ <span className="min-w-0 flex-1">
18
+ <span className="flex items-center gap-2">
19
+ <span className="font-semibold text-foreground">{service.name}</span>
20
+ {featured && brand.featuredBadge ? (
21
+ <span className="rounded-full bg-accent px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
22
+ {brand.featuredBadge}
23
+ </span>
24
+ ) : null}
25
+ </span>
26
+ <span className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
27
+ <Clock className="h-3 w-3" /> {service.durationMinutes} min{brand.serviceMeta ? ` · ${brand.serviceMeta}` : ''}
28
+ </span>
29
+ {service.description ? (
30
+ <span className="mt-1 block text-sm text-muted-foreground">{service.description}</span>
31
+ ) : null}
32
+ </span>
33
+ <span className="shrink-0 text-right">
34
+ <span className="block font-semibold tabular-nums text-primary">{fmt.price.format(service.price)}</span>
35
+ <ChevronRight className="ml-auto mt-1 h-4 w-4 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
36
+ </span>
37
+ </button>
38
+ )
39
+ }