@fayz-ai/saas 0.8.0 → 0.8.2

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 (236) hide show
  1. package/dist/access/context.d.ts +47 -0
  2. package/dist/access/context.d.ts.map +1 -0
  3. package/dist/access/headless.d.ts +21 -0
  4. package/dist/access/headless.d.ts.map +1 -0
  5. package/dist/access/index.d.ts +12 -0
  6. package/dist/access/index.d.ts.map +1 -0
  7. package/dist/access/limits-registry.d.ts +33 -0
  8. package/dist/access/limits-registry.d.ts.map +1 -0
  9. package/dist/access/module-nav.d.ts +15 -0
  10. package/dist/access/module-nav.d.ts.map +1 -0
  11. package/dist/access/resolver.d.ts +2 -0
  12. package/dist/access/resolver.d.ts.map +1 -0
  13. package/dist/access/types.d.ts +43 -0
  14. package/dist/access/types.d.ts.map +1 -0
  15. package/dist/access/upgrade-modal-store.d.ts +23 -0
  16. package/dist/access/upgrade-modal-store.d.ts.map +1 -0
  17. package/dist/app/AdminShell.d.ts +3 -0
  18. package/dist/app/AdminShell.d.ts.map +1 -1
  19. package/dist/app/admin-app.d.ts +3 -1
  20. package/dist/app/admin-app.d.ts.map +1 -1
  21. package/dist/app/config.d.ts +47 -2
  22. package/dist/app/config.d.ts.map +1 -1
  23. package/dist/app/core-entities.d.ts +25 -0
  24. package/dist/app/core-entities.d.ts.map +1 -0
  25. package/dist/app/derive-contract.d.ts +8 -0
  26. package/dist/app/derive-contract.d.ts.map +1 -0
  27. package/dist/app/scaffold.d.ts.map +1 -1
  28. package/dist/archetype-lookup.d.ts.map +1 -1
  29. package/dist/billing/index.cjs +6 -2
  30. package/dist/billing/index.d.ts +2 -0
  31. package/dist/billing/index.d.ts.map +1 -1
  32. package/dist/billing/index.js +1 -1
  33. package/dist/billing/plan-badge.d.ts +17 -0
  34. package/dist/billing/plan-badge.d.ts.map +1 -0
  35. package/dist/billing/store.d.ts +6 -1
  36. package/dist/billing/store.d.ts.map +1 -1
  37. package/dist/{chunk-3M25GRRU.js → chunk-I3DY47N5.js} +3 -4
  38. package/dist/chunk-I3DY47N5.js.map +1 -0
  39. package/dist/chunk-NNFGMUF2.cjs +39 -0
  40. package/dist/chunk-NNFGMUF2.cjs.map +1 -0
  41. package/dist/{chunk-4XTQ4V5L.cjs → chunk-SAZLW54Y.cjs} +16 -12
  42. package/dist/chunk-SAZLW54Y.cjs.map +1 -0
  43. package/dist/{chunk-IPWCHJPE.cjs → chunk-VKYZACG5.cjs} +5 -6
  44. package/dist/chunk-VKYZACG5.cjs.map +1 -0
  45. package/dist/chunk-VN5NTK26.js +36 -0
  46. package/dist/chunk-VN5NTK26.js.map +1 -0
  47. package/dist/{chunk-XNWCGJYH.js → chunk-VTEP2AQX.js} +15 -11
  48. package/dist/chunk-VTEP2AQX.js.map +1 -0
  49. package/dist/components/shared/ContactPicker.d.ts +92 -0
  50. package/dist/components/shared/ContactPicker.d.ts.map +1 -0
  51. package/dist/components/shared/PersonLink.d.ts +12 -1
  52. package/dist/components/shared/PersonLink.d.ts.map +1 -1
  53. package/dist/components/shared/create-person.d.ts +20 -0
  54. package/dist/components/shared/create-person.d.ts.map +1 -0
  55. package/dist/crud/CrudDetailPage.d.ts.map +1 -1
  56. package/dist/crud/CrudFormPage.d.ts.map +1 -1
  57. package/dist/crud/CrudListView.d.ts.map +1 -1
  58. package/dist/crud/CrudPage.d.ts.map +1 -1
  59. package/dist/crud/archetypes/AddressesTab.d.ts +21 -0
  60. package/dist/crud/archetypes/AddressesTab.d.ts.map +1 -0
  61. package/dist/crud/archetypes/PersonDetailTabs.d.ts +2 -1
  62. package/dist/crud/archetypes/PersonDetailTabs.d.ts.map +1 -1
  63. package/dist/crud/createCrudPage.d.ts.map +1 -1
  64. package/dist/crud/index.d.ts +1 -0
  65. package/dist/crud/index.d.ts.map +1 -1
  66. package/dist/index.cjs +4300 -722
  67. package/dist/index.cjs.map +1 -1
  68. package/dist/index.d.ts +17 -2
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +4080 -542
  71. package/dist/index.js.map +1 -1
  72. package/dist/org/index.cjs +11 -11
  73. package/dist/org/index.js +2 -2
  74. package/dist/org/store.d.ts.map +1 -1
  75. package/dist/permissions/context.d.ts +2 -0
  76. package/dist/permissions/context.d.ts.map +1 -1
  77. package/dist/permissions/index.cjs +7 -7
  78. package/dist/permissions/index.js +1 -1
  79. package/dist/shell/components/auth/ProtectedRoute.d.ts.map +1 -1
  80. package/dist/shell/components/billing/SoftLimitBanner.d.ts +5 -0
  81. package/dist/shell/components/billing/SoftLimitBanner.d.ts.map +1 -0
  82. package/dist/shell/components/billing/SubscriptionPage.d.ts +5 -0
  83. package/dist/shell/components/billing/SubscriptionPage.d.ts.map +1 -0
  84. package/dist/shell/components/billing/UpgradeModal.d.ts +3 -0
  85. package/dist/shell/components/billing/UpgradeModal.d.ts.map +1 -0
  86. package/dist/shell/components/billing/UpgradePrompt.d.ts +10 -0
  87. package/dist/shell/components/billing/UpgradePrompt.d.ts.map +1 -0
  88. package/dist/shell/components/billing/access-contract.d.ts +18 -0
  89. package/dist/shell/components/billing/access-contract.d.ts.map +1 -0
  90. package/dist/shell/components/billing/gates.d.ts +14 -0
  91. package/dist/shell/components/billing/gates.d.ts.map +1 -0
  92. package/dist/shell/components/billing/index.d.ts +9 -2
  93. package/dist/shell/components/billing/index.d.ts.map +1 -1
  94. package/dist/shell/components/chat/ChatFab.d.ts +3 -1
  95. package/dist/shell/components/chat/ChatFab.d.ts.map +1 -1
  96. package/dist/shell/components/chat/ChatPanel.d.ts +3 -1
  97. package/dist/shell/components/chat/ChatPanel.d.ts.map +1 -1
  98. package/dist/shell/components/chat/ConfirmActionCard.d.ts +13 -0
  99. package/dist/shell/components/chat/ConfirmActionCard.d.ts.map +1 -0
  100. package/dist/shell/components/chat/markdown.d.ts +5 -0
  101. package/dist/shell/components/chat/markdown.d.ts.map +1 -0
  102. package/dist/shell/components/organization/TeamTab.d.ts.map +1 -1
  103. package/dist/shell/components/organization/TenantOnboarding.d.ts.map +1 -1
  104. package/dist/shell/components/organization/index.d.ts +0 -1
  105. package/dist/shell/components/organization/index.d.ts.map +1 -1
  106. package/dist/shell/components/settings/ConnectedCompanySettings.d.ts.map +1 -1
  107. package/dist/shell/components/settings/ConnectedFieldRulesSettings.d.ts.map +1 -1
  108. package/dist/shell/components/settings/ConnectedSecuritySettings.d.ts.map +1 -1
  109. package/dist/shell/components/settings/LocationsCrudPage.d.ts +1 -1
  110. package/dist/shell/components/settings/LocationsCrudPage.d.ts.map +1 -1
  111. package/dist/shell/components/settings/SettingsPage.d.ts +23 -2
  112. package/dist/shell/components/settings/SettingsPage.d.ts.map +1 -1
  113. package/dist/shell/hooks/index.d.ts +3 -2
  114. package/dist/shell/hooks/index.d.ts.map +1 -1
  115. package/dist/shell/hooks/useAITools.d.ts +2 -0
  116. package/dist/shell/hooks/useAITools.d.ts.map +1 -1
  117. package/dist/shell/hooks/useChat.d.ts +22 -0
  118. package/dist/shell/hooks/useChat.d.ts.map +1 -1
  119. package/dist/shell/hooks/useSystemPermission.d.ts +3 -0
  120. package/dist/shell/hooks/useSystemPermission.d.ts.map +1 -0
  121. package/dist/shell/hooks/useTenantPluginSettings.d.ts +12 -0
  122. package/dist/shell/hooks/useTenantPluginSettings.d.ts.map +1 -0
  123. package/dist/shell/lib/ai-tool-handlers.d.ts +119 -0
  124. package/dist/shell/lib/ai-tool-handlers.d.ts.map +1 -0
  125. package/dist/shell/lib/core-ai-tools.d.ts +14 -0
  126. package/dist/shell/lib/core-ai-tools.d.ts.map +1 -1
  127. package/dist/shell/lib/create-client-orders-provider.d.ts.map +1 -1
  128. package/dist/shell/lib/fayz-agent.d.ts +23 -0
  129. package/dist/shell/lib/fayz-agent.d.ts.map +1 -0
  130. package/dist/shell/lib/i18n.d.ts.map +1 -1
  131. package/dist/shell/stores/chat.store.d.ts +69 -0
  132. package/dist/shell/stores/chat.store.d.ts.map +1 -1
  133. package/dist/shell/types/billing.d.ts +7 -0
  134. package/dist/shell/types/billing.d.ts.map +1 -1
  135. package/dist/shell/types/crud.d.ts +1 -1
  136. package/dist/shell/types/crud.d.ts.map +1 -1
  137. package/dist/shell/types/plugins.d.ts +2 -45
  138. package/dist/shell/types/plugins.d.ts.map +1 -1
  139. package/dist/stores/createCrudStore.d.ts.map +1 -1
  140. package/package.json +15 -12
  141. package/src/access/context.tsx +228 -0
  142. package/src/access/headless.ts +71 -0
  143. package/src/access/index.ts +27 -0
  144. package/src/access/limits-registry.ts +86 -0
  145. package/src/access/module-nav.test.ts +78 -0
  146. package/src/access/module-nav.ts +82 -0
  147. package/src/access/resolver.test.ts +143 -0
  148. package/src/access/resolver.ts +5 -0
  149. package/src/access/types.ts +47 -0
  150. package/src/access/upgrade-modal-store.ts +30 -0
  151. package/src/app/AdminShell.tsx +451 -68
  152. package/src/app/admin-app.tsx +46 -9
  153. package/src/app/config.ts +54 -1
  154. package/src/app/core-entities.ts +110 -0
  155. package/src/app/derive-contract.test.ts +155 -0
  156. package/src/app/derive-contract.ts +214 -0
  157. package/src/app/scaffold.tsx +4 -0
  158. package/src/archetype-lookup.ts +8 -2
  159. package/src/billing/index.ts +2 -0
  160. package/src/billing/plan-badge.ts +32 -0
  161. package/src/billing/store.ts +8 -1
  162. package/src/components/shared/ContactPicker.tsx +394 -0
  163. package/src/components/shared/PersonLink.tsx +11 -3
  164. package/src/components/shared/create-person.ts +74 -0
  165. package/src/crud/CrudDetailPage.tsx +8 -1
  166. package/src/crud/CrudFormPage.tsx +124 -6
  167. package/src/crud/CrudListView.tsx +16 -1
  168. package/src/crud/CrudPage.tsx +27 -5
  169. package/src/crud/DeleteConfirmDialog.tsx +1 -1
  170. package/src/crud/archetypes/AddressesTab.tsx +387 -0
  171. package/src/crud/archetypes/PersonDetailTabs.tsx +5 -0
  172. package/src/crud/createCrudPage.tsx +2 -0
  173. package/src/crud/index.ts +6 -0
  174. package/src/env.d.ts +7 -0
  175. package/src/index.ts +49 -1
  176. package/src/org/store.ts +9 -1
  177. package/src/permissions/context.tsx +29 -15
  178. package/src/shell/components/auth/ProtectedRoute.tsx +12 -2
  179. package/src/shell/components/billing/SoftLimitBanner.tsx +81 -0
  180. package/src/shell/components/billing/SubscriptionPage.tsx +372 -0
  181. package/src/shell/components/billing/UpgradeModal.tsx +102 -0
  182. package/src/shell/components/billing/UpgradePrompt.tsx +229 -0
  183. package/src/shell/components/billing/access-contract.ts +64 -0
  184. package/src/shell/components/billing/gates.tsx +62 -0
  185. package/src/shell/components/billing/index.ts +18 -2
  186. package/src/shell/components/chat/ChatFab.tsx +4 -2
  187. package/src/shell/components/chat/ChatPanel.tsx +174 -23
  188. package/src/shell/components/chat/ConfirmActionCard.tsx +57 -0
  189. package/src/shell/components/chat/markdown.tsx +102 -0
  190. package/src/shell/components/organization/TeamTab.tsx +21 -4
  191. package/src/shell/components/organization/TenantOnboarding.tsx +3 -1
  192. package/src/shell/components/organization/index.ts +0 -1
  193. package/src/shell/components/settings/ConnectedCompanySettings.tsx +4 -1
  194. package/src/shell/components/settings/ConnectedFieldRulesSettings.tsx +3 -1
  195. package/src/shell/components/settings/ConnectedSecuritySettings.tsx +3 -1
  196. package/src/shell/components/settings/LocationsCrudPage.ts +6 -1
  197. package/src/shell/components/settings/SettingsPage.tsx +63 -21
  198. package/src/shell/hooks/index.ts +3 -2
  199. package/src/shell/hooks/useAITools.ts +27 -23
  200. package/src/shell/hooks/useChat.ts +381 -16
  201. package/src/shell/hooks/useSystemPermission.ts +23 -0
  202. package/src/shell/hooks/useTenantPluginSettings.ts +127 -0
  203. package/src/shell/lib/ai-tool-handlers.ts +910 -0
  204. package/src/shell/lib/core-ai-tools.ts +234 -2
  205. package/src/shell/lib/create-client-orders-provider.ts +24 -2
  206. package/src/shell/lib/fayz-agent.ts +56 -0
  207. package/src/shell/lib/i18n.ts +71 -0
  208. package/src/shell/stores/chat.store.ts +118 -1
  209. package/src/shell/styles.css +30 -0
  210. package/src/shell/types/billing.ts +8 -0
  211. package/src/shell/types/crud.ts +1 -1
  212. package/src/shell/types/plugins.ts +13 -50
  213. package/src/stores/createCrudStore.ts +9 -0
  214. package/dist/chunk-3M25GRRU.js.map +0 -1
  215. package/dist/chunk-4XTQ4V5L.cjs.map +0 -1
  216. package/dist/chunk-5GYQK5IW.js +0 -21
  217. package/dist/chunk-5GYQK5IW.js.map +0 -1
  218. package/dist/chunk-IPWCHJPE.cjs.map +0 -1
  219. package/dist/chunk-KYID2MR6.cjs +0 -23
  220. package/dist/chunk-KYID2MR6.cjs.map +0 -1
  221. package/dist/chunk-XNWCGJYH.js.map +0 -1
  222. package/dist/shell/components/billing/BillingPage.d.ts +0 -7
  223. package/dist/shell/components/billing/BillingPage.d.ts.map +0 -1
  224. package/dist/shell/components/billing/PaywallGate.d.ts +0 -11
  225. package/dist/shell/components/billing/PaywallGate.d.ts.map +0 -1
  226. package/dist/shell/components/organization/PermissionGate.d.ts +0 -11
  227. package/dist/shell/components/organization/PermissionGate.d.ts.map +0 -1
  228. package/dist/shell/hooks/useBilling.d.ts +0 -14
  229. package/dist/shell/hooks/useBilling.d.ts.map +0 -1
  230. package/dist/shell/hooks/usePermission.d.ts +0 -16
  231. package/dist/shell/hooks/usePermission.d.ts.map +0 -1
  232. package/src/shell/components/billing/BillingPage.tsx +0 -56
  233. package/src/shell/components/billing/PaywallGate.tsx +0 -80
  234. package/src/shell/components/organization/PermissionGate.tsx +0 -16
  235. package/src/shell/hooks/useBilling.ts +0 -151
  236. package/src/shell/hooks/usePermission.ts +0 -101
@@ -0,0 +1,394 @@
1
+ import React, { useState, useEffect, useMemo, useRef } from 'react'
2
+ import { X } from 'lucide-react'
3
+ import { SearchCombobox, cn, toast, type ComboboxOption } from '@fayz-ai/ui'
4
+ import { useTranslation } from '@fayz-ai/core'
5
+ import { createArchetypeLookup, type EntityLookup } from '../../archetype-lookup'
6
+ import { createPerson } from './create-person'
7
+ import { PersonLink } from './PersonLink'
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // ContactPicker — "find the person, or create them" as ONE component.
11
+ //
12
+ // The agenda invented this flow inline (search a client → no match → quick-add
13
+ // with name/phone/email → keep going without leaving the modal). It's the same
14
+ // need anywhere a plugin starts something FOR someone: a booking, a
15
+ // conversation, an invoice. This is that flow, extracted, so the behaviour and
16
+ // the rows written stay identical across surfaces (see `createPerson`).
17
+ //
18
+ // Three states, one component:
19
+ // • empty → SearchCombobox over the person lookup, with "+ Create <text>"
20
+ // • selected → a compact chip (PersonLink + phone) with an optional clear
21
+ // • creating → inline name / phone / email form → `createPerson` → selected
22
+ //
23
+ // ONE behaviour, two skins (`variant`). What used to differ between hosts was
24
+ // the FLOW — the agenda hid its search behind a collapsed "+ Add client" and
25
+ // rendered its own quick-create, conversations rendered its own phone field —
26
+ // and that is what made the same feature read as two. Those are gone: search,
27
+ // chip, inline create, label and handle capture all live here now, and the host
28
+ // passes configuration, not layout. `variant` only picks how much chrome the
29
+ // input wears, so it can sit in a form-shaped modal or in a dense icon row
30
+ // without the surrounding layout looking broken. Always open in both: the
31
+ // contact is the primary input everywhere this is mounted.
32
+ //
33
+ // Uncontrolled-friendly: pass `value`/`onChange` and the parent owns the id.
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export interface ContactPickerValue {
37
+ /** Person id — undefined while the user has only typed a name. */
38
+ id?: string
39
+ name: string
40
+ phone?: string
41
+ email?: string
42
+ }
43
+
44
+ export interface ContactPickerProps {
45
+ value: ContactPickerValue | null
46
+ onChange: (value: ContactPickerValue | null) => void
47
+ /**
48
+ * `people.kind` for records created here (e.g. 'client', 'lead', 'contact').
49
+ * Also narrows the default lookup, so a vertical only searches its own people.
50
+ */
51
+ kind?: string
52
+ /** Per-vertical extension table linked by `person_id` — skipped when absent from the pool. */
53
+ extensionTable?: string
54
+ /** Search source. Defaults to the `person` archetype lookup narrowed by `kind`. */
55
+ lookup?: EntityLookup
56
+ /**
57
+ * Allow committing a typed name with NO person record (`{ name }` and no id).
58
+ * Off by default: the point of the picker is to end up with a real contact.
59
+ * Conversations turns it on so an ad-hoc handle can still start a thread.
60
+ */
61
+ allowFreeText?: boolean
62
+ /** Hide the clear button (agenda locks the client on a paid booking). */
63
+ clearable?: boolean
64
+ disabled?: boolean
65
+ autoFocus?: boolean
66
+ placeholder?: string
67
+ /**
68
+ * How much chrome the field carries. The BEHAVIOUR — search, chip, inline
69
+ * create, handle capture — is identical either way; only the input's skin
70
+ * changes, so it can sit in whichever layout grammar the host already uses.
71
+ *
72
+ * • 'field' (default) — label above + bordered input. For form-shaped modals
73
+ * (the conversations composer), where every control is a labelled field.
74
+ * • 'inline' — no label, borderless input that underlines on focus. For the
75
+ * appointment modal's icon rows, where a bordered box next to "Scheduled"
76
+ * and "Add notes" is the thing that looks out of place.
77
+ */
78
+ variant?: 'field' | 'inline'
79
+ /** Field label, rendered BY the component so every host gets the same one. Ignored when `variant='inline'`. */
80
+ label?: string
81
+ /**
82
+ * Right-hand detail on the selected chip. Defaults to the person's phone; a
83
+ * host that cares about a different datum (the conversations composer shows
84
+ * the handle for the ACTIVE channel) passes its own.
85
+ */
86
+ secondaryText?: string
87
+ /**
88
+ * Optional second datum captured alongside the contact — the address to reach
89
+ * them at in this context (a channel handle, a delivery phone…). Lives HERE,
90
+ * not in the host, because it is contact data and hosts were rendering their
91
+ * own field around the picker, which is exactly what made the same flow look
92
+ * like two different features.
93
+ *
94
+ * `derived` is what we already know from the picked person: when set it rides
95
+ * on the chip and no field is offered. Otherwise the picker shows a discreet
96
+ * "+ Add {label}" that expands into the input.
97
+ */
98
+ handleField?: {
99
+ /** Human name of the datum, interpolated into "+ Add {label}". */
100
+ label: string
101
+ /** Known from the contact — suppresses the field entirely. */
102
+ derived?: string
103
+ value: string
104
+ onChange: (value: string) => void
105
+ /** Label above the input once open. Defaults to `label`. */
106
+ fieldLabel?: string
107
+ placeholder?: string
108
+ }
109
+ /**
110
+ * Copy for the "+ Create …" row. Defaults to the shared "New contact"; a
111
+ * plugin whose vertical calls them something else (the agenda says "client")
112
+ * passes its own label so the wording doesn't regress into generic SDK copy.
113
+ */
114
+ createLabel?: string
115
+ /**
116
+ * Fires when the inline create form opens/closes. The create form owns phone
117
+ * and email while it is open, so a host that ALSO renders a phone/handle field
118
+ * (the conversations composer does) hides its own to avoid asking twice.
119
+ */
120
+ onCreatingChange?: (creating: boolean) => void
121
+ className?: string
122
+ /** Extra hint under the input while a name is typed but nothing is selected. */
123
+ hint?: React.ReactNode
124
+ }
125
+
126
+ export function ContactPicker({
127
+ value,
128
+ onChange,
129
+ kind,
130
+ extensionTable,
131
+ lookup,
132
+ allowFreeText = false,
133
+ clearable = true,
134
+ disabled,
135
+ autoFocus,
136
+ placeholder,
137
+ variant = 'field',
138
+ label,
139
+ secondaryText,
140
+ handleField,
141
+ createLabel,
142
+ onCreatingChange,
143
+ className,
144
+ hint,
145
+ }: ContactPickerProps) {
146
+ const t = useTranslation()
147
+ const resolvedLookup = useMemo(
148
+ () => lookup ?? createArchetypeLookup({ archetype: 'person', kind }),
149
+ [lookup, kind],
150
+ )
151
+
152
+ const [search, setSearch] = useState(value?.id ? value.name : (value?.name ?? ''))
153
+ const [options, setOptions] = useState<ComboboxOption[]>([])
154
+ const [searching, setSearching] = useState(false)
155
+
156
+ const [creating, setCreatingState] = useState(false)
157
+ const creatingChanged = useRef(onCreatingChange)
158
+ creatingChanged.current = onCreatingChange
159
+ const setCreating = (next: boolean) => {
160
+ setCreatingState(next)
161
+ creatingChanged.current?.(next)
162
+ }
163
+ const [handleOpen, setHandleOpen] = useState(false)
164
+ const [newName, setNewName] = useState('')
165
+ const [newPhone, setNewPhone] = useState('')
166
+ const [newEmail, setNewEmail] = useState('')
167
+ const [saving, setSaving] = useState(false)
168
+
169
+ // Keep the visible text in sync with the parent — but ONLY on an id
170
+ // transition. Parents build `value` as a fresh object literal every render, so
171
+ // reacting to the object itself would re-run each keystroke and wipe the text
172
+ // the user is typing (there is no id yet while they type).
173
+ const selectedId = value?.id
174
+ const selectedName = value?.id ? value.name : undefined
175
+ const lastSelectedId = useRef(selectedId)
176
+ useEffect(() => {
177
+ if (selectedId === lastSelectedId.current) return
178
+ lastSelectedId.current = selectedId
179
+ if (selectedId) {
180
+ setSearch(selectedName ?? '')
181
+ setCreating(false)
182
+ } else {
183
+ // Parent dropped the selection (form reset, "change contact", …).
184
+ setSearch('')
185
+ }
186
+ }, [selectedId, selectedName])
187
+
188
+ // Debounced search — skipped once a person is selected.
189
+ useEffect(() => {
190
+ if (value?.id || creating || search.trim().length < 1) {
191
+ setOptions([])
192
+ setSearching(false)
193
+ return
194
+ }
195
+ setSearching(true)
196
+ const timer = setTimeout(async () => {
197
+ try {
198
+ const results = await resolvedLookup.search(search)
199
+ setOptions(results.map((r) => ({ id: r.id, label: r.label, subtitle: r.subtitle, data: r.data })))
200
+ } catch {
201
+ setOptions([])
202
+ }
203
+ setSearching(false)
204
+ }, 250)
205
+ return () => clearTimeout(timer)
206
+ }, [search, value?.id, creating, resolvedLookup])
207
+
208
+ // Free-text mode: a typed name with no match is still a usable value, so the
209
+ // parent sees every keystroke. Without it the text would be lost on submit.
210
+ function handleSearchChange(next: string) {
211
+ setSearch(next)
212
+ if (value?.id) onChange(null)
213
+ if (allowFreeText) onChange(next.trim() ? { name: next } : null)
214
+ }
215
+
216
+ function handleSelect(opt: ComboboxOption) {
217
+ const data = (opt.data ?? {}) as Record<string, unknown>
218
+ setSearch(opt.label)
219
+ setOptions([])
220
+ onChange({
221
+ id: opt.id,
222
+ name: opt.label,
223
+ phone: (data.phone as string) || undefined,
224
+ email: (data.email as string) || undefined,
225
+ })
226
+ }
227
+
228
+ function startCreate(prefillName: string) {
229
+ setCreating(true)
230
+ setNewName(prefillName)
231
+ setNewPhone('')
232
+ setNewEmail('')
233
+ }
234
+
235
+ async function handleCreate() {
236
+ if (!newName.trim() || saving) return
237
+ setSaving(true)
238
+ try {
239
+ const person = await createPerson({
240
+ name: newName,
241
+ phone: newPhone,
242
+ email: newEmail,
243
+ kind: kind ?? 'contact',
244
+ extensionTable,
245
+ })
246
+ setSearch(person.name)
247
+ setCreating(false)
248
+ onChange(person)
249
+ } catch (err) {
250
+ const message = err instanceof Error ? err.message : String(err)
251
+ toast.error(t('contactPicker.createFailed'), { description: message })
252
+ }
253
+ setSaving(false)
254
+ }
255
+
256
+ function handleClear() {
257
+ setSearch('')
258
+ setOptions([])
259
+ onChange(null)
260
+ }
261
+
262
+ const inputClass =
263
+ 'w-full 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 focus:outline-none focus:ring-2 focus:ring-ring'
264
+
265
+ // Each state renders only its BODY; `frame` below wraps every one of them in
266
+ // the same label + handle chrome, so the picker looks identical wherever it is
267
+ // mounted no matter which state it is in.
268
+ const frame = (body: React.ReactNode) => (
269
+ <div className={cn('flex flex-col', className)}>
270
+ {label && variant === 'field' && (
271
+ <label className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</label>
272
+ )}
273
+ {body}
274
+ {handleField && !creating && !handleField.derived && (
275
+ handleOpen || handleField.value ? (
276
+ <div className="mt-3">
277
+ <label htmlFor="contact-handle" className="mb-1.5 block text-xs font-medium text-muted-foreground">
278
+ {handleField.fieldLabel ?? handleField.label}
279
+ </label>
280
+ <input
281
+ id="contact-handle"
282
+ type="text"
283
+ value={handleField.value}
284
+ onChange={(e) => handleField.onChange(e.target.value)}
285
+ placeholder={handleField.placeholder}
286
+ className={inputClass}
287
+ autoFocus={handleOpen}
288
+ />
289
+ </div>
290
+ ) : (
291
+ <button
292
+ type="button"
293
+ onClick={() => setHandleOpen(true)}
294
+ className="mt-2 self-start text-xs font-medium text-primary hover:underline"
295
+ >
296
+ + {t('contactPicker.addField').replace('{label}', handleField.label)}
297
+ </button>
298
+ )
299
+ )}
300
+ </div>
301
+ )
302
+
303
+ // ---- Selected -----------------------------------------------------------
304
+ if (value?.id && !creating) {
305
+ return frame(
306
+ <div className="flex items-center gap-2 rounded-md bg-muted/30 px-2.5 py-1.5 text-sm">
307
+ <PersonLink personId={value.id} name={value.name} size="default" className="flex-1 min-w-0" />
308
+ {(secondaryText ?? value.phone) && (
309
+ <span className="text-[10px] text-muted-foreground shrink-0">{secondaryText ?? value.phone}</span>
310
+ )}
311
+ {clearable && !disabled && (
312
+ <button
313
+ type="button"
314
+ onClick={handleClear}
315
+ aria-label={t('contactPicker.clear')}
316
+ className="p-0.5 text-muted-foreground hover:text-destructive shrink-0"
317
+ >
318
+ <X className="h-3 w-3" />
319
+ </button>
320
+ )}
321
+ </div>,
322
+ )
323
+ }
324
+
325
+ // ---- Creating -----------------------------------------------------------
326
+ if (creating) {
327
+ return frame(
328
+ <div className="rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] p-2.5 space-y-2">
329
+ <div className="flex items-center gap-2">
330
+ <input
331
+ type="text"
332
+ value={newName}
333
+ onChange={(e) => setNewName(e.target.value)}
334
+ placeholder={t('contactPicker.name')}
335
+ className={cn(inputClass, 'flex-1 font-medium')}
336
+ autoFocus
337
+ />
338
+ <button
339
+ type="button"
340
+ onClick={() => setCreating(false)}
341
+ aria-label={t('contactPicker.cancel')}
342
+ className="p-1 text-muted-foreground hover:text-foreground shrink-0"
343
+ >
344
+ <X className="h-3.5 w-3.5" />
345
+ </button>
346
+ </div>
347
+ <div className="grid grid-cols-2 gap-2">
348
+ <input
349
+ type="tel"
350
+ value={newPhone}
351
+ onChange={(e) => setNewPhone(e.target.value)}
352
+ placeholder={t('contactPicker.phone')}
353
+ className={inputClass}
354
+ />
355
+ <input
356
+ type="email"
357
+ value={newEmail}
358
+ onChange={(e) => setNewEmail(e.target.value)}
359
+ placeholder={t('contactPicker.emailOptional')}
360
+ className={inputClass}
361
+ />
362
+ </div>
363
+ <button
364
+ type="button"
365
+ disabled={saving || !newName.trim()}
366
+ onClick={handleCreate}
367
+ className="w-full rounded-md bg-primary border border-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 shadow-button-primary active:shadow-button-inset disabled:opacity-40 transition-colors"
368
+ >
369
+ {saving ? t('contactPicker.creating') : t('contactPicker.create')}
370
+ </button>
371
+ </div>,
372
+ )
373
+ }
374
+
375
+ // ---- Empty / searching --------------------------------------------------
376
+ return frame(
377
+ <div>
378
+ <SearchCombobox
379
+ value={search}
380
+ onChange={handleSearchChange}
381
+ onSelect={handleSelect}
382
+ options={options}
383
+ loading={searching}
384
+ placeholder={placeholder ?? t('contactPicker.search')}
385
+ allowCreate
386
+ createLabel={createLabel ?? t('contactPicker.newContact')}
387
+ onCreateNew={startCreate}
388
+ autoFocus={autoFocus}
389
+ minimal={variant === 'inline'}
390
+ />
391
+ {hint && search.trim() && !searching && <div className="mt-1">{hint}</div>}
392
+ </div>,
393
+ )
394
+ }
@@ -12,6 +12,14 @@ const personLookup = createArchetypeLookup({ archetype: 'person' })
12
12
  interface PersonLinkProps {
13
13
  /** Person UUID — required for fetching details */
14
14
  personId?: string | null
15
+ /**
16
+ * Where to load the details from. Defaults to the `person` archetype lookup
17
+ * (the `people` table). Modules whose "person" lives elsewhere pass their own
18
+ * loader — a shop customer, for instance, is a plg_shop_customers row and the
19
+ * default lookup would never find it. Without this the popover silently shows
20
+ * an empty card, which reads as a bug rather than as a wrong data source.
21
+ */
22
+ lookup?: { getById: (id: string) => Promise<EntityLookupResult | null> }
15
23
  /** Display name (shown as the clickable text) */
16
24
  name: string
17
25
  /** Optional href to navigate to the person's profile */
@@ -34,7 +42,7 @@ function formatAge(dob: string): string | null {
34
42
  } catch { return null }
35
43
  }
36
44
 
37
- export function PersonLink({ personId, name, profileHref, tab, size = 'default', className }: PersonLinkProps) {
45
+ export function PersonLink({ personId, name, lookup, profileHref, tab, size = 'default', className }: PersonLinkProps) {
38
46
  const [person, setPerson] = useState<EntityLookupResult | null>(null)
39
47
  const [loading, setLoading] = useState(false)
40
48
  const [fetched, setFetched] = useState(false)
@@ -44,7 +52,7 @@ export function PersonLink({ personId, name, profileHref, tab, size = 'default',
44
52
  const fetchPerson = useCallback(() => {
45
53
  if (!fetched && personId) {
46
54
  setLoading(true)
47
- personLookup.getById(personId).then((result) => {
55
+ ;(lookup ?? personLookup).getById(personId).then((result) => {
48
56
  setPerson(result)
49
57
  setFetched(true)
50
58
  setLoading(false)
@@ -53,7 +61,7 @@ export function PersonLink({ personId, name, profileHref, tab, size = 'default',
53
61
  setLoading(false)
54
62
  })
55
63
  }
56
- }, [personId, fetched])
64
+ }, [personId, fetched, lookup])
57
65
 
58
66
  const handleOpenChange = useCallback((v: boolean) => {
59
67
  setOpen(v)
@@ -0,0 +1,74 @@
1
+ import { getSupabaseClientOptional } from '@fayz-ai/core'
2
+ import { useOrganizationStore } from '../../org/store'
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // createPerson — the ONE way a plugin turns "a name someone typed" into a real
6
+ // person record. Extracted from the agenda's inline quick-create so every
7
+ // surface that needs "find the contact, or create it" writes the SAME rows:
8
+ // `public.people` (the archetype base) plus, when the pool ships it, a
9
+ // per-vertical extension table (`clients`, `leads`, …).
10
+ //
11
+ // Pool tolerance is the whole reason this is shared: beauty-style pools have
12
+ // `public.clients`, school/agency pools live off `public.people` alone. A
13
+ // MISSING extension table is not an error (PostgREST reports PGRST205 /
14
+ // "Could not find the table"); any other failure rolls the person back so a
15
+ // half-written contact never survives.
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export interface CreatePersonInput {
19
+ name: string
20
+ phone?: string
21
+ email?: string
22
+ /** `people.kind` discriminator for this vertical (e.g. 'client', 'lead'). */
23
+ kind: string
24
+ /**
25
+ * Optional per-vertical extension table linked by `person_id`. Absent in the
26
+ * pool → silently skipped (see above).
27
+ */
28
+ extensionTable?: string
29
+ }
30
+
31
+ export interface CreatedPerson {
32
+ id: string
33
+ name: string
34
+ phone?: string
35
+ email?: string
36
+ }
37
+
38
+ export async function createPerson(input: CreatePersonInput): Promise<CreatedPerson> {
39
+ const supabase = getSupabaseClientOptional() as any
40
+ const tenantId = useOrganizationStore.getState().currentOrg?.id
41
+ if (!supabase) throw new Error('[createPerson] Supabase client not registered.')
42
+ if (!tenantId) throw new Error('[createPerson] Active tenant not resolved — try again in a moment.')
43
+
44
+ const name = input.name.trim()
45
+ const phone = input.phone?.trim() || null
46
+ const email = input.email?.trim() || null
47
+
48
+ const { data: personRow, error: personError } = await supabase
49
+ .from('people')
50
+ .insert({ tenant_id: tenantId, kind: input.kind, name, phone, email })
51
+ .select('id, name, phone, email')
52
+ .single()
53
+ if (personError) throw personError
54
+
55
+ if (input.extensionTable) {
56
+ const { error: extError } = await supabase
57
+ .from(input.extensionTable)
58
+ .insert({ person_id: personRow.id, tenant_id: tenantId })
59
+ const missingExtensionTable =
60
+ extError?.code === 'PGRST205' || extError?.message?.includes('Could not find the table')
61
+ if (extError && !missingExtensionTable) {
62
+ // Roll the person back so the failure leaves nothing behind.
63
+ await supabase.from('people').delete().eq('id', personRow.id)
64
+ throw extError
65
+ }
66
+ }
67
+
68
+ return {
69
+ id: String(personRow.id),
70
+ name: String(personRow.name ?? name),
71
+ phone: personRow.phone ?? undefined,
72
+ email: personRow.email ?? undefined,
73
+ }
74
+ }
@@ -7,6 +7,7 @@ import { Badge } from '@fayz-ai/ui'
7
7
  import { Breadcrumb } from '@fayz-ai/ui'
8
8
  import { Tabs, TabsList, TabsTrigger, TabsContent } from '@fayz-ai/ui'
9
9
  import { Separator } from '@fayz-ai/ui'
10
+ import { Markdown } from '@fayz-ai/ui'
10
11
  import { WidgetSlot } from '../plugins/WidgetSlot'
11
12
  import { formatCurrency } from '@fayz-ai/core'
12
13
  import { PERSON_DETAIL_TABS } from './archetypes/PersonDetailTabs'
@@ -76,6 +77,8 @@ function formatValue(field: FieldDef, value: any, t?: (key: string) => string):
76
77
  return <a href={value} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">{value}</a>
77
78
  case 'textarea':
78
79
  return <p className="whitespace-pre-wrap">{value}</p>
80
+ case 'markdown':
81
+ return value ? <Markdown source={String(value)} /> : null
79
82
  default:
80
83
  return String(value)
81
84
  }
@@ -270,7 +273,11 @@ export function CrudDetailPage({
270
273
  const displayField = entityDef.displayField ?? entityDef.fields[0]?.key ?? 'id'
271
274
  const displayValue = item[displayField] ?? 'Untitled'
272
275
  const subtitleValue = entityDef.subtitleField ? item[entityDef.subtitleField] : null
273
- const imageValue = entityDef.imageField ? item[entityDef.imageField] : null
276
+ // `imageField` only reaches a plain string column. Entities whose image lives
277
+ // somewhere richer (a product's images[] relation, say) supply `image.get`,
278
+ // and without consulting it the hero fell back to the initial letter even for
279
+ // records that clearly have a photo.
280
+ const imageValue = entityDef.image?.get(item) ?? (entityDef.imageField ? item[entityDef.imageField] : null)
274
281
  const initial = typeof displayValue === 'string' ? displayValue.charAt(0).toUpperCase() : '?'
275
282
 
276
283
  // Merge archetype tabs + custom entity tabs. Entity-specific tabs win by id