@fayz-ai/plugin-agenda 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/chunk-DRZF6CV2.js +421 -0
  2. package/dist/chunk-DRZF6CV2.js.map +1 -0
  3. package/dist/chunk-LFUELUIT.cjs +423 -0
  4. package/dist/chunk-LFUELUIT.cjs.map +1 -0
  5. package/dist/data/mock.d.ts.map +1 -1
  6. package/dist/index.cjs +300 -421
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +297 -420
  10. package/dist/index.js.map +1 -1
  11. package/dist/migrations/index.d.ts +2 -0
  12. package/dist/migrations/index.d.ts.map +1 -0
  13. package/dist/public/BookingWidget.d.ts +11 -0
  14. package/dist/public/BookingWidget.d.ts.map +1 -0
  15. package/dist/public/components/BrandHeader.d.ts +6 -0
  16. package/dist/public/components/BrandHeader.d.ts.map +1 -0
  17. package/dist/public/components/ServiceCard.d.ts +5 -0
  18. package/dist/public/components/ServiceCard.d.ts.map +1 -0
  19. package/dist/public/components/SidebarExtras.d.ts +4 -0
  20. package/dist/public/components/SidebarExtras.d.ts.map +1 -0
  21. package/dist/public/components/StepRailItem.d.ts +4 -0
  22. package/dist/public/components/StepRailItem.d.ts.map +1 -0
  23. package/dist/public/components/SuccessPanel.d.ts +4 -0
  24. package/dist/public/components/SuccessPanel.d.ts.map +1 -0
  25. package/dist/public/components/SummaryRail.d.ts +27 -0
  26. package/dist/public/components/SummaryRail.d.ts.map +1 -0
  27. package/dist/public/components/defaults.d.ts +3 -0
  28. package/dist/public/components/defaults.d.ts.map +1 -0
  29. package/dist/public/context.d.ts +51 -0
  30. package/dist/public/context.d.ts.map +1 -0
  31. package/dist/public/createPublicBookingPlugin.d.ts +62 -0
  32. package/dist/public/createPublicBookingPlugin.d.ts.map +1 -0
  33. package/dist/public/data.d.ts +37 -0
  34. package/dist/public/data.d.ts.map +1 -0
  35. package/dist/public/data.supabase.d.ts +7 -0
  36. package/dist/public/data.supabase.d.ts.map +1 -0
  37. package/dist/public/format.d.ts +18 -0
  38. package/dist/public/format.d.ts.map +1 -0
  39. package/dist/public/hooks.d.ts +34 -0
  40. package/dist/public/hooks.d.ts.map +1 -0
  41. package/dist/public/index.cjs +1423 -0
  42. package/dist/public/index.cjs.map +1 -0
  43. package/dist/public/index.d.ts +20 -0
  44. package/dist/public/index.d.ts.map +1 -0
  45. package/dist/public/index.js +1402 -0
  46. package/dist/public/index.js.map +1 -0
  47. package/dist/public/steps/ContactStep.d.ts +30 -0
  48. package/dist/public/steps/ContactStep.d.ts.map +1 -0
  49. package/dist/public/steps/DateTimeStep.d.ts +23 -0
  50. package/dist/public/steps/DateTimeStep.d.ts.map +1 -0
  51. package/dist/public/steps/OverviewStep.d.ts +16 -0
  52. package/dist/public/steps/OverviewStep.d.ts.map +1 -0
  53. package/dist/public/steps/PaymentStep.d.ts +20 -0
  54. package/dist/public/steps/PaymentStep.d.ts.map +1 -0
  55. package/dist/public/types.d.ts +185 -0
  56. package/dist/public/types.d.ts.map +1 -0
  57. package/package.json +20 -14
  58. package/src/data/mock.ts +4 -1
  59. package/src/index.ts +14 -0
  60. package/src/integrations/google-calendar/integration.test.ts +1 -1
  61. package/src/migrations/001_public_booking.sql +278 -0
  62. package/src/migrations/index.ts +282 -0
  63. package/src/public/BookingWidget.tsx +315 -0
  64. package/src/public/components/BrandHeader.tsx +27 -0
  65. package/src/public/components/ServiceCard.tsx +39 -0
  66. package/src/public/components/SidebarExtras.tsx +30 -0
  67. package/src/public/components/StepRailItem.tsx +27 -0
  68. package/src/public/components/SuccessPanel.tsx +27 -0
  69. package/src/public/components/SummaryRail.tsx +90 -0
  70. package/src/public/components/defaults.ts +14 -0
  71. package/src/public/context.tsx +100 -0
  72. package/src/public/createPublicBookingPlugin.ts +265 -0
  73. package/src/public/data.supabase.ts +94 -0
  74. package/src/public/data.ts +99 -0
  75. package/src/public/format.ts +61 -0
  76. package/src/public/hooks.ts +105 -0
  77. package/src/public/index.ts +68 -0
  78. package/src/public/steps/ContactStep.tsx +179 -0
  79. package/src/public/steps/DateTimeStep.tsx +138 -0
  80. package/src/public/steps/OverviewStep.tsx +81 -0
  81. package/src/public/steps/PaymentStep.tsx +180 -0
  82. package/src/public/types.ts +229 -0
@@ -0,0 +1,30 @@
1
+ import { User } from 'lucide-react'
2
+ import type { SidebarExtrasProps } from '../types'
3
+
4
+ /** Default overview sidebar: primary CTA + brand highlights + professional. */
5
+ export function DefaultSidebarExtras({ professionalName, brand, onStartBooking, labels }: SidebarExtrasProps) {
6
+ return (
7
+ <div className="rounded-2xl border border-border bg-card p-5 shadow-sm">
8
+ <button
9
+ type="button"
10
+ onClick={onStartBooking}
11
+ 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"
12
+ >
13
+ {labels.bookNowCta}
14
+ </button>
15
+ <div className="mt-5 space-y-2 border-t border-border pt-4 text-xs text-muted-foreground">
16
+ {brand.highlights.map((h, i) => {
17
+ const Icon = h.icon ?? User
18
+ return (
19
+ <p key={i} className="flex items-center gap-2">
20
+ <Icon className="h-3.5 w-3.5 text-primary" /> {h.text}
21
+ </p>
22
+ )
23
+ })}
24
+ <p className="flex items-center gap-2">
25
+ <User className="h-3.5 w-3.5 text-primary" /> {professionalName}
26
+ </p>
27
+ </div>
28
+ </div>
29
+ )
30
+ }
@@ -0,0 +1,27 @@
1
+ import { Check } from 'lucide-react'
2
+ import type { StepRailItemProps } from '../types'
3
+
4
+ /** One row in the wizard's right-hand progress rail. */
5
+ export function DefaultStepRailItem({ n, label, state, children }: StepRailItemProps) {
6
+ return (
7
+ <li className="relative pl-11">
8
+ <span aria-hidden className="absolute left-[15px] top-8 bottom-[-18px] w-px bg-border last:hidden" />
9
+ <span
10
+ className={[
11
+ 'absolute left-0 top-0 flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold transition-colors',
12
+ state === 'done'
13
+ ? 'bg-primary text-primary-foreground'
14
+ : state === 'active'
15
+ ? 'bg-accent text-primary ring-2 ring-primary'
16
+ : 'bg-muted text-muted-foreground',
17
+ ].join(' ')}
18
+ >
19
+ {state === 'done' ? <Check className="h-4 w-4" /> : n}
20
+ </span>
21
+ <p className={`pt-1 text-sm font-semibold ${state === 'todo' ? 'text-muted-foreground' : 'text-foreground'}`}>
22
+ {label}
23
+ </p>
24
+ {children ? <div className="mt-2">{children}</div> : null}
25
+ </li>
26
+ )
27
+ }
@@ -0,0 +1,27 @@
1
+ import { Calendar, Check, User } from 'lucide-react'
2
+ import { formatSlotRange } from '../format'
3
+ import type { SuccessPanelProps } from '../types'
4
+
5
+ /** Default confirmation panel (final step). */
6
+ export function DefaultSuccessPanel({ service, slotStart, professionalName, labels, fmt }: SuccessPanelProps) {
7
+ return (
8
+ <div className="py-8 text-center">
9
+ <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-accent text-primary">
10
+ <Check className="h-8 w-8" />
11
+ </div>
12
+ <h3 className="font-heading text-2xl font-bold text-foreground">{labels.successTitle}</h3>
13
+ <p className="mx-auto mt-2 max-w-sm text-muted-foreground">{labels.successBody}</p>
14
+ {service && slotStart ? (
15
+ <div className="mx-auto mt-6 max-w-sm rounded-2xl border border-border bg-muted/50 p-4 text-left">
16
+ <p className="font-semibold text-foreground">{service.name}</p>
17
+ <p className="mt-1 flex items-center gap-1.5 text-sm tabular-nums text-muted-foreground">
18
+ <Calendar className="h-4 w-4 text-primary" /> {formatSlotRange(slotStart, service.durationMinutes, fmt)}
19
+ </p>
20
+ <p className="mt-1 flex items-center gap-1.5 text-sm text-muted-foreground">
21
+ <User className="h-4 w-4 text-primary" /> {professionalName}
22
+ </p>
23
+ </div>
24
+ ) : null}
25
+ </div>
26
+ )
27
+ }
@@ -0,0 +1,90 @@
1
+ import { CreditCard } from 'lucide-react'
2
+ import { formatSlotRange } from '../format'
3
+ import type {
4
+ BookingComponents,
5
+ BookingFormatters,
6
+ PublicBookingLabels,
7
+ PublicService,
8
+ ResolvedPayment,
9
+ StepState,
10
+ } from '../types'
11
+
12
+ export interface RailState {
13
+ service: StepState
14
+ professional: StepState
15
+ datetime: StepState
16
+ contact: StepState
17
+ payment: StepState
18
+ }
19
+
20
+ interface SummaryRailProps {
21
+ title: string
22
+ railState: RailState
23
+ service: PublicService | null
24
+ professionalName: string
25
+ slotStart: string | null
26
+ /** Display string for the verified contact ("Nome · +55 (11) …"), or null. */
27
+ contactSummary: string | null
28
+ payment: ResolvedPayment | null
29
+ paymentReached: boolean
30
+ labels: PublicBookingLabels
31
+ fmt: BookingFormatters
32
+ components: BookingComponents
33
+ serviceMeta: string | null
34
+ }
35
+
36
+ /** Right-hand progress + summary rail (internal; rows via components.StepRailItem). */
37
+ export function SummaryRail({
38
+ title, railState, service, professionalName, slotStart, contactSummary,
39
+ payment, paymentReached, labels, fmt, components: C, serviceMeta,
40
+ }: SummaryRailProps) {
41
+ return (
42
+ <div className="sticky top-24 rounded-2xl border border-border bg-card p-5 shadow-sm">
43
+ <p className="mb-5 font-heading text-sm font-bold uppercase tracking-wide text-muted-foreground">{title}</p>
44
+ <ol className="space-y-6">
45
+ <C.StepRailItem n={1} label={labels.railService} state={railState.service}>
46
+ {service ? (
47
+ <div className="rounded-xl border border-border bg-background p-3">
48
+ <p className="truncate text-sm font-semibold text-foreground">{service.name}</p>
49
+ <p className="mt-0.5 flex items-center justify-between text-xs text-muted-foreground">
50
+ <span>{serviceMeta ? `${serviceMeta} · ` : ''}{service.durationMinutes} min</span>
51
+ <span className="font-semibold tabular-nums text-primary">{fmt.price.format(service.price)}</span>
52
+ </p>
53
+ </div>
54
+ ) : null}
55
+ </C.StepRailItem>
56
+
57
+ <C.StepRailItem n={2} label={labels.railProfessional} state={railState.professional}>
58
+ <div className="flex items-center gap-2">
59
+ <span className="flex h-7 w-7 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
60
+ {professionalName.charAt(0)}
61
+ </span>
62
+ <span className="text-sm text-foreground">{professionalName}</span>
63
+ </div>
64
+ </C.StepRailItem>
65
+
66
+ <C.StepRailItem n={3} label={labels.railDatetime} state={railState.datetime}>
67
+ {slotStart && service ? (
68
+ <p className="text-sm tabular-nums text-foreground">
69
+ {formatSlotRange(slotStart, service.durationMinutes, fmt)}
70
+ </p>
71
+ ) : null}
72
+ </C.StepRailItem>
73
+
74
+ <C.StepRailItem n={4} label={labels.railContact} state={railState.contact}>
75
+ {contactSummary ? <p className="text-sm text-foreground">{contactSummary}</p> : null}
76
+ </C.StepRailItem>
77
+
78
+ {payment ? (
79
+ <C.StepRailItem n={5} label={labels.railPayment} state={railState.payment}>
80
+ {paymentReached ? (
81
+ <p className="flex items-center gap-1.5 text-sm text-foreground">
82
+ <CreditCard className="h-3.5 w-3.5 text-primary" /> Pix à vista
83
+ </p>
84
+ ) : null}
85
+ </C.StepRailItem>
86
+ ) : null}
87
+ </ol>
88
+ </div>
89
+ )
90
+ }
@@ -0,0 +1,14 @@
1
+ import type { BookingComponents } from '../types'
2
+ import { DefaultBrandHeader } from './BrandHeader'
3
+ import { DefaultServiceCard } from './ServiceCard'
4
+ import { DefaultSidebarExtras } from './SidebarExtras'
5
+ import { DefaultStepRailItem } from './StepRailItem'
6
+ import { DefaultSuccessPanel } from './SuccessPanel'
7
+
8
+ export const DEFAULT_BOOKING_COMPONENTS: BookingComponents = {
9
+ BrandHeader: DefaultBrandHeader,
10
+ ServiceCard: DefaultServiceCard,
11
+ SidebarExtras: DefaultSidebarExtras,
12
+ StepRailItem: DefaultStepRailItem,
13
+ SuccessPanel: DefaultSuccessPanel,
14
+ }
@@ -0,0 +1,100 @@
1
+ import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
2
+ import type { PaymentProvider } from '@fayz-ai/core'
3
+ import type { PublicBookingDataProvider } from './data'
4
+ import type {
5
+ BookingComponents,
6
+ BookingWindow,
7
+ PublicBookingLabels,
8
+ PublicService,
9
+ ResolvedBookingBrand,
10
+ ResolvedPayment,
11
+ WorkingHours,
12
+ } from './types'
13
+
14
+ /** Emitted when the customer's phone is verified / identity captured, for the
15
+ * host to bridge to auth (booking stays decoupled — data-only, optional). */
16
+ export type OnIdentityVerified = (identity: { name: string; phone: string; email?: string }) => void
17
+
18
+ export interface PublicBookingContextValue {
19
+ provider: PublicBookingDataProvider
20
+ /** The professional/resource bookings are created against; null → any. */
21
+ professionalId: string | null
22
+ professionalName: string
23
+ /** Resolved catalog: static `services` option, or fetched from the provider. */
24
+ services: PublicService[]
25
+ servicesLoading: boolean
26
+ /** Business opening hours, shown on the overview "Sobre" section. */
27
+ businessHours: WorkingHours
28
+ /** Payment step config, or null when the flow ends at confirmation. */
29
+ payment: ResolvedPayment | null
30
+ /** Injected charge provider (Pix). When absent, the payment step self-mocks. */
31
+ paymentProvider: PaymentProvider | null
32
+ /** Fired on phone-verify / details submit so the host can sign the user in. */
33
+ onIdentityVerified: OnIdentityVerified | null
34
+ window: BookingWindow
35
+ labels: PublicBookingLabels
36
+ /** Brand personalization (resolved with defaults). */
37
+ brand: ResolvedBookingBrand
38
+ /** Host-overridable subcomponents (resolved with defaults). */
39
+ components: BookingComponents
40
+ /** Currency code for price formatting (e.g. 'BRL'). */
41
+ currency: string
42
+ locale: string
43
+ }
44
+
45
+ const PublicBookingContext = createContext<PublicBookingContextValue | null>(null)
46
+
47
+ export interface BookingProviderProps {
48
+ /** Everything but the (possibly async) service catalog. */
49
+ base: Omit<PublicBookingContextValue, 'services' | 'servicesLoading'>
50
+ /** Static catalog from options — wins over the provider fetch when present. */
51
+ staticServices: PublicService[] | null
52
+ children: ReactNode
53
+ }
54
+
55
+ /**
56
+ * Owns the service-catalog resolution: static services pass through untouched;
57
+ * otherwise the provider is queried once (e.g. Supabase v_public_services).
58
+ */
59
+ export function BookingProvider({ base, staticServices, children }: BookingProviderProps) {
60
+ const [fetched, setFetched] = useState<PublicService[] | null>(null)
61
+ const [loading, setLoading] = useState(staticServices === null)
62
+
63
+ useEffect(() => {
64
+ if (staticServices !== null) return
65
+ let active = true
66
+ setLoading(true)
67
+ base.provider
68
+ .getServices()
69
+ .then((result) => {
70
+ if (active) setFetched(result)
71
+ })
72
+ .catch((err) => {
73
+ // Surface in dev; the overview renders its empty state.
74
+ console.error('[plugin-agenda/public] failed to load services:', err)
75
+ if (active) setFetched([])
76
+ })
77
+ .finally(() => {
78
+ if (active) setLoading(false)
79
+ })
80
+ return () => {
81
+ active = false
82
+ }
83
+ // base.provider is stable (created once by the plugin factory).
84
+ }, [base.provider, staticServices])
85
+
86
+ const value = useMemo<PublicBookingContextValue>(
87
+ () => ({ ...base, services: staticServices ?? fetched ?? [], servicesLoading: loading }),
88
+ [base, staticServices, fetched, loading],
89
+ )
90
+
91
+ return <PublicBookingContext.Provider value={value}>{children}</PublicBookingContext.Provider>
92
+ }
93
+
94
+ export function useBooking(): PublicBookingContextValue {
95
+ const ctx = useContext(PublicBookingContext)
96
+ if (!ctx) {
97
+ throw new Error('[plugin-agenda] useBooking must be used within <BookingProvider>.')
98
+ }
99
+ return ctx
100
+ }
@@ -0,0 +1,265 @@
1
+ import { createElement, type FC, type ReactNode } from 'react'
2
+ import { CalendarCheck, ShieldCheck, Video } from 'lucide-react'
3
+ import type { PluginManifest, PluginScope, VerticalId, PaymentProvider } from '@fayz-ai/core'
4
+ import { createSafeDataProvider } from '@fayz-ai/core'
5
+ import type { Professional, Schedule } from '../types'
6
+ import type { MockAgendaSeed } from '../data/mock'
7
+ import { createMockPublicBookingProvider, type PublicBookingDataProvider } from './data'
8
+ import { createSupabasePublicBookingProvider } from './data.supabase'
9
+ import { BookingProvider, type PublicBookingContextValue, type OnIdentityVerified } from './context'
10
+ import { BookingWidget } from './BookingWidget'
11
+ import { DEFAULT_BOOKING_COMPONENTS } from './components/defaults'
12
+ import type {
13
+ BookingBrand,
14
+ BookingComponents,
15
+ BookingWindow,
16
+ PaymentConfig,
17
+ PublicBookingLabels,
18
+ PublicService,
19
+ ResolvedBookingBrand,
20
+ ResolvedPayment,
21
+ WorkingHours,
22
+ } from './types'
23
+
24
+ export interface PublicBookingOptions {
25
+ /** Mount path for the public booking route. Default '/agendar'. */
26
+ basePath?: string
27
+ /**
28
+ * Tenant this public page books against. REQUIRED when a Supabase client is
29
+ * configured (setGlobalSupabaseClient) — it scopes services/slots/bookings.
30
+ */
31
+ tenantId?: string
32
+ /**
33
+ * The single professional/resource bookings target. With Supabase this must
34
+ * be the staff person UUID; omit → any active professional (RPC unions
35
+ * schedules across staff).
36
+ */
37
+ professional?: { id?: string; name?: string }
38
+ /**
39
+ * Bookable services. Optional: when omitted, the widget fetches the catalog
40
+ * from the provider (Supabase v_public_services). Static wins when provided.
41
+ */
42
+ services?: PublicService[]
43
+ /** Working hours used to seed mock availability + the overview "Sobre" card. */
44
+ workingHours?: WorkingHours
45
+ /** Booking-window bounds for the date/slot picker. */
46
+ window?: Partial<BookingWindow>
47
+ /** Every user-visible string (all defaulted, pt-BR). */
48
+ labels?: Partial<PublicBookingLabels>
49
+ /** Brand personalization: name/logo/tagline/icons/badges/highlights. */
50
+ brand?: BookingBrand
51
+ /** Swap whole subcomponents when structure must change (defaults exported). */
52
+ components?: Partial<BookingComponents>
53
+ /** Adds a final Pagamento (Pix) step. Omit/disable to end the flow at confirmation. */
54
+ payment?: PaymentConfig
55
+ /** Charge provider (from a payments plugin) used by the Pagamento step. Optional. */
56
+ paymentProvider?: PaymentProvider
57
+ /** Host bridge: fired when the customer's phone is verified / details submitted. */
58
+ onIdentityVerified?: OnIdentityVerified
59
+ currency?: string
60
+ locale?: string
61
+ /** Inject a custom provider (overrides the mock/Supabase safe resolver). */
62
+ dataProvider?: PublicBookingDataProvider
63
+ /** Override the full mock seed (advanced). */
64
+ seed?: MockAgendaSeed
65
+ scope?: PluginScope
66
+ verticalId?: VerticalId
67
+ }
68
+
69
+ export interface PublicBookingPlugin {
70
+ manifest: PluginManifest
71
+ Provider: FC<{ children: ReactNode }>
72
+ dataProvider: PublicBookingDataProvider
73
+ }
74
+
75
+ const DEFAULT_LABELS: PublicBookingLabels = {
76
+ title: 'Agende sua consulta',
77
+ serviceStep: 'Escolha o serviço',
78
+ dateStep: 'Escolha a data',
79
+ contactStep: 'Seus dados',
80
+ confirmCta: 'Confirmar agendamento',
81
+ successTitle: 'Agendamento confirmado!',
82
+ successBody: 'Você receberá os detalhes em breve. Até logo!',
83
+ // overview
84
+ servicesHeading: 'Serviços',
85
+ aboutHeading: 'Sobre',
86
+ openingHoursHeading: 'Horário de funcionamento',
87
+ closedLabel: 'Fechado',
88
+ bookNowCta: 'Agendar agora',
89
+ // datetime
90
+ timezoneNote: 'Fuso: Horário de Brasília',
91
+ slotsHeading: 'Horários disponíveis',
92
+ noSlots: 'Nenhum horário disponível neste dia. Tente outra data.',
93
+ continueCta: 'Continuar',
94
+ backToServices: 'Serviços',
95
+ back: 'Voltar',
96
+ prevWeek: 'Semana anterior',
97
+ nextWeek: 'Próxima semana',
98
+ // contact
99
+ contactIntro: 'Preencha as informações abaixo para que seu agendamento seja identificado.',
100
+ phoneLabel: 'Celular',
101
+ phoneHint:
102
+ '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.',
103
+ sendCodeCta: 'Enviar código via WhatsApp',
104
+ codeTitle: 'Insira o código',
105
+ codeBody: 'Enviamos um código via WhatsApp para você confirmar seu número.',
106
+ codeInvalid: 'Código inválido. Para testar, use 0000.',
107
+ codeConfirmCta: 'Confirmar',
108
+ codeResendHint: 'Reenviar código via WhatsApp em 28 segundos.',
109
+ phoneVerified: 'Celular verificado',
110
+ changePhone: 'Trocar telefone',
111
+ nameLabel: 'Nome completo *',
112
+ namePlaceholder: 'Seu nome',
113
+ emailLabel: 'E-mail',
114
+ emailPlaceholder: 'voce@email.com',
115
+ emailHint: 'Enviaremos uma confirmação e um lembrete antes do agendamento.',
116
+ notesLabel: 'Observação',
117
+ notesPlaceholder: 'Use esse espaço para deixar uma observação sobre este agendamento',
118
+ saveAndContinueCta: 'Salvar e continuar',
119
+ submitError: 'Não foi possível concluir o agendamento. Tente novamente.',
120
+ noChargeNote: 'Pagamento via Pix · Sem cobrança agora',
121
+ // right rail
122
+ railTitle: 'Passos para agendar',
123
+ railWaitingPayment: 'Aguardando pagamento',
124
+ railService: 'Serviço',
125
+ railProfessional: 'Profissional',
126
+ railDatetime: 'Data e horário',
127
+ railContact: 'Informações pessoais',
128
+ railPayment: 'Pagamento',
129
+ }
130
+
131
+ const DEFAULT_WINDOW: BookingWindow = { minAdvanceHours: 2, maxAdvanceDays: 21, slotInterval: 30 }
132
+
133
+ const DEFAULT_HOURS: WorkingHours = { daysOfWeek: [1, 2, 3, 4, 5, 6], start: '09:00', end: '18:00' }
134
+
135
+ function resolveBrand(brand: BookingBrand | undefined, labels: PublicBookingLabels): ResolvedBookingBrand {
136
+ const name = brand?.name ?? labels.title
137
+ return {
138
+ name,
139
+ initials: brand?.initials ?? name.charAt(0).toUpperCase(),
140
+ logoUrl: brand?.logoUrl,
141
+ tagline: brand?.tagline === undefined ? 'Agendamento online seguro' : brand.tagline,
142
+ serviceIcon: brand?.serviceIcon ?? Video,
143
+ serviceMeta: brand?.serviceMeta === undefined ? 'Online' : brand.serviceMeta,
144
+ featuredBadge: brand?.featuredBadge === undefined ? 'Mais procurado' : brand.featuredBadge,
145
+ highlights: brand?.highlights ?? [
146
+ { icon: CalendarCheck, text: 'Confirmação imediata' },
147
+ { icon: ShieldCheck, text: 'Agendamento seguro' },
148
+ ],
149
+ }
150
+ }
151
+
152
+ let scheduleId = 0
153
+
154
+ function buildSchedules(professionalId: string, hours: WorkingHours): Schedule[] {
155
+ return hours.daysOfWeek.map((day) => ({
156
+ id: `sched-${professionalId}-${day}-${scheduleId++}`,
157
+ tenantId: 'public',
158
+ kind: 'working_hours',
159
+ assigneeId: professionalId,
160
+ locationId: null,
161
+ dayOfWeek: day,
162
+ specificDate: null,
163
+ startsAt: hours.start,
164
+ endsAt: hours.end,
165
+ isActive: true,
166
+ metadata: {},
167
+ }))
168
+ }
169
+
170
+ export function createPublicBookingPlugin(options: PublicBookingOptions): PublicBookingPlugin {
171
+ const basePath = options.basePath ?? '/agendar'
172
+ // Mock needs a concrete seed professional; Supabase can target any (null).
173
+ const mockProfessionalId = options.professional?.id ?? 'pro-1'
174
+ const professionalId = options.professional?.id ?? null
175
+ const professionalName = options.professional?.name ?? 'Especialista'
176
+ const window: BookingWindow = { ...DEFAULT_WINDOW, ...options.window }
177
+ const labels: PublicBookingLabels = { ...DEFAULT_LABELS, ...options.labels }
178
+ const brand = resolveBrand(options.brand, labels)
179
+ const components: BookingComponents = { ...DEFAULT_BOOKING_COMPONENTS, ...options.components }
180
+ const currency = options.currency ?? 'BRL'
181
+ const locale = options.locale ?? 'pt-BR'
182
+ const hours = options.workingHours ?? DEFAULT_HOURS
183
+ const staticServices = options.services ?? null
184
+ const payment: ResolvedPayment | null = options.payment?.enabled
185
+ ? {
186
+ reservationFeePercent: options.payment.reservationFeePercent ?? 100,
187
+ method: options.payment.method ?? 'pix',
188
+ }
189
+ : null
190
+
191
+ const professional: Professional = {
192
+ id: mockProfessionalId,
193
+ name: professionalName,
194
+ avatarUrl: null,
195
+ locationId: null,
196
+ locationName: null,
197
+ isActive: true,
198
+ }
199
+ const mockSeed: MockAgendaSeed = options.seed ?? {
200
+ professionals: [professional],
201
+ schedules: buildSchedules(mockProfessionalId, hours),
202
+ bookings: [],
203
+ }
204
+
205
+ const provider =
206
+ options.dataProvider ??
207
+ createSafeDataProvider<PublicBookingDataProvider>(
208
+ () => {
209
+ if (!options.tenantId) {
210
+ throw new Error(
211
+ '[plugin-agenda/public] `tenantId` is required when a Supabase client is configured (setGlobalSupabaseClient).',
212
+ )
213
+ }
214
+ return createSupabasePublicBookingProvider({ tenantId: options.tenantId })
215
+ },
216
+ () =>
217
+ createMockPublicBookingProvider({
218
+ seed: mockSeed,
219
+ services: staticServices ?? [],
220
+ professionalId: mockProfessionalId,
221
+ }),
222
+ )
223
+
224
+ const base: Omit<PublicBookingContextValue, 'services' | 'servicesLoading'> = {
225
+ provider,
226
+ professionalId,
227
+ professionalName,
228
+ businessHours: hours,
229
+ payment,
230
+ paymentProvider: options.paymentProvider ?? null,
231
+ onIdentityVerified: options.onIdentityVerified ?? null,
232
+ window,
233
+ labels,
234
+ brand,
235
+ components,
236
+ currency,
237
+ locale,
238
+ }
239
+ const Provider: FC<{ children: ReactNode }> = ({ children }) =>
240
+ createElement(BookingProvider, { base, staticServices, children })
241
+ Provider.displayName = 'PublicBookingProvider'
242
+
243
+ const BookingScreen: FC<unknown> = () =>
244
+ createElement('section', { className: 'py-16 bg-background' },
245
+ createElement('div', { className: 'container mx-auto px-6' },
246
+ createElement(BookingWidget)))
247
+ BookingScreen.displayName = 'BookingScreen'
248
+
249
+ const manifest: PluginManifest = {
250
+ id: 'booking',
251
+ name: 'Agendamento',
252
+ icon: 'CalendarCheck',
253
+ version: '0.3.0',
254
+ scope: options.scope ?? 'universal',
255
+ verticalId: options.verticalId,
256
+ scaffolds: ['website', 'landing_page'],
257
+ defaultEnabled: true,
258
+ dependencies: [],
259
+ navigation: [],
260
+ routes: [{ path: basePath, component: BookingScreen, guard: 'public' }],
261
+ widgets: [],
262
+ }
263
+
264
+ return { manifest, Provider, dataProvider: provider }
265
+ }
@@ -0,0 +1,94 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Supabase implementation of PublicBookingDataProvider — anon-safe.
3
+ //
4
+ // Public booking pages run with the publishable (anon) key and NO user session,
5
+ // so the canonical tenant RLS (`tenant_id IN user_tenant_ids()`) yields nothing.
6
+ // This provider therefore only touches objects designed for anon access:
7
+ // - public.v_public_services (column-whitelisted view of active services)
8
+ // - public.get_available_slots (SECURITY DEFINER RPC, mirrors the mock algo)
9
+ // - public.create_public_booking (SECURITY DEFINER RPC: validates the slot,
10
+ // upserts the customer person, writes order+booking+order_item atomically)
11
+ // Shipped by plugins/plugin-agenda/src/migrations/001_public_booking.sql.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ import { getSupabaseClientOptional } from '@fayz-ai/core'
15
+ import type { TimeSlot } from '../types'
16
+ import type { PublicService } from './types'
17
+ import type { PublicBookingDataProvider } from './data'
18
+
19
+ export interface SupabasePublicBookingOptions {
20
+ /** The tenant this public page books against (RPC arg — required). */
21
+ tenantId: string
22
+ }
23
+
24
+ interface SupabaseLikeClient {
25
+ from(table: string): any
26
+ rpc(fn: string, args: Record<string, unknown>): PromiseLike<{ data: unknown; error: { message: string } | null }>
27
+ }
28
+
29
+ function getClient(): SupabaseLikeClient {
30
+ const client = getSupabaseClientOptional() as SupabaseLikeClient | null
31
+ if (!client) throw new Error('[plugin-agenda/public] Supabase client not initialized')
32
+ return client
33
+ }
34
+
35
+ /** Digits-only phone (RPC normalizes again server-side; keep the dial code). */
36
+ function toPhoneDigits(phone: string | undefined): string {
37
+ return (phone ?? '').replace(/\D/g, '')
38
+ }
39
+
40
+ export function createSupabasePublicBookingProvider(options: SupabasePublicBookingOptions): PublicBookingDataProvider {
41
+ const { tenantId } = options
42
+
43
+ return {
44
+ async getServices(): Promise<PublicService[]> {
45
+ const { data, error } = await getClient()
46
+ .from('v_public_services')
47
+ .select('id, name, description, price, currency, duration_minutes, sort_order')
48
+ .eq('tenant_id', tenantId)
49
+ .order('sort_order', { ascending: true })
50
+ if (error) throw new Error(`[plugin-agenda/public] getServices: ${error.message}`)
51
+ return ((data ?? []) as Array<Record<string, unknown>>).map((row) => ({
52
+ id: String(row.id),
53
+ name: String(row.name),
54
+ description: (row.description as string | null) ?? undefined,
55
+ price: Number(row.price ?? 0),
56
+ durationMinutes: Number(row.duration_minutes ?? 30),
57
+ }))
58
+ },
59
+
60
+ async getAvailableSlots(query): Promise<TimeSlot[]> {
61
+ const { data, error } = await getClient().rpc('get_available_slots', {
62
+ p_tenant_id: tenantId,
63
+ p_assignee_id: query.assigneeId,
64
+ p_date: query.date,
65
+ p_duration_minutes: query.durationMinutes,
66
+ p_slot_interval: query.slotInterval,
67
+ })
68
+ if (error) throw new Error(`[plugin-agenda/public] getAvailableSlots: ${error.message}`)
69
+ return ((data ?? []) as Array<{ slot_start: string; slot_end: string }>).map((row) => ({
70
+ start: row.slot_start,
71
+ end: row.slot_end,
72
+ }))
73
+ },
74
+
75
+ async createBooking(input) {
76
+ const phone = toPhoneDigits(input.contact.phone)
77
+ const { data, error } = await getClient().rpc('create_public_booking', {
78
+ p_tenant_id: tenantId,
79
+ p_service_id: input.serviceId,
80
+ p_starts_at: input.startsAt,
81
+ p_name: input.contact.name.trim(),
82
+ p_phone: phone,
83
+ p_email: input.contact.email?.trim() || null,
84
+ p_notes: input.contact.notes?.trim() || null,
85
+ })
86
+ if (error) throw new Error(`[plugin-agenda/public] createBooking: ${error.message}`)
87
+ const row = (Array.isArray(data) ? data[0] : data) as
88
+ | { booking_id: string; starts_at: string; ends_at: string }
89
+ | undefined
90
+ if (!row) throw new Error('[plugin-agenda/public] createBooking: empty RPC response')
91
+ return { bookingId: row.booking_id, startsAt: row.starts_at, endsAt: row.ends_at }
92
+ },
93
+ }
94
+ }