@fayz-ai/plugin-agenda 0.2.0 → 0.2.1

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.
@@ -0,0 +1,892 @@
1
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2
+ import {
3
+ Calendar, Clock, Check, ChevronLeft, ChevronRight, ChevronDown, Loader2,
4
+ MapPin, CreditCard, ShieldCheck, User, Video, QrCode, Copy, CalendarCheck, Plus,
5
+ } from 'lucide-react'
6
+ import { COUNTRIES, DEFAULT_COUNTRY, getCountry, maskPhone, unmaskPhone } from '@fayz-ai/core'
7
+ import type { PixCharge } from '@fayz-ai/core'
8
+ import { useBooking } from './context'
9
+ import { useAvailableSlots, useCreateBooking } from './hooks'
10
+ import type { PublicService } from './types'
11
+
12
+ type Step = 'overview' | 'datetime' | 'contact' | 'payment' | 'success'
13
+ type StepState = 'done' | 'active' | 'todo'
14
+
15
+ const DAY_NAMES = ['Domingo', 'Segunda-feira', 'Terça-feira', 'Quarta-feira', 'Quinta-feira', 'Sexta-feira', 'Sábado']
16
+ const WEEK_ORDER = [1, 2, 3, 4, 5, 6, 0] // Monday-first, like the reference
17
+
18
+ function useFormatters(locale: string, currency: string) {
19
+ return useMemo(
20
+ () => ({
21
+ price: new Intl.NumberFormat(locale, { style: 'currency', currency }),
22
+ time: new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }),
23
+ weekday: new Intl.DateTimeFormat(locale, { weekday: 'short' }),
24
+ dayNum: new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit' }),
25
+ longDay: new Intl.DateTimeFormat(locale, { weekday: 'long', day: '2-digit', month: 'long' }),
26
+ dateShort: new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit', year: 'numeric' }),
27
+ month: new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }),
28
+ }),
29
+ [locale, currency],
30
+ )
31
+ }
32
+
33
+ function toIso(d: Date): string {
34
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
35
+ }
36
+
37
+ /** Read a preselected service id from the URL (?service=…), router-agnostic. */
38
+ function readServiceParam(): string | null {
39
+ if (typeof window === 'undefined') return null
40
+ return new URLSearchParams(window.location.search).get('service')
41
+ }
42
+
43
+ interface DayCell {
44
+ iso: string
45
+ date: Date
46
+ isToday: boolean
47
+ }
48
+
49
+ /** 7 consecutive days starting `weekOffset*7` days from today. */
50
+ function buildWeek(weekOffset: number): DayCell[] {
51
+ const base = new Date()
52
+ base.setHours(0, 0, 0, 0)
53
+ const cells: DayCell[] = []
54
+ for (let i = 0; i < 7; i++) {
55
+ const d = new Date(base.getFullYear(), base.getMonth(), base.getDate() + weekOffset * 7 + i)
56
+ cells.push({ iso: toIso(d), date: d, isToday: weekOffset === 0 && i === 0 })
57
+ }
58
+ return cells
59
+ }
60
+
61
+ /** "13/07/2026 – 21:30 até 22:00 (30min)" */
62
+ function formatSlotRange(startIso: string, durationMin: number, fmt: ReturnType<typeof useFormatters>): string {
63
+ const start = new Date(startIso)
64
+ const end = new Date(start.getTime() + durationMin * 60_000)
65
+ return `${fmt.dateShort.format(start)} – ${fmt.time.format(start)} até ${fmt.time.format(end)} (${durationMin}min)`
66
+ }
67
+
68
+ // Static, realistic-looking Pix payload for the POC copia-e-cola field.
69
+ const PIX_PAYLOAD =
70
+ '00020126840014br.gov.bcb.pix2562qrcode.hempdent.com.br/pix/v2/9d8f7a6b-consulta-canabica5204000053039865802BR5913HEMPDENT6009SAO PAULO62070503***6304AB12'
71
+
72
+ /** Deterministic QR-looking placeholder rendered from a payload (POC visual). */
73
+ function QrPlaceholder({ data }: { data: string }) {
74
+ const size = 25
75
+ // Seed the data-cell pattern from the payload so different charges look different.
76
+ let seed = 0
77
+ for (let i = 0; i < data.length; i++) seed = (seed * 31 + data.charCodeAt(i)) % 100000
78
+ const cells: { x: number; y: number }[] = []
79
+ const isFinder = (x: number, y: number) => {
80
+ const inBox = (bx: number, by: number) => x >= bx && x < bx + 7 && y >= by && y < by + 7
81
+ return inBox(0, 0) || inBox(size - 7, 0) || inBox(0, size - 7)
82
+ }
83
+ for (let y = 0; y < size; y++) {
84
+ for (let x = 0; x < size; x++) {
85
+ if (isFinder(x, y)) {
86
+ const ring = x === 0 || y === 0 || x === size - 1 || y === size - 1
87
+ // draw finder squares (outer ring + inner block) deterministically
88
+ const local = (bx: number, by: number) => ({ lx: x - bx, ly: y - by })
89
+ const corner = x < 7 && y < 7 ? { bx: 0, by: 0 } : x >= size - 7 && y < 7 ? { bx: size - 7, by: 0 } : { bx: 0, by: size - 7 }
90
+ const { lx, ly } = local(corner.bx, corner.by)
91
+ const outer = lx === 0 || lx === 6 || ly === 0 || ly === 6
92
+ const inner = lx >= 2 && lx <= 4 && ly >= 2 && ly <= 4
93
+ if (outer || inner) cells.push({ x, y })
94
+ void ring
95
+ } else if ((x * 31 + y * 17 + x * y + seed) % 3 === 0) {
96
+ cells.push({ x, y })
97
+ }
98
+ }
99
+ }
100
+ return (
101
+ <svg viewBox={`0 0 ${size} ${size}`} className="h-40 w-40" role="img" aria-label="QR Code Pix (demonstração)">
102
+ <rect width={size} height={size} fill="white" />
103
+ {cells.map((c, i) => (
104
+ <rect key={i} x={c.x} y={c.y} width={1} height={1} fill="currentColor" className="text-foreground" />
105
+ ))}
106
+ </svg>
107
+ )
108
+ }
109
+
110
+ /** Brand header shown above every booking view. */
111
+ function BrandHeader() {
112
+ return (
113
+ <div className="mb-5 flex items-center gap-3 rounded-2xl border border-border bg-card px-5 py-4 shadow-sm">
114
+ <span className="flex h-11 w-11 items-center justify-center rounded-xl bg-primary text-lg font-bold text-primary-foreground">
115
+ H
116
+ </span>
117
+ <div>
118
+ <p className="font-heading text-base font-bold text-foreground">HempDent · Consulta Canábica</p>
119
+ <p className="flex items-center gap-1.5 text-xs text-muted-foreground">
120
+ <ShieldCheck className="h-3.5 w-3.5 text-primary" /> Agendamento online seguro · CRO-SP 166513
121
+ </p>
122
+ </div>
123
+ </div>
124
+ )
125
+ }
126
+
127
+ /** A service row (used on the overview and reused elsewhere). */
128
+ function ServiceRow({
129
+ service, featured, onSelect, fmt,
130
+ }: {
131
+ service: PublicService
132
+ featured?: boolean
133
+ onSelect: () => void
134
+ fmt: ReturnType<typeof useFormatters>
135
+ }) {
136
+ return (
137
+ <button
138
+ type="button"
139
+ onClick={onSelect}
140
+ 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"
141
+ >
142
+ <span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-accent text-primary">
143
+ <Video className="h-5 w-5" />
144
+ </span>
145
+ <span className="min-w-0 flex-1">
146
+ <span className="flex items-center gap-2">
147
+ <span className="font-semibold text-foreground">{service.name}</span>
148
+ {featured ? (
149
+ <span className="rounded-full bg-accent px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
150
+ Mais procurado
151
+ </span>
152
+ ) : null}
153
+ </span>
154
+ <span className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
155
+ <Clock className="h-3 w-3" /> {service.durationMinutes} min · Online
156
+ </span>
157
+ {service.description ? (
158
+ <span className="mt-1 block text-sm text-muted-foreground">{service.description}</span>
159
+ ) : null}
160
+ </span>
161
+ <span className="shrink-0 text-right">
162
+ <span className="block font-semibold tabular-nums text-primary">{fmt.price.format(service.price)}</span>
163
+ <ChevronRight className="ml-auto mt-1 h-4 w-4 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
164
+ </span>
165
+ </button>
166
+ )
167
+ }
168
+
169
+ /** One row in the wizard's right-hand progress rail. */
170
+ function StepRail({
171
+ n, label, state, children,
172
+ }: { n: number; label: string; state: StepState; children?: ReactNode }) {
173
+ return (
174
+ <li className="relative pl-11">
175
+ <span aria-hidden className="absolute left-[15px] top-8 bottom-[-18px] w-px bg-border last:hidden" />
176
+ <span
177
+ className={[
178
+ 'absolute left-0 top-0 flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold transition-colors',
179
+ state === 'done'
180
+ ? 'bg-primary text-primary-foreground'
181
+ : state === 'active'
182
+ ? 'bg-accent text-primary ring-2 ring-primary'
183
+ : 'bg-muted text-muted-foreground',
184
+ ].join(' ')}
185
+ >
186
+ {state === 'done' ? <Check className="h-4 w-4" /> : n}
187
+ </span>
188
+ <p className={`pt-1 text-sm font-semibold ${state === 'todo' ? 'text-muted-foreground' : 'text-foreground'}`}>
189
+ {label}
190
+ </p>
191
+ {children ? <div className="mt-2">{children}</div> : null}
192
+ </li>
193
+ )
194
+ }
195
+
196
+ /**
197
+ * Customer-facing booking surface. Default view is an overview (services +
198
+ * opening hours + CTA) like Quaddro; picking a service — or landing with
199
+ * ?service=… — enters a stepped flow (date → time → contact → confirmation)
200
+ * with a live progress + summary rail. Token-only, keyboard-accessible, 44px
201
+ * targets. Runs on mock slots today; real availability is a provider swap.
202
+ */
203
+ export function BookingWidget() {
204
+ const {
205
+ services, professionalName, businessHours, payment, paymentProvider, onIdentityVerified,
206
+ labels, window: bookingWindow, locale, currency,
207
+ } = useBooking()
208
+ const fmt = useFormatters(locale, currency)
209
+
210
+ const preselected = useMemo(() => {
211
+ const q = readServiceParam()
212
+ if (q && services.some((s) => s.id === q)) return q
213
+ return null
214
+ }, [services])
215
+ const todayIso = useMemo(() => toIso(new Date()), [])
216
+
217
+ const [step, setStep] = useState<Step>(preselected ? 'datetime' : 'overview')
218
+ const [serviceId, setServiceId] = useState<string | null>(preselected)
219
+ const [weekOffset, setWeekOffset] = useState(0)
220
+ const [date, setDate] = useState<string | null>(todayIso)
221
+ const [slotStart, setSlotStart] = useState<string | null>(null)
222
+ const [contact, setContact] = useState({ name: '', phone: '', email: '', notes: '' })
223
+ // Contact step is phone-verification-first (like Quaddro): phone → code → details.
224
+ const [contactStep, setContactStep] = useState<'phone' | 'code' | 'details'>('phone')
225
+ const [code, setCode] = useState('')
226
+ const [codeError, setCodeError] = useState(false)
227
+ // Payment sub-flow: choose method → Pix QR ("aguardando pagamento").
228
+ const [paymentStep, setPaymentStep] = useState<'choose' | 'pix'>('choose')
229
+ const [copied, setCopied] = useState(false)
230
+ const [countryIso, setCountryIso] = useState(DEFAULT_COUNTRY)
231
+ const country = getCountry(countryIso)
232
+ // Pix charge from the injected payment provider (null → self-mocked fallback).
233
+ const [charge, setCharge] = useState<PixCharge | null>(null)
234
+ const [charging, setCharging] = useState(false)
235
+ const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
236
+
237
+ // Poll the charge status while on the Pix screen; settle → success.
238
+ useEffect(() => {
239
+ if (!(step === 'payment' && paymentStep === 'pix' && charge && paymentProvider)) return
240
+ let active = true
241
+ const clear = () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null } }
242
+ const tick = () => {
243
+ paymentProvider.getChargeStatus(charge.chargeId).then((s) => {
244
+ if (!active) return
245
+ if (s === 'paid') { clear(); setStep('success') }
246
+ else if (s === 'expired' || s === 'failed') clear()
247
+ }).catch(() => { /* keep polling on transient errors */ })
248
+ }
249
+ tick()
250
+ pollRef.current = setInterval(tick, 2500)
251
+ return () => { active = false; clear() }
252
+ }, [step, paymentStep, charge, paymentProvider])
253
+
254
+ const week = useMemo(() => buildWeek(weekOffset), [weekOffset])
255
+ const { slots, loading: slotsLoading } = useAvailableSlots({ serviceId, date })
256
+ const { submit, submitting, error } = useCreateBooking()
257
+
258
+ const minTime = Date.now() + bookingWindow.minAdvanceHours * 3600_000
259
+ const bookableSlots = slots.filter((s) => new Date(s.start).getTime() >= minTime)
260
+ const service = services.find((s) => s.id === serviceId) ?? null
261
+ const maxWeekOffset = Math.floor(bookingWindow.maxAdvanceDays / 7)
262
+ const openDays = new Set(businessHours.daysOfWeek)
263
+
264
+ function startBooking(id?: string) {
265
+ setServiceId(id ?? serviceId ?? services[0]?.id ?? null)
266
+ setDate(todayIso)
267
+ setSlotStart(null)
268
+ setStep('datetime')
269
+ }
270
+
271
+ async function handleConfirm() {
272
+ if (!serviceId || !slotStart || !contact.name.trim()) return
273
+ try {
274
+ await submit({ serviceId, startsAt: slotStart, contact })
275
+ setStep('success')
276
+ } catch {
277
+ /* error surfaced inline */
278
+ }
279
+ }
280
+
281
+ function emitIdentity() {
282
+ onIdentityVerified?.({
283
+ name: contact.name,
284
+ phone: `${country.dial} ${contact.phone}`,
285
+ email: contact.email || undefined,
286
+ })
287
+ }
288
+
289
+ // Contact sub-step submit handlers (also used by Enter via <form onSubmit>).
290
+ function sendCode() {
291
+ if (unmaskPhone(contact.phone).length < 8) return
292
+ setCode(''); setCodeError(false); setContactStep('code')
293
+ }
294
+ function confirmCode() {
295
+ if (code.trim() === '0000') {
296
+ setCodeError(false)
297
+ emitIdentity() // phone verified → host signs the user in
298
+ setContactStep('details')
299
+ } else {
300
+ setCodeError(true)
301
+ }
302
+ }
303
+ function submitDetails() {
304
+ if (!contact.name.trim() || submitting) return
305
+ emitIdentity() // re-fire with the full name to enrich the account
306
+ if (payment) { setPaymentStep('choose'); setStep('payment') } else void handleConfirm()
307
+ }
308
+ async function payWithPix(fee: number, svcId: string, svcName: string) {
309
+ if (charging) return
310
+ setCharging(true)
311
+ try {
312
+ await submit({ serviceId: svcId, startsAt: slotStart!, contact })
313
+ if (paymentProvider) {
314
+ const c = await paymentProvider.createCharge({
315
+ amount: fee, currency, method: 'pix', description: svcName,
316
+ customer: { name: contact.name, email: contact.email || undefined, phone: contact.phone || undefined },
317
+ })
318
+ setCharge(c)
319
+ }
320
+ setPaymentStep('pix')
321
+ } catch {
322
+ /* error surfaced inline */
323
+ } finally {
324
+ setCharging(false)
325
+ }
326
+ }
327
+
328
+ // ---- OVERVIEW (default view, no service chosen) ----
329
+ if (step === 'overview') {
330
+ return (
331
+ <div className="mx-auto max-w-4xl">
332
+ <BrandHeader />
333
+ <div className="grid gap-6 lg:grid-cols-[1fr_18rem]">
334
+ <div className="space-y-6">
335
+ {/* Serviços */}
336
+ <section className="rounded-2xl border border-border bg-card p-6 shadow-sm">
337
+ <h3 className="mb-4 font-heading text-lg font-bold text-foreground">Serviços</h3>
338
+ <div className="space-y-3">
339
+ {services.map((s, i) => (
340
+ <ServiceRow key={s.id} service={s} featured={i === 0} onSelect={() => startBooking(s.id)} fmt={fmt} />
341
+ ))}
342
+ </div>
343
+ </section>
344
+
345
+ {/* Sobre — horário de funcionamento */}
346
+ <section className="rounded-2xl border border-border bg-card p-6 shadow-sm">
347
+ <h3 className="font-heading text-lg font-bold text-foreground">Sobre</h3>
348
+ <p className="mt-3 mb-2 flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
349
+ <Clock className="h-4 w-4" /> Horário de funcionamento
350
+ </p>
351
+ <ul className="divide-y divide-border border-l-2 border-primary/20 pl-4">
352
+ {WEEK_ORDER.map((day) => {
353
+ const open = openDays.has(day)
354
+ return (
355
+ <li key={day} className="flex items-center justify-between py-2 text-sm">
356
+ <span className="text-foreground">{DAY_NAMES[day]}</span>
357
+ <span className={open ? 'tabular-nums text-foreground' : 'text-muted-foreground'}>
358
+ {open ? `${businessHours.start} – ${businessHours.end}` : 'Fechado'}
359
+ </span>
360
+ </li>
361
+ )
362
+ })}
363
+ </ul>
364
+ </section>
365
+ </div>
366
+
367
+ {/* Sidebar CTA */}
368
+ <aside className="lg:sticky lg:top-24 lg:self-start">
369
+ <div className="rounded-2xl border border-border bg-card p-5 shadow-sm">
370
+ <button
371
+ type="button"
372
+ onClick={() => startBooking()}
373
+ className="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"
374
+ >
375
+ Agendar agora
376
+ </button>
377
+ <div className="mt-5 space-y-2 border-t border-border pt-4 text-xs text-muted-foreground">
378
+ <p className="flex items-center gap-2"><Video className="h-3.5 w-3.5 text-primary" /> Consulta 100% online</p>
379
+ <p className="flex items-center gap-2"><MapPin className="h-3.5 w-3.5 text-primary" /> Atende todo o Brasil</p>
380
+ <p className="flex items-center gap-2"><CreditCard className="h-3.5 w-3.5 text-primary" /> Aceitamos Pix</p>
381
+ <p className="flex items-center gap-2"><User className="h-3.5 w-3.5 text-primary" /> {professionalName}</p>
382
+ </div>
383
+ </div>
384
+ </aside>
385
+ </div>
386
+ </div>
387
+ )
388
+ }
389
+
390
+ // ---- WIZARD (datetime → contact → success) ----
391
+ const railState = {
392
+ service: 'done' as StepState,
393
+ professional: 'done' as StepState,
394
+ datetime: (slotStart ? 'done' : step === 'datetime' ? 'active' : 'todo') as StepState,
395
+ contact: (step === 'payment' || step === 'success' ? 'done' : step === 'contact' ? 'active' : 'todo') as StepState,
396
+ payment: (step === 'success' ? 'done' : step === 'payment' ? 'active' : 'todo') as StepState,
397
+ }
398
+ const railTitle = step === 'payment' && paymentStep === 'pix' ? 'Aguardando pagamento' : 'Passos para agendar'
399
+
400
+ return (
401
+ <div className="mx-auto max-w-4xl">
402
+ <BrandHeader />
403
+ <div className="grid gap-6 lg:grid-cols-[1fr_18rem]">
404
+ {/* LEFT — step panel */}
405
+ <div className="rounded-2xl border border-border bg-card p-6 md:p-7 shadow-sm">
406
+ <div className="mb-5 flex items-center gap-1.5 lg:hidden">
407
+ {(['datetime', 'contact'] as const).map((s) => (
408
+ <span key={s} className={`h-1.5 flex-1 rounded-full ${railState[s] !== 'todo' ? 'bg-primary' : 'bg-muted'}`} />
409
+ ))}
410
+ </div>
411
+
412
+ {step !== 'success' ? (
413
+ <button
414
+ type="button"
415
+ onClick={() => setStep(step === 'payment' ? 'contact' : step === 'contact' ? 'datetime' : 'overview')}
416
+ 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"
417
+ >
418
+ <ChevronLeft className="h-4 w-4" /> {step === 'datetime' ? 'Serviços' : 'Voltar'}
419
+ </button>
420
+ ) : null}
421
+
422
+ {/* STEP: datetime */}
423
+ {step === 'datetime' ? (
424
+ <div>
425
+ <div className="flex flex-wrap items-baseline justify-between gap-2">
426
+ <h3 className="font-heading text-xl font-bold text-foreground">{labels.dateStep}</h3>
427
+ <span className="flex items-center gap-1 text-xs text-muted-foreground">
428
+ <Clock className="h-3.5 w-3.5" /> Fuso: Horário de Brasília
429
+ </span>
430
+ </div>
431
+
432
+ <div className="mt-5 flex items-center justify-between">
433
+ <span className="text-sm font-semibold capitalize text-foreground">{fmt.month.format(week[0].date)}</span>
434
+ <div className="flex items-center gap-1">
435
+ <button
436
+ type="button"
437
+ disabled={weekOffset === 0}
438
+ onClick={() => setWeekOffset((w) => Math.max(0, w - 1))}
439
+ className="flex h-9 w-9 items-center justify-center rounded-lg border border-border text-foreground transition-colors hover:bg-accent disabled:opacity-40 disabled:hover:bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
440
+ aria-label="Semana anterior"
441
+ >
442
+ <ChevronLeft className="h-4 w-4" />
443
+ </button>
444
+ <button
445
+ type="button"
446
+ disabled={weekOffset >= maxWeekOffset}
447
+ onClick={() => setWeekOffset((w) => Math.min(maxWeekOffset, w + 1))}
448
+ className="flex h-9 w-9 items-center justify-center rounded-lg border border-border text-foreground transition-colors hover:bg-accent disabled:opacity-40 disabled:hover:bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
449
+ aria-label="Próxima semana"
450
+ >
451
+ <ChevronRight className="h-4 w-4" />
452
+ </button>
453
+ </div>
454
+ </div>
455
+
456
+ <div className="mt-3 grid grid-cols-7 gap-1.5">
457
+ {week.map((d) => {
458
+ const selected = date === d.iso
459
+ const closed = !openDays.has(d.date.getDay())
460
+ return (
461
+ <button
462
+ key={d.iso}
463
+ type="button"
464
+ disabled={closed}
465
+ onClick={() => { setDate(d.iso); setSlotStart(null) }}
466
+ className={[
467
+ 'flex flex-col items-center gap-0.5 rounded-xl border py-2.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
468
+ closed
469
+ ? 'cursor-not-allowed border-transparent text-muted-foreground/40'
470
+ : selected
471
+ ? 'border-primary bg-primary text-primary-foreground'
472
+ : 'border-border text-foreground hover:border-primary/60 hover:bg-accent/40',
473
+ ].join(' ')}
474
+ >
475
+ <span className="text-[11px] font-medium capitalize opacity-80">
476
+ {fmt.weekday.format(d.date).replace('.', '')}
477
+ </span>
478
+ <span className="text-sm font-bold tabular-nums">{fmt.dayNum.format(d.date)}</span>
479
+ </button>
480
+ )
481
+ })}
482
+ </div>
483
+
484
+ {date ? (
485
+ <div className="mt-6">
486
+ <p className="mb-2 text-sm font-semibold text-foreground">Horários disponíveis</p>
487
+ {slotsLoading ? (
488
+ <div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
489
+ {Array.from({ length: 8 }).map((_, i) => (
490
+ <div key={i} className="h-10 animate-pulse rounded-lg bg-muted" />
491
+ ))}
492
+ </div>
493
+ ) : bookableSlots.length === 0 ? (
494
+ <div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
495
+ Nenhum horário disponível neste dia. Tente outra data.
496
+ </div>
497
+ ) : (
498
+ <div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
499
+ {bookableSlots.map((s) => {
500
+ const selected = slotStart === s.start
501
+ return (
502
+ <button
503
+ key={s.start}
504
+ type="button"
505
+ onClick={() => setSlotStart(s.start)}
506
+ className={[
507
+ 'rounded-lg border py-2.5 text-sm font-semibold tabular-nums transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
508
+ selected
509
+ ? 'border-primary bg-primary text-primary-foreground'
510
+ : 'border-border text-foreground hover:border-primary/60 hover:bg-accent/40',
511
+ ].join(' ')}
512
+ >
513
+ {fmt.time.format(new Date(s.start))}
514
+ </button>
515
+ )
516
+ })}
517
+ </div>
518
+ )}
519
+ </div>
520
+ ) : null}
521
+
522
+ <button
523
+ type="button"
524
+ disabled={!slotStart}
525
+ onClick={() => setStep('contact')}
526
+ className="mt-6 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"
527
+ >
528
+ Continuar
529
+ </button>
530
+ </div>
531
+ ) : null}
532
+
533
+ {/* STEP: contact — phone verification first, then details */}
534
+ {step === 'contact' ? (
535
+ <div>
536
+ <h3 className="font-heading text-xl font-bold text-foreground">{labels.contactStep}</h3>
537
+
538
+ {/* Sub-step: phone */}
539
+ {contactStep === 'phone' ? (
540
+ <form onSubmit={(e) => { e.preventDefault(); sendCode() }}>
541
+ <p className="mt-1 text-sm text-muted-foreground">
542
+ Preencha as informações abaixo para que seu agendamento seja identificado.
543
+ </p>
544
+ <div className="mt-5">
545
+ <label htmlFor="bk-phone" className="mb-1 block text-sm font-medium text-foreground">Celular</label>
546
+ <div className="flex">
547
+ <div className="relative">
548
+ <select
549
+ aria-label="País"
550
+ value={countryIso}
551
+ onChange={(e) => {
552
+ const c = getCountry(e.target.value)
553
+ setCountryIso(c.iso2)
554
+ setContact({ ...contact, phone: maskPhone(unmaskPhone(contact.phone), c.mask) })
555
+ }}
556
+ 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"
557
+ >
558
+ {COUNTRIES.map((c) => (
559
+ <option key={c.iso2} value={c.iso2}>{c.flag} {c.dial}</option>
560
+ ))}
561
+ </select>
562
+ <ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
563
+ </div>
564
+ <input
565
+ id="bk-phone" type="tel" autoComplete="tel" inputMode="tel" value={contact.phone}
566
+ onChange={(e) => setContact({ ...contact, phone: maskPhone(e.target.value, country.mask) })}
567
+ 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"
568
+ placeholder={`Ex.: ${maskPhone('11987654321', country.mask)}`}
569
+ />
570
+ </div>
571
+ <p className="mt-2 text-xs text-muted-foreground">
572
+ Enviaremos um código para confirmar que o número de telefone é seu. Usaremos este número para associar as suas informações à sua conta.
573
+ </p>
574
+ </div>
575
+ <button
576
+ type="submit"
577
+ disabled={unmaskPhone(contact.phone).length < 8}
578
+ 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"
579
+ >
580
+ Enviar código via WhatsApp
581
+ </button>
582
+ </form>
583
+ ) : null}
584
+
585
+ {/* Sub-step: details (after verification) */}
586
+ {contactStep === 'details' ? (
587
+ <form onSubmit={(e) => { e.preventDefault(); submitDetails() }}>
588
+ <p className="mt-1 text-sm text-muted-foreground">
589
+ Preencha as informações abaixo para que seu agendamento seja identificado.
590
+ </p>
591
+ <div className="mt-4 flex items-center justify-between rounded-xl border border-border bg-muted/40 px-4 py-3">
592
+ <span className="flex items-center gap-2 text-sm text-foreground">
593
+ <ShieldCheck className="h-4 w-4 text-primary" /> Celular verificado · {country.flag} {country.dial} {contact.phone}
594
+ </span>
595
+ <button
596
+ type="button"
597
+ onClick={() => setContactStep('phone')}
598
+ className="rounded text-sm font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
599
+ >
600
+ Trocar telefone
601
+ </button>
602
+ </div>
603
+ <div className="mt-4 space-y-3">
604
+ <div>
605
+ <label htmlFor="bk-name" className="mb-1 block text-sm font-medium text-foreground">Nome completo *</label>
606
+ <input
607
+ id="bk-name" type="text" autoComplete="name" value={contact.name}
608
+ onChange={(e) => setContact({ ...contact, name: e.target.value })}
609
+ 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"
610
+ placeholder="Seu nome"
611
+ />
612
+ </div>
613
+ <div>
614
+ <label htmlFor="bk-email" className="mb-1 block text-sm font-medium text-foreground">E-mail</label>
615
+ <input
616
+ id="bk-email" type="email" autoComplete="email" inputMode="email" value={contact.email}
617
+ onChange={(e) => setContact({ ...contact, email: e.target.value })}
618
+ 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"
619
+ placeholder="voce@email.com"
620
+ />
621
+ <p className="mt-1 text-xs text-muted-foreground">Enviaremos uma confirmação e um lembrete antes do agendamento.</p>
622
+ </div>
623
+ <div>
624
+ <label htmlFor="bk-notes" className="mb-1 block text-sm font-medium text-foreground">Observação</label>
625
+ <textarea
626
+ id="bk-notes" rows={3} value={contact.notes}
627
+ onChange={(e) => setContact({ ...contact, notes: e.target.value })}
628
+ 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"
629
+ placeholder="Use esse espaço para deixar uma observação sobre este agendamento"
630
+ />
631
+ </div>
632
+ </div>
633
+ {error ? (
634
+ <p role="alert" className="mt-3 text-sm text-destructive">
635
+ Não foi possível concluir o agendamento. Tente novamente.
636
+ </p>
637
+ ) : null}
638
+ <button
639
+ type="submit"
640
+ disabled={!contact.name.trim() || submitting}
641
+ 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"
642
+ >
643
+ {submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
644
+ {payment ? 'Salvar e continuar' : labels.confirmCta}
645
+ </button>
646
+ {!payment ? (
647
+ <p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
648
+ <CreditCard className="h-3.5 w-3.5" /> Pagamento via Pix · Sem cobrança agora
649
+ </p>
650
+ ) : null}
651
+ </form>
652
+ ) : null}
653
+ </div>
654
+ ) : null}
655
+
656
+ {/* Code verification modal */}
657
+ {step === 'contact' && contactStep === 'code' ? (
658
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-foreground/40 p-4" role="dialog" aria-modal="true" aria-label="Verificação de código">
659
+ <div className="w-full max-w-md rounded-2xl border border-border bg-card p-6 shadow-xl">
660
+ <h4 className="font-heading text-lg font-bold text-foreground">Insira o código</h4>
661
+ <p className="mt-1 text-sm text-muted-foreground">
662
+ Enviamos um código via WhatsApp para você confirmar seu número.
663
+ </p>
664
+ <form onSubmit={(e) => { e.preventDefault(); confirmCode() }}>
665
+ <input
666
+ type="text" inputMode="numeric" value={code} autoFocus
667
+ onChange={(e) => { setCode(e.target.value); setCodeError(false) }}
668
+ 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"
669
+ placeholder="Digite o código"
670
+ />
671
+ {codeError ? (
672
+ <p role="alert" className="mt-2 text-sm text-destructive">Código inválido. Para testar, use 0000.</p>
673
+ ) : null}
674
+ <button
675
+ type="submit"
676
+ 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"
677
+ >
678
+ Confirmar
679
+ </button>
680
+ </form>
681
+ <div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
682
+ <span>Reenviar código via WhatsApp em 28 segundos.</span>
683
+ <button type="button" onClick={() => setContactStep('phone')} className="font-medium text-primary hover:underline">
684
+ Voltar
685
+ </button>
686
+ </div>
687
+ </div>
688
+ </div>
689
+ ) : null}
690
+
691
+ {/* STEP: payment */}
692
+ {step === 'payment' && service ? (() => {
693
+ const feePct = payment?.reservationFeePercent ?? 100
694
+ const fee = Math.round(service.price * feePct) / 100
695
+ const remaining = Math.round((service.price - fee) * 100) / 100
696
+ return (
697
+ <div>
698
+ {paymentStep === 'choose' ? (
699
+ <div>
700
+ {/* Order summary */}
701
+ <div className="rounded-2xl border border-border p-4">
702
+ <div className="flex items-center justify-between font-semibold text-foreground">
703
+ <span>{service.name}</span>
704
+ <span className="tabular-nums">{fmt.price.format(service.price)}</span>
705
+ </div>
706
+ <div className="mt-3 space-y-2 text-sm">
707
+ <div className="flex items-center justify-between text-foreground">
708
+ <span className="flex items-center gap-2">
709
+ <span className="flex h-4 w-4 items-center justify-center rounded bg-primary text-primary-foreground"><Check className="h-3 w-3" /></span>
710
+ Taxa de reserva <span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">{feePct}%</span>
711
+ </span>
712
+ <span className="tabular-nums">{fmt.price.format(fee)}</span>
713
+ </div>
714
+ <div className="flex items-center justify-between text-muted-foreground">
715
+ <span className="flex items-center gap-2">
716
+ <span className="h-4 w-4 rounded border border-border" /> Valor restante
717
+ </span>
718
+ <span className="tabular-nums">{remaining <= 0 ? 'Grátis' : fmt.price.format(remaining)}</span>
719
+ </div>
720
+ <button type="button" className="flex items-center gap-1 text-sm font-medium text-primary hover:underline">
721
+ <Plus className="h-3.5 w-3.5" /> Adicionar cupom
722
+ </button>
723
+ </div>
724
+ <div className="mt-3 flex items-center justify-between border-t border-dashed border-border pt-3 font-semibold text-foreground">
725
+ <span>Valor total</span>
726
+ <span className="tabular-nums">{fmt.price.format(service.price)}</span>
727
+ </div>
728
+ </div>
729
+
730
+ {/* Method */}
731
+ <div className="mt-6 flex items-center justify-between">
732
+ <h3 className="font-heading text-lg font-bold text-foreground">Escolha como pagar</h3>
733
+ <span className="flex items-center gap-1 text-xs text-muted-foreground"><ShieldCheck className="h-3.5 w-3.5 text-primary" /> Pagamento seguro</span>
734
+ </div>
735
+ <div className="mt-3 rounded-2xl border-2 border-primary bg-accent/20 p-4">
736
+ <p className="flex items-center gap-2 font-semibold text-foreground">
737
+ <span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground"><Check className="h-3.5 w-3.5" /></span>
738
+ Pix
739
+ </p>
740
+ <div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-3">
741
+ {[
742
+ { icon: Check, t: 'Finalize o agendamento clicando em "Pagar com Pix".' },
743
+ { icon: QrCode, t: 'Mostraremos um QR Code e o Pix copia-e-cola.' },
744
+ { icon: CalendarCheck, t: 'Após o pagamento, seu agendamento é reservado.' },
745
+ ].map((s, i) => (
746
+ <div key={i} className="text-sm text-muted-foreground">
747
+ <span className="mb-2 flex h-8 w-8 items-center justify-center rounded-lg bg-accent text-primary"><s.icon className="h-4 w-4" /></span>
748
+ <span className="font-semibold text-foreground">{i + 1}.</span> {s.t}
749
+ </div>
750
+ ))}
751
+ </div>
752
+ </div>
753
+
754
+ {error ? <p role="alert" className="mt-3 text-sm text-destructive">Não foi possível iniciar o pagamento. Tente novamente.</p> : null}
755
+ <button
756
+ type="button"
757
+ disabled={submitting || charging}
758
+ onClick={() => void payWithPix(fee, service.id, service.name)}
759
+ className="mt-6 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"
760
+ >
761
+ {submitting || charging ? <Loader2 className="h-4 w-4 animate-spin" /> : <CreditCard className="h-4 w-4" />}
762
+ Pagar {fmt.price.format(fee)} com Pix
763
+ </button>
764
+ </div>
765
+ ) : (
766
+ /* Pix QR — aguardando pagamento */
767
+ <div>
768
+ <div className="rounded-xl border border-amber-300/60 bg-amber-50 p-4 dark:border-amber-400/30 dark:bg-amber-950/30">
769
+ <p className="flex items-center gap-2 text-sm font-semibold text-foreground">
770
+ <Loader2 className="h-4 w-4 animate-spin text-primary" /> Realize o pagamento para finalizar
771
+ </p>
772
+ <p className="mt-1 text-xs text-muted-foreground">
773
+ Este código Pix é válido por somente 5 minutos. Não feche esta página até receber a confirmação do pagamento.
774
+ </p>
775
+ </div>
776
+ <h3 className="mt-6 font-heading text-lg font-bold text-foreground">Escaneie ou copie este código para pagar</h3>
777
+ <ol className="mt-2 space-y-1 text-sm text-muted-foreground">
778
+ <li>1. Acesse o app do seu banco, pagamentos ou Internet Banking.</li>
779
+ <li>2. Escolha pagar via Pix.</li>
780
+ <li>3. Escaneie o QR Code ou use o Pix copia-e-cola:</li>
781
+ </ol>
782
+ <div className="mt-4 w-fit rounded-xl border border-border bg-white p-3">
783
+ <QrPlaceholder data={charge?.pixQrCode ?? PIX_PAYLOAD} />
784
+ </div>
785
+ <div className="mt-4 flex max-w-md items-center gap-2">
786
+ <code className="flex-1 truncate rounded-lg border border-border bg-muted px-3 py-2 text-xs text-muted-foreground">{charge?.pixCopyPaste ?? PIX_PAYLOAD}</code>
787
+ <button
788
+ type="button"
789
+ onClick={() => {
790
+ const payload = charge?.pixCopyPaste ?? PIX_PAYLOAD
791
+ try { void navigator.clipboard?.writeText(payload); setCopied(true) } catch { setCopied(true) }
792
+ }}
793
+ className="inline-flex shrink-0 items-center gap-1.5 rounded-lg bg-foreground px-3 py-2 text-xs font-medium text-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
794
+ >
795
+ {copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />} {copied ? 'Copiado' : 'Copiar'}
796
+ </button>
797
+ </div>
798
+ {/* POC shortcut — the mock provider also auto-confirms via polling after a few seconds. */}
799
+ <button
800
+ type="button"
801
+ onClick={() => setStep('success')}
802
+ className="mt-6 w-full rounded-xl border border-primary py-3 font-semibold text-primary transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
803
+ >
804
+ Já paguei — confirmar (simulação)
805
+ </button>
806
+ </div>
807
+ )}
808
+ </div>
809
+ )
810
+ })() : null}
811
+
812
+ {/* STEP: success */}
813
+ {step === 'success' ? (
814
+ <div className="py-8 text-center">
815
+ <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-accent text-primary">
816
+ <Check className="h-8 w-8" />
817
+ </div>
818
+ <h3 className="font-heading text-2xl font-bold text-foreground">{labels.successTitle}</h3>
819
+ <p className="mx-auto mt-2 max-w-sm text-muted-foreground">{labels.successBody}</p>
820
+ {service && slotStart ? (
821
+ <div className="mx-auto mt-6 max-w-sm rounded-2xl border border-border bg-muted/50 p-4 text-left">
822
+ <p className="font-semibold text-foreground">{service.name}</p>
823
+ <p className="mt-1 flex items-center gap-1.5 text-sm tabular-nums text-muted-foreground">
824
+ <Calendar className="h-4 w-4 text-primary" /> {formatSlotRange(slotStart, service.durationMinutes, fmt)}
825
+ </p>
826
+ <p className="mt-1 flex items-center gap-1.5 text-sm text-muted-foreground">
827
+ <User className="h-4 w-4 text-primary" /> {professionalName}
828
+ </p>
829
+ </div>
830
+ ) : null}
831
+ </div>
832
+ ) : null}
833
+ </div>
834
+
835
+ {/* RIGHT — progress + summary rail */}
836
+ <aside className="hidden lg:block">
837
+ <div className="sticky top-24 rounded-2xl border border-border bg-card p-5 shadow-sm">
838
+ <p className="mb-5 font-heading text-sm font-bold uppercase tracking-wide text-muted-foreground">
839
+ {railTitle}
840
+ </p>
841
+ <ol className="space-y-6">
842
+ <StepRail n={1} label="Serviço" state={railState.service}>
843
+ {service ? (
844
+ <div className="rounded-xl border border-border bg-background p-3">
845
+ <p className="truncate text-sm font-semibold text-foreground">{service.name}</p>
846
+ <p className="mt-0.5 flex items-center justify-between text-xs text-muted-foreground">
847
+ <span>Online · {service.durationMinutes} min</span>
848
+ <span className="font-semibold tabular-nums text-primary">{fmt.price.format(service.price)}</span>
849
+ </p>
850
+ </div>
851
+ ) : null}
852
+ </StepRail>
853
+
854
+ <StepRail n={2} label="Profissional" state={railState.professional}>
855
+ <div className="flex items-center gap-2">
856
+ <span className="flex h-7 w-7 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
857
+ {professionalName.charAt(0)}
858
+ </span>
859
+ <span className="text-sm text-foreground">{professionalName}</span>
860
+ </div>
861
+ </StepRail>
862
+
863
+ <StepRail n={3} label="Data e horário" state={railState.datetime}>
864
+ {slotStart && service ? (
865
+ <p className="text-sm tabular-nums text-foreground">
866
+ {formatSlotRange(slotStart, service.durationMinutes, fmt)}
867
+ </p>
868
+ ) : null}
869
+ </StepRail>
870
+
871
+ <StepRail n={4} label="Informações pessoais" state={railState.contact}>
872
+ {(contact.name || contact.phone) && (step === 'payment' || step === 'success' || contactStep === 'details') ? (
873
+ <p className="text-sm text-foreground">
874
+ {contact.name || 'Contato'}{contact.phone ? ` · ${country.dial} ${contact.phone}` : ''}
875
+ </p>
876
+ ) : null}
877
+ </StepRail>
878
+
879
+ {payment ? (
880
+ <StepRail n={5} label="Pagamento" state={railState.payment}>
881
+ {step === 'payment' || step === 'success' ? (
882
+ <p className="flex items-center gap-1.5 text-sm text-foreground"><CreditCard className="h-3.5 w-3.5 text-primary" /> Pix à vista</p>
883
+ ) : null}
884
+ </StepRail>
885
+ ) : null}
886
+ </ol>
887
+ </div>
888
+ </aside>
889
+ </div>
890
+ </div>
891
+ )
892
+ }