@fayz-ai/plugin-agenda 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,43 @@
1
+ import { createContext, useContext, type ReactNode } from 'react'
2
+ import type { PaymentProvider } from '@fayz-ai/core'
3
+ import type { AgendaDataProvider } from '../data/types'
4
+ import type { PublicService, BookingWindow, PublicBookingLabels, WorkingHours, ResolvedPayment } from './types'
5
+
6
+ /** Emitted when the customer's phone is verified / identity captured, for the
7
+ * host to bridge to auth (booking stays decoupled — data-only, optional). */
8
+ export type OnIdentityVerified = (identity: { name: string; phone: string; email?: string }) => void
9
+
10
+ export interface PublicBookingContextValue {
11
+ provider: AgendaDataProvider
12
+ /** The professional/resource bookings are created against (POC: single professional). */
13
+ professionalId: string
14
+ professionalName: string
15
+ services: PublicService[]
16
+ /** Business opening hours, shown on the overview "Sobre" section. */
17
+ businessHours: WorkingHours
18
+ /** Payment step config, or null when the flow ends at confirmation. */
19
+ payment: ResolvedPayment | null
20
+ /** Injected charge provider (Pix). When absent, the payment step self-mocks. */
21
+ paymentProvider: PaymentProvider | null
22
+ /** Fired on phone-verify / details submit so the host can sign the user in. */
23
+ onIdentityVerified: OnIdentityVerified | null
24
+ window: BookingWindow
25
+ labels: PublicBookingLabels
26
+ /** Currency code for price formatting (e.g. 'BRL'). */
27
+ currency: string
28
+ locale: string
29
+ }
30
+
31
+ const PublicBookingContext = createContext<PublicBookingContextValue | null>(null)
32
+
33
+ export function BookingProvider({ value, children }: { value: PublicBookingContextValue; children: ReactNode }) {
34
+ return <PublicBookingContext.Provider value={value}>{children}</PublicBookingContext.Provider>
35
+ }
36
+
37
+ export function useBooking(): PublicBookingContextValue {
38
+ const ctx = useContext(PublicBookingContext)
39
+ if (!ctx) {
40
+ throw new Error('[plugin-agenda] useBooking must be used within <BookingProvider>.')
41
+ }
42
+ return ctx
43
+ }
@@ -0,0 +1,155 @@
1
+ import { createElement, type FC, type ReactNode } from 'react'
2
+ import type { PluginManifest, PluginScope, VerticalId, PaymentProvider } from '@fayz-ai/core'
3
+ import { createSafeDataProvider } from '@fayz-ai/core'
4
+ import type { AgendaDataProvider } from '../data/types'
5
+ import type { Professional, Schedule } from '../types'
6
+ import { createMockAgendaProvider, type MockAgendaSeed } from '../data/mock'
7
+ import { createSupabaseAgendaProvider } from '../data/supabase'
8
+ import { BookingProvider, type PublicBookingContextValue, type OnIdentityVerified } from './context'
9
+ import { BookingWidget } from './BookingWidget'
10
+ import type { PublicService, BookingWindow, PublicBookingLabels, WorkingHours, PaymentConfig, ResolvedPayment } from './types'
11
+
12
+ export interface PublicBookingOptions {
13
+ /** Mount path for the public booking route. Default '/agendar'. */
14
+ basePath?: string
15
+ /** The single professional/resource bookings target (POC). */
16
+ professional?: { id?: string; name?: string }
17
+ /** Bookable services shown in the widget. */
18
+ services: PublicService[]
19
+ /** Working hours used to seed the mock provider's availability. */
20
+ workingHours?: WorkingHours
21
+ /** Booking-window bounds for the date/slot picker. */
22
+ window?: Partial<BookingWindow>
23
+ labels?: Partial<PublicBookingLabels>
24
+ /** Adds a final Pagamento (Pix) step. Omit/disable to end the flow at confirmation. */
25
+ payment?: PaymentConfig
26
+ /** Charge provider (from a payments plugin) used by the Pagamento step. Optional. */
27
+ paymentProvider?: PaymentProvider
28
+ /** Host bridge: fired when the customer's phone is verified / details submitted. */
29
+ onIdentityVerified?: OnIdentityVerified
30
+ currency?: string
31
+ locale?: string
32
+ /** Inject a custom provider (real availability). Overrides the safe resolver. */
33
+ dataProvider?: AgendaDataProvider
34
+ /** Override the full mock seed (advanced). */
35
+ seed?: MockAgendaSeed
36
+ scope?: PluginScope
37
+ verticalId?: VerticalId
38
+ }
39
+
40
+ export interface PublicBookingPlugin {
41
+ manifest: PluginManifest
42
+ Provider: FC<{ children: ReactNode }>
43
+ dataProvider: AgendaDataProvider
44
+ }
45
+
46
+ const DEFAULT_LABELS: PublicBookingLabels = {
47
+ title: 'Agende sua consulta',
48
+ serviceStep: 'Escolha o serviço',
49
+ dateStep: 'Escolha a data',
50
+ contactStep: 'Seus dados',
51
+ confirmCta: 'Confirmar agendamento',
52
+ successTitle: 'Agendamento confirmado!',
53
+ successBody: 'Você receberá os detalhes em breve. Até logo!',
54
+ }
55
+
56
+ const DEFAULT_WINDOW: BookingWindow = { minAdvanceHours: 2, maxAdvanceDays: 21, slotInterval: 30 }
57
+
58
+ const DEFAULT_HOURS: WorkingHours = { daysOfWeek: [1, 2, 3, 4, 5, 6], start: '09:00', end: '18:00' }
59
+
60
+ let scheduleId = 0
61
+
62
+ function buildSchedules(professionalId: string, hours: WorkingHours): Schedule[] {
63
+ return hours.daysOfWeek.map((day) => ({
64
+ id: `sched-${professionalId}-${day}-${scheduleId++}`,
65
+ tenantId: 'public',
66
+ kind: 'working_hours',
67
+ assigneeId: professionalId,
68
+ locationId: null,
69
+ dayOfWeek: day,
70
+ specificDate: null,
71
+ startsAt: hours.start,
72
+ endsAt: hours.end,
73
+ isActive: true,
74
+ metadata: {},
75
+ }))
76
+ }
77
+
78
+ export function createPublicBookingPlugin(options: PublicBookingOptions): PublicBookingPlugin {
79
+ const basePath = options.basePath ?? '/agendar'
80
+ const professionalId = options.professional?.id ?? 'dr-hiago'
81
+ const professionalName = options.professional?.name ?? 'Especialista'
82
+ const window: BookingWindow = { ...DEFAULT_WINDOW, ...options.window }
83
+ const labels: PublicBookingLabels = { ...DEFAULT_LABELS, ...options.labels }
84
+ const currency = options.currency ?? 'BRL'
85
+ const locale = options.locale ?? 'pt-BR'
86
+ const hours = options.workingHours ?? DEFAULT_HOURS
87
+ const payment: ResolvedPayment | null = options.payment?.enabled
88
+ ? {
89
+ reservationFeePercent: options.payment.reservationFeePercent ?? 100,
90
+ method: options.payment.method ?? 'pix',
91
+ }
92
+ : null
93
+
94
+ const professional: Professional = {
95
+ id: professionalId,
96
+ name: professionalName,
97
+ avatarUrl: null,
98
+ locationId: null,
99
+ locationName: null,
100
+ isActive: true,
101
+ }
102
+ const mockSeed: MockAgendaSeed = options.seed ?? {
103
+ professionals: [professional],
104
+ schedules: buildSchedules(professionalId, hours),
105
+ bookings: [],
106
+ }
107
+
108
+ const provider =
109
+ options.dataProvider ??
110
+ createSafeDataProvider(
111
+ () => createSupabaseAgendaProvider(),
112
+ () => createMockAgendaProvider({ seed: mockSeed }),
113
+ )
114
+
115
+ const value: PublicBookingContextValue = {
116
+ provider,
117
+ professionalId,
118
+ professionalName,
119
+ services: options.services,
120
+ businessHours: hours,
121
+ payment,
122
+ paymentProvider: options.paymentProvider ?? null,
123
+ onIdentityVerified: options.onIdentityVerified ?? null,
124
+ window,
125
+ labels,
126
+ currency,
127
+ locale,
128
+ }
129
+ const Provider: FC<{ children: ReactNode }> = ({ children }) =>
130
+ createElement(BookingProvider, { value, children })
131
+ Provider.displayName = 'PublicBookingProvider'
132
+
133
+ const BookingScreen: FC<unknown> = () =>
134
+ createElement('section', { className: 'py-16 bg-background' },
135
+ createElement('div', { className: 'container mx-auto px-6' },
136
+ createElement(BookingWidget)))
137
+ BookingScreen.displayName = 'BookingScreen'
138
+
139
+ const manifest: PluginManifest = {
140
+ id: 'booking',
141
+ name: 'Agendamento',
142
+ icon: 'CalendarCheck',
143
+ version: '0.1.0',
144
+ scope: options.scope ?? 'universal',
145
+ verticalId: options.verticalId,
146
+ scaffolds: ['website', 'landing_page'],
147
+ defaultEnabled: true,
148
+ dependencies: [],
149
+ navigation: [],
150
+ routes: [{ path: basePath, component: BookingScreen, guard: 'public' }],
151
+ widgets: [],
152
+ }
153
+
154
+ return { manifest, Provider, dataProvider: provider }
155
+ }
@@ -0,0 +1,117 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { useBooking } from './context'
3
+ import type { PublicService, ContactInfo } from './types'
4
+ import type { TimeSlot } from '../types'
5
+
6
+ /** The configured bookable services (from context; POC-static, ERP-driven later). */
7
+ export function useServices(): PublicService[] {
8
+ return useBooking().services
9
+ }
10
+
11
+ export interface UseAvailableSlotsResult {
12
+ slots: TimeSlot[]
13
+ loading: boolean
14
+ error: Error | null
15
+ }
16
+
17
+ /**
18
+ * Available time slots for a service on a given date. Delegates to
19
+ * provider.getAvailableSlots — mock now, real availability later, same call.
20
+ */
21
+ export function useAvailableSlots(params: { serviceId: string | null; date: string | null }): UseAvailableSlotsResult {
22
+ const { provider, professionalId, services, window } = useBooking()
23
+ const [slots, setSlots] = useState<TimeSlot[]>([])
24
+ const [loading, setLoading] = useState(false)
25
+ const [error, setError] = useState<Error | null>(null)
26
+
27
+ const { serviceId, date } = params
28
+ const service = services.find((s) => s.id === serviceId)
29
+ const durationMinutes = service?.durationMinutes ?? 0
30
+
31
+ useEffect(() => {
32
+ let active = true
33
+ if (!serviceId || !date || !durationMinutes) {
34
+ setSlots([])
35
+ return
36
+ }
37
+ setLoading(true)
38
+ provider
39
+ .getAvailableSlots({ assigneeId: professionalId, date, durationMinutes, slotInterval: window.slotInterval })
40
+ .then((result) => {
41
+ if (!active) return
42
+ setSlots(result)
43
+ setError(null)
44
+ })
45
+ .catch((err) => {
46
+ if (!active) return
47
+ setError(err instanceof Error ? err : new Error(String(err)))
48
+ })
49
+ .finally(() => {
50
+ if (active) setLoading(false)
51
+ })
52
+ return () => {
53
+ active = false
54
+ }
55
+ }, [provider, professionalId, serviceId, date, durationMinutes, window.slotInterval])
56
+
57
+ return { slots, loading, error }
58
+ }
59
+
60
+ export interface CreatePublicBookingInput {
61
+ serviceId: string
62
+ /** ISO datetime of the chosen slot start. */
63
+ startsAt: string
64
+ contact: ContactInfo
65
+ }
66
+
67
+ export interface UseCreateBookingResult {
68
+ submit: (input: CreatePublicBookingInput) => Promise<void>
69
+ submitting: boolean
70
+ error: Error | null
71
+ done: boolean
72
+ }
73
+
74
+ /** Create a booking via the provider. POC: mock append; real persistence later. */
75
+ export function useCreateBooking(): UseCreateBookingResult {
76
+ const { provider, professionalId, services } = useBooking()
77
+ const [submitting, setSubmitting] = useState(false)
78
+ const [error, setError] = useState<Error | null>(null)
79
+ const [done, setDone] = useState(false)
80
+
81
+ async function submit(input: CreatePublicBookingInput): Promise<void> {
82
+ const service = services.find((s) => s.id === input.serviceId)
83
+ if (!service) throw new Error('Serviço inválido')
84
+ setSubmitting(true)
85
+ setError(null)
86
+ try {
87
+ await provider.createBooking({
88
+ kind: 'appointment',
89
+ // POC: the customer is captured as the booking title; a real flow would
90
+ // upsert a client/person record and pass its id here.
91
+ clientId: 'public-guest',
92
+ professionalId,
93
+ startsAt: input.startsAt,
94
+ title: input.contact.name,
95
+ notes: [input.contact.phone, input.contact.email, input.contact.notes].filter(Boolean).join(' · ') || undefined,
96
+ services: [
97
+ {
98
+ serviceId: service.id,
99
+ name: service.name,
100
+ durationMinutes: service.durationMinutes,
101
+ price: service.price,
102
+ assigneeId: professionalId,
103
+ },
104
+ ],
105
+ })
106
+ setDone(true)
107
+ } catch (err) {
108
+ const e = err instanceof Error ? err : new Error(String(err))
109
+ setError(e)
110
+ throw e
111
+ } finally {
112
+ setSubmitting(false)
113
+ }
114
+ }
115
+
116
+ return { submit, submitting, error, done }
117
+ }
@@ -0,0 +1,15 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @fayz-ai/plugin-agenda/public — customer-facing booking surface.
3
+ //
4
+ // Separate entry from the admin plugin so a website host imports only the lean
5
+ // booking widget + data seam (no @fayz-ai/saas / calendar admin graph).
6
+ // ---------------------------------------------------------------------------
7
+
8
+ export { createPublicBookingPlugin } from './createPublicBookingPlugin'
9
+ export type { PublicBookingOptions, PublicBookingPlugin } from './createPublicBookingPlugin'
10
+ export { BookingWidget } from './BookingWidget'
11
+ export { BookingProvider, useBooking } from './context'
12
+ export type { PublicBookingContextValue } from './context'
13
+ export { useServices, useAvailableSlots, useCreateBooking } from './hooks'
14
+ export type { UseAvailableSlotsResult, UseCreateBookingResult, CreatePublicBookingInput } from './hooks'
15
+ export type { PublicService, ContactInfo, BookingWindow, PublicBookingLabels, WorkingHours } from './types'
@@ -0,0 +1,63 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Public (customer-facing) booking types. This surface is deliberately decoupled
3
+ // from the admin calendar: it depends only on the AgendaDataProvider interface,
4
+ // so mock slots today and real availability (Supabase RPC / ERP / Google
5
+ // Calendar) later are a provider swap with no widget change.
6
+ // ---------------------------------------------------------------------------
7
+
8
+ export interface PublicService {
9
+ id: string
10
+ name: string
11
+ durationMinutes: number
12
+ price: number
13
+ description?: string
14
+ }
15
+
16
+ export interface ContactInfo {
17
+ name: string
18
+ email?: string
19
+ phone?: string
20
+ notes?: string
21
+ }
22
+
23
+ export interface WorkingHours {
24
+ /** 0=Sun … 6=Sat. */
25
+ daysOfWeek: number[]
26
+ /** 'HH:MM' */
27
+ start: string
28
+ /** 'HH:MM' */
29
+ end: string
30
+ }
31
+
32
+ export interface BookingWindow {
33
+ /** Earliest lead time before a slot can be booked. */
34
+ minAdvanceHours: number
35
+ /** How many days ahead the date picker offers. */
36
+ maxAdvanceDays: number
37
+ /** Slot granularity in minutes. */
38
+ slotInterval: number
39
+ }
40
+
41
+ export interface PaymentConfig {
42
+ /** Show a Pagamento step as the final stage of the flow. */
43
+ enabled?: boolean
44
+ /** % of the service price charged up-front as a reservation fee (default 100). */
45
+ reservationFeePercent?: number
46
+ /** Payment method (POC ships 'pix'). */
47
+ method?: 'pix'
48
+ }
49
+
50
+ export interface ResolvedPayment {
51
+ reservationFeePercent: number
52
+ method: 'pix'
53
+ }
54
+
55
+ export interface PublicBookingLabels {
56
+ title: string
57
+ serviceStep: string
58
+ dateStep: string
59
+ contactStep: string
60
+ confirmCta: string
61
+ successTitle: string
62
+ successBody: string
63
+ }