@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
|
@@ -1,209 +1,32 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState
|
|
2
|
-
import {
|
|
3
|
-
|
|
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'
|
|
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'
|
|
7
4
|
import type { PixCharge } from '@fayz-ai/core'
|
|
8
5
|
import { useBooking } from './context'
|
|
9
6
|
import { useAvailableSlots, useCreateBooking } from './hooks'
|
|
10
|
-
import
|
|
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'
|
|
11
14
|
|
|
12
15
|
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
16
|
|
|
196
17
|
/**
|
|
197
18
|
* Customer-facing booking surface. Default view is an overview (services +
|
|
198
19
|
* opening hours + CTA) like Quaddro; picking a service — or landing with
|
|
199
|
-
* ?service=… — enters a stepped flow (date → time → contact →
|
|
20
|
+
* ?service=… — enters a stepped flow (date → time → contact → [payment])
|
|
200
21
|
* with a live progress + summary rail. Token-only, keyboard-accessible, 44px
|
|
201
|
-
* targets.
|
|
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).
|
|
202
25
|
*/
|
|
203
26
|
export function BookingWidget() {
|
|
204
27
|
const {
|
|
205
|
-
services, professionalName, businessHours, payment, paymentProvider,
|
|
206
|
-
labels, window: bookingWindow, locale, currency,
|
|
28
|
+
services, servicesLoading, professionalName, businessHours, payment, paymentProvider,
|
|
29
|
+
onIdentityVerified, labels, brand, components: C, window: bookingWindow, locale, currency,
|
|
207
30
|
} = useBooking()
|
|
208
31
|
const fmt = useFormatters(locale, currency)
|
|
209
32
|
|
|
@@ -219,7 +42,7 @@ export function BookingWidget() {
|
|
|
219
42
|
const [weekOffset, setWeekOffset] = useState(0)
|
|
220
43
|
const [date, setDate] = useState<string | null>(todayIso)
|
|
221
44
|
const [slotStart, setSlotStart] = useState<string | null>(null)
|
|
222
|
-
const [contact, setContact] = useState({ name: '', phone: '', email: '', notes: '' })
|
|
45
|
+
const [contact, setContact] = useState<ContactDraft>({ name: '', phone: '', email: '', notes: '' })
|
|
223
46
|
// Contact step is phone-verification-first (like Quaddro): phone → code → details.
|
|
224
47
|
const [contactStep, setContactStep] = useState<'phone' | 'code' | 'details'>('phone')
|
|
225
48
|
const [code, setCode] = useState('')
|
|
@@ -234,6 +57,15 @@ export function BookingWidget() {
|
|
|
234
57
|
const [charging, setCharging] = useState(false)
|
|
235
58
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
236
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
|
+
|
|
237
69
|
// Poll the charge status while on the Pix screen; settle → success.
|
|
238
70
|
useEffect(() => {
|
|
239
71
|
if (!(step === 'payment' && paymentStep === 'pix' && charge && paymentProvider)) return
|
|
@@ -259,7 +91,11 @@ export function BookingWidget() {
|
|
|
259
91
|
const bookableSlots = slots.filter((s) => new Date(s.start).getTime() >= minTime)
|
|
260
92
|
const service = services.find((s) => s.id === serviceId) ?? null
|
|
261
93
|
const maxWeekOffset = Math.floor(bookingWindow.maxAdvanceDays / 7)
|
|
262
|
-
|
|
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
|
+
}
|
|
263
99
|
|
|
264
100
|
function startBooking(id?: string) {
|
|
265
101
|
setServiceId(id ?? serviceId ?? services[0]?.id ?? null)
|
|
@@ -271,7 +107,7 @@ export function BookingWidget() {
|
|
|
271
107
|
async function handleConfirm() {
|
|
272
108
|
if (!serviceId || !slotStart || !contact.name.trim()) return
|
|
273
109
|
try {
|
|
274
|
-
await submit({ serviceId, startsAt: slotStart, contact })
|
|
110
|
+
await submit({ serviceId, startsAt: slotStart, contact: contactForSubmit() })
|
|
275
111
|
setStep('success')
|
|
276
112
|
} catch {
|
|
277
113
|
/* error surfaced inline */
|
|
@@ -305,14 +141,14 @@ export function BookingWidget() {
|
|
|
305
141
|
emitIdentity() // re-fire with the full name to enrich the account
|
|
306
142
|
if (payment) { setPaymentStep('choose'); setStep('payment') } else void handleConfirm()
|
|
307
143
|
}
|
|
308
|
-
async function payWithPix(fee: number
|
|
309
|
-
if (charging) return
|
|
144
|
+
async function payWithPix(fee: number) {
|
|
145
|
+
if (charging || !service || !slotStart) return
|
|
310
146
|
setCharging(true)
|
|
311
147
|
try {
|
|
312
|
-
await submit({ serviceId:
|
|
148
|
+
await submit({ serviceId: service.id, startsAt: slotStart, contact: contactForSubmit() })
|
|
313
149
|
if (paymentProvider) {
|
|
314
150
|
const c = await paymentProvider.createCharge({
|
|
315
|
-
amount: fee, currency, method: 'pix', description:
|
|
151
|
+
amount: fee, currency, method: 'pix', description: service.name,
|
|
316
152
|
customer: { name: contact.name, email: contact.email || undefined, phone: contact.phone || undefined },
|
|
317
153
|
})
|
|
318
154
|
setCharge(c)
|
|
@@ -324,82 +160,48 @@ export function BookingWidget() {
|
|
|
324
160
|
setCharging(false)
|
|
325
161
|
}
|
|
326
162
|
}
|
|
163
|
+
function copyPixPayload() {
|
|
164
|
+
const payload = charge?.pixCopyPaste ?? PIX_PAYLOAD
|
|
165
|
+
try { void navigator.clipboard?.writeText(payload); setCopied(true) } catch { setCopied(true) }
|
|
166
|
+
}
|
|
327
167
|
|
|
328
168
|
// ---- OVERVIEW (default view, no service chosen) ----
|
|
329
169
|
if (step === 'overview') {
|
|
330
170
|
return (
|
|
331
171
|
<div className="mx-auto max-w-4xl">
|
|
332
|
-
<BrandHeader />
|
|
333
|
-
<
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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>
|
|
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
|
+
/>
|
|
386
184
|
</div>
|
|
387
185
|
)
|
|
388
186
|
}
|
|
389
187
|
|
|
390
|
-
// ---- WIZARD (datetime → contact → success) ----
|
|
391
|
-
const railState = {
|
|
392
|
-
service: 'done'
|
|
393
|
-
professional: 'done'
|
|
188
|
+
// ---- WIZARD (datetime → contact → [payment] → success) ----
|
|
189
|
+
const railState: RailState = {
|
|
190
|
+
service: 'done',
|
|
191
|
+
professional: 'done',
|
|
394
192
|
datetime: (slotStart ? 'done' : step === 'datetime' ? 'active' : 'todo') as StepState,
|
|
395
193
|
contact: (step === 'payment' || step === 'success' ? 'done' : step === 'contact' ? 'active' : 'todo') as StepState,
|
|
396
194
|
payment: (step === 'success' ? 'done' : step === 'payment' ? 'active' : 'todo') as StepState,
|
|
397
195
|
}
|
|
398
|
-
const railTitle = step === 'payment' && paymentStep === 'pix' ?
|
|
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
|
|
399
201
|
|
|
400
202
|
return (
|
|
401
203
|
<div className="mx-auto max-w-4xl">
|
|
402
|
-
<BrandHeader />
|
|
204
|
+
<C.BrandHeader brand={brand} />
|
|
403
205
|
<div className="grid gap-6 lg:grid-cols-[1fr_18rem]">
|
|
404
206
|
{/* LEFT — step panel */}
|
|
405
207
|
<div className="rounded-2xl border border-border bg-card p-6 md:p-7 shadow-sm">
|
|
@@ -415,476 +217,97 @@ export function BookingWidget() {
|
|
|
415
217
|
onClick={() => setStep(step === 'payment' ? 'contact' : step === 'contact' ? 'datetime' : 'overview')}
|
|
416
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"
|
|
417
219
|
>
|
|
418
|
-
<ChevronLeft className="h-4 w-4" /> {step === 'datetime' ?
|
|
220
|
+
<ChevronLeft className="h-4 w-4" /> {step === 'datetime' ? labels.backToServices : labels.back}
|
|
419
221
|
</button>
|
|
420
222
|
) : null}
|
|
421
223
|
|
|
422
|
-
{/* STEP: datetime */}
|
|
423
224
|
{step === 'datetime' ? (
|
|
424
|
-
<
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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>
|
|
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
|
+
/>
|
|
531
241
|
) : null}
|
|
532
242
|
|
|
533
|
-
{/* STEP: contact — phone verification first, then details */}
|
|
534
243
|
{step === 'contact' ? (
|
|
535
|
-
<
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
{
|
|
539
|
-
{contactStep
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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>
|
|
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
|
+
/>
|
|
654
266
|
) : null}
|
|
655
267
|
|
|
656
|
-
{
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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>
|
|
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
|
+
/>
|
|
689
282
|
) : null}
|
|
690
283
|
|
|
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
284
|
{step === 'success' ? (
|
|
814
|
-
<
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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>
|
|
285
|
+
<C.SuccessPanel
|
|
286
|
+
service={service}
|
|
287
|
+
slotStart={slotStart}
|
|
288
|
+
professionalName={professionalName}
|
|
289
|
+
labels={labels}
|
|
290
|
+
fmt={fmt}
|
|
291
|
+
/>
|
|
832
292
|
) : null}
|
|
833
293
|
</div>
|
|
834
294
|
|
|
835
295
|
{/* RIGHT — progress + summary rail */}
|
|
836
296
|
<aside className="hidden lg:block">
|
|
837
|
-
<
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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>
|
|
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
|
+
/>
|
|
888
311
|
</aside>
|
|
889
312
|
</div>
|
|
890
313
|
</div>
|