@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.
- 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/data/mock.d.ts.map +1 -1
- package/dist/index.cjs +300 -421
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +297 -420
- 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 +11 -0
- package/dist/public/BookingWidget.d.ts.map +1 -0
- 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 +51 -0
- package/dist/public/context.d.ts.map +1 -0
- package/dist/public/createPublicBookingPlugin.d.ts +62 -0
- package/dist/public/createPublicBookingPlugin.d.ts.map +1 -0
- 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 +34 -0
- package/dist/public/hooks.d.ts.map +1 -0
- package/dist/public/index.cjs +1423 -0
- package/dist/public/index.cjs.map +1 -0
- package/dist/public/index.d.ts +20 -0
- package/dist/public/index.d.ts.map +1 -0
- package/dist/public/index.js +1402 -0
- package/dist/public/index.js.map +1 -0
- 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 +185 -0
- package/dist/public/types.d.ts.map +1 -0
- package/package.json +20 -14
- package/src/data/mock.ts +4 -1
- package/src/index.ts +14 -0
- package/src/integrations/google-calendar/integration.test.ts +1 -1
- package/src/migrations/001_public_booking.sql +278 -0
- package/src/migrations/index.ts +282 -0
- package/src/public/BookingWidget.tsx +315 -0
- 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 +100 -0
- package/src/public/createPublicBookingPlugin.ts +265 -0
- 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 +105 -0
- package/src/public/index.ts +68 -0
- 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 +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
|
+
}
|