@fayz-ai/plugin-agenda 0.11.0 → 0.12.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.
@@ -1 +1 @@
1
- {"version":3,"file":"create-financial-bridge.d.ts","sourceRoot":"","sources":["../../src/bridges/create-financial-bridge.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAA;AACtE,OAAO,KAAK,EAAE,qBAAqB,EAAqE,MAAM,oBAAoB,CAAA;AAGlI;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAKlF;AAeD;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,qBAAqB,GAAG,qBAAqB,CA0O5F"}
1
+ {"version":3,"file":"create-financial-bridge.d.ts","sourceRoot":"","sources":["../../src/bridges/create-financial-bridge.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAA;AACtE,OAAO,KAAK,EAAE,qBAAqB,EAAqE,MAAM,oBAAoB,CAAA;AAGlI;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAKlF;AAeD;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,qBAAqB,GAAG,qBAAqB,CA4O5F"}
@@ -5,6 +5,15 @@ export interface AgendaMobileListProps {
5
5
  statusColors: Record<string, string>;
6
6
  currencyCode: string;
7
7
  onSelect: (booking: CalendarBooking) => void;
8
+ /** Lets the empty state offer the one thing the screen is for. */
9
+ onCreate?: () => void;
8
10
  }
9
- export declare function AgendaMobileList({ bookings, statusColors, currencyCode, onSelect }: AgendaMobileListProps): React.JSX.Element;
11
+ /**
12
+ * Loading state for the mobile list. Shaped like the list it replaces — a day
13
+ * heading and a couple of rows — so the screen does not jump when real data
14
+ * lands. A blank pane while the query runs reads as "nothing scheduled", which
15
+ * is the one answer this screen must never give wrongly.
16
+ */
17
+ export declare function AgendaMobileListSkeleton(): React.JSX.Element;
18
+ export declare function AgendaMobileList({ bookings, statusColors, currencyCode, onSelect, onCreate }: AgendaMobileListProps): React.JSX.Element;
10
19
  //# sourceMappingURL=AgendaMobileList.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"AgendaMobileList.d.ts","sourceRoot":"","sources":["../../src/components/AgendaMobileList.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAkB,MAAM,OAAO,CAAA;AAEtC,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,UAAU,CAAA;AAoB/C,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,eAAe,EAAE,CAAA;IAC3B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACpC,YAAY,EAAE,MAAM,CAAA;IACpB,QAAQ,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,IAAI,CAAA;CAC7C;AAED,wBAAgB,gBAAgB,CAAC,EAAE,QAAQ,EAAE,YAAY,EAAE,YAAY,EAAE,QAAQ,EAAE,EAAE,qBAAqB,qBAyEzG"}
1
+ {"version":3,"file":"AgendaMobileList.d.ts","sourceRoot":"","sources":["../../src/components/AgendaMobileList.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAkB,MAAM,OAAO,CAAA;AAEtC,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,UAAU,CAAA;AAoB/C,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,eAAe,EAAE,CAAA;IAC3B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACpC,YAAY,EAAE,MAAM,CAAA;IACpB,QAAQ,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,IAAI,CAAA;IAC5C,kEAAkE;IAClE,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,sBAsBvC;AAED,wBAAgB,gBAAgB,CAAC,EAAE,QAAQ,EAAE,YAAY,EAAE,YAAY,EAAE,QAAQ,EAAE,QAAQ,EAAE,EAAE,qBAAqB,qBAkFnH"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AAGnD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAA;AAiBnD,wBAAgB,kBAAkB,CAAC,OAAO,CAAC,EAAE,mBAAmB,GAAG,cAAc,CAiRhF;AAGD,YAAY,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAA;AACnD,YAAY,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAA;AACpD,YAAY,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAA;AAGtD,OAAO,EAAE,wBAAwB,EAAE,MAAM,aAAa,CAAA;AACtD,YAAY,EAAE,cAAc,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAA;AAC5E,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAA;AAC1I,OAAO,EAAE,qBAAqB,EAAE,uBAAuB,EAAE,MAAM,mCAAmC,CAAA;AAGlG,OAAO,EAAE,0BAA0B,EAAE,4BAA4B,EAAE,uBAAuB,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAA;AAIvK,OAAO,EAAE,uBAAuB,EAAE,0BAA0B,EAAE,MAAM,sBAAsB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AAGnD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAA;AAiBnD,wBAAgB,kBAAkB,CAAC,OAAO,CAAC,EAAE,mBAAmB,GAAG,cAAc,CA0RhF;AAGD,YAAY,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAA;AACnD,YAAY,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAA;AACpD,YAAY,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAA;AAGtD,OAAO,EAAE,wBAAwB,EAAE,MAAM,aAAa,CAAA;AACtD,YAAY,EAAE,cAAc,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAA;AAC5E,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAA;AAC1I,OAAO,EAAE,qBAAqB,EAAE,uBAAuB,EAAE,MAAM,mCAAmC,CAAA;AAGlG,OAAO,EAAE,0BAA0B,EAAE,4BAA4B,EAAE,uBAAuB,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAA;AAIvK,OAAO,EAAE,uBAAuB,EAAE,0BAA0B,EAAE,MAAM,sBAAsB,CAAA"}
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import { createMockAgendaProvider, createSupabasePublicBookingProvider } from '.
2
2
  export { createMockAgendaProvider } from './chunk-ISLJXP7O.js';
3
3
  import React13, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
4
4
  import { connectionStatus, createConnectionStore, getSupabaseClientOptional, getActiveTenantId, registerTranslations, registerMessageDefaults, createSafeDataProvider, connectorRuntimeToken, CONNECTOR_RUNTIME_TOKEN_HEADER, emitDataChanged, usePluginRuntimeOptional, useTranslation, emitDomainEvent, getCurrentLocale, useDataChanged, formatCurrency } from '@fayz-ai/core';
5
- import { createPluginContext, fayzConnectorOAuth, setScheduleBlockConfig, getScheduleBlockConfig, PluginSettingsPanel, dedup, useTenantPluginSettings, SettingsGroup, ToggleRow, SelectRow, usePluginPrefs, PersonLink, useLimitGuard, useLookupCreate, RightRailPage, CrudFormPage, CrudDetailPage, ContactPicker, invalidateLimit } from '@fayz-ai/admin';
5
+ import { createPluginContext, fayzConnectorOAuth, setScheduleBlockConfig, getScheduleBlockConfig, PluginSettingsPanel, dedup, useTenantPluginSettings, SettingsGroup, ToggleRow, SelectRow, usePluginPrefs, PersonLink, PageActions, useLimitGuard, useLookupCreate, RightRailPage, CrudFormPage, CrudDetailPage, ContactPicker, invalidateLimit } from '@fayz-ai/admin';
6
6
  import FullCalendar from '@fullcalendar/react';
7
7
  import dayGridPlugin from '@fullcalendar/daygrid';
8
8
  import timeGridPlugin from '@fullcalendar/timegrid';
@@ -10,20 +10,29 @@ import resourceTimeGridPlugin from '@fullcalendar/resource-timegrid';
10
10
  import interactionPlugin from '@fullcalendar/interaction';
11
11
  import listPlugin from '@fullcalendar/list';
12
12
  import ptBrLocale from '@fullcalendar/core/locales/pt-br';
13
- import { Loader2, AlertCircle, RefreshCw, CalendarPlus, X, CheckCircle2, Plus, ChevronDown, ChevronLeft, ChevronRight, Settings, Pencil, Trash2, Clock, User, MapPin, FileText, AlertTriangle, Check, DollarSign, HandCoins, Ban, CheckSquare, Calendar, Briefcase, CircleDot, FileCheck, ArrowRightLeft, CreditCard, QrCode, Banknote, ExternalLink } from 'lucide-react';
13
+ import { Loader2, AlertCircle, RefreshCw, CalendarPlus, X, CheckCircle2, Plus, ChevronDown, Settings, ChevronLeft, ChevronRight, Pencil, Trash2, Clock, User, MapPin, FileText, AlertTriangle, Check, DollarSign, HandCoins, Ban, CheckSquare, Calendar, Briefcase, CircleDot, FileCheck, ArrowRightLeft, CreditCard, QrCode, Banknote, ExternalLink } from 'lucide-react';
14
14
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
15
15
  import { createPortal } from 'react-dom';
16
- import { toast, Button, Checkbox, Badge, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, ConfirmDialog, SearchCombobox, cn, TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@fayz-ai/ui';
16
+ import { toast, Button, Checkbox, Badge, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, PageHeaderActions, ConfirmDialog, SearchCombobox, cn, TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@fayz-ai/ui';
17
17
  import { createStore } from 'zustand/vanilla';
18
18
 
19
19
  // src/lib/message-events.ts
20
20
  var BOOKING_FIELDS = [
21
21
  { name: "customer_name", label: "Cliente", type: "string", example: "Maria Silva" },
22
+ // Para ONDE a mensagem vai — não é texto de mensagem nenhuma, é o endereço.
23
+ // A automação de WhatsApp lê este campo (`recipientField`, padrão
24
+ // 'customer_phone') e pula o disparo quando ele vem vazio: sem número não há
25
+ // envio, e um evento sem ele é um lembrete que nunca sai.
26
+ { name: "customer_phone", label: "Telefone do cliente", type: "string", example: "(21) 99889-9889" },
22
27
  { name: "service_name", label: "Servi\xE7o", type: "string", example: "Corte + Escova" },
23
28
  { name: "professional_name", label: "Profissional", type: "string", example: "Camila Duarte" },
24
29
  { name: "booking_date", label: "Data", type: "date", example: "12/08/2026" },
25
30
  { name: "booking_time", label: "Hor\xE1rio", type: "string", example: "14:30" },
26
31
  { name: "location_name", label: "Local", type: "string", example: "Unidade Centro" },
32
+ // Not in bookingPayload on purpose: the browser has no reliable source for
33
+ // the tenant's public name, so the SENDER fills this slot (messaging-send
34
+ // reads the tenants row). The field stays declared because templates use it
35
+ // and the editor needs its placeholder.
27
36
  { name: "business_name", label: "Seu neg\xF3cio", type: "string", example: "Seu Neg\xF3cio" }
28
37
  ];
29
38
  function buildAgendaDomainEvents() {
@@ -84,6 +93,10 @@ function buildAgendaDomainEvents() {
84
93
  }
85
94
  ];
86
95
  }
96
+ var CONFIRM_OR_CANCEL = [
97
+ { id: "confirm", label: "Confirma", kind: "reply" },
98
+ { id: "cancel", label: "Cancela", kind: "reply" }
99
+ ];
87
100
  function buildAgendaMessageDefaults() {
88
101
  return [
89
102
  {
@@ -94,7 +107,13 @@ function buildAgendaMessageDefaults() {
94
107
  channels: ["whatsapp", "email"],
95
108
  defaults: {
96
109
  whatsapp: {
97
- body: "Oi {{customer_name}}! Seu hor\xE1rio est\xE1 marcado \u2705\n\n*{{service_name}}*\n{{booking_date}} \xE0s {{booking_time}}\ncom {{professional_name}}\n\nQualquer coisa \xE9 s\xF3 chamar aqui. \u2014 {{business_name}}"
110
+ // Curto porque os botões dizem o resto: a linha "qualquer coisa é só
111
+ // chamar" virou Confirmar/Cancelar, que além de ser menos texto já
112
+ // atualiza o agendamento sozinho.
113
+ // Sem botões, de propósito (decisão do founder 2026-08-24): esta é a
114
+ // confirmação informativa de que a reserva EXISTE. Confirmar presença é
115
+ // trabalho do lembrete de véspera, onde a resposta ainda muda o dia.
116
+ body: "Oi {{customer_name}}! Tudo certo \u2014 seu hor\xE1rio foi reservado na nossa agenda \u2705\n\nServi\xE7o: *{{service_name}}*\nData: {{booking_date}} \xE0s {{booking_time}}\nProfissional: {{professional_name}}\n\nSe precisar ajustar alguma coisa, \xE9 s\xF3 responder esta mensagem.\n\nEquipe {{business_name}} agradece a prefer\xEAncia!"
98
117
  },
99
118
  email: {
100
119
  subject: "Hor\xE1rio confirmado \u2014 {{booking_date}} \xE0s {{booking_time}}",
@@ -112,7 +131,10 @@ function buildAgendaMessageDefaults() {
112
131
  channels: ["whatsapp", "email"],
113
132
  defaults: {
114
133
  whatsapp: {
115
- body: "Oi {{customer_name}}! Passando para lembrar do seu hor\xE1rio amanh\xE3 \u{1F487}\n\n*{{service_name}}*\n{{booking_date}} \xE0s {{booking_time}}\ncom {{professional_name}}\n\nConsegue vir? Se precisar remarcar, me avisa por aqui. \u2014 {{business_name}}"
134
+ // AQUI moram Confirma/Cancela: na véspera a resposta ainda muda o dia —
135
+ // libera o horário ou garante a presença.
136
+ body: "Oi {{customer_name}}! Passando para lembrar do seu hor\xE1rio amanh\xE3 \u{1F487}\n\nServi\xE7o: *{{service_name}}*\nData: {{booking_date}} \xE0s {{booking_time}}\nProfissional: {{professional_name}}\n\nConsegue vir? \xC9 s\xF3 tocar em Confirma ou Cancela aqui embaixo \u2014 e se precisar de outro hor\xE1rio, responde esta mensagem.\n\nEquipe {{business_name}} te espera!",
137
+ actions: CONFIRM_OR_CANCEL
116
138
  },
117
139
  email: {
118
140
  subject: "Lembrete: seu hor\xE1rio \xE9 amanh\xE3, {{booking_time}}",
@@ -127,7 +149,10 @@ function buildAgendaMessageDefaults() {
127
149
  channels: ["whatsapp", "email"],
128
150
  defaults: {
129
151
  whatsapp: {
130
- body: "Oi {{customer_name}}! Seu hor\xE1rio foi remarcado \u{1F5D3}\n\n*{{service_name}}*\nAgora: {{booking_date}} \xE0s {{booking_time}}\ncom {{professional_name}}\n\n\u2014 {{business_name}}"
152
+ body: "Oi {{customer_name}}! Seu hor\xE1rio foi remarcado \u{1F5D3}\n\nServi\xE7o: *{{service_name}}*\nNova data: {{booking_date}} \xE0s {{booking_time}}\nProfissional: {{professional_name}}\n\nSe a nova data n\xE3o funcionar para voc\xEA, \xE9 s\xF3 responder esta mensagem ou usar os bot\xF5es abaixo.\n\nEquipe {{business_name}} agradece a compreens\xE3o!",
153
+ // A data mudou, então a confirmação vale de novo: é o momento em que
154
+ // mais gente some sem avisar.
155
+ actions: CONFIRM_OR_CANCEL
131
156
  },
132
157
  email: {
133
158
  subject: "Seu hor\xE1rio mudou para {{booking_date}}, {{booking_time}}",
@@ -142,7 +167,9 @@ function buildAgendaMessageDefaults() {
142
167
  channels: ["whatsapp", "email"],
143
168
  defaults: {
144
169
  whatsapp: {
145
- body: "Oi {{customer_name}}, seu hor\xE1rio de {{booking_date}} \xE0s {{booking_time}} foi cancelado.\n\nQuer que eu veja outra data para voc\xEA? \u2014 {{business_name}}"
170
+ // Sem botões, de propósito: não há o que confirmar num horário que já
171
+ // foi desmarcado, e um "Confirmar" aqui só poderia dar erro.
172
+ body: "Oi {{customer_name}}, seu hor\xE1rio de {{booking_date}} \xE0s {{booking_time}} foi cancelado.\n\nQuer que eu veja outra data? A equipe {{business_name}} fica \xE0 disposi\xE7\xE3o."
146
173
  },
147
174
  email: {
148
175
  subject: "Hor\xE1rio de {{booking_date}} cancelado",
@@ -159,7 +186,7 @@ function buildAgendaMessageDefaults() {
159
186
  channels: ["whatsapp"],
160
187
  defaults: {
161
188
  whatsapp: {
162
- body: "Oi {{customer_name}}! Foi \xF3timo te atender hoje \u{1F49B}\n\nComo foi a experi\xEAncia com {{professional_name}}? Sua opini\xE3o ajuda demais.\n\n\u2014 {{business_name}}"
189
+ body: "Oi {{customer_name}}! Foi \xF3timo te atender hoje \u{1F49B}\n\nComo foi a experi\xEAncia com {{professional_name}}? Sua opini\xE3o ajuda demais a equipe {{business_name}} a melhorar sempre."
163
190
  }
164
191
  }
165
192
  },
@@ -169,7 +196,7 @@ function buildAgendaMessageDefaults() {
169
196
  channels: ["whatsapp"],
170
197
  defaults: {
171
198
  whatsapp: {
172
- body: "Oi {{customer_name}}, senti sua falta no hor\xE1rio de {{booking_date}} \u{1F614}\n\nAconteceu alguma coisa? Se quiser, j\xE1 deixo outro hor\xE1rio separado para voc\xEA. \u2014 {{business_name}}"
199
+ body: "Oi {{customer_name}}, senti sua falta no hor\xE1rio de {{booking_date}} \u{1F614}\n\nAconteceu alguma coisa? Se quiser, j\xE1 deixo outro hor\xE1rio separado para voc\xEA aqui na {{business_name}}, \xE9 s\xF3 responder."
173
200
  }
174
201
  }
175
202
  }
@@ -2406,7 +2433,20 @@ function timeLabel(iso) {
2406
2433
  const d = new Date(iso);
2407
2434
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
2408
2435
  }
2409
- function AgendaMobileList({ bookings, statusColors, currencyCode, onSelect }) {
2436
+ function AgendaMobileListSkeleton() {
2437
+ return /* @__PURE__ */ jsx("div", { className: "flex-1 space-y-5 overflow-hidden px-3 py-3", "aria-busy": "true", "aria-live": "polite", children: [0, 1].map((g) => /* @__PURE__ */ jsxs("div", { className: "animate-pulse", children: [
2438
+ /* @__PURE__ */ jsx("div", { className: "mb-2 ml-1 h-3 w-20 rounded bg-muted" }),
2439
+ /* @__PURE__ */ jsx("div", { className: "space-y-1.5", children: [0, 1].map((r) => /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 rounded-card border border-border bg-card px-3 py-3", children: [
2440
+ /* @__PURE__ */ jsx("div", { className: "h-2 w-2 shrink-0 rounded-full bg-muted" }),
2441
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1 space-y-1.5", children: [
2442
+ /* @__PURE__ */ jsx("div", { className: "h-3.5 w-2/3 rounded bg-muted" }),
2443
+ /* @__PURE__ */ jsx("div", { className: "h-3 w-12 rounded bg-muted" })
2444
+ ] }),
2445
+ /* @__PURE__ */ jsx("div", { className: "h-3.5 w-14 shrink-0 rounded bg-muted" })
2446
+ ] }, r)) })
2447
+ ] }, g)) });
2448
+ }
2449
+ function AgendaMobileList({ bookings, statusColors, currencyCode, onSelect, onCreate }) {
2410
2450
  const t2 = useTranslation();
2411
2451
  const locale = getCurrentLocale() === "pt-BR" ? "pt-BR" : "en";
2412
2452
  const groups = useMemo(() => {
@@ -2431,7 +2471,18 @@ function AgendaMobileList({ bookings, statusColors, currencyCode, onSelect }) {
2431
2471
  return date.toLocaleDateString(locale, { weekday: "long", day: "numeric", month: "long" });
2432
2472
  }
2433
2473
  if (groups.length === 0) {
2434
- return /* @__PURE__ */ jsx("div", { className: "flex-1 flex flex-col items-center justify-center px-6 py-16 text-center text-muted-foreground", children: /* @__PURE__ */ jsx("p", { className: "text-sm", children: t2("agenda.list.empty") }) });
2474
+ return /* @__PURE__ */ jsxs("div", { className: "flex flex-1 flex-col items-center justify-center gap-4 px-6 py-16 text-center", children: [
2475
+ /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: t2("agenda.list.empty") }),
2476
+ onCreate && /* @__PURE__ */ jsx(
2477
+ "button",
2478
+ {
2479
+ type: "button",
2480
+ onClick: onCreate,
2481
+ className: "fz-tap inline-flex items-center rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground active:scale-[0.98]",
2482
+ children: t2("agenda.calendar.newAppointment")
2483
+ }
2484
+ )
2485
+ ] });
2435
2486
  }
2436
2487
  return /* @__PURE__ */ jsx("div", { className: "flex-1 overflow-y-auto px-3 py-3 space-y-5", children: groups.map((g) => /* @__PURE__ */ jsxs("div", { children: [
2437
2488
  /* @__PURE__ */ jsx("h3", { className: "px-1 pb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground", children: dayLabel(g.date) }),
@@ -2632,7 +2683,7 @@ function CalendarView() {
2632
2683
  endTime: agendaSettings.endTime
2633
2684
  };
2634
2685
  const bookings = useAgendaStore((s) => s.bookings);
2635
- useAgendaStore((s) => s.bookingsLoading);
2686
+ const bookingsLoading = useAgendaStore((s) => s.bookingsLoading);
2636
2687
  const professionals = useAgendaStore((s) => s.professionals);
2637
2688
  const professionalsLoading = useAgendaStore((s) => s.professionalsLoading);
2638
2689
  const currentView = useAgendaStore((s) => s.currentView);
@@ -3178,18 +3229,37 @@ function CalendarView() {
3178
3229
  }) })
3179
3230
  ] })
3180
3231
  ] }),
3232
+ isMobile && /* @__PURE__ */ jsx(PageHeaderActions, { children: /* @__PURE__ */ jsx(
3233
+ PageActions,
3234
+ {
3235
+ actions: [
3236
+ {
3237
+ id: "new-booking",
3238
+ label: t2("agenda.calendar.newAppointment"),
3239
+ icon: Plus,
3240
+ intent: "primary",
3241
+ creates: true,
3242
+ onSelect: () => openModal("create")
3243
+ },
3244
+ {
3245
+ id: "agenda-settings",
3246
+ label: t2("agenda.settings.title"),
3247
+ icon: Settings,
3248
+ // Utility, not an alternative way to book: the shell gives it
3249
+ // its own quiet circle above the "+" instead of hiding it in a
3250
+ // create menu.
3251
+ intent: "utility",
3252
+ onSelect: () => {
3253
+ window.location.hash = "/settings/agenda";
3254
+ }
3255
+ }
3256
+ ]
3257
+ }
3258
+ ) }),
3181
3259
  /* @__PURE__ */ jsxs("div", { className: "flex-1 flex flex-col min-w-0 overflow-hidden", children: [
3182
3260
  /* @__PURE__ */ jsxs("div", { className: "shrink-0 border-b", children: [
3183
- /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between pl-4 pr-14 md:px-4 h-12", children: [
3261
+ /* @__PURE__ */ jsxs("div", { className: `${mobileGridActive ? "flex" : "hidden"} md:flex h-12 items-center justify-between pl-4 pr-4`, children: [
3184
3262
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 min-w-0", children: [
3185
- /* @__PURE__ */ jsx(
3186
- "button",
3187
- {
3188
- onClick: () => openModal("create"),
3189
- className: "md:hidden flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground",
3190
- children: /* @__PURE__ */ jsx(Plus, { className: "h-4 w-4" })
3191
- }
3192
- ),
3193
3263
  /* @__PURE__ */ jsxs("div", { className: `${mobileGridActive ? "flex" : "hidden"} md:flex items-center gap-3`, children: [
3194
3264
  /* @__PURE__ */ jsx(
3195
3265
  "button",
@@ -3204,7 +3274,7 @@ function CalendarView() {
3204
3274
  /* @__PURE__ */ jsx("button", { onClick: handleNext, className: "flex h-7 w-7 items-center justify-center rounded-full hover:bg-muted transition-colors", children: /* @__PURE__ */ jsx(ChevronRight, { className: "h-4 w-4" }) })
3205
3275
  ] })
3206
3276
  ] }),
3207
- /* @__PURE__ */ jsx("h1", { className: "text-base sm:text-lg font-normal truncate", children: isMobile && !mobileGridActive ? t2("agenda.calendar.viewAgenda") : calendarTitle })
3277
+ !(isMobile && !mobileGridActive) && /* @__PURE__ */ jsx("h2", { className: "truncate text-base font-normal sm:text-lg", children: calendarTitle })
3208
3278
  ] }),
3209
3279
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1 shrink-0", children: [
3210
3280
  /* @__PURE__ */ jsx("div", { className: "hidden md:flex items-center gap-1", children: viewOptions.map((opt) => /* @__PURE__ */ jsx(
@@ -3222,8 +3292,9 @@ function CalendarView() {
3222
3292
  variant: "outline",
3223
3293
  size: "icon",
3224
3294
  onClick: () => window.location.hash = "/settings/agenda",
3225
- title: "Agenda Settings",
3226
- className: "ml-1",
3295
+ "aria-label": t2("agenda.settings.title"),
3296
+ title: t2("agenda.settings.title"),
3297
+ className: "ml-1 hidden md:inline-flex",
3227
3298
  children: /* @__PURE__ */ jsx(Settings, { className: "h-4 w-4" })
3228
3299
  }
3229
3300
  )
@@ -3233,20 +3304,27 @@ function CalendarView() {
3233
3304
  "button",
3234
3305
  {
3235
3306
  onClick: () => handleMobileViewChange(opt.key),
3236
- className: `shrink-0 rounded-md px-3 py-1 text-sm transition-colors ${mobileView === opt.key ? "bg-primary/10 text-primary font-medium" : "text-muted-foreground hover:bg-muted/50"}`,
3307
+ "aria-pressed": mobileView === opt.key,
3308
+ className: `flex min-h-[2.75rem] shrink-0 items-center rounded-full px-4 text-sm transition-colors active:scale-[0.98] ${mobileView === opt.key ? "bg-primary/10 font-medium text-primary" : "text-muted-foreground hover:bg-muted/50"}`,
3237
3309
  children: t2(opt.labelKey)
3238
3310
  },
3239
3311
  opt.key
3240
3312
  )) })
3241
3313
  ] }),
3242
- isMobile && !mobileGridActive ? /* @__PURE__ */ jsx(
3243
- AgendaMobileList,
3244
- {
3245
- bookings,
3246
- statusColors,
3247
- currencyCode: config.currency.code,
3248
- onSelect: (b) => openModal("edit", { bookingId: b.id })
3249
- }
3314
+ isMobile && !mobileGridActive ? (
3315
+ // The mobile branch used to sit in front of the loading check, so the
3316
+ // list rendered empty while the query ran — which on this screen reads
3317
+ // as "nothing scheduled", the one wrong answer it must not give.
3318
+ bookingsLoading && bookings.length === 0 ? /* @__PURE__ */ jsx(AgendaMobileListSkeleton, {}) : /* @__PURE__ */ jsx(
3319
+ AgendaMobileList,
3320
+ {
3321
+ bookings,
3322
+ statusColors,
3323
+ currencyCode: config.currency.code,
3324
+ onSelect: (b) => openModal("edit", { bookingId: b.id }),
3325
+ onCreate: () => openModal("create")
3326
+ }
3327
+ )
3250
3328
  ) : professionalsLoading && professionals.length === 0 ? /* @__PURE__ */ jsx(CalendarSkeleton, { profCount: 2 }) : /* @__PURE__ */ jsx("div", { ref: wrapperRef, className: "flex-1 overflow-hidden agenda-calendar", children: /* @__PURE__ */ jsx(
3251
3329
  FullCalendar,
3252
3330
  {
@@ -3885,6 +3963,12 @@ function bookingPayload(booking) {
3885
3963
  const starts = new Date(booking.startsAt);
3886
3964
  return {
3887
3965
  customer_name: booking.clientName ?? "",
3966
+ // O destino do WhatsApp, exatamente como está na ficha do cliente — máscara,
3967
+ // DDD solto, o que houver. Normalizar é trabalho de quem envia
3968
+ // (normalizeBrPhoneToE164), e adivinhar aqui só criaria uma quinta versão da
3969
+ // mesma regra. Vazio quando a ficha não tem telefone: a automação pula, e um
3970
+ // envio pulado é melhor que um envio para o número de outra pessoa.
3971
+ customer_phone: booking.clientPhone ?? "",
3888
3972
  service_name: (booking.services ?? []).map((s) => s.name).join(" + "),
3889
3973
  professional_name: booking.professionalName ?? "",
3890
3974
  booking_date: starts.toLocaleDateString("pt-BR"),
@@ -4707,8 +4791,14 @@ ALTER TABLE public.order_items
4707
4791
  ADD COLUMN IF NOT EXISTS duration_minutes integer,
4708
4792
  ADD COLUMN IF NOT EXISTS assignee_id uuid REFERENCES public.people(id) ON DELETE SET NULL;
4709
4793
 
4710
- -- Upsert key for public customers (per tenant, by normalized phone)
4711
- CREATE UNIQUE INDEX IF NOT EXISTS people_tenant_customer_phone
4794
+ -- Lookup key for public customers (per tenant, by normalized phone).
4795
+ -- NOT UNIQUE. It was, and spine/110 says why it cannot be: \`kind\` became a
4796
+ -- derived column, so an imported 'client' turns into a 'customer' and a
4797
+ -- placeholder phone shared by eight real people lands under one key. The upsert
4798
+ -- that used to lean on it takes a per-(tenant, phone) advisory lock instead
4799
+ -- (003, 009) \u2014 the index is here for the lookup that lock guards.
4800
+ DROP INDEX IF EXISTS public.people_tenant_customer_phone;
4801
+ CREATE INDEX IF NOT EXISTS people_tenant_customer_phone
4712
4802
  ON public.people(tenant_id, phone)
4713
4803
  WHERE kind = 'customer' AND phone IS NOT NULL;
4714
4804
 
@@ -4720,6 +4810,8 @@ CREATE INDEX IF NOT EXISTS appointments_assignee_time
4720
4810
  -- \xA72 \u2014 admin read model (security_invoker \u2192 underlying RLS applies; anon sees 0 rows)
4721
4811
  -- DROP first: 013 widens this view and CREATE OR REPLACE cannot narrow a live
4722
4812
  -- view on replay ("cannot drop columns from view"). 013 replays right after.
4813
+ -- 013 builds on this one, so the drop needs its dependents out and back (143).
4814
+ SELECT app.detach_view_dependents('public.v_appointments');
4723
4815
  DROP VIEW IF EXISTS public.v_appointments;
4724
4816
  CREATE VIEW public.v_appointments
4725
4817
  WITH (security_invoker = true) AS
@@ -4929,16 +5021,32 @@ BEGIN
4929
5021
  RAISE EXCEPTION 'too many open bookings for this phone';
4930
5022
  END IF;
4931
5023
 
4932
- -- upsert the customer by (tenant, phone) \u2014 matches people_tenant_customer_phone
4933
- INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
4934
- VALUES (p_tenant_id, 'customer', trim(p_name), v_phone, p_email,
4935
- jsonb_build_object('source', 'public_booking'))
4936
- ON CONFLICT (tenant_id, phone) WHERE kind = 'customer' AND phone IS NOT NULL
4937
- DO UPDATE SET
4938
- name = EXCLUDED.name,
4939
- email = COALESCE(EXCLUDED.email, people.email),
4940
- updated_at = now()
4941
- RETURNING id INTO v_person_id;
5024
+ -- Upsert the customer by (tenant, phone). NOT ON CONFLICT: the partial unique
5025
+ -- index this used to infer is gone (spine/110 \u2014 a placeholder phone is shared
5026
+ -- by real, distinct people). 003 already settled the replacement: take a
5027
+ -- per-(tenant, phone) advisory lock so two first-bookings serialise, then look
5028
+ -- before inserting. Oldest match wins, so a returning caller keeps the record
5029
+ -- that carries their history.
5030
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || ':' || v_phone, 1));
5031
+
5032
+ SELECT p.id INTO v_person_id
5033
+ FROM people p
5034
+ WHERE p.tenant_id = p_tenant_id AND p.kind = 'customer' AND p.phone = v_phone
5035
+ ORDER BY p.created_at
5036
+ LIMIT 1;
5037
+
5038
+ IF v_person_id IS NOT NULL THEN
5039
+ UPDATE people SET
5040
+ name = trim(p_name),
5041
+ email = COALESCE(p_email, people.email),
5042
+ updated_at = now()
5043
+ WHERE id = v_person_id;
5044
+ ELSE
5045
+ INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
5046
+ VALUES (p_tenant_id, 'customer', trim(p_name), v_phone, p_email,
5047
+ jsonb_build_object('source', 'public_booking'))
5048
+ RETURNING id INTO v_person_id;
5049
+ END IF;
4942
5050
 
4943
5051
  -- order + booking + order_item (statuses hardcoded \u2014 caller cannot set)
4944
5052
  INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
@@ -4968,6 +5076,8 @@ $$;
4968
5076
  REVOKE ALL ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text) FROM public;
4969
5077
  GRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text)
4970
5078
  TO anon, authenticated, service_role;
5079
+
5080
+ SELECT app.reattach_view_dependents();
4971
5081
  `;
4972
5082
  function computeCommissionAmount(total, ratePercent) {
4973
5083
  const base = Number(total) || 0;
@@ -5088,7 +5198,7 @@ function createFinancialBridge(provider2) {
5088
5198
  if (!supabase) return null;
5089
5199
  const { data: order } = await supabase.from("orders").select("id, tenant_id, assignee_id, total").eq("id", orderId).single();
5090
5200
  if (!order || !order.assignee_id) return null;
5091
- const { data: staff } = await supabase.from("v_staff").select("commission_rate").eq("id", order.assignee_id).single();
5201
+ const { data: staff } = await supabase.from("v_professional_rates").select("commission_rate").eq("id", order.assignee_id).single();
5092
5202
  const rate = Number(staff?.commission_rate) || 0;
5093
5203
  const base = Number(order.total) || 0;
5094
5204
  const amount = computeCommissionAmount(base, rate);
@@ -8056,6 +8166,15 @@ function createAgendaPlugin(options) {
8056
8166
  // the saas/admin Panel world, not the ecommerce storefront.
8057
8167
  scaffolds: ["saas"],
8058
8168
  defaultEnabled: true,
8169
+ // The parts a plan can permit separately (#281, ADR 0025). This is the
8170
+ // plugin ADR 0018 named: it ships open with the "suggest a slot" button,
8171
+ // and the intelligence behind that button is a service we host — so the
8172
+ // facet is what says whether this tenant contracted it.
8173
+ facets: [
8174
+ { id: "scheduling", label: "Agenda", description: "Hor\xE1rios, profissionais e reservas.", includedWithPlugin: true },
8175
+ { id: "public_booking", label: "Agendamento online", description: "A p\xE1gina p\xFAblica onde o cliente marca sozinho." },
8176
+ { id: "smart_fit", label: "Encaixe inteligente", description: "Sugere o melhor hor\xE1rio lendo a agenda inteira." }
8177
+ ],
8059
8178
  dependencies: [],
8060
8179
  // Os FATOS da agenda — só expostos enquanto o módulo está ATIVO.
8061
8180
  events: buildAgendaDomainEvents(),