@fayz-ai/plugin-agenda 0.9.0 → 0.10.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/README.md +3 -3
- package/dist/bridges/create-financial-bridge.d.ts +1 -1
- package/dist/bridges/create-financial-bridge.d.ts.map +1 -1
- package/dist/bridges/financial-bridge.d.ts.map +1 -0
- package/dist/{chunk-Q2PAWBPR.js → chunk-ISLJXP7O.js} +7 -2
- package/dist/chunk-ISLJXP7O.js.map +1 -0
- package/dist/components/AppointmentModal.d.ts.map +1 -1
- package/dist/components/PaymentStatusBadge.d.ts +1 -1
- package/dist/components/PaymentStatusBadge.d.ts.map +1 -1
- package/dist/config.d.ts +2 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/{AgendaContext.d.ts → context.d.ts} +1 -1
- package/dist/context.d.ts.map +1 -0
- package/dist/data/mock.d.ts.map +1 -1
- package/dist/data/realtime.test.d.ts +2 -0
- package/dist/data/realtime.test.d.ts.map +1 -0
- package/dist/data/registries.d.ts.map +1 -0
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/data/types.d.ts +7 -0
- package/dist/data/types.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2515 -187
- package/dist/index.js.map +1 -1
- package/dist/integrations/google-calendar/conflict.d.ts +25 -0
- package/dist/integrations/google-calendar/conflict.d.ts.map +1 -0
- package/dist/integrations/google-calendar/connectorDef.d.ts +12 -0
- package/dist/integrations/google-calendar/connectorDef.d.ts.map +1 -1
- package/dist/integrations/google-calendar/credentials.d.ts +27 -0
- package/dist/integrations/google-calendar/credentials.d.ts.map +1 -0
- package/dist/integrations/google-calendar/data/supabase.d.ts +10 -2
- package/dist/integrations/google-calendar/data/supabase.d.ts.map +1 -1
- package/dist/integrations/google-calendar/data/tables.d.ts +2 -0
- package/dist/integrations/google-calendar/data/tables.d.ts.map +1 -1
- package/dist/integrations/google-calendar/health.d.ts +23 -0
- package/dist/integrations/google-calendar/health.d.ts.map +1 -0
- package/dist/integrations/google-calendar/health.test.d.ts +2 -0
- package/dist/integrations/google-calendar/health.test.d.ts.map +1 -0
- package/dist/integrations/google-calendar/index.d.ts +6 -0
- package/dist/integrations/google-calendar/index.d.ts.map +1 -1
- package/dist/integrations/google-calendar/migrations/index.d.ts +12 -0
- package/dist/integrations/google-calendar/migrations/index.d.ts.map +1 -0
- package/dist/integrations/google-calendar/panel-data.d.ts +26 -0
- package/dist/integrations/google-calendar/panel-data.d.ts.map +1 -0
- package/dist/integrations/google-calendar/panel-data.test.d.ts +2 -0
- package/dist/integrations/google-calendar/panel-data.test.d.ts.map +1 -0
- package/dist/integrations/google-calendar/types.d.ts +17 -0
- package/dist/integrations/google-calendar/types.d.ts.map +1 -1
- package/dist/lib/booking-events.d.ts +5 -0
- package/dist/lib/booking-events.d.ts.map +1 -0
- package/dist/lib/calendar-events.d.ts +8 -0
- package/dist/lib/calendar-events.d.ts.map +1 -0
- package/dist/lib/calendar-events.test.d.ts +2 -0
- package/dist/lib/calendar-events.test.d.ts.map +1 -0
- package/dist/lib/message-events.d.ts +12 -0
- package/dist/lib/message-events.d.ts.map +1 -0
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts.map +1 -1
- package/dist/public/{data.d.ts → data/index.d.ts} +4 -4
- package/dist/public/data/index.d.ts.map +1 -0
- package/dist/public/{data.supabase.d.ts → data/supabase.d.ts} +2 -2
- package/dist/public/data/supabase.d.ts.map +1 -0
- package/dist/public/{hooks.d.ts → hooks/index.d.ts} +4 -4
- package/dist/public/hooks/index.d.ts.map +1 -0
- package/dist/public/index.d.ts +4 -4
- package/dist/public/index.d.ts.map +1 -1
- package/dist/public/index.js +3 -3
- package/dist/public/index.js.map +1 -1
- package/dist/public/{format.d.ts → lib/format.d.ts} +1 -1
- package/dist/public/lib/format.d.ts.map +1 -0
- package/dist/public/views/BookingWidget.d.ts.map +1 -0
- package/dist/public/{steps → views/steps}/ContactStep.d.ts +1 -1
- package/dist/public/views/steps/ContactStep.d.ts.map +1 -0
- package/dist/public/{steps → views/steps}/DateTimeStep.d.ts +3 -3
- package/dist/public/views/steps/DateTimeStep.d.ts.map +1 -0
- package/dist/public/{steps → views/steps}/OverviewStep.d.ts +1 -1
- package/dist/public/views/steps/OverviewStep.d.ts.map +1 -0
- package/dist/public/{steps → views/steps}/PaymentStep.d.ts +1 -1
- package/dist/public/views/steps/PaymentStep.d.ts.map +1 -0
- package/dist/store.d.ts +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/styles/calendar-styles.d.ts.map +1 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/{AgendaPage.d.ts → views/AgendaPage.d.ts} +3 -3
- package/dist/views/AgendaPage.d.ts.map +1 -0
- package/dist/views/CalendarView.d.ts.map +1 -1
- package/package.json +9 -11
- package/dist/AgendaContext.d.ts.map +0 -1
- package/dist/AgendaPage.d.ts.map +0 -1
- package/dist/calendar-styles.d.ts.map +0 -1
- package/dist/chunk-Q2PAWBPR.js.map +0 -1
- package/dist/financial-bridge.d.ts.map +0 -1
- package/dist/integrations/google-calendar/GoogleCalendarSettings.d.ts +0 -3
- package/dist/integrations/google-calendar/GoogleCalendarSettings.d.ts.map +0 -1
- package/dist/public/BookingWidget.d.ts.map +0 -1
- package/dist/public/data.d.ts.map +0 -1
- package/dist/public/data.supabase.d.ts.map +0 -1
- package/dist/public/format.d.ts.map +0 -1
- package/dist/public/hooks.d.ts.map +0 -1
- package/dist/public/steps/ContactStep.d.ts.map +0 -1
- package/dist/public/steps/DateTimeStep.d.ts.map +0 -1
- package/dist/public/steps/OverviewStep.d.ts.map +0 -1
- package/dist/public/steps/PaymentStep.d.ts.map +0 -1
- package/dist/registries.d.ts.map +0 -1
- package/src/AgendaContext.tsx +0 -11
- package/src/AgendaPage.tsx +0 -33
- package/src/bridges/create-financial-bridge.ts +0 -296
- package/src/calendar-styles.css +0 -245
- package/src/calendar-styles.ts +0 -285
- package/src/components/AgendaGeneralSettings.tsx +0 -86
- package/src/components/AgendaMobileList.tsx +0 -103
- package/src/components/AppointmentModal.tsx +0 -1050
- package/src/components/AppointmentPopover.tsx +0 -231
- package/src/components/BookingPaymentPanel.tsx +0 -394
- package/src/components/CalendarSidebar.tsx +0 -119
- package/src/components/CalendarToolbar.tsx +0 -86
- package/src/components/EventContextMenu.tsx +0 -125
- package/src/components/FloatingPanel.tsx +0 -123
- package/src/components/MiniCalendar.tsx +0 -120
- package/src/components/PaymentStatusBadge.tsx +0 -62
- package/src/components/StatusBadge.tsx +0 -23
- package/src/config.ts +0 -368
- package/src/data/index.ts +0 -4
- package/src/data/mock.ts +0 -344
- package/src/data/supabase.ts +0 -636
- package/src/data/types.ts +0 -37
- package/src/financial-bridge.ts +0 -96
- package/src/hooks/useAgendaSettings.ts +0 -17
- package/src/index.ts +0 -297
- package/src/integrations/google-calendar/GoogleCalendarSettings.tsx +0 -136
- package/src/integrations/google-calendar/components/CalendarColorDot.tsx +0 -23
- package/src/integrations/google-calendar/components/ChannelMappingList.tsx +0 -329
- package/src/integrations/google-calendar/components/ImportEventsWizard.tsx +0 -215
- package/src/integrations/google-calendar/components/useAgendaLookups.ts +0 -100
- package/src/integrations/google-calendar/connector.ts +0 -20
- package/src/integrations/google-calendar/connectorDef.tsx +0 -185
- package/src/integrations/google-calendar/data/supabase.ts +0 -170
- package/src/integrations/google-calendar/data/tables.ts +0 -12
- package/src/integrations/google-calendar/index.ts +0 -45
- package/src/integrations/google-calendar/integration.test.ts +0 -199
- package/src/integrations/google-calendar/mapping.ts +0 -183
- package/src/integrations/google-calendar/migrations/001_google_calendar.sql +0 -97
- package/src/integrations/google-calendar/migrations/002_smart_sync.sql +0 -517
- package/src/integrations/google-calendar/migrations/003_plg_rename.sql +0 -273
- package/src/integrations/google-calendar/types.ts +0 -142
- package/src/lib/tenant.ts +0 -3
- package/src/locales/en.ts +0 -220
- package/src/locales/index.ts +0 -7
- package/src/locales/pt-BR.ts +0 -220
- package/src/migrations/000b_v_bookings_rename.sql +0 -21
- package/src/migrations/000c_dedupe_customer_phones.sql +0 -43
- package/src/migrations/001_public_booking.sql +0 -296
- package/src/migrations/002_v_bookings_compat.sql +0 -14
- package/src/migrations/003_public_client_kind.sql +0 -175
- package/src/migrations/004_public_booking_assignee.sql +0 -176
- package/src/migrations/005_agent_rpcs.sql +0 -168
- package/src/migrations/006_agent_rpc_localtime.sql +0 -181
- package/src/migrations/007_schedule_exception_precedence.sql +0 -290
- package/src/migrations/008_agent_rpc_exception_precedence.sql +0 -172
- package/src/migrations/009_public_booking_location.sql +0 -162
- package/src/migrations/010_public_service_curation.sql +0 -31
- package/src/migrations/011_default_assignee.sql +0 -167
- package/src/migrations/012_drop_legacy_booking_overload.sql +0 -29
- package/src/migrations/013_normalize_v_appointments.sql +0 -199
- package/src/migrations/014_agent_rpc_update_appointment.sql +0 -199
- package/src/migrations/index.ts +0 -2377
- package/src/public/BookingWidget.tsx +0 -324
- package/src/public/components/BrandHeader.tsx +0 -27
- package/src/public/components/ServiceCard.tsx +0 -39
- package/src/public/components/SidebarExtras.tsx +0 -30
- package/src/public/components/StepRailItem.tsx +0 -27
- package/src/public/components/SuccessPanel.tsx +0 -27
- package/src/public/components/SummaryRail.tsx +0 -90
- package/src/public/components/defaults.ts +0 -14
- package/src/public/context.tsx +0 -103
- package/src/public/createPublicBookingPlugin.ts +0 -281
- package/src/public/data.supabase.ts +0 -97
- package/src/public/data.ts +0 -105
- package/src/public/format.ts +0 -96
- package/src/public/hooks.ts +0 -110
- package/src/public/index.ts +0 -69
- package/src/public/steps/ContactStep.tsx +0 -184
- package/src/public/steps/DateTimeStep.tsx +0 -138
- package/src/public/steps/OverviewStep.tsx +0 -81
- package/src/public/steps/PaymentStep.tsx +0 -180
- package/src/public/types.ts +0 -250
- package/src/registries.ts +0 -35
- package/src/store.ts +0 -311
- package/src/types.ts +0 -273
- package/src/views/CalendarView.tsx +0 -1112
- package/src/views/ConfirmationsView.tsx +0 -155
- /package/dist/{financial-bridge.d.ts → bridges/financial-bridge.d.ts} +0 -0
- /package/dist/{registries.d.ts → data/registries.d.ts} +0 -0
- /package/dist/public/{BookingWidget.d.ts → views/BookingWidget.d.ts} +0 -0
- /package/dist/{calendar-styles.d.ts → styles/calendar-styles.d.ts} +0 -0
package/src/public/context.tsx
DELETED
|
@@ -1,103 +0,0 @@
|
|
|
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
|
-
PhoneVerificationMode,
|
|
8
|
-
PublicBookingLabels,
|
|
9
|
-
PublicService,
|
|
10
|
-
ResolvedBookingBrand,
|
|
11
|
-
ResolvedPayment,
|
|
12
|
-
WorkingHours,
|
|
13
|
-
} from './types'
|
|
14
|
-
|
|
15
|
-
/** Emitted when the customer's phone is verified / identity captured, for the
|
|
16
|
-
* host to bridge to auth (booking stays decoupled — data-only, optional). */
|
|
17
|
-
export type OnIdentityVerified = (identity: { name: string; phone: string; email?: string }) => void
|
|
18
|
-
|
|
19
|
-
export interface PublicBookingContextValue {
|
|
20
|
-
provider: PublicBookingDataProvider
|
|
21
|
-
/** The professional/resource bookings are created against; null → any. */
|
|
22
|
-
professionalId: string | null
|
|
23
|
-
professionalName: string
|
|
24
|
-
/** Resolved catalog: static `services` option, or fetched from the provider. */
|
|
25
|
-
services: PublicService[]
|
|
26
|
-
servicesLoading: boolean
|
|
27
|
-
/** Business opening hours, shown on the overview "Sobre" section. */
|
|
28
|
-
businessHours: WorkingHours
|
|
29
|
-
/** 'otp' shows the code modal; 'none' trusts the typed number (default). */
|
|
30
|
-
phoneVerification: PhoneVerificationMode
|
|
31
|
-
/** Payment step config, or null when the flow ends at confirmation. */
|
|
32
|
-
payment: ResolvedPayment | null
|
|
33
|
-
/** Injected charge provider (Pix). When absent, the payment step self-mocks. */
|
|
34
|
-
paymentProvider: PaymentProvider | null
|
|
35
|
-
/** Fired on phone-verify / details submit so the host can sign the user in. */
|
|
36
|
-
onIdentityVerified: OnIdentityVerified | null
|
|
37
|
-
window: BookingWindow
|
|
38
|
-
labels: PublicBookingLabels
|
|
39
|
-
/** Brand personalization (resolved with defaults). */
|
|
40
|
-
brand: ResolvedBookingBrand
|
|
41
|
-
/** Host-overridable subcomponents (resolved with defaults). */
|
|
42
|
-
components: BookingComponents
|
|
43
|
-
/** Currency code for price formatting (e.g. 'BRL'). */
|
|
44
|
-
currency: string
|
|
45
|
-
locale: string
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const PublicBookingContext = createContext<PublicBookingContextValue | null>(null)
|
|
49
|
-
|
|
50
|
-
export interface BookingProviderProps {
|
|
51
|
-
/** Everything but the (possibly async) service catalog. */
|
|
52
|
-
base: Omit<PublicBookingContextValue, 'services' | 'servicesLoading'>
|
|
53
|
-
/** Static catalog from options — wins over the provider fetch when present. */
|
|
54
|
-
staticServices: PublicService[] | null
|
|
55
|
-
children: ReactNode
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Owns the service-catalog resolution: static services pass through untouched;
|
|
60
|
-
* otherwise the provider is queried once (e.g. Supabase v_public_services).
|
|
61
|
-
*/
|
|
62
|
-
export function BookingProvider({ base, staticServices, children }: BookingProviderProps) {
|
|
63
|
-
const [fetched, setFetched] = useState<PublicService[] | null>(null)
|
|
64
|
-
const [loading, setLoading] = useState(staticServices === null)
|
|
65
|
-
|
|
66
|
-
useEffect(() => {
|
|
67
|
-
if (staticServices !== null) return
|
|
68
|
-
let active = true
|
|
69
|
-
setLoading(true)
|
|
70
|
-
base.provider
|
|
71
|
-
.getServices()
|
|
72
|
-
.then((result) => {
|
|
73
|
-
if (active) setFetched(result)
|
|
74
|
-
})
|
|
75
|
-
.catch((err) => {
|
|
76
|
-
// Surface in dev; the overview renders its empty state.
|
|
77
|
-
console.error('[plugin-agenda/public] failed to load services:', err)
|
|
78
|
-
if (active) setFetched([])
|
|
79
|
-
})
|
|
80
|
-
.finally(() => {
|
|
81
|
-
if (active) setLoading(false)
|
|
82
|
-
})
|
|
83
|
-
return () => {
|
|
84
|
-
active = false
|
|
85
|
-
}
|
|
86
|
-
// base.provider is stable (created once by the plugin factory).
|
|
87
|
-
}, [base.provider, staticServices])
|
|
88
|
-
|
|
89
|
-
const value = useMemo<PublicBookingContextValue>(
|
|
90
|
-
() => ({ ...base, services: staticServices ?? fetched ?? [], servicesLoading: loading }),
|
|
91
|
-
[base, staticServices, fetched, loading],
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
return <PublicBookingContext.Provider value={value}>{children}</PublicBookingContext.Provider>
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export function useBooking(): PublicBookingContextValue {
|
|
98
|
-
const ctx = useContext(PublicBookingContext)
|
|
99
|
-
if (!ctx) {
|
|
100
|
-
throw new Error('[plugin-agenda] useBooking must be used within <BookingProvider>.')
|
|
101
|
-
}
|
|
102
|
-
return ctx
|
|
103
|
-
}
|
|
@@ -1,281 +0,0 @@
|
|
|
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 type { Professional, Schedule } from '../types'
|
|
5
|
-
import type { MockAgendaSeed } from '../data/mock'
|
|
6
|
-
import { createMockPublicBookingProvider, type PublicBookingDataProvider } from './data'
|
|
7
|
-
import { createSupabasePublicBookingProvider } from './data.supabase'
|
|
8
|
-
import { BookingProvider, type PublicBookingContextValue, type OnIdentityVerified } from './context'
|
|
9
|
-
import { BookingWidget } from './BookingWidget'
|
|
10
|
-
import { DEFAULT_BOOKING_COMPONENTS } from './components/defaults'
|
|
11
|
-
import type {
|
|
12
|
-
BookingBrand,
|
|
13
|
-
BookingComponents,
|
|
14
|
-
BookingWindow,
|
|
15
|
-
PaymentConfig,
|
|
16
|
-
PhoneVerificationMode,
|
|
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
|
-
/**
|
|
54
|
-
* Phone verification before the details sub-step. Default 'none' — the typed
|
|
55
|
-
* number is trusted. Set 'otp' only where a real code sender is wired: the
|
|
56
|
-
* built-in modal is a POC that accepts the literal '0000' and sends nothing.
|
|
57
|
-
*/
|
|
58
|
-
phoneVerification?: PhoneVerificationMode
|
|
59
|
-
/** Adds a final Pagamento (Pix) step. Omit/disable to end the flow at confirmation. */
|
|
60
|
-
payment?: PaymentConfig
|
|
61
|
-
/** Charge provider (from a payments plugin) used by the Pagamento step. Optional. */
|
|
62
|
-
paymentProvider?: PaymentProvider
|
|
63
|
-
/** Host bridge: fired when the customer's phone is verified / details submitted. */
|
|
64
|
-
onIdentityVerified?: OnIdentityVerified
|
|
65
|
-
currency?: string
|
|
66
|
-
locale?: string
|
|
67
|
-
/** Inject a custom provider (overrides the mock/Supabase safe resolver). */
|
|
68
|
-
dataProvider?: PublicBookingDataProvider
|
|
69
|
-
/** Override the full mock seed (advanced). */
|
|
70
|
-
seed?: MockAgendaSeed
|
|
71
|
-
scope?: PluginScope
|
|
72
|
-
verticalId?: VerticalId
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export interface PublicBookingPlugin {
|
|
76
|
-
manifest: PluginManifest
|
|
77
|
-
Provider: FC<{ children: ReactNode }>
|
|
78
|
-
dataProvider: PublicBookingDataProvider
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const DEFAULT_LABELS: PublicBookingLabels = {
|
|
82
|
-
title: 'Agende sua consulta',
|
|
83
|
-
serviceStep: 'Escolha o serviço',
|
|
84
|
-
dateStep: 'Escolha a data',
|
|
85
|
-
contactStep: 'Seus dados',
|
|
86
|
-
confirmCta: 'Confirmar agendamento',
|
|
87
|
-
successTitle: 'Agendamento confirmado!',
|
|
88
|
-
successBody: 'Você receberá os detalhes em breve. Até logo!',
|
|
89
|
-
// overview
|
|
90
|
-
servicesHeading: 'Serviços',
|
|
91
|
-
aboutHeading: 'Sobre',
|
|
92
|
-
openingHoursHeading: 'Horário de funcionamento',
|
|
93
|
-
closedLabel: 'Fechado',
|
|
94
|
-
bookNowCta: 'Agendar agora',
|
|
95
|
-
// datetime
|
|
96
|
-
timezoneNote: 'Fuso: Horário de Brasília',
|
|
97
|
-
slotsHeading: 'Horários disponíveis',
|
|
98
|
-
noSlots: 'Nenhum horário disponível neste dia. Tente outra data.',
|
|
99
|
-
continueCta: 'Continuar',
|
|
100
|
-
backToServices: 'Serviços',
|
|
101
|
-
back: 'Voltar',
|
|
102
|
-
prevWeek: 'Semana anterior',
|
|
103
|
-
nextWeek: 'Próxima semana',
|
|
104
|
-
// contact
|
|
105
|
-
contactIntro: 'Preencha as informações abaixo para que seu agendamento seja identificado.',
|
|
106
|
-
phoneLabel: 'Celular',
|
|
107
|
-
phoneHint:
|
|
108
|
-
'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.',
|
|
109
|
-
sendCodeCta: 'Enviar código via WhatsApp',
|
|
110
|
-
phoneHintNoVerification: 'Usaremos este número para confirmar seu agendamento pelo WhatsApp.',
|
|
111
|
-
phoneContinueCta: 'Continuar',
|
|
112
|
-
codeTitle: 'Insira o código',
|
|
113
|
-
codeBody: 'Enviamos um código via WhatsApp para você confirmar seu número.',
|
|
114
|
-
codeInvalid: 'Código inválido. Para testar, use 0000.',
|
|
115
|
-
codeConfirmCta: 'Confirmar',
|
|
116
|
-
codeResendHint: 'Reenviar código via WhatsApp em 28 segundos.',
|
|
117
|
-
phoneVerified: 'Celular verificado',
|
|
118
|
-
changePhone: 'Trocar telefone',
|
|
119
|
-
nameLabel: 'Nome completo *',
|
|
120
|
-
namePlaceholder: 'Seu nome',
|
|
121
|
-
emailLabel: 'E-mail',
|
|
122
|
-
emailPlaceholder: 'voce@email.com',
|
|
123
|
-
emailHint: 'Enviaremos uma confirmação e um lembrete antes do agendamento.',
|
|
124
|
-
notesLabel: 'Observação',
|
|
125
|
-
notesPlaceholder: 'Use esse espaço para deixar uma observação sobre este agendamento',
|
|
126
|
-
saveAndContinueCta: 'Salvar e continuar',
|
|
127
|
-
submitError: 'Não foi possível concluir o agendamento. Tente novamente.',
|
|
128
|
-
// Rendered ONLY when the payment step is off — so it must not promise a
|
|
129
|
-
// payment method the flow never shows.
|
|
130
|
-
noChargeNote: 'Sem cobrança agora · o pagamento é feito no atendimento',
|
|
131
|
-
// right rail
|
|
132
|
-
railTitle: 'Passos para agendar',
|
|
133
|
-
railWaitingPayment: 'Aguardando pagamento',
|
|
134
|
-
railService: 'Serviço',
|
|
135
|
-
railProfessional: 'Profissional',
|
|
136
|
-
railDatetime: 'Data e horário',
|
|
137
|
-
railContact: 'Informações pessoais',
|
|
138
|
-
railPayment: 'Pagamento',
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const DEFAULT_WINDOW: BookingWindow = { minAdvanceHours: 2, maxAdvanceDays: 21, slotInterval: 30 }
|
|
142
|
-
|
|
143
|
-
const DEFAULT_HOURS: WorkingHours = { daysOfWeek: [1, 2, 3, 4, 5, 6], start: '09:00', end: '18:00' }
|
|
144
|
-
|
|
145
|
-
function resolveBrand(brand: BookingBrand | undefined, labels: PublicBookingLabels): ResolvedBookingBrand {
|
|
146
|
-
const name = brand?.name ?? labels.title
|
|
147
|
-
return {
|
|
148
|
-
name,
|
|
149
|
-
initials: brand?.initials ?? name.charAt(0).toUpperCase(),
|
|
150
|
-
logoUrl: brand?.logoUrl,
|
|
151
|
-
tagline: brand?.tagline === undefined ? 'Agendamento online seguro' : brand.tagline,
|
|
152
|
-
serviceIcon: brand?.serviceIcon ?? Video,
|
|
153
|
-
serviceMeta: brand?.serviceMeta === undefined ? 'Online' : brand.serviceMeta,
|
|
154
|
-
featuredBadge: brand?.featuredBadge === undefined ? 'Mais procurado' : brand.featuredBadge,
|
|
155
|
-
highlights: brand?.highlights ?? [
|
|
156
|
-
{ icon: CalendarCheck, text: 'Confirmação imediata' },
|
|
157
|
-
{ icon: ShieldCheck, text: 'Agendamento seguro' },
|
|
158
|
-
],
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
let scheduleId = 0
|
|
163
|
-
|
|
164
|
-
function buildSchedules(professionalId: string, hours: WorkingHours): Schedule[] {
|
|
165
|
-
return hours.daysOfWeek.map((day) => ({
|
|
166
|
-
id: `sched-${professionalId}-${day}-${scheduleId++}`,
|
|
167
|
-
tenantId: 'public',
|
|
168
|
-
kind: 'working_hours',
|
|
169
|
-
assigneeId: professionalId,
|
|
170
|
-
locationId: null,
|
|
171
|
-
dayOfWeek: day,
|
|
172
|
-
specificDate: null,
|
|
173
|
-
startsAt: hours.start,
|
|
174
|
-
endsAt: hours.end,
|
|
175
|
-
isActive: true,
|
|
176
|
-
metadata: {},
|
|
177
|
-
}))
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
export function createPublicBookingPlugin(options: PublicBookingOptions): PublicBookingPlugin {
|
|
181
|
-
const basePath = options.basePath ?? '/agendar'
|
|
182
|
-
// Mock needs a concrete seed professional; Supabase can target any (null).
|
|
183
|
-
const mockProfessionalId = options.professional?.id ?? 'pro-1'
|
|
184
|
-
const professionalId = options.professional?.id ?? null
|
|
185
|
-
const professionalName = options.professional?.name ?? 'Especialista'
|
|
186
|
-
const window: BookingWindow = { ...DEFAULT_WINDOW, ...options.window }
|
|
187
|
-
const labels: PublicBookingLabels = { ...DEFAULT_LABELS, ...options.labels }
|
|
188
|
-
const brand = resolveBrand(options.brand, labels)
|
|
189
|
-
const components: BookingComponents = { ...DEFAULT_BOOKING_COMPONENTS, ...options.components }
|
|
190
|
-
const currency = options.currency ?? 'BRL'
|
|
191
|
-
const locale = options.locale ?? 'pt-BR'
|
|
192
|
-
const hours = options.workingHours ?? DEFAULT_HOURS
|
|
193
|
-
const staticServices = options.services ?? null
|
|
194
|
-
const phoneVerification: PhoneVerificationMode = options.phoneVerification ?? 'none'
|
|
195
|
-
const payment: ResolvedPayment | null = options.payment?.enabled
|
|
196
|
-
? {
|
|
197
|
-
reservationFeePercent: options.payment.reservationFeePercent ?? 100,
|
|
198
|
-
method: options.payment.method ?? 'pix',
|
|
199
|
-
}
|
|
200
|
-
: null
|
|
201
|
-
|
|
202
|
-
const professional: Professional = {
|
|
203
|
-
id: mockProfessionalId,
|
|
204
|
-
name: professionalName,
|
|
205
|
-
avatarUrl: null,
|
|
206
|
-
locationId: null,
|
|
207
|
-
locationName: null,
|
|
208
|
-
isActive: true,
|
|
209
|
-
}
|
|
210
|
-
const mockSeed: MockAgendaSeed = options.seed ?? {
|
|
211
|
-
professionals: [professional],
|
|
212
|
-
schedules: buildSchedules(mockProfessionalId, hours),
|
|
213
|
-
bookings: [],
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Resolution: explicit dataProvider → Supabase whenever a tenantId is
|
|
217
|
-
// declared (the app's global client if registered, else Fayz Cloud — the
|
|
218
|
-
// shared hosted backend) → seeded mock. The mock fallback is LOUD on
|
|
219
|
-
// purpose: the old silent fallback hid missing-env bugs as empty catalogs.
|
|
220
|
-
let provider: PublicBookingDataProvider
|
|
221
|
-
if (options.dataProvider) {
|
|
222
|
-
provider = options.dataProvider
|
|
223
|
-
} else if (options.tenantId) {
|
|
224
|
-
provider = createSupabasePublicBookingProvider({ tenantId: options.tenantId })
|
|
225
|
-
} else {
|
|
226
|
-
if (typeof console !== 'undefined') {
|
|
227
|
-
console.warn(
|
|
228
|
-
'[plugin-agenda/public] no `tenantId` and no `dataProvider` — falling back to the seeded MOCK provider. ' +
|
|
229
|
-
'Pass `tenantId` to book against Fayz Cloud, or inject a dataProvider.',
|
|
230
|
-
)
|
|
231
|
-
}
|
|
232
|
-
provider = createMockPublicBookingProvider({
|
|
233
|
-
seed: mockSeed,
|
|
234
|
-
services: staticServices ?? [],
|
|
235
|
-
professionalId: mockProfessionalId,
|
|
236
|
-
})
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const base: Omit<PublicBookingContextValue, 'services' | 'servicesLoading'> = {
|
|
240
|
-
provider,
|
|
241
|
-
professionalId,
|
|
242
|
-
professionalName,
|
|
243
|
-
businessHours: hours,
|
|
244
|
-
phoneVerification,
|
|
245
|
-
payment,
|
|
246
|
-
paymentProvider: options.paymentProvider ?? null,
|
|
247
|
-
onIdentityVerified: options.onIdentityVerified ?? null,
|
|
248
|
-
window,
|
|
249
|
-
labels,
|
|
250
|
-
brand,
|
|
251
|
-
components,
|
|
252
|
-
currency,
|
|
253
|
-
locale,
|
|
254
|
-
}
|
|
255
|
-
const Provider: FC<{ children: ReactNode }> = ({ children }) =>
|
|
256
|
-
createElement(BookingProvider, { base, staticServices, children })
|
|
257
|
-
Provider.displayName = 'PublicBookingProvider'
|
|
258
|
-
|
|
259
|
-
const BookingScreen: FC<unknown> = () =>
|
|
260
|
-
createElement('section', { className: 'py-16 bg-background' },
|
|
261
|
-
createElement('div', { className: 'container mx-auto px-6' },
|
|
262
|
-
createElement(BookingWidget)))
|
|
263
|
-
BookingScreen.displayName = 'BookingScreen'
|
|
264
|
-
|
|
265
|
-
const manifest: PluginManifest = {
|
|
266
|
-
id: 'booking',
|
|
267
|
-
name: 'Agendamento',
|
|
268
|
-
icon: 'CalendarCheck',
|
|
269
|
-
version: '0.4.0',
|
|
270
|
-
scope: options.scope ?? 'universal',
|
|
271
|
-
verticalId: options.verticalId,
|
|
272
|
-
scaffolds: ['website', 'landing_page'],
|
|
273
|
-
defaultEnabled: true,
|
|
274
|
-
dependencies: [],
|
|
275
|
-
navigation: [],
|
|
276
|
-
routes: [{ path: basePath, component: BookingScreen, guard: 'public' }],
|
|
277
|
-
widgets: [],
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
return { manifest, Provider, dataProvider: provider }
|
|
281
|
-
}
|
|
@@ -1,97 +0,0 @@
|
|
|
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()
|
|
31
|
-
if (!client) throw new Error('No Supabase client registered — call setGlobalSupabaseClient() at app startup.')
|
|
32
|
-
return client as SupabaseLikeClient
|
|
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
|
-
// Only sent when pinned — keeps compatibility with pools still running
|
|
86
|
-
// the pre-004 7-arg function (unknown named arg would 404 on PostgREST).
|
|
87
|
-
...(input.assigneeId ? { p_assignee_id: input.assigneeId } : {}),
|
|
88
|
-
})
|
|
89
|
-
if (error) throw new Error(`[plugin-agenda/public] createBooking: ${error.message}`)
|
|
90
|
-
const row = (Array.isArray(data) ? data[0] : data) as
|
|
91
|
-
| { booking_id: string; starts_at: string; ends_at: string }
|
|
92
|
-
| undefined
|
|
93
|
-
if (!row) throw new Error('[plugin-agenda/public] createBooking: empty RPC response')
|
|
94
|
-
return { bookingId: row.booking_id, startsAt: row.starts_at, endsAt: row.ends_at }
|
|
95
|
-
},
|
|
96
|
-
}
|
|
97
|
-
}
|
package/src/public/data.ts
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
// ---------------------------------------------------------------------------
|
|
2
|
-
// PublicBookingDataProvider — the narrow data seam the public booking surface
|
|
3
|
-
// depends on. The admin calendar's 15-method AgendaDataProvider is deliberately
|
|
4
|
-
// NOT used here: a public page needs exactly three capabilities, and the
|
|
5
|
-
// Supabase implementation must be anon-safe (SECURITY DEFINER RPCs + whitelisted
|
|
6
|
-
// views) rather than the authenticated table access the admin provider does.
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
8
|
-
|
|
9
|
-
import type { TimeSlot } from '../types'
|
|
10
|
-
import { createMockAgendaProvider, type MockAgendaSeed } from '../data/mock'
|
|
11
|
-
import type { PublicService, ContactInfo } from './types'
|
|
12
|
-
|
|
13
|
-
export interface PublicSlotQuery {
|
|
14
|
-
/** Staff person id; null → any active professional for the tenant. */
|
|
15
|
-
assigneeId: string | null
|
|
16
|
-
/** 'YYYY-MM-DD' local calendar day. */
|
|
17
|
-
date: string
|
|
18
|
-
durationMinutes: number
|
|
19
|
-
slotInterval: number
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface PublicCreateBookingInput {
|
|
23
|
-
serviceId: string
|
|
24
|
-
/** ISO datetime of the chosen slot start. */
|
|
25
|
-
startsAt: string
|
|
26
|
-
/** name required; phone should include the country dial for real backends. */
|
|
27
|
-
contact: ContactInfo
|
|
28
|
-
/**
|
|
29
|
-
* Staff person id the visitor saw availability for. When set, the backend
|
|
30
|
-
* books THAT professional (or fails if their hours don't cover the slot) —
|
|
31
|
-
* never silently reassigns. null/undefined → backend auto-picks.
|
|
32
|
-
*/
|
|
33
|
-
assigneeId?: string | null
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface PublicBookingResult {
|
|
37
|
-
bookingId: string
|
|
38
|
-
startsAt: string
|
|
39
|
-
endsAt: string
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface PublicBookingDataProvider {
|
|
43
|
-
/** Bookable catalog. Used when the host doesn't pass static `services`. */
|
|
44
|
-
getServices(): Promise<PublicService[]>
|
|
45
|
-
getAvailableSlots(query: PublicSlotQuery): Promise<TimeSlot[]>
|
|
46
|
-
createBooking(input: PublicCreateBookingInput): Promise<PublicBookingResult>
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// ---------------------------------------------------------------------------
|
|
50
|
-
// Mock implementation — adapter over the admin mock provider (reuses its slot
|
|
51
|
-
// algorithm + seed machinery, so mock and live behave identically).
|
|
52
|
-
// ---------------------------------------------------------------------------
|
|
53
|
-
|
|
54
|
-
export interface MockPublicBookingOptions {
|
|
55
|
-
seed: MockAgendaSeed
|
|
56
|
-
services: PublicService[]
|
|
57
|
-
/** The seed professional bookings are created against. */
|
|
58
|
-
professionalId: string
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function createMockPublicBookingProvider(options: MockPublicBookingOptions): PublicBookingDataProvider {
|
|
62
|
-
const inner = createMockAgendaProvider({ seed: options.seed })
|
|
63
|
-
const { services, professionalId } = options
|
|
64
|
-
|
|
65
|
-
return {
|
|
66
|
-
async getServices() {
|
|
67
|
-
return services
|
|
68
|
-
},
|
|
69
|
-
async getAvailableSlots(query) {
|
|
70
|
-
return inner.getAvailableSlots({
|
|
71
|
-
assigneeId: query.assigneeId ?? professionalId,
|
|
72
|
-
date: query.date,
|
|
73
|
-
durationMinutes: query.durationMinutes,
|
|
74
|
-
slotInterval: query.slotInterval,
|
|
75
|
-
})
|
|
76
|
-
},
|
|
77
|
-
async createBooking(input) {
|
|
78
|
-
const service = services.find((s) => s.id === input.serviceId)
|
|
79
|
-
if (!service) throw new Error('Serviço inválido')
|
|
80
|
-
const booking = await inner.createBooking({
|
|
81
|
-
kind: 'appointment',
|
|
82
|
-
// Mock: the customer is captured as the booking title; the Supabase
|
|
83
|
-
// path upserts a person record server-side instead.
|
|
84
|
-
clientId: 'public-guest',
|
|
85
|
-
professionalId,
|
|
86
|
-
startsAt: input.startsAt,
|
|
87
|
-
title: input.contact.name,
|
|
88
|
-
notes:
|
|
89
|
-
[input.contact.phone, input.contact.email, input.contact.notes].filter(Boolean).join(' · ') || undefined,
|
|
90
|
-
services: [
|
|
91
|
-
{
|
|
92
|
-
serviceId: service.id,
|
|
93
|
-
name: service.name,
|
|
94
|
-
durationMinutes: service.durationMinutes,
|
|
95
|
-
price: service.price,
|
|
96
|
-
assigneeId: professionalId,
|
|
97
|
-
},
|
|
98
|
-
],
|
|
99
|
-
})
|
|
100
|
-
const endsAt =
|
|
101
|
-
booking.endsAt ?? new Date(new Date(input.startsAt).getTime() + service.durationMinutes * 60_000).toISOString()
|
|
102
|
-
return { bookingId: booking.id, startsAt: booking.startsAt, endsAt }
|
|
103
|
-
},
|
|
104
|
-
}
|
|
105
|
-
}
|
package/src/public/format.ts
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
// ---------------------------------------------------------------------------
|
|
2
|
-
// Date/price formatting + calendar helpers shared by the booking widget and
|
|
3
|
-
// its (host-overridable) subcomponents.
|
|
4
|
-
// ---------------------------------------------------------------------------
|
|
5
|
-
|
|
6
|
-
import { useMemo } from 'react'
|
|
7
|
-
import type { BookingFormatters } from './types'
|
|
8
|
-
|
|
9
|
-
export const DAY_NAMES = ['Domingo', 'Segunda-feira', 'Terça-feira', 'Quarta-feira', 'Quinta-feira', 'Sexta-feira', 'Sábado']
|
|
10
|
-
export const WEEK_ORDER = [1, 2, 3, 4, 5, 6, 0] // Monday-first, like the reference
|
|
11
|
-
|
|
12
|
-
export function useFormatters(locale: string, currency: string): BookingFormatters {
|
|
13
|
-
return useMemo(
|
|
14
|
-
() => ({
|
|
15
|
-
price: new Intl.NumberFormat(locale, { style: 'currency', currency }),
|
|
16
|
-
time: new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }),
|
|
17
|
-
weekday: new Intl.DateTimeFormat(locale, { weekday: 'short' }),
|
|
18
|
-
dayNum: new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit' }),
|
|
19
|
-
longDay: new Intl.DateTimeFormat(locale, { weekday: 'long', day: '2-digit', month: 'long' }),
|
|
20
|
-
dateShort: new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit', year: 'numeric' }),
|
|
21
|
-
month: new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }),
|
|
22
|
-
}),
|
|
23
|
-
[locale, currency],
|
|
24
|
-
)
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Local 'YYYY-MM-DD' for a Date. */
|
|
28
|
-
export function toIso(d: Date): string {
|
|
29
|
-
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Read a preselected service from the URL (?service=…), router-agnostic. */
|
|
33
|
-
export function readServiceParam(): string | null {
|
|
34
|
-
if (typeof window === 'undefined') return null
|
|
35
|
-
return new URLSearchParams(window.location.search).get('service')
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Comparable form of a service name: lowercase, accent-free, non-alphanumerics
|
|
40
|
-
* collapsed to single dashes.
|
|
41
|
-
*
|
|
42
|
-
* Exists because the marketing site and the catalog spell the same service
|
|
43
|
-
* differently — the site writes "Peeling Químico" and "Drenagem Linfática", the
|
|
44
|
-
* catalog stores "Peeling Quimico" and "Drenagem Linfatica". Matching on the
|
|
45
|
-
* raw name would miss most of them, and making the site link raw UUIDs instead
|
|
46
|
-
* would scatter database ids through page markup and break on every reseed.
|
|
47
|
-
*/
|
|
48
|
-
export function serviceSlug(name: string): string {
|
|
49
|
-
return name
|
|
50
|
-
.normalize('NFD')
|
|
51
|
-
.replace(/[̀-ͯ]/g, '')
|
|
52
|
-
.toLowerCase()
|
|
53
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
54
|
-
.replace(/^-|-$/g, '')
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Resolve `?service=` against the catalog. Accepts the service id or its
|
|
59
|
-
* slugified name, so a site can link `/agendar?service=limpeza-de-pele`
|
|
60
|
-
* without knowing any ids. Unknown value ⇒ null, and the widget opens on the
|
|
61
|
-
* overview as if no service had been asked for.
|
|
62
|
-
*/
|
|
63
|
-
export function resolvePreselectedService<T extends { id: string; name: string }>(
|
|
64
|
-
param: string | null,
|
|
65
|
-
services: T[],
|
|
66
|
-
): string | null {
|
|
67
|
-
if (!param) return null
|
|
68
|
-
if (services.some((s) => s.id === param)) return param
|
|
69
|
-
const wanted = serviceSlug(param)
|
|
70
|
-
return services.find((s) => serviceSlug(s.name) === wanted)?.id ?? null
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export interface DayCell {
|
|
74
|
-
iso: string
|
|
75
|
-
date: Date
|
|
76
|
-
isToday: boolean
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** 7 consecutive days starting `weekOffset*7` days from today. */
|
|
80
|
-
export function buildWeek(weekOffset: number): DayCell[] {
|
|
81
|
-
const base = new Date()
|
|
82
|
-
base.setHours(0, 0, 0, 0)
|
|
83
|
-
const cells: DayCell[] = []
|
|
84
|
-
for (let i = 0; i < 7; i++) {
|
|
85
|
-
const d = new Date(base.getFullYear(), base.getMonth(), base.getDate() + weekOffset * 7 + i)
|
|
86
|
-
cells.push({ iso: toIso(d), date: d, isToday: weekOffset === 0 && i === 0 })
|
|
87
|
-
}
|
|
88
|
-
return cells
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** "13/07/2026 – 21:30 até 22:00 (30min)" */
|
|
92
|
-
export function formatSlotRange(startIso: string, durationMin: number, fmt: BookingFormatters): string {
|
|
93
|
-
const start = new Date(startIso)
|
|
94
|
-
const end = new Date(start.getTime() + durationMin * 60_000)
|
|
95
|
-
return `${fmt.dateShort.format(start)} – ${fmt.time.format(start)} até ${fmt.time.format(end)} (${durationMin}min)`
|
|
96
|
-
}
|