@fayz-ai/plugin-agenda 0.1.8 → 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.
Files changed (68) hide show
  1. package/dist/chunk-GAJVIEXM.cjs +870 -0
  2. package/dist/chunk-GAJVIEXM.cjs.map +1 -0
  3. package/dist/chunk-U6VTLOHN.js +867 -0
  4. package/dist/chunk-U6VTLOHN.js.map +1 -0
  5. package/dist/components/AgendaMobileList.d.ts +10 -0
  6. package/dist/components/AgendaMobileList.d.ts.map +1 -0
  7. package/dist/components/AppointmentModal.d.ts +4 -2
  8. package/dist/components/AppointmentModal.d.ts.map +1 -1
  9. package/dist/components/AppointmentPopover.d.ts.map +1 -1
  10. package/dist/components/EventContextMenu.d.ts.map +1 -1
  11. package/dist/config.d.ts +62 -1
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/data/index.d.ts +1 -0
  14. package/dist/data/index.d.ts.map +1 -1
  15. package/dist/data/mock.d.ts +15 -1
  16. package/dist/data/mock.d.ts.map +1 -1
  17. package/dist/data/supabase.d.ts.map +1 -1
  18. package/dist/index.cjs +611 -1157
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.ts +2 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +445 -994
  23. package/dist/index.js.map +1 -1
  24. package/dist/locales/en.d.ts.map +1 -1
  25. package/dist/locales/pt-BR.d.ts.map +1 -1
  26. package/dist/public/BookingWidget.d.ts +9 -0
  27. package/dist/public/BookingWidget.d.ts.map +1 -0
  28. package/dist/public/context.d.ts +37 -0
  29. package/dist/public/context.d.ts.map +1 -0
  30. package/dist/public/createPublicBookingPlugin.d.ts +45 -0
  31. package/dist/public/createPublicBookingPlugin.d.ts.map +1 -0
  32. package/dist/public/hooks.d.ts +32 -0
  33. package/dist/public/hooks.d.ts.map +1 -0
  34. package/dist/public/index.cjs +1020 -0
  35. package/dist/public/index.cjs.map +1 -0
  36. package/dist/public/index.d.ts +9 -0
  37. package/dist/public/index.d.ts.map +1 -0
  38. package/dist/public/index.js +1012 -0
  39. package/dist/public/index.js.map +1 -0
  40. package/dist/public/types.d.ts +51 -0
  41. package/dist/public/types.d.ts.map +1 -0
  42. package/dist/store.d.ts +3 -0
  43. package/dist/store.d.ts.map +1 -1
  44. package/dist/types.d.ts +8 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/views/CalendarView.d.ts.map +1 -1
  47. package/package.json +11 -8
  48. package/src/components/AgendaMobileList.tsx +103 -0
  49. package/src/components/AppointmentModal.tsx +86 -9
  50. package/src/components/AppointmentPopover.tsx +20 -10
  51. package/src/components/EventContextMenu.tsx +3 -0
  52. package/src/config.ts +104 -5
  53. package/src/data/index.ts +1 -0
  54. package/src/data/mock.ts +26 -6
  55. package/src/data/supabase.ts +13 -2
  56. package/src/index.ts +11 -3
  57. package/src/integrations/google-calendar/integration.test.ts +1 -1
  58. package/src/locales/en.ts +12 -0
  59. package/src/locales/pt-BR.ts +12 -0
  60. package/src/public/BookingWidget.tsx +892 -0
  61. package/src/public/context.tsx +43 -0
  62. package/src/public/createPublicBookingPlugin.ts +155 -0
  63. package/src/public/hooks.ts +117 -0
  64. package/src/public/index.ts +15 -0
  65. package/src/public/types.ts +63 -0
  66. package/src/store.ts +4 -2
  67. package/src/types.ts +8 -0
  68. package/src/views/CalendarView.tsx +220 -54
@@ -21,6 +21,7 @@ import { useTranslation } from '@fayz-ai/core'
21
21
  import { getCurrentLocale } from '@fayz-ai/core'
22
22
  import { isStatusAvailable } from '../types'
23
23
  import type { CreateBookingInput, BookingTypeId } from '../types'
24
+ import type { ExternalBookingSource } from '../config'
24
25
 
25
26
  // ---------------------------------------------------------------------------
26
27
  // Helpers
@@ -38,6 +39,7 @@ function formatCompactDate(dateStr: string, locale: string): string {
38
39
  }
39
40
 
40
41
  interface ServiceItem { serviceId: string; name: string; durationMinutes: number; price: number }
42
+ interface CancellationReasonItem { id: string; label: string; requiresNotes?: boolean }
41
43
 
42
44
  // ---------------------------------------------------------------------------
43
45
  // Inline editable number — allows clearing, commits on blur/enter
@@ -266,11 +268,12 @@ interface Props {
266
268
  bookingId?: string
267
269
  prefill?: Partial<CreateBookingInput> & { endsAt?: string }
268
270
  initialTab?: string
271
+ externalSource?: ExternalBookingSource
269
272
  onClose: () => void
270
- onCreated?: (bookingId: string) => void
273
+ onCreated?: (bookingId: string, source?: ExternalBookingSource) => void
271
274
  }
272
275
 
273
- export function AppointmentModal({ open, mode, bookingId, prefill, initialTab, onClose, onCreated }: Props) {
276
+ export function AppointmentModal({ open, mode, bookingId, prefill, initialTab, externalSource, onClose, onCreated }: Props) {
274
277
  const t = useTranslation()
275
278
  const locale = getCurrentLocale()
276
279
  const config = useAgendaConfig()
@@ -285,8 +288,12 @@ export function AppointmentModal({ open, mode, bookingId, prefill, initialTab, o
285
288
  const storeLocations = useAgendaStore((s) => s.locations)
286
289
 
287
290
  // --- State ---
291
+ // Default to the first configured booking type (e.g. 'event' in simple mode,
292
+ // 'appointment' otherwise) so single-type agendas resolve their type cleanly.
293
+ const defaultBookingType = (config.bookingTypes[0]?.id ?? 'appointment') as BookingTypeId
288
294
  const [loading, setLoading] = useState(mode === 'edit')
289
- const [bookingType, setBookingType] = useState<BookingTypeId>(prefill?.kind ?? 'appointment')
295
+ const [bookingType, setBookingType] = useState<BookingTypeId>(prefill?.kind ?? defaultBookingType)
296
+ const [title, setTitle] = useState('')
290
297
  const [clientId, setClientId] = useState(prefill?.clientId ?? '')
291
298
  const [clientSearch, setClientSearch] = useState('')
292
299
  const [clientPhone, setClientPhone] = useState('')
@@ -331,6 +338,10 @@ export function AppointmentModal({ open, mode, bookingId, prefill, initialTab, o
331
338
  const [clientData, setClientData] = useState<Record<string, any> | null>(null)
332
339
  const [clientDataLoading, setClientDataLoading] = useState(false)
333
340
  const [clientEditing, setClientEditing] = useState(false)
341
+ const [cancellationReasons, setCancellationReasons] = useState<CancellationReasonItem[]>([])
342
+ const [cancellationReasonId, setCancellationReasonId] = useState('')
343
+ const [cancellationNotes, setCancellationNotes] = useState('')
344
+ const [cancellationReasonsLoading, setCancellationReasonsLoading] = useState(false)
334
345
 
335
346
  // Active booking type config
336
347
  const activeType = config.bookingTypes.find((bt) => bt.id === bookingType) ?? config.bookingTypes[0]
@@ -343,10 +354,11 @@ export function AppointmentModal({ open, mode, bookingId, prefill, initialTab, o
343
354
  setServiceSearch(''); setNotes(''); setStatus('scheduled'); setShowNotes(false)
344
355
  setConflict(false); setLoading(mode === 'edit')
345
356
  setQuickCreate(false); setNewClientName(''); setNewClientPhone(''); setNewClientEmail(''); setCreatingClient(false)
346
- setBookingType(prefill?.kind ?? 'appointment')
357
+ setBookingType(prefill?.kind ?? defaultBookingType); setTitle('')
347
358
  setEditingDate(false); setEditingTime(false); setConfirmingDelete(false); setDeleting(false)
348
359
  setEditOrderId(null); setEditOrderTotal(0); setEditPaymentStatus('none'); setPaymentStatusLoading(false)
349
360
  setClientData(null); setClientDataLoading(false); setClientEditing(false)
361
+ setCancellationReasonId(''); setCancellationNotes(''); setCancellationReasonsLoading(false)
350
362
  setModalTab((initialTab as 'appointment' | 'client' | 'financial') || 'appointment')
351
363
  if (!prefill?.professionalId && professionals.length === 1) setProfessionalId(professionals[0].id)
352
364
  setDate(() => {
@@ -368,7 +380,8 @@ export function AppointmentModal({ open, mode, bookingId, prefill, initialTab, o
368
380
  setLoading(true)
369
381
  provider.getBookingById(bookingId).then((b) => {
370
382
  if (!b) { setLoading(false); return }
371
- setBookingType((b.kind as BookingTypeId) ?? 'appointment')
383
+ setBookingType((b.kind as BookingTypeId) ?? defaultBookingType)
384
+ setTitle(b.clientName ?? '')
372
385
  setClientId(b.clientId ?? ''); setClientSearch(b.clientName ?? '')
373
386
  setProfessionalId(b.professionalId ?? ''); setLocationId(b.locationId ?? '')
374
387
  setDate(b.startsAt.slice(0, 10))
@@ -385,10 +398,11 @@ export function AppointmentModal({ open, mode, bookingId, prefill, initialTab, o
385
398
  // Save initial snapshot for dirty check
386
399
  const svcSnap = b.services ? b.services.map((s: any) => ({ id: s.serviceId ?? '', dur: s.durationMinutes, price: s.price })) : []
387
400
  initialSnapshot.current = JSON.stringify({
401
+ title: b.clientName ?? '',
388
402
  clientId: b.clientId ?? '', professionalId: b.professionalId ?? '', locationId: b.locationId ?? '',
389
403
  date: b.startsAt.slice(0, 10),
390
404
  startTime: `${String(new Date(b.startsAt).getHours()).padStart(2, '0')}:${String(new Date(b.startsAt).getMinutes()).padStart(2, '0')}`,
391
- status: b.status, notes: b.notes ?? '', services: svcSnap,
405
+ status: b.status, notes: b.notes ?? '', cancellationReasonId: '', cancellationNotes: '', services: svcSnap,
392
406
  })
393
407
  setLoading(false)
394
408
  // Resolve payment status from bridge if available
@@ -415,6 +429,15 @@ export function AppointmentModal({ open, mode, bookingId, prefill, initialTab, o
415
429
  }).catch(() => setClientDataLoading(false))
416
430
  }, [modalTab, clientId])
417
431
 
432
+ useEffect(() => {
433
+ if (!open || status !== 'cancelled' || !config.cancellationDetails || cancellationReasons.length > 0) return
434
+ setCancellationReasonsLoading(true)
435
+ config.cancellationDetails.listReasons()
436
+ .then((reasons) => setCancellationReasons(reasons))
437
+ .catch(() => setCancellationReasons([]))
438
+ .finally(() => setCancellationReasonsLoading(false))
439
+ }, [open, status, config.cancellationDetails, cancellationReasons.length])
440
+
418
441
  // Fetch initial service list when modal opens
419
442
  useEffect(() => {
420
443
  if (!open || !config.serviceLookup) return
@@ -497,14 +520,24 @@ export function AppointmentModal({ open, mode, bookingId, prefill, initialTab, o
497
520
  const startsAt = new Date(`${date}T${startTime}:00`).toISOString()
498
521
  if (mode === 'create') {
499
522
  const newBooking = await createBooking({ kind: bookingType, clientId, professionalId, locationId: locationId || undefined, startsAt, notes: notes || undefined,
523
+ title: activeType.fields.title ? title.trim() : undefined,
500
524
  services: services.map((s: any) => ({ serviceId: s.serviceId, name: s.name, durationMinutes: s.durationMinutes, price: s.price })) })
501
525
  onClose()
502
- onCreated?.(newBooking.id)
526
+ onCreated?.(newBooking.id, externalSource)
503
527
  } else if (bookingId) {
504
528
  await updateBooking(bookingId, {
505
529
  clientId, professionalId, locationId: locationId || undefined, startsAt, notes: notes || undefined, status: status as any,
530
+ title: activeType.fields.title ? title.trim() : undefined,
506
531
  services: services.map((s: any) => ({ serviceId: s.serviceId, name: s.name, durationMinutes: s.durationMinutes, price: s.price })),
507
532
  })
533
+ if (status === 'cancelled' && config.cancellationDetails && cancellationReasonId) {
534
+ await config.cancellationDetails.saveCancellationDetails({
535
+ bookingId,
536
+ reasonId: cancellationReasonId,
537
+ notes: cancellationNotes.trim() || undefined,
538
+ cancelledAt: new Date().toISOString(),
539
+ })
540
+ }
508
541
  onClose()
509
542
  }
510
543
  } catch { /* toast */ }
@@ -519,18 +552,24 @@ export function AppointmentModal({ open, mode, bookingId, prefill, initialTab, o
519
552
  }
520
553
 
521
554
  const isPaidBooking = editPaymentStatus === 'paid'
555
+ const selectedCancellationReason = cancellationReasons.find((reason) => reason.id === cancellationReasonId)
556
+ const needsCancellationReason = mode === 'edit' && status === 'cancelled' && !!config.cancellationDetails
557
+ const needsCancellationNotes = needsCancellationReason && !!selectedCancellationReason?.requiresNotes
522
558
 
523
559
  // Dirty check — only enable Update when something changed
524
560
  const formSnapshot = useMemo(() =>
525
- JSON.stringify({ clientId, professionalId, locationId, date, startTime, status, notes, services: services.map((s: any) => ({ id: s.serviceId, dur: s.durationMinutes, price: s.price })) }),
526
- [clientId, professionalId, locationId, date, startTime, status, notes, services]
561
+ JSON.stringify({ title, clientId, professionalId, locationId, date, startTime, status, notes, cancellationReasonId, cancellationNotes, services: services.map((s: any) => ({ id: s.serviceId, dur: s.durationMinutes, price: s.price })) }),
562
+ [title, clientId, professionalId, locationId, date, startTime, status, notes, cancellationReasonId, cancellationNotes, services]
527
563
  )
528
564
  const isDirty = mode === 'create' || formSnapshot !== initialSnapshot.current
529
565
 
530
566
  const canSubmit = (
567
+ (!activeType.fields.title || title.trim()) &&
531
568
  (!activeType.requiresClient || clientId) &&
532
569
  (!activeType.fields.professional || professionalId) &&
533
570
  (!activeType.requiresServices || services.length > 0) &&
571
+ (!needsCancellationReason || !!cancellationReasonId) &&
572
+ (!needsCancellationNotes || !!cancellationNotes.trim()) &&
534
573
  !conflict && !saving
535
574
  )
536
575
 
@@ -748,6 +787,16 @@ export function AppointmentModal({ open, mode, bookingId, prefill, initialTab, o
748
787
 
749
788
  {!loading && <>
750
789
 
790
+ {/* Title — free-text event name (simple / Google-Calendar-style events) */}
791
+ {activeType.fields.title && (
792
+ <div className="flex items-center gap-3 py-1">
793
+ <Calendar className="h-4 w-4 text-muted-foreground shrink-0" />
794
+ <input type="text" value={title} onChange={(e) => setTitle(e.target.value)}
795
+ placeholder={t('agenda.appointment.addTitle')} autoFocus={mode === 'create'}
796
+ className="w-full flex-1 rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-2.5 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-ring" />
797
+ </div>
798
+ )}
799
+
751
800
  {/* Client */}
752
801
  {activeType.fields.client && (
753
802
  <div className="flex items-center gap-3 py-1">
@@ -927,6 +976,34 @@ export function AppointmentModal({ open, mode, bookingId, prefill, initialTab, o
927
976
  </div>
928
977
  )}
929
978
 
979
+ {needsCancellationReason && (
980
+ <div className="flex items-start gap-3 py-1">
981
+ <Ban className="h-4 w-4 mt-2 text-muted-foreground shrink-0" />
982
+ <div className="flex-1 space-y-2">
983
+ <select
984
+ value={cancellationReasonId}
985
+ onChange={(event) => setCancellationReasonId(event.target.value)}
986
+ disabled={cancellationReasonsLoading}
987
+ className="w-full rounded-input border border-input bg-card px-2.5 py-1.5 text-sm shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] focus:outline-none focus:ring-2 focus:ring-ring"
988
+ >
989
+ <option value="">{cancellationReasonsLoading ? t('agenda.cancellation.loadingReasons') : t('agenda.cancellation.selectReason')}</option>
990
+ {cancellationReasons.map((reason) => (
991
+ <option key={reason.id} value={reason.id}>
992
+ {reason.label}{reason.requiresNotes ? ' *' : ''}
993
+ </option>
994
+ ))}
995
+ </select>
996
+ <textarea
997
+ value={cancellationNotes}
998
+ onChange={(event) => setCancellationNotes(event.target.value)}
999
+ placeholder={needsCancellationNotes ? t('agenda.cancellation.notesRequired') : t('agenda.cancellation.notesOptional')}
1000
+ rows={2}
1001
+ className="w-full rounded-input border border-input bg-card px-2.5 py-1.5 text-sm resize-none shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] focus:outline-none focus:ring-2 focus:ring-ring"
1002
+ />
1003
+ </div>
1004
+ </div>
1005
+ )}
1006
+
930
1007
  {/* Notes — progressive disclosure */}
931
1008
  {!showNotes ? (
932
1009
  <div className="flex items-center gap-3 py-1">
@@ -126,7 +126,14 @@ export function AppointmentPopover({ booking, position, onClose, onEdit }: Props
126
126
  <div className="flex items-start gap-3">
127
127
  <span className="mt-1.5 h-3 w-3 rounded shrink-0" style={{ backgroundColor: color }} />
128
128
  <div className="min-w-0">
129
- <PersonLink personId={booking.clientId} name={booking.clientName ?? 'Unknown'} className="text-base" />
129
+ {/* Client agendas link to the person; simple events (no client
130
+ capability) render the free-text title as a plain heading —
131
+ booking.clientName carries the event title in that model. */}
132
+ {config.capabilities.client ? (
133
+ <PersonLink personId={booking.clientId} name={booking.clientName ?? 'Unknown'} className="text-base" />
134
+ ) : (
135
+ <p className="text-base font-medium truncate">{booking.clientName || t('agenda.appointment.untitled')}</p>
136
+ )}
130
137
  <p className="text-sm text-muted-foreground mt-0.5">{fmtDate(booking.startsAt)} &middot; {timeRange}</p>
131
138
  </div>
132
139
  </div>
@@ -142,7 +149,7 @@ export function AppointmentPopover({ booking, position, onClose, onEdit }: Props
142
149
  </div>
143
150
  )}
144
151
 
145
- {booking.professionalName && (
152
+ {config.capabilities.professional && booking.professionalName && (
146
153
  <div className="flex items-center gap-3">
147
154
  <User className="h-4 w-4 text-muted-foreground shrink-0" />
148
155
  <span className="text-sm">{booking.professionalName}</span>
@@ -204,14 +211,17 @@ export function AppointmentPopover({ booking, position, onClose, onEdit }: Props
204
211
  )
205
212
  })()}
206
213
 
207
- <div className="pt-2 border-t fayz-glass-divider">
208
- <PopoverStatusSelect
209
- value={booking.status}
210
- statuses={config.statuses}
211
- bookingStartsAt={booking.startsAt}
212
- onChange={(v) => { updateStatus(booking.id, v); onClose() }}
213
- />
214
- </div>
214
+ {/* Status workflow — hidden for simple events (no status capability). */}
215
+ {config.capabilities.status && (
216
+ <div className="pt-2 border-t fayz-glass-divider">
217
+ <PopoverStatusSelect
218
+ value={booking.status}
219
+ statuses={config.statuses}
220
+ bookingStartsAt={booking.startsAt}
221
+ onChange={(v) => { updateStatus(booking.id, v); onClose() }}
222
+ />
223
+ </div>
224
+ )}
215
225
  </div>
216
226
  </>
217
227
  )}
@@ -101,6 +101,8 @@ export function EventContextMenu({ booking, position, onClose, onEdit }: Props)
101
101
  )}
102
102
  </div>
103
103
 
104
+ {/* Status workflow — hidden for simple events (no status capability). */}
105
+ {config.capabilities.status && (
104
106
  <div className="border-t px-4 py-3">
105
107
  <p className="text-[11px] font-medium text-muted-foreground mb-2">{t('agenda.contextMenu.changeStatus')}</p>
106
108
  <div className="flex items-center gap-1.5">
@@ -115,6 +117,7 @@ export function EventContextMenu({ booking, position, onClose, onEdit }: Props)
115
117
  })}
116
118
  </div>
117
119
  </div>
120
+ )}
118
121
  </>
119
122
  )}
120
123
  </FloatingPanel>
package/src/config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PluginScope, VerticalId } from '@fayz-ai/core'
1
+ import type { PluginRegistryDef, PluginScope, VerticalId } from '@fayz-ai/core'
2
2
  import type { EntityLookup } from '@fayz-ai/saas'
3
3
  import type { AgendaDataProvider } from './data/types'
4
4
  import type { StatusConfig, BookingTypeConfig } from './types'
@@ -29,6 +29,28 @@ export interface ConfirmationChannelOption {
29
29
  icon: string
30
30
  }
31
31
 
32
+ export interface CancellationReasonOption {
33
+ id: string
34
+ label: string
35
+ requiresNotes?: boolean
36
+ }
37
+
38
+ export interface CancellationDetailsProvider {
39
+ listReasons(): Promise<CancellationReasonOption[]>
40
+ saveCancellationDetails(input: {
41
+ bookingId: string
42
+ reasonId: string
43
+ notes?: string
44
+ cancelledAt?: string
45
+ }): Promise<void>
46
+ }
47
+
48
+ export interface ExternalBookingSource {
49
+ type: string
50
+ id?: string
51
+ [key: string]: unknown
52
+ }
53
+
32
54
  export interface LocationOption {
33
55
  id: string
34
56
  name: string
@@ -60,6 +82,14 @@ export interface AgendaPluginOptions {
60
82
  /** Booking type tabs shown in the appointment modal */
61
83
  bookingTypes?: BookingTypeConfig[]
62
84
 
85
+ /**
86
+ * Simplifies the agenda to Google-Calendar-style events (no client /
87
+ * professional / service). When set to 'simple', the plugin exposes a single
88
+ * "Evento" booking type whose modal shows only title / date-time / notes.
89
+ * Ignored when an explicit `bookingTypes` override is provided.
90
+ */
91
+ eventMode?: 'simple'
92
+
63
93
  /** Booking status options */
64
94
  statuses?: StatusConfig[]
65
95
 
@@ -105,6 +135,15 @@ export interface AgendaPluginOptions {
105
135
  /** Confirmation channel options */
106
136
  confirmationChannels?: ConfirmationChannelOption[]
107
137
 
138
+ /** Optional app/plugin bridge for cancel-time reason capture. */
139
+ cancellationDetails?: CancellationDetailsProvider
140
+
141
+ /** Called after a booking is created from an external operational source. */
142
+ onBookingCreated?: (input: { bookingId: string; source?: ExternalBookingSource }) => void | Promise<void>
143
+
144
+ /** App-owned registries shown under /settings/agenda Properties. */
145
+ settingsRegistries?: PluginRegistryDef[]
146
+
108
147
  /** Financial bridge for agenda × financial cross-plugin integration */
109
148
  financialBridge?: AgendaFinancialBridge
110
149
 
@@ -172,6 +211,17 @@ const DEFAULT_BOOKING_TYPES: BookingTypeConfig[] = [
172
211
  },
173
212
  ]
174
213
 
214
+ // Simple / Google-Calendar-style event — a single "Evento" type with just a
215
+ // free-text title, date-time and notes. No client / professional / service.
216
+ // Used by personal-finance-style agendas (see eventMode: 'simple').
217
+ const SIMPLE_EVENT_BOOKING_TYPES: BookingTypeConfig[] = [
218
+ {
219
+ id: 'event', label: 'Event', icon: 'Calendar', color: '#6366f1',
220
+ fields: { title: true, client: false, professional: false, services: false, location: false, status: false },
221
+ requiresServices: false, requiresClient: false,
222
+ },
223
+ ]
224
+
175
225
  // ---------------------------------------------------------------------------
176
226
  // Resolved config — fully merged, no optionals
177
227
  // ---------------------------------------------------------------------------
@@ -185,6 +235,24 @@ export interface AgendaModules {
185
235
  financial: boolean
186
236
  }
187
237
 
238
+ /**
239
+ * Derived UI capabilities — computed ONCE in resolveConfig from the resolved
240
+ * bookingTypes so no component ever re-derives "is this a simple event agenda?"
241
+ * ad-hoc. A capability is enabled when ANY booking type uses that field. In
242
+ * eventMode:'simple' the single "Evento" type disables
243
+ * client/professional/services/location/status, so every consumer (popover,
244
+ * filter rail, modal) hides the professional/service/status machinery in one
245
+ * place instead of scattering `eventMode === 'simple'` checks.
246
+ */
247
+ export interface AgendaCapabilities {
248
+ title: boolean
249
+ client: boolean
250
+ professional: boolean
251
+ services: boolean
252
+ location: boolean
253
+ status: boolean
254
+ }
255
+
188
256
  export interface AgendaCurrency {
189
257
  code: string
190
258
  locale: string
@@ -193,6 +261,10 @@ export interface AgendaCurrency {
193
261
 
194
262
  export interface ResolvedAgendaConfig {
195
263
  modules: AgendaModules
264
+ /** Whether this is a simple Google-Calendar-style event agenda (eventMode:'simple'). */
265
+ simpleMode: boolean
266
+ /** Derived once from bookingTypes — see AgendaCapabilities. */
267
+ capabilities: AgendaCapabilities
196
268
  labels: AgendaPluginLabels
197
269
  currency: AgendaCurrency
198
270
  bookingKind: string
@@ -207,6 +279,8 @@ export interface ResolvedAgendaConfig {
207
279
  autoCreateOrder: boolean
208
280
  locations: LocationOption[]
209
281
  confirmationChannels: ConfirmationChannelOption[]
282
+ cancellationDetails?: CancellationDetailsProvider
283
+ onBookingCreated?: (input: { bookingId: string; source?: ExternalBookingSource }) => void | Promise<void>
210
284
  contactLookup?: EntityLookup
211
285
  clientEntityDef?: EntityDef
212
286
  serviceLookup?: EntityLookup
@@ -228,22 +302,45 @@ export interface ResolvedAgendaConfig {
228
302
  // ---------------------------------------------------------------------------
229
303
 
230
304
  export function resolveConfig(options?: AgendaPluginOptions): ResolvedAgendaConfig {
305
+ // ── eventMode:'simple' derives ALL module + capability defaults ONCE, here. ──
306
+ // Simple = Google-Calendar-style events (title/description + timestamp). It
307
+ // flips professional/service machinery OFF by default: no working-hours /
308
+ // schedule checks, no confirmations outreach, no conflict (double-booking)
309
+ // detection, no location. Explicit `modules.*` re-enable individually. Full
310
+ // mode (beauty) is unchanged — every default below matches the old behavior
311
+ // when `simple` is false. Field-level machinery (client/professional/
312
+ // services/location/status/title) is derived into `capabilities` from the
313
+ // resolved bookingTypes, so components never test eventMode themselves.
314
+ const simple = options?.eventMode === 'simple'
315
+ const bookingTypes = options?.bookingTypes
316
+ ?? (simple ? SIMPLE_EVENT_BOOKING_TYPES : DEFAULT_BOOKING_TYPES)
317
+ const capabilities: AgendaCapabilities = {
318
+ title: bookingTypes.some((bt) => bt.fields.title === true),
319
+ client: bookingTypes.some((bt) => bt.fields.client),
320
+ professional: bookingTypes.some((bt) => bt.fields.professional),
321
+ services: bookingTypes.some((bt) => bt.fields.services),
322
+ location: bookingTypes.some((bt) => bt.fields.location),
323
+ status: bookingTypes.some((bt) => bt.fields.status),
324
+ }
231
325
  return {
232
326
  modules: {
233
- workingHours: options?.modules?.workingHours !== false,
234
- confirmations: options?.modules?.confirmations !== false,
235
- conflictDetection: options?.modules?.conflictDetection !== false,
327
+ // Off-unless-explicit in simple mode (`?? !simple`); default-on in full.
328
+ workingHours: options?.modules?.workingHours ?? !simple,
329
+ confirmations: options?.modules?.confirmations ?? !simple,
330
+ conflictDetection: options?.modules?.conflictDetection ?? !simple,
236
331
  dragAndDrop: options?.modules?.dragAndDrop !== false,
237
332
  locationSelection: options?.modules?.locationSelection === true,
238
333
  financial: options?.modules?.financial ?? !!options?.financialBridge,
239
334
  },
335
+ simpleMode: simple,
336
+ capabilities,
240
337
  labels: { ...DEFAULT_LABELS, ...options?.labels },
241
338
  currency: { ...DEFAULT_CURRENCY, ...options?.currency },
242
339
  bookingKind: options?.bookingKind ?? 'appointment',
243
340
  orderKind: options?.orderKind ?? 'service_order',
244
341
  scheduleKind: options?.scheduleKind ?? 'working_hours',
245
342
  statuses: options?.statuses ?? DEFAULT_STATUSES,
246
- bookingTypes: options?.bookingTypes ?? DEFAULT_BOOKING_TYPES,
343
+ bookingTypes,
247
344
  businessHours: options?.businessHours ?? { startTime: '07:00', endTime: '21:00' },
248
345
  slotDuration: options?.slotDuration ?? 30,
249
346
  professionalKind: options?.professionalKind ?? 'staff',
@@ -251,6 +348,8 @@ export function resolveConfig(options?: AgendaPluginOptions): ResolvedAgendaConf
251
348
  autoCreateOrder: options?.autoCreateOrder !== false,
252
349
  locations: options?.locations ?? [],
253
350
  confirmationChannels: options?.confirmationChannels ?? [],
351
+ cancellationDetails: options?.cancellationDetails,
352
+ onBookingCreated: options?.onBookingCreated,
254
353
  contactLookup: options?.contactLookup,
255
354
  clientEntityDef: options?.clientEntityDef,
256
355
  serviceLookup: options?.serviceLookup,
package/src/data/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export type { AgendaDataProvider } from './types'
2
2
  export { createMockAgendaProvider } from './mock'
3
+ export type { MockAgendaSeed, MockAgendaProviderOptions } from './mock'
3
4
  export { createSupabaseAgendaProvider } from './supabase'
package/src/data/mock.ts CHANGED
@@ -133,10 +133,26 @@ function createSampleSchedules(): Schedule[] {
133
133
  // Mock provider factory
134
134
  // ---------------------------------------------------------------------------
135
135
 
136
- export function createMockAgendaProvider(): AgendaDataProvider {
137
- const bookings: CalendarBooking[] = createSampleBookings()
138
- const schedules: Schedule[] = createSampleSchedules()
139
- const professionals: Professional[] = [...SAMPLE_PROFESSIONALS]
136
+ /**
137
+ * Pre-populated agenda data. Any omitted collection falls back to the built-in
138
+ * salon-style sample. Pass `bookings` (and optionally an empty `professionals`)
139
+ * to render a calendar seeded with app-specific events.
140
+ */
141
+ export interface MockAgendaSeed {
142
+ bookings?: CalendarBooking[]
143
+ professionals?: Professional[]
144
+ schedules?: Schedule[]
145
+ }
146
+
147
+ export interface MockAgendaProviderOptions {
148
+ seed?: MockAgendaSeed
149
+ }
150
+
151
+ export function createMockAgendaProvider(options?: MockAgendaProviderOptions): AgendaDataProvider {
152
+ const seed = options?.seed
153
+ const bookings: CalendarBooking[] = seed?.bookings ? [...seed.bookings] : createSampleBookings()
154
+ const schedules: Schedule[] = seed?.schedules ? [...seed.schedules] : createSampleSchedules()
155
+ const professionals: Professional[] = seed?.professionals ? [...seed.professionals] : [...SAMPLE_PROFESSIONALS]
140
156
 
141
157
  return {
142
158
  async getLocations(): Promise<Array<{ id: string; name: string }>> {
@@ -167,7 +183,7 @@ export function createMockAgendaProvider(): AgendaDataProvider {
167
183
  startsAt: input.startsAt, endsAt: addMinutes(input.startsAt, totalDuration || 30),
168
184
  status: 'scheduled', notes: input.notes ?? null,
169
185
  orderId: input.services.length > 0 ? uid() : null, locationId: input.locationId ?? null, metadata: {},
170
- clientId: input.clientId, clientName: 'New Client', clientPhone: null, clientEmail: null, clientAvatarUrl: null,
186
+ clientId: input.clientId, clientName: input.title?.trim() || 'New Client', clientPhone: null, clientEmail: null, clientAvatarUrl: null,
171
187
  professionalId: input.professionalId,
172
188
  professionalName: professionals.find((p) => p.id === input.professionalId)?.name ?? 'Unknown',
173
189
  professionalAvatarUrl: null,
@@ -201,6 +217,7 @@ export function createMockAgendaProvider(): AgendaDataProvider {
201
217
  ...existing,
202
218
  ...(data.startsAt && { startsAt: data.startsAt }),
203
219
  ...(data.endsAt && { endsAt: data.endsAt }),
220
+ ...(data.title !== undefined && { clientName: data.title }),
204
221
  ...(data.status && { status: data.status }),
205
222
  ...(data.notes !== undefined && { notes: data.notes ?? null }),
206
223
  ...(data.professionalId && { professionalId: data.professionalId }),
@@ -235,7 +252,10 @@ export function createMockAgendaProvider(): AgendaDataProvider {
235
252
  },
236
253
 
237
254
  async getAvailableSlots(params: AvailableSlotsParams): Promise<TimeSlot[]> {
238
- const date = new Date(params.date)
255
+ // Parse 'YYYY-MM-DD' as LOCAL midnight (not UTC) so the weekday and the
256
+ // generated slot instants land on the requested calendar day in any tz.
257
+ const [yy, mm, dd] = params.date.split('-').map(Number)
258
+ const date = new Date(yy, (mm ?? 1) - 1, dd ?? 1)
239
259
  const dayOfWeek = date.getDay()
240
260
  const interval = params.slotInterval ?? 30
241
261
 
@@ -150,7 +150,8 @@ async function hydrateCanonicalBookings(core: any, bookings: any[]): Promise<Cal
150
150
  locationId: booking.location_id,
151
151
  metadata: booking.metadata ?? {},
152
152
  clientId: booking.party_id,
153
- clientName: client?.name ?? null,
153
+ // Simple events (no party) fall back to the title stored in metadata.
154
+ clientName: client?.name ?? (booking.metadata?.title as string | undefined) ?? null,
154
155
  clientPhone: client?.phone ?? null,
155
156
  clientEmail: client?.email ?? null,
156
157
  clientAvatarUrl: client?.avatar_url ?? null,
@@ -253,6 +254,10 @@ export function createSupabaseAgendaProvider(): AgendaDataProvider {
253
254
  contactName = person?.name as string | undefined
254
255
  }
255
256
 
257
+ // Simple / Google-Calendar-style events have no client party — persist the
258
+ // free-text title in metadata so the read model can surface it as the name.
259
+ const eventTitle = input.title?.trim() || undefined
260
+
256
261
  const { data: order, error: orderErr } = await core.from('orders').insert({
257
262
  tenant_id: tenantId,
258
263
  kind: input.kind ?? 'appointment',
@@ -267,6 +272,7 @@ export function createSupabaseAgendaProvider(): AgendaDataProvider {
267
272
  source: 'agenda',
268
273
  ...(serviceNames ? { serviceNames, itemsSummary: serviceNames } : {}),
269
274
  ...(contactName ? { contactName } : {}),
275
+ ...(eventTitle ? { title: eventTitle } : {}),
270
276
  },
271
277
  }).select('id').single()
272
278
  if (orderErr) throw orderErr
@@ -286,6 +292,7 @@ export function createSupabaseAgendaProvider(): AgendaDataProvider {
286
292
  source: 'agenda',
287
293
  ...(serviceNames ? { serviceNames, itemsSummary: serviceNames } : {}),
288
294
  ...(contactName ? { contactName } : {}),
295
+ ...(eventTitle ? { title: eventTitle } : {}),
289
296
  },
290
297
  }).select('id').single()
291
298
  if (bookingErr) throw bookingErr
@@ -315,7 +322,7 @@ export function createSupabaseAgendaProvider(): AgendaDataProvider {
315
322
  async updateBooking(id: string, data: UpdateBookingInput): Promise<CalendarBooking> {
316
323
  const { core } = getClients()
317
324
  const { data: existing, error: existingErr } = await core.from('bookings')
318
- .select('id, order_id, starts_at, assignee_id')
325
+ .select('id, order_id, starts_at, assignee_id, metadata')
319
326
  .eq('id', id)
320
327
  .single()
321
328
  if (existingErr) throw existingErr
@@ -323,6 +330,10 @@ export function createSupabaseAgendaProvider(): AgendaDataProvider {
323
330
  const updates: Record<string, unknown> = {}
324
331
  if (data.startsAt) updates.starts_at = data.startsAt
325
332
  if (data.endsAt) updates.ends_at = data.endsAt
333
+ // Simple-event title lives in metadata (no client party) — merge, don't clobber.
334
+ if (data.title !== undefined) {
335
+ updates.metadata = { ...(existing?.metadata as Record<string, unknown> ?? {}), title: data.title }
336
+ }
326
337
  if (data.status) {
327
338
  // Only update the agenda status — never touch the financial stage
328
339
  updates.status = data.status
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@ export function createAgendaPlugin(options?: AgendaPluginOptions): PluginManifes
25
25
  () => createMockAgendaProvider(),
26
26
  )
27
27
  const store = createAgendaStore(provider, options?.financialBridge)
28
+ const registries = [...agendaRegistries, ...(options?.settingsRegistries ?? [])]
28
29
 
29
30
  // Register schedule block config globally so ScheduleEditor can read it
30
31
  setScheduleBlockConfig({
@@ -55,6 +56,9 @@ export function createAgendaPlugin(options?: AgendaPluginOptions): PluginManifes
55
56
  version: '1.0.0',
56
57
  scope: options?.scope ?? 'universal',
57
58
  verticalId: options?.verticalId,
59
+ // Booking/scheduling is business-ops (clinic, salon, services) — targets
60
+ // the saas/admin Panel world, not the ecommerce storefront.
61
+ scaffolds: ['saas'],
58
62
  defaultEnabled: true,
59
63
  dependencies: [],
60
64
  declaredFeatures: [
@@ -152,19 +156,19 @@ export function createAgendaPlugin(options?: AgendaPluginOptions): PluginManifes
152
156
  },
153
157
  ],
154
158
 
155
- registries: agendaRegistries,
159
+ registries,
156
160
 
157
161
  settings: [
158
162
  {
159
163
  id: 'agenda',
160
164
  label: 'Agenda',
161
165
  icon: 'Calendar',
162
- // Standard layout: General | Integrations (Google Calendar addon) | Properties (holidays).
166
+ // Standard layout: General | Integrations (Google Calendar addon) | Properties.
163
167
  component: (() => {
164
168
  const AgendaSettingsTab: React.FC = () => React.createElement(PluginSettingsPanel, {
165
169
  title: 'Agenda',
166
170
  generalSettings: React.createElement(AgendaGeneralSettings),
167
- registries: agendaRegistries,
171
+ registries,
168
172
  hostPluginId: 'agenda',
169
173
  routeBase: '/settings/agenda',
170
174
  })
@@ -183,6 +187,10 @@ export function createAgendaPlugin(options?: AgendaPluginOptions): PluginManifes
183
187
  export type { AgendaPluginOptions } from './config'
184
188
  export type { ResolvedAgendaConfig } from './config'
185
189
  export type { AgendaDataProvider } from './data/types'
190
+ // Seedable in-memory provider — apps pass a seed to render the calendar with
191
+ // app-specific events instead of the built-in salon sample.
192
+ export { createMockAgendaProvider } from './data/mock'
193
+ export type { MockAgendaSeed, MockAgendaProviderOptions } from './data/mock'
186
194
  export type { AgendaFinancialBridge, BookingPaymentStatus, BookingPaymentSummary, BookingPaymentDetail } from './financial-bridge'
187
195
  export { createFinancialBridge, computeCommissionAmount } from './bridges/create-financial-bridge'
188
196
 
@@ -4,7 +4,7 @@
4
4
  // booking↔event mapping are all proven HERE — before/without the deployed edge
5
5
  // function. The data-plane behaviors that need a live Google account are written
6
6
  // as it.todo specs so the intended contract is documented and pending, not
7
- // silently missing. See PLUGIN_PATTERNS.md → integration capability.
7
+ // silently missing. See docs/PLUGIN-PATTERNS.md → integration capability.
8
8
  import { describe, it, expect } from 'vitest'
9
9
  import { assertConnectorContract } from '@fayz-ai/core/testing'
10
10
  import { googleCalendarConnector } from './connector'