@fayz-ai/plugin-agenda 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-DRZF6CV2.js +421 -0
- package/dist/chunk-DRZF6CV2.js.map +1 -0
- package/dist/chunk-LFUELUIT.cjs +423 -0
- package/dist/chunk-LFUELUIT.cjs.map +1 -0
- package/dist/index.cjs +741 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +741 -10
- package/dist/index.js.map +1 -1
- package/dist/migrations/index.d.ts +2 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/public/BookingWidget.d.ts +4 -2
- package/dist/public/BookingWidget.d.ts.map +1 -1
- package/dist/public/components/BrandHeader.d.ts +6 -0
- package/dist/public/components/BrandHeader.d.ts.map +1 -0
- package/dist/public/components/ServiceCard.d.ts +5 -0
- package/dist/public/components/ServiceCard.d.ts.map +1 -0
- package/dist/public/components/SidebarExtras.d.ts +4 -0
- package/dist/public/components/SidebarExtras.d.ts.map +1 -0
- package/dist/public/components/StepRailItem.d.ts +4 -0
- package/dist/public/components/StepRailItem.d.ts.map +1 -0
- package/dist/public/components/SuccessPanel.d.ts +4 -0
- package/dist/public/components/SuccessPanel.d.ts.map +1 -0
- package/dist/public/components/SummaryRail.d.ts +27 -0
- package/dist/public/components/SummaryRail.d.ts.map +1 -0
- package/dist/public/components/defaults.d.ts +3 -0
- package/dist/public/components/defaults.d.ts.map +1 -0
- package/dist/public/context.d.ts +22 -8
- package/dist/public/context.d.ts.map +1 -1
- package/dist/public/createPublicBookingPlugin.d.ts +27 -10
- package/dist/public/createPublicBookingPlugin.d.ts.map +1 -1
- package/dist/public/data.d.ts +37 -0
- package/dist/public/data.d.ts.map +1 -0
- package/dist/public/data.supabase.d.ts +7 -0
- package/dist/public/data.supabase.d.ts.map +1 -0
- package/dist/public/format.d.ts +18 -0
- package/dist/public/format.d.ts.map +1 -0
- package/dist/public/hooks.d.ts +13 -11
- package/dist/public/hooks.d.ts.map +1 -1
- package/dist/public/index.cjs +1016 -613
- package/dist/public/index.cjs.map +1 -1
- package/dist/public/index.d.ts +15 -4
- package/dist/public/index.d.ts.map +1 -1
- package/dist/public/index.js +1006 -616
- package/dist/public/index.js.map +1 -1
- package/dist/public/steps/ContactStep.d.ts +30 -0
- package/dist/public/steps/ContactStep.d.ts.map +1 -0
- package/dist/public/steps/DateTimeStep.d.ts +23 -0
- package/dist/public/steps/DateTimeStep.d.ts.map +1 -0
- package/dist/public/steps/OverviewStep.d.ts +16 -0
- package/dist/public/steps/OverviewStep.d.ts.map +1 -0
- package/dist/public/steps/PaymentStep.d.ts +20 -0
- package/dist/public/steps/PaymentStep.d.ts.map +1 -0
- package/dist/public/types.d.ts +134 -0
- package/dist/public/types.d.ts.map +1 -1
- package/package.json +12 -12
- package/src/index.ts +11 -0
- package/src/migrations/001_public_booking.sql +278 -0
- package/src/migrations/index.ts +282 -0
- package/src/public/BookingWidget.tsx +136 -713
- package/src/public/components/BrandHeader.tsx +27 -0
- package/src/public/components/ServiceCard.tsx +39 -0
- package/src/public/components/SidebarExtras.tsx +30 -0
- package/src/public/components/StepRailItem.tsx +27 -0
- package/src/public/components/SuccessPanel.tsx +27 -0
- package/src/public/components/SummaryRail.tsx +90 -0
- package/src/public/components/defaults.ts +14 -0
- package/src/public/context.tsx +64 -7
- package/src/public/createPublicBookingPlugin.ts +131 -21
- package/src/public/data.supabase.ts +94 -0
- package/src/public/data.ts +99 -0
- package/src/public/format.ts +61 -0
- package/src/public/hooks.ts +22 -34
- package/src/public/index.ts +57 -4
- package/src/public/steps/ContactStep.tsx +179 -0
- package/src/public/steps/DateTimeStep.tsx +138 -0
- package/src/public/steps/OverviewStep.tsx +81 -0
- package/src/public/steps/PaymentStep.tsx +180 -0
- package/src/public/types.ts +169 -3
- package/dist/chunk-GAJVIEXM.cjs +0 -870
- package/dist/chunk-GAJVIEXM.cjs.map +0 -1
- package/dist/chunk-U6VTLOHN.js +0 -867
- package/dist/chunk-U6VTLOHN.js.map +0 -1
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { ChevronLeft, ChevronRight, Clock } from 'lucide-react'
|
|
2
|
+
import type { TimeSlot } from '../../types'
|
|
3
|
+
import type { DayCell } from '../format'
|
|
4
|
+
import type { BookingFormatters, PublicBookingLabels, WorkingHours } from '../types'
|
|
5
|
+
|
|
6
|
+
interface DateTimeStepProps {
|
|
7
|
+
labels: PublicBookingLabels
|
|
8
|
+
fmt: BookingFormatters
|
|
9
|
+
week: DayCell[]
|
|
10
|
+
weekOffset: number
|
|
11
|
+
maxWeekOffset: number
|
|
12
|
+
onWeekOffset: (updater: (w: number) => number) => void
|
|
13
|
+
businessHours: WorkingHours
|
|
14
|
+
date: string | null
|
|
15
|
+
onSelectDate: (iso: string) => void
|
|
16
|
+
slots: TimeSlot[]
|
|
17
|
+
slotsLoading: boolean
|
|
18
|
+
slotStart: string | null
|
|
19
|
+
onSelectSlot: (startIso: string) => void
|
|
20
|
+
onContinue: () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Week strip + available-slot grid. */
|
|
24
|
+
export function DateTimeStep({
|
|
25
|
+
labels, fmt, week, weekOffset, maxWeekOffset, onWeekOffset, businessHours,
|
|
26
|
+
date, onSelectDate, slots, slotsLoading, slotStart, onSelectSlot, onContinue,
|
|
27
|
+
}: DateTimeStepProps) {
|
|
28
|
+
const openDays = new Set(businessHours.daysOfWeek)
|
|
29
|
+
return (
|
|
30
|
+
<div>
|
|
31
|
+
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
|
32
|
+
<h3 className="font-heading text-xl font-bold text-foreground">{labels.dateStep}</h3>
|
|
33
|
+
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
34
|
+
<Clock className="h-3.5 w-3.5" /> {labels.timezoneNote}
|
|
35
|
+
</span>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div className="mt-5 flex items-center justify-between">
|
|
39
|
+
<span className="text-sm font-semibold capitalize text-foreground">{fmt.month.format(week[0].date)}</span>
|
|
40
|
+
<div className="flex items-center gap-1">
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
disabled={weekOffset === 0}
|
|
44
|
+
onClick={() => onWeekOffset((w) => Math.max(0, w - 1))}
|
|
45
|
+
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"
|
|
46
|
+
aria-label={labels.prevWeek}
|
|
47
|
+
>
|
|
48
|
+
<ChevronLeft className="h-4 w-4" />
|
|
49
|
+
</button>
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
disabled={weekOffset >= maxWeekOffset}
|
|
53
|
+
onClick={() => onWeekOffset((w) => Math.min(maxWeekOffset, w + 1))}
|
|
54
|
+
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"
|
|
55
|
+
aria-label={labels.nextWeek}
|
|
56
|
+
>
|
|
57
|
+
<ChevronRight className="h-4 w-4" />
|
|
58
|
+
</button>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<div className="mt-3 grid grid-cols-7 gap-1.5">
|
|
63
|
+
{week.map((d) => {
|
|
64
|
+
const selected = date === d.iso
|
|
65
|
+
const closed = !openDays.has(d.date.getDay())
|
|
66
|
+
return (
|
|
67
|
+
<button
|
|
68
|
+
key={d.iso}
|
|
69
|
+
type="button"
|
|
70
|
+
disabled={closed}
|
|
71
|
+
onClick={() => onSelectDate(d.iso)}
|
|
72
|
+
className={[
|
|
73
|
+
'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',
|
|
74
|
+
closed
|
|
75
|
+
? 'cursor-not-allowed border-transparent text-muted-foreground/40'
|
|
76
|
+
: selected
|
|
77
|
+
? 'border-primary bg-primary text-primary-foreground'
|
|
78
|
+
: 'border-border text-foreground hover:border-primary/60 hover:bg-accent/40',
|
|
79
|
+
].join(' ')}
|
|
80
|
+
>
|
|
81
|
+
<span className="text-[11px] font-medium capitalize opacity-80">
|
|
82
|
+
{fmt.weekday.format(d.date).replace('.', '')}
|
|
83
|
+
</span>
|
|
84
|
+
<span className="text-sm font-bold tabular-nums">{fmt.dayNum.format(d.date)}</span>
|
|
85
|
+
</button>
|
|
86
|
+
)
|
|
87
|
+
})}
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
{date ? (
|
|
91
|
+
<div className="mt-6">
|
|
92
|
+
<p className="mb-2 text-sm font-semibold text-foreground">{labels.slotsHeading}</p>
|
|
93
|
+
{slotsLoading ? (
|
|
94
|
+
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
|
|
95
|
+
{Array.from({ length: 8 }).map((_, i) => (
|
|
96
|
+
<div key={i} className="h-10 animate-pulse rounded-lg bg-muted" />
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
) : slots.length === 0 ? (
|
|
100
|
+
<div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
|
101
|
+
{labels.noSlots}
|
|
102
|
+
</div>
|
|
103
|
+
) : (
|
|
104
|
+
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
|
|
105
|
+
{slots.map((s) => {
|
|
106
|
+
const selected = slotStart === s.start
|
|
107
|
+
return (
|
|
108
|
+
<button
|
|
109
|
+
key={s.start}
|
|
110
|
+
type="button"
|
|
111
|
+
onClick={() => onSelectSlot(s.start)}
|
|
112
|
+
className={[
|
|
113
|
+
'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',
|
|
114
|
+
selected
|
|
115
|
+
? 'border-primary bg-primary text-primary-foreground'
|
|
116
|
+
: 'border-border text-foreground hover:border-primary/60 hover:bg-accent/40',
|
|
117
|
+
].join(' ')}
|
|
118
|
+
>
|
|
119
|
+
{fmt.time.format(new Date(s.start))}
|
|
120
|
+
</button>
|
|
121
|
+
)
|
|
122
|
+
})}
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
) : null}
|
|
127
|
+
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
disabled={!slotStart}
|
|
131
|
+
onClick={onContinue}
|
|
132
|
+
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"
|
|
133
|
+
>
|
|
134
|
+
{labels.continueCta}
|
|
135
|
+
</button>
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Clock } from 'lucide-react'
|
|
2
|
+
import { DAY_NAMES, WEEK_ORDER } from '../format'
|
|
3
|
+
import type { BookingComponents, BookingFormatters, PublicBookingLabels, PublicService, ResolvedBookingBrand, WorkingHours } from '../types'
|
|
4
|
+
|
|
5
|
+
interface OverviewStepProps {
|
|
6
|
+
services: PublicService[]
|
|
7
|
+
servicesLoading: boolean
|
|
8
|
+
businessHours: WorkingHours
|
|
9
|
+
professionalName: string
|
|
10
|
+
labels: PublicBookingLabels
|
|
11
|
+
brand: ResolvedBookingBrand
|
|
12
|
+
fmt: BookingFormatters
|
|
13
|
+
components: BookingComponents
|
|
14
|
+
onStartBooking: (serviceId?: string) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Default view: services + opening hours + sidebar CTA (Quaddro-style). */
|
|
18
|
+
export function OverviewStep({
|
|
19
|
+
services, servicesLoading, businessHours, professionalName, labels, brand, fmt, components: C, onStartBooking,
|
|
20
|
+
}: OverviewStepProps) {
|
|
21
|
+
const openDays = new Set(businessHours.daysOfWeek)
|
|
22
|
+
return (
|
|
23
|
+
<div className="grid gap-6 lg:grid-cols-[1fr_18rem]">
|
|
24
|
+
<div className="space-y-6">
|
|
25
|
+
{/* Serviços */}
|
|
26
|
+
<section className="rounded-2xl border border-border bg-card p-6 shadow-sm">
|
|
27
|
+
<h3 className="mb-4 font-heading text-lg font-bold text-foreground">{labels.servicesHeading}</h3>
|
|
28
|
+
<div className="space-y-3">
|
|
29
|
+
{servicesLoading ? (
|
|
30
|
+
Array.from({ length: 2 }).map((_, i) => (
|
|
31
|
+
<div key={i} className="h-24 animate-pulse rounded-2xl bg-muted" />
|
|
32
|
+
))
|
|
33
|
+
) : (
|
|
34
|
+
services.map((s, i) => (
|
|
35
|
+
<C.ServiceCard
|
|
36
|
+
key={s.id}
|
|
37
|
+
service={s}
|
|
38
|
+
featured={i === 0}
|
|
39
|
+
onSelect={() => onStartBooking(s.id)}
|
|
40
|
+
fmt={fmt}
|
|
41
|
+
brand={brand}
|
|
42
|
+
/>
|
|
43
|
+
))
|
|
44
|
+
)}
|
|
45
|
+
</div>
|
|
46
|
+
</section>
|
|
47
|
+
|
|
48
|
+
{/* Sobre — horário de funcionamento */}
|
|
49
|
+
<section className="rounded-2xl border border-border bg-card p-6 shadow-sm">
|
|
50
|
+
<h3 className="font-heading text-lg font-bold text-foreground">{labels.aboutHeading}</h3>
|
|
51
|
+
<p className="mt-3 mb-2 flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
|
|
52
|
+
<Clock className="h-4 w-4" /> {labels.openingHoursHeading}
|
|
53
|
+
</p>
|
|
54
|
+
<ul className="divide-y divide-border border-l-2 border-primary/20 pl-4">
|
|
55
|
+
{WEEK_ORDER.map((day) => {
|
|
56
|
+
const open = openDays.has(day)
|
|
57
|
+
return (
|
|
58
|
+
<li key={day} className="flex items-center justify-between py-2 text-sm">
|
|
59
|
+
<span className="text-foreground">{DAY_NAMES[day]}</span>
|
|
60
|
+
<span className={open ? 'tabular-nums text-foreground' : 'text-muted-foreground'}>
|
|
61
|
+
{open ? `${businessHours.start} – ${businessHours.end}` : labels.closedLabel}
|
|
62
|
+
</span>
|
|
63
|
+
</li>
|
|
64
|
+
)
|
|
65
|
+
})}
|
|
66
|
+
</ul>
|
|
67
|
+
</section>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
{/* Sidebar CTA + highlights */}
|
|
71
|
+
<aside className="lg:sticky lg:top-24 lg:self-start">
|
|
72
|
+
<C.SidebarExtras
|
|
73
|
+
professionalName={professionalName}
|
|
74
|
+
brand={brand}
|
|
75
|
+
labels={labels}
|
|
76
|
+
onStartBooking={() => onStartBooking()}
|
|
77
|
+
/>
|
|
78
|
+
</aside>
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { CalendarCheck, Check, Copy, CreditCard, Loader2, Plus, QrCode, ShieldCheck } from 'lucide-react'
|
|
2
|
+
import type { PixCharge } from '@fayz-ai/core'
|
|
3
|
+
import type { BookingFormatters, PublicService, ResolvedPayment } from '../types'
|
|
4
|
+
|
|
5
|
+
// Static, realistic-looking Pix payload for the POC copia-e-cola fallback
|
|
6
|
+
// (used only when no payment provider is injected).
|
|
7
|
+
const PIX_PAYLOAD =
|
|
8
|
+
'00020126840014br.gov.bcb.pix2562qrcode.fayz.ai/pix/v2/9d8f7a6b-agendamento5204000053039865802BR5904FAYZ6009SAO PAULO62070503***6304AB12'
|
|
9
|
+
|
|
10
|
+
/** Deterministic QR-looking placeholder rendered from a payload (POC visual). */
|
|
11
|
+
function QrPlaceholder({ data }: { data: string }) {
|
|
12
|
+
const size = 25
|
|
13
|
+
// Seed the data-cell pattern from the payload so different charges look different.
|
|
14
|
+
let seed = 0
|
|
15
|
+
for (let i = 0; i < data.length; i++) seed = (seed * 31 + data.charCodeAt(i)) % 100000
|
|
16
|
+
const cells: { x: number; y: number }[] = []
|
|
17
|
+
const isFinder = (x: number, y: number) => {
|
|
18
|
+
const inBox = (bx: number, by: number) => x >= bx && x < bx + 7 && y >= by && y < by + 7
|
|
19
|
+
return inBox(0, 0) || inBox(size - 7, 0) || inBox(0, size - 7)
|
|
20
|
+
}
|
|
21
|
+
for (let y = 0; y < size; y++) {
|
|
22
|
+
for (let x = 0; x < size; x++) {
|
|
23
|
+
if (isFinder(x, y)) {
|
|
24
|
+
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 }
|
|
25
|
+
const lx = x - corner.bx
|
|
26
|
+
const ly = y - corner.by
|
|
27
|
+
const outer = lx === 0 || lx === 6 || ly === 0 || ly === 6
|
|
28
|
+
const inner = lx >= 2 && lx <= 4 && ly >= 2 && ly <= 4
|
|
29
|
+
if (outer || inner) cells.push({ x, y })
|
|
30
|
+
} else if ((x * 31 + y * 17 + x * y + seed) % 3 === 0) {
|
|
31
|
+
cells.push({ x, y })
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return (
|
|
36
|
+
<svg viewBox={`0 0 ${size} ${size}`} className="h-40 w-40" role="img" aria-label="QR Code Pix (demonstração)">
|
|
37
|
+
<rect width={size} height={size} fill="white" />
|
|
38
|
+
{cells.map((c, i) => (
|
|
39
|
+
<rect key={i} x={c.x} y={c.y} width={1} height={1} fill="currentColor" className="text-foreground" />
|
|
40
|
+
))}
|
|
41
|
+
</svg>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface PaymentStepProps {
|
|
46
|
+
service: PublicService
|
|
47
|
+
payment: ResolvedPayment
|
|
48
|
+
paymentStep: 'choose' | 'pix'
|
|
49
|
+
charge: PixCharge | null
|
|
50
|
+
busy: boolean
|
|
51
|
+
error: Error | null
|
|
52
|
+
copied: boolean
|
|
53
|
+
fmt: BookingFormatters
|
|
54
|
+
onPayWithPix: (fee: number) => void
|
|
55
|
+
onCopy: () => void
|
|
56
|
+
onSimulatePaid: () => void
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Payment step: order summary + Pix method → QR/copia-e-cola while polling. */
|
|
60
|
+
export function PaymentStep({
|
|
61
|
+
service, payment, paymentStep, charge, busy, error, copied, fmt, onPayWithPix, onCopy, onSimulatePaid,
|
|
62
|
+
}: PaymentStepProps) {
|
|
63
|
+
const feePct = payment.reservationFeePercent
|
|
64
|
+
const fee = Math.round(service.price * feePct) / 100
|
|
65
|
+
const remaining = Math.round((service.price - fee) * 100) / 100
|
|
66
|
+
|
|
67
|
+
if (paymentStep === 'choose') {
|
|
68
|
+
return (
|
|
69
|
+
<div>
|
|
70
|
+
{/* Order summary */}
|
|
71
|
+
<div className="rounded-2xl border border-border p-4">
|
|
72
|
+
<div className="flex items-center justify-between font-semibold text-foreground">
|
|
73
|
+
<span>{service.name}</span>
|
|
74
|
+
<span className="tabular-nums">{fmt.price.format(service.price)}</span>
|
|
75
|
+
</div>
|
|
76
|
+
<div className="mt-3 space-y-2 text-sm">
|
|
77
|
+
<div className="flex items-center justify-between text-foreground">
|
|
78
|
+
<span className="flex items-center gap-2">
|
|
79
|
+
<span className="flex h-4 w-4 items-center justify-center rounded bg-primary text-primary-foreground"><Check className="h-3 w-3" /></span>
|
|
80
|
+
Taxa de reserva <span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">{feePct}%</span>
|
|
81
|
+
</span>
|
|
82
|
+
<span className="tabular-nums">{fmt.price.format(fee)}</span>
|
|
83
|
+
</div>
|
|
84
|
+
<div className="flex items-center justify-between text-muted-foreground">
|
|
85
|
+
<span className="flex items-center gap-2">
|
|
86
|
+
<span className="h-4 w-4 rounded border border-border" /> Valor restante
|
|
87
|
+
</span>
|
|
88
|
+
<span className="tabular-nums">{remaining <= 0 ? 'Grátis' : fmt.price.format(remaining)}</span>
|
|
89
|
+
</div>
|
|
90
|
+
<button type="button" className="flex items-center gap-1 text-sm font-medium text-primary hover:underline">
|
|
91
|
+
<Plus className="h-3.5 w-3.5" /> Adicionar cupom
|
|
92
|
+
</button>
|
|
93
|
+
</div>
|
|
94
|
+
<div className="mt-3 flex items-center justify-between border-t border-dashed border-border pt-3 font-semibold text-foreground">
|
|
95
|
+
<span>Valor total</span>
|
|
96
|
+
<span className="tabular-nums">{fmt.price.format(service.price)}</span>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
{/* Method */}
|
|
101
|
+
<div className="mt-6 flex items-center justify-between">
|
|
102
|
+
<h3 className="font-heading text-lg font-bold text-foreground">Escolha como pagar</h3>
|
|
103
|
+
<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>
|
|
104
|
+
</div>
|
|
105
|
+
<div className="mt-3 rounded-2xl border-2 border-primary bg-accent/20 p-4">
|
|
106
|
+
<p className="flex items-center gap-2 font-semibold text-foreground">
|
|
107
|
+
<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>
|
|
108
|
+
Pix
|
|
109
|
+
</p>
|
|
110
|
+
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
|
111
|
+
{[
|
|
112
|
+
{ icon: Check, t: 'Finalize o agendamento clicando em "Pagar com Pix".' },
|
|
113
|
+
{ icon: QrCode, t: 'Mostraremos um QR Code e o Pix copia-e-cola.' },
|
|
114
|
+
{ icon: CalendarCheck, t: 'Após o pagamento, seu agendamento é reservado.' },
|
|
115
|
+
].map((s, i) => (
|
|
116
|
+
<div key={i} className="text-sm text-muted-foreground">
|
|
117
|
+
<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>
|
|
118
|
+
<span className="font-semibold text-foreground">{i + 1}.</span> {s.t}
|
|
119
|
+
</div>
|
|
120
|
+
))}
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
{error ? <p role="alert" className="mt-3 text-sm text-destructive">Não foi possível iniciar o pagamento. Tente novamente.</p> : null}
|
|
125
|
+
<button
|
|
126
|
+
type="button"
|
|
127
|
+
disabled={busy}
|
|
128
|
+
onClick={() => onPayWithPix(fee)}
|
|
129
|
+
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"
|
|
130
|
+
>
|
|
131
|
+
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <CreditCard className="h-4 w-4" />}
|
|
132
|
+
Pagar {fmt.price.format(fee)} com Pix
|
|
133
|
+
</button>
|
|
134
|
+
</div>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Pix QR — aguardando pagamento
|
|
139
|
+
return (
|
|
140
|
+
<div>
|
|
141
|
+
<div className="rounded-xl border border-amber-300/60 bg-amber-50 p-4 dark:border-amber-400/30 dark:bg-amber-950/30">
|
|
142
|
+
<p className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
|
143
|
+
<Loader2 className="h-4 w-4 animate-spin text-primary" /> Realize o pagamento para finalizar
|
|
144
|
+
</p>
|
|
145
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
146
|
+
Este código Pix é válido por somente 5 minutos. Não feche esta página até receber a confirmação do pagamento.
|
|
147
|
+
</p>
|
|
148
|
+
</div>
|
|
149
|
+
<h3 className="mt-6 font-heading text-lg font-bold text-foreground">Escaneie ou copie este código para pagar</h3>
|
|
150
|
+
<ol className="mt-2 space-y-1 text-sm text-muted-foreground">
|
|
151
|
+
<li>1. Acesse o app do seu banco, pagamentos ou Internet Banking.</li>
|
|
152
|
+
<li>2. Escolha pagar via Pix.</li>
|
|
153
|
+
<li>3. Escaneie o QR Code ou use o Pix copia-e-cola:</li>
|
|
154
|
+
</ol>
|
|
155
|
+
<div className="mt-4 w-fit rounded-xl border border-border bg-white p-3">
|
|
156
|
+
<QrPlaceholder data={charge?.pixQrCode ?? PIX_PAYLOAD} />
|
|
157
|
+
</div>
|
|
158
|
+
<div className="mt-4 flex max-w-md items-center gap-2">
|
|
159
|
+
<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>
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
onClick={onCopy}
|
|
163
|
+
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"
|
|
164
|
+
>
|
|
165
|
+
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />} {copied ? 'Copiado' : 'Copiar'}
|
|
166
|
+
</button>
|
|
167
|
+
</div>
|
|
168
|
+
{/* POC shortcut — the mock provider also auto-confirms via polling after a few seconds. */}
|
|
169
|
+
<button
|
|
170
|
+
type="button"
|
|
171
|
+
onClick={onSimulatePaid}
|
|
172
|
+
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"
|
|
173
|
+
>
|
|
174
|
+
Já paguei — confirmar (simulação)
|
|
175
|
+
</button>
|
|
176
|
+
</div>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export { PIX_PAYLOAD }
|
package/src/public/types.ts
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
2
|
// Public (customer-facing) booking types. This surface is deliberately decoupled
|
|
3
|
-
// from the admin calendar: it depends only on the
|
|
4
|
-
// so mock slots today and real availability (Supabase RPC /
|
|
5
|
-
// Calendar) later are a provider swap with no widget change.
|
|
3
|
+
// from the admin calendar: it depends only on the narrow PublicBookingDataProvider
|
|
4
|
+
// seam (see ./data), so mock slots today and real availability (Supabase RPC /
|
|
5
|
+
// ERP / Google Calendar) later are a provider swap with no widget change.
|
|
6
|
+
//
|
|
7
|
+
// Customization model: hosts personalize via three levers, cheapest first —
|
|
8
|
+
// 1. `labels` — every user-visible string (all defaulted, pt-BR)
|
|
9
|
+
// 2. `brand` — name/logo/tagline/icons/badges/highlights (text + assets)
|
|
10
|
+
// 3. `components` — swap whole subcomponents (BookingComponents) when the
|
|
11
|
+
// structure itself must change. Defaults are exported for composition.
|
|
6
12
|
// ---------------------------------------------------------------------------
|
|
7
13
|
|
|
14
|
+
import type { ComponentType, ReactNode } from 'react'
|
|
15
|
+
|
|
8
16
|
export interface PublicService {
|
|
9
17
|
id: string
|
|
10
18
|
name: string
|
|
@@ -52,6 +60,119 @@ export interface ResolvedPayment {
|
|
|
52
60
|
method: 'pix'
|
|
53
61
|
}
|
|
54
62
|
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Brand
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/** One icon+text bullet on the overview sidebar. */
|
|
68
|
+
export interface BookingHighlight {
|
|
69
|
+
icon?: ComponentType<{ className?: string }>
|
|
70
|
+
text: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Brand/text-level personalization — no components needed for these. */
|
|
74
|
+
export interface BookingBrand {
|
|
75
|
+
/** e.g. "HempDent · Consulta Canábica". */
|
|
76
|
+
name?: string
|
|
77
|
+
/** Avatar letter; defaults to name.charAt(0). Ignored when logoUrl is set. */
|
|
78
|
+
initials?: string
|
|
79
|
+
logoUrl?: string
|
|
80
|
+
/** Subheader line under the name. `null` hides the row. */
|
|
81
|
+
tagline?: string | null
|
|
82
|
+
/** Icon on service rows. Default: lucide Video. */
|
|
83
|
+
serviceIcon?: ComponentType<{ className?: string }>
|
|
84
|
+
/** Chip after the duration ("Online", "Presencial"…). `null` hides. */
|
|
85
|
+
serviceMeta?: string | null
|
|
86
|
+
/** Badge on the first service. `null` disables. Default 'Mais procurado'. */
|
|
87
|
+
featuredBadge?: string | null
|
|
88
|
+
/** Overview sidebar bullets (replaces the default generic ones). */
|
|
89
|
+
highlights?: BookingHighlight[]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ResolvedBookingBrand {
|
|
93
|
+
name: string
|
|
94
|
+
initials: string
|
|
95
|
+
logoUrl?: string
|
|
96
|
+
tagline: string | null
|
|
97
|
+
serviceIcon: ComponentType<{ className?: string }>
|
|
98
|
+
serviceMeta: string | null
|
|
99
|
+
featuredBadge: string | null
|
|
100
|
+
highlights: BookingHighlight[]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Formatters (shared Intl instances, see ./format)
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
export interface BookingFormatters {
|
|
108
|
+
price: Intl.NumberFormat
|
|
109
|
+
time: Intl.DateTimeFormat
|
|
110
|
+
weekday: Intl.DateTimeFormat
|
|
111
|
+
dayNum: Intl.DateTimeFormat
|
|
112
|
+
longDay: Intl.DateTimeFormat
|
|
113
|
+
dateShort: Intl.DateTimeFormat
|
|
114
|
+
month: Intl.DateTimeFormat
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Component override seam
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
export type StepState = 'done' | 'active' | 'todo'
|
|
122
|
+
|
|
123
|
+
export interface BrandHeaderProps {
|
|
124
|
+
brand: ResolvedBookingBrand
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface ServiceCardProps {
|
|
128
|
+
service: PublicService
|
|
129
|
+
featured: boolean
|
|
130
|
+
onSelect: () => void
|
|
131
|
+
fmt: BookingFormatters
|
|
132
|
+
brand: ResolvedBookingBrand
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface SidebarExtrasProps {
|
|
136
|
+
professionalName: string
|
|
137
|
+
brand: ResolvedBookingBrand
|
|
138
|
+
/** Renders the primary CTA — replacements can call it from their own button. */
|
|
139
|
+
onStartBooking: () => void
|
|
140
|
+
labels: PublicBookingLabels
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface SuccessPanelProps {
|
|
144
|
+
service: PublicService | null
|
|
145
|
+
/** ISO datetime of the booked slot. */
|
|
146
|
+
slotStart: string | null
|
|
147
|
+
professionalName: string
|
|
148
|
+
labels: PublicBookingLabels
|
|
149
|
+
fmt: BookingFormatters
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface StepRailItemProps {
|
|
153
|
+
n: number
|
|
154
|
+
label: string
|
|
155
|
+
state: StepState
|
|
156
|
+
children?: ReactNode
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Host-overridable subcomponents. Flow-critical parts (week strip, slot grid,
|
|
161
|
+
* contact forms, payment panel) stay internal — these are the brand-visible
|
|
162
|
+
* seams. Defaults are exported so hosts can wrap instead of rewriting.
|
|
163
|
+
*/
|
|
164
|
+
export interface BookingComponents {
|
|
165
|
+
BrandHeader: ComponentType<BrandHeaderProps>
|
|
166
|
+
ServiceCard: ComponentType<ServiceCardProps>
|
|
167
|
+
SidebarExtras: ComponentType<SidebarExtrasProps>
|
|
168
|
+
SuccessPanel: ComponentType<SuccessPanelProps>
|
|
169
|
+
StepRailItem: ComponentType<StepRailItemProps>
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Labels — every user-visible string in the flow (all defaulted, pt-BR)
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
55
176
|
export interface PublicBookingLabels {
|
|
56
177
|
title: string
|
|
57
178
|
serviceStep: string
|
|
@@ -60,4 +181,49 @@ export interface PublicBookingLabels {
|
|
|
60
181
|
confirmCta: string
|
|
61
182
|
successTitle: string
|
|
62
183
|
successBody: string
|
|
184
|
+
// overview
|
|
185
|
+
servicesHeading: string
|
|
186
|
+
aboutHeading: string
|
|
187
|
+
openingHoursHeading: string
|
|
188
|
+
closedLabel: string
|
|
189
|
+
bookNowCta: string
|
|
190
|
+
// datetime
|
|
191
|
+
timezoneNote: string
|
|
192
|
+
slotsHeading: string
|
|
193
|
+
noSlots: string
|
|
194
|
+
continueCta: string
|
|
195
|
+
backToServices: string
|
|
196
|
+
back: string
|
|
197
|
+
prevWeek: string
|
|
198
|
+
nextWeek: string
|
|
199
|
+
// contact
|
|
200
|
+
contactIntro: string
|
|
201
|
+
phoneLabel: string
|
|
202
|
+
phoneHint: string
|
|
203
|
+
sendCodeCta: string
|
|
204
|
+
codeTitle: string
|
|
205
|
+
codeBody: string
|
|
206
|
+
codeInvalid: string
|
|
207
|
+
codeConfirmCta: string
|
|
208
|
+
codeResendHint: string
|
|
209
|
+
phoneVerified: string
|
|
210
|
+
changePhone: string
|
|
211
|
+
nameLabel: string
|
|
212
|
+
namePlaceholder: string
|
|
213
|
+
emailLabel: string
|
|
214
|
+
emailPlaceholder: string
|
|
215
|
+
emailHint: string
|
|
216
|
+
notesLabel: string
|
|
217
|
+
notesPlaceholder: string
|
|
218
|
+
saveAndContinueCta: string
|
|
219
|
+
submitError: string
|
|
220
|
+
noChargeNote: string
|
|
221
|
+
// right rail
|
|
222
|
+
railTitle: string
|
|
223
|
+
railWaitingPayment: string
|
|
224
|
+
railService: string
|
|
225
|
+
railProfessional: string
|
|
226
|
+
railDatetime: string
|
|
227
|
+
railContact: string
|
|
228
|
+
railPayment: string
|
|
63
229
|
}
|