@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.
- package/dist/chunk-GAJVIEXM.cjs +870 -0
- package/dist/chunk-GAJVIEXM.cjs.map +1 -0
- package/dist/chunk-U6VTLOHN.js +867 -0
- package/dist/chunk-U6VTLOHN.js.map +1 -0
- package/dist/data/mock.d.ts.map +1 -1
- package/dist/index.cjs +16 -868
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -868
- package/dist/index.js.map +1 -1
- package/dist/public/BookingWidget.d.ts +9 -0
- package/dist/public/BookingWidget.d.ts.map +1 -0
- package/dist/public/context.d.ts +37 -0
- package/dist/public/context.d.ts.map +1 -0
- package/dist/public/createPublicBookingPlugin.d.ts +45 -0
- package/dist/public/createPublicBookingPlugin.d.ts.map +1 -0
- package/dist/public/hooks.d.ts +32 -0
- package/dist/public/hooks.d.ts.map +1 -0
- package/dist/public/index.cjs +1020 -0
- package/dist/public/index.cjs.map +1 -0
- package/dist/public/index.d.ts +9 -0
- package/dist/public/index.d.ts.map +1 -0
- package/dist/public/index.js +1012 -0
- package/dist/public/index.js.map +1 -0
- package/dist/public/types.d.ts +51 -0
- package/dist/public/types.d.ts.map +1 -0
- package/package.json +11 -5
- package/src/data/mock.ts +4 -1
- package/src/index.ts +3 -0
- package/src/integrations/google-calendar/integration.test.ts +1 -1
- package/src/public/BookingWidget.tsx +892 -0
- package/src/public/context.tsx +43 -0
- package/src/public/createPublicBookingPlugin.ts +155 -0
- package/src/public/hooks.ts +117 -0
- package/src/public/index.ts +15 -0
- package/src/public/types.ts +63 -0
|
@@ -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
|
+
}
|