@fayz-ai/saas 0.8.1 → 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 (233) 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 +4281 -716
  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 +4067 -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/plugins.d.ts +2 -45
  136. package/dist/shell/types/plugins.d.ts.map +1 -1
  137. package/dist/stores/createCrudStore.d.ts.map +1 -1
  138. package/package.json +14 -11
  139. package/src/access/context.tsx +228 -0
  140. package/src/access/headless.ts +71 -0
  141. package/src/access/index.ts +27 -0
  142. package/src/access/limits-registry.ts +86 -0
  143. package/src/access/module-nav.test.ts +78 -0
  144. package/src/access/module-nav.ts +82 -0
  145. package/src/access/resolver.test.ts +143 -0
  146. package/src/access/resolver.ts +5 -0
  147. package/src/access/types.ts +47 -0
  148. package/src/access/upgrade-modal-store.ts +30 -0
  149. package/src/app/AdminShell.tsx +451 -68
  150. package/src/app/admin-app.tsx +46 -9
  151. package/src/app/config.ts +54 -1
  152. package/src/app/core-entities.ts +110 -0
  153. package/src/app/derive-contract.test.ts +155 -0
  154. package/src/app/derive-contract.ts +214 -0
  155. package/src/app/scaffold.tsx +4 -0
  156. package/src/archetype-lookup.ts +8 -2
  157. package/src/billing/index.ts +2 -0
  158. package/src/billing/plan-badge.ts +32 -0
  159. package/src/billing/store.ts +8 -1
  160. package/src/components/shared/ContactPicker.tsx +394 -0
  161. package/src/components/shared/PersonLink.tsx +11 -3
  162. package/src/components/shared/create-person.ts +74 -0
  163. package/src/crud/CrudDetailPage.tsx +5 -1
  164. package/src/crud/CrudFormPage.tsx +113 -6
  165. package/src/crud/CrudListView.tsx +16 -1
  166. package/src/crud/CrudPage.tsx +27 -5
  167. package/src/crud/DeleteConfirmDialog.tsx +1 -1
  168. package/src/crud/archetypes/AddressesTab.tsx +387 -0
  169. package/src/crud/archetypes/PersonDetailTabs.tsx +5 -0
  170. package/src/crud/createCrudPage.tsx +2 -0
  171. package/src/crud/index.ts +6 -0
  172. package/src/env.d.ts +7 -0
  173. package/src/index.ts +49 -1
  174. package/src/org/store.ts +9 -1
  175. package/src/permissions/context.tsx +29 -15
  176. package/src/shell/components/auth/ProtectedRoute.tsx +12 -2
  177. package/src/shell/components/billing/SoftLimitBanner.tsx +81 -0
  178. package/src/shell/components/billing/SubscriptionPage.tsx +372 -0
  179. package/src/shell/components/billing/UpgradeModal.tsx +102 -0
  180. package/src/shell/components/billing/UpgradePrompt.tsx +229 -0
  181. package/src/shell/components/billing/access-contract.ts +64 -0
  182. package/src/shell/components/billing/gates.tsx +62 -0
  183. package/src/shell/components/billing/index.ts +18 -2
  184. package/src/shell/components/chat/ChatFab.tsx +4 -2
  185. package/src/shell/components/chat/ChatPanel.tsx +174 -23
  186. package/src/shell/components/chat/ConfirmActionCard.tsx +57 -0
  187. package/src/shell/components/chat/markdown.tsx +102 -0
  188. package/src/shell/components/organization/TeamTab.tsx +21 -4
  189. package/src/shell/components/organization/TenantOnboarding.tsx +3 -1
  190. package/src/shell/components/organization/index.ts +0 -1
  191. package/src/shell/components/settings/ConnectedCompanySettings.tsx +4 -1
  192. package/src/shell/components/settings/ConnectedFieldRulesSettings.tsx +3 -1
  193. package/src/shell/components/settings/ConnectedSecuritySettings.tsx +3 -1
  194. package/src/shell/components/settings/LocationsCrudPage.ts +6 -1
  195. package/src/shell/components/settings/SettingsPage.tsx +63 -21
  196. package/src/shell/hooks/index.ts +3 -2
  197. package/src/shell/hooks/useAITools.ts +27 -23
  198. package/src/shell/hooks/useChat.ts +381 -16
  199. package/src/shell/hooks/useSystemPermission.ts +23 -0
  200. package/src/shell/hooks/useTenantPluginSettings.ts +127 -0
  201. package/src/shell/lib/ai-tool-handlers.ts +910 -0
  202. package/src/shell/lib/core-ai-tools.ts +234 -2
  203. package/src/shell/lib/create-client-orders-provider.ts +24 -2
  204. package/src/shell/lib/fayz-agent.ts +56 -0
  205. package/src/shell/lib/i18n.ts +71 -0
  206. package/src/shell/stores/chat.store.ts +118 -1
  207. package/src/shell/styles.css +30 -0
  208. package/src/shell/types/billing.ts +8 -0
  209. package/src/shell/types/plugins.ts +13 -50
  210. package/src/stores/createCrudStore.ts +9 -0
  211. package/dist/chunk-3M25GRRU.js.map +0 -1
  212. package/dist/chunk-4XTQ4V5L.cjs.map +0 -1
  213. package/dist/chunk-5GYQK5IW.js +0 -21
  214. package/dist/chunk-5GYQK5IW.js.map +0 -1
  215. package/dist/chunk-IPWCHJPE.cjs.map +0 -1
  216. package/dist/chunk-KYID2MR6.cjs +0 -23
  217. package/dist/chunk-KYID2MR6.cjs.map +0 -1
  218. package/dist/chunk-XNWCGJYH.js.map +0 -1
  219. package/dist/shell/components/billing/BillingPage.d.ts +0 -7
  220. package/dist/shell/components/billing/BillingPage.d.ts.map +0 -1
  221. package/dist/shell/components/billing/PaywallGate.d.ts +0 -11
  222. package/dist/shell/components/billing/PaywallGate.d.ts.map +0 -1
  223. package/dist/shell/components/organization/PermissionGate.d.ts +0 -11
  224. package/dist/shell/components/organization/PermissionGate.d.ts.map +0 -1
  225. package/dist/shell/hooks/useBilling.d.ts +0 -14
  226. package/dist/shell/hooks/useBilling.d.ts.map +0 -1
  227. package/dist/shell/hooks/usePermission.d.ts +0 -16
  228. package/dist/shell/hooks/usePermission.d.ts.map +0 -1
  229. package/src/shell/components/billing/BillingPage.tsx +0 -56
  230. package/src/shell/components/billing/PaywallGate.tsx +0 -80
  231. package/src/shell/components/organization/PermissionGate.tsx +0 -16
  232. package/src/shell/hooks/useBilling.ts +0 -151
  233. package/src/shell/hooks/usePermission.ts +0 -101
@@ -1,4 +1,7 @@
1
1
  import type { PluginAITool, PluginRegistryDef } from '../types/plugins'
2
+ import type { EntityDef } from '@fayz-ai/core'
3
+ import { getAllEntities, type RegisteredEntity } from '@fayz-ai/core'
4
+ import { CORE_QUERY_ENTITIES } from '../../app/core-entities'
2
5
 
3
6
  /**
4
7
  * Core SaaS-level AI tools — always available, no plugin required.
@@ -19,7 +22,7 @@ export const coreAITools: PluginAITool[] = [
19
22
  {
20
23
  id: 'core.team-members',
21
24
  name: 'getTeamMembers',
22
- description: 'Lists team members for the current tenant with their roles and status.',
25
+ description: 'Lists the WORKSPACE USERS (people with a login) and their roles. This is NOT the professionals/staff registry — for professionals, stylists or other staff the business manages as records, use the staff/people search tools instead.',
23
26
  icon: 'Users',
24
27
  mode: 'read',
25
28
  parameters: {
@@ -51,6 +54,37 @@ export const coreAITools: PluginAITool[] = [
51
54
  },
52
55
  ]
53
56
 
57
+ /**
58
+ * Builds the LLM-facing function name for a registry tool.
59
+ *
60
+ * Derived from the registry id, never from the entity's display label: the
61
+ * label is translated, so a pt-BR app would mint `listCategoriasdeServiço` —
62
+ * which both violates the provider's `^[a-zA-Z0-9_-]+$` function-name rule and
63
+ * renames the tool whenever the user switches locale. The id is a stable ASCII
64
+ * slug. The human plural still drives the description, which is what the model
65
+ * actually reads to decide when to call it.
66
+ */
67
+ function pascalCase(value: string): string {
68
+ // Transliterate diacritics BEFORE splitting: splitting on [^a-zA-Z0-9]
69
+ // treated an accent as a word boundary and dropped it mid-word — "Serviço"
70
+ // became "ServiO" and "Preço de Serviço" became "PreODeServiO", which both
71
+ // renames tools per locale and can trip the broker's ^[a-zA-Z0-9_-]+$ rule.
72
+ return value
73
+ .normalize('NFD')
74
+ .replace(/[\u0300-\u036f]/g, '')
75
+ .split(/[^a-zA-Z0-9]+/)
76
+ .filter(Boolean)
77
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
78
+ .join('')
79
+ }
80
+
81
+ export function registryToolName(pluginId: string, registryId: string): string {
82
+ // Namespaced by plugin because registry ids only need to be unique within
83
+ // their own plugin — `crm` and `inventory` can both ship a `tags` registry,
84
+ // and two tools sharing a function name is ambiguous to the model.
85
+ return `list${pascalCase(pluginId)}${pascalCase(registryId)}`
86
+ }
87
+
54
88
  /**
55
89
  * Auto-generates read tools from plugin registry definitions.
56
90
  * Each registry gets a basic "list" tool so plugins get AI capabilities for free.
@@ -62,7 +96,7 @@ export function generateRegistryTools(pluginId: string, registries: PluginRegist
62
96
  const plural = registry.entity.namePlural ?? `${registry.entity.name}s`
63
97
  return {
64
98
  id: `${pluginId}.list-${registry.id}`,
65
- name: `list${plural.replace(/\s+/g, '')}`,
99
+ name: registryToolName(pluginId, registry.id),
66
100
  description: `Lists all ${plural.toLowerCase()} for the current business.`,
67
101
  icon: registry.icon ?? registry.entity.icon,
68
102
  mode: 'read' as const,
@@ -88,3 +122,201 @@ export function formatToolSignature(tool: PluginAITool): string {
88
122
  : ''
89
123
  return `${tool.name}(${params})`
90
124
  }
125
+
126
+ export function entityToolName(entityKey: string): string {
127
+ return `search${pascalCase(entityKey)}`
128
+ }
129
+
130
+ /**
131
+ * Auto-generates a search tool for every entity the app exposes as a CRUD page.
132
+ *
133
+ * Registries cover a plugin's auxiliary tables; these are the entities the
134
+ * business actually talks about — clients, appointments, services. Deriving
135
+ * them from the entity registry is what makes the agent useful in a new vertical
136
+ * without anyone hand-writing tools: whatever the app declares, the agent can
137
+ * read.
138
+ */
139
+ /**
140
+ * The TWO generic data primitives that replace the per-entity/per-registry
141
+ * generated tools (43 searches → 2 tools): the model picks the TARGET as a
142
+ * parameter (closed enum), and the executor checks the target entity's OWN
143
+ * read permission at call time — one tool, per-user access preserved.
144
+ */
145
+ /**
146
+ * Sentences describing entities that ATTACH to another record instead of
147
+ * standing alone. Appended to createRecord's description so the relationship is
148
+ * stated once, up front, rather than discovered through a failed write.
149
+ * Only entities actually present in this app's catalog are mentioned.
150
+ */
151
+ function attachedRecordGuidance(writableKeys: string[]): string {
152
+ const hints = CORE_QUERY_ENTITIES
153
+ .filter((q) => q.agentHint && writableKeys.includes(q.key))
154
+ .map((q) => q.agentHint as string)
155
+ return hints.length ? ` Records that attach to another record: ${hints.join(' ')}` : ''
156
+ }
157
+
158
+ export function buildDataPrimitiveTools(input: {
159
+ entities: RegisteredEntity[]
160
+ registries: Map<string, PluginRegistryDef[]>
161
+ queryEntities?: Array<{ key: string; entity: EntityDef; writable?: boolean }>
162
+ }): PluginAITool[] {
163
+ const options: Array<{ key: string; label: string }> = []
164
+ for (const e of input.entities) {
165
+ if (e.entityDef && !e.entityDef.agentHidden) options.push({ key: e.entityKey, label: e.labelPlural })
166
+ }
167
+ for (const [pluginId, defs] of input.registries) {
168
+ for (const registry of defs) {
169
+ if (registry.readOnly) continue
170
+ options.push({
171
+ key: `${pluginId}:${registry.id}`,
172
+ label: registry.entity.namePlural ?? registry.entity.name,
173
+ })
174
+ }
175
+ }
176
+ // Read-models are readable; base-table ones may opt into writes.
177
+ const writableKeys = options.map((o) => o.key)
178
+ // CORE_QUERY_ENTITIES is merged HERE, not at each call site: the primitives
179
+ // are built from three different places (deriveAgentContract, useAITools and
180
+ // the conversational e2e harness), and a spine entity added to only some of
181
+ // them is a catalog that disagrees with itself.
182
+ for (const q of [...(input.queryEntities ?? []), ...CORE_QUERY_ENTITIES]) {
183
+ options.push({ key: q.key, label: q.entity.namePlural ?? q.entity.name })
184
+ if (q.writable) writableKeys.push(q.key)
185
+ }
186
+ if (!options.length) return []
187
+ const keys = options.map((o) => o.key)
188
+ const catalogLine = options.map((o) => `${o.key} (${o.label.toLowerCase()})`).join(', ')
189
+
190
+ return [
191
+ {
192
+ id: 'data.find-anything',
193
+ name: 'findAnything',
194
+ description:
195
+ 'Global search (like the app command center): looks a name/text up across ALL record types the user may see at once and returns grouped matches. Use this FIRST whenever you do not know what kind of record a name refers to ("quem é X?", "o que é Y?") — then drill down with searchRecords.' +
196
+ ' It matches TEXT INSIDE each record, so records attached to a person are never returned by that person\'s name — reaching them takes a second searchRecords call filtered by owner_id.' +
197
+ attachedRecordGuidance(keys),
198
+ icon: 'Command',
199
+ mode: 'read',
200
+ parameters: {
201
+ type: 'object',
202
+ properties: {
203
+ search: { type: 'string', description: 'Name or text to look up everywhere.' },
204
+ },
205
+ required: ['search'],
206
+ },
207
+ category: 'Data',
208
+ suggestions: [{ label: 'Quem é …?' }],
209
+ },
210
+ ...(writableKeys.length
211
+ ? [
212
+ {
213
+ id: 'data.create-record',
214
+ name: 'createRecord',
215
+ description:
216
+ 'Creates ONE new record of an entity (client, service, supplier, …). ALWAYS search first to avoid duplicates. Field keys and required fields come from the entity — on a field error the response lists the valid fields; fix and retry. The user will confirm before anything is written.' +
217
+ attachedRecordGuidance(writableKeys),
218
+ icon: 'Plus',
219
+ mode: 'persist' as const,
220
+ // The handler orchestrates its own guard chain (permission → plan
221
+ // cap → confirmation → create) because the target is dynamic.
222
+ requiresConfirmation: false,
223
+ parameters: {
224
+ type: 'object' as const,
225
+ properties: {
226
+ entity: { type: 'string' as const, description: 'Which entity to create', enum: writableKeys },
227
+ values: { type: 'object' as const, description: 'Field values for the new record, e.g. {"name":"…","phone":"…"}' },
228
+ },
229
+ required: ['entity', 'values'],
230
+ },
231
+ category: 'Data',
232
+ } satisfies PluginAITool,
233
+ ]
234
+ : []),
235
+ ...(writableKeys.length
236
+ ? [
237
+ {
238
+ id: 'data.update-record',
239
+ name: 'updateRecord',
240
+ description:
241
+ 'Updates fields on an EXISTING record (add an email, fix a phone, change a name…). Use the record id already known from this conversation or from a search. Only send the fields being changed. The user confirms before anything is written.',
242
+ icon: 'Pencil',
243
+ mode: 'persist' as const,
244
+ requiresConfirmation: false,
245
+ parameters: {
246
+ type: 'object' as const,
247
+ properties: {
248
+ entity: { type: 'string' as const, description: 'Which entity the record belongs to', enum: writableKeys },
249
+ id: { type: 'string' as const, description: 'Id of the record to update' },
250
+ values: { type: 'object' as const, description: 'Only the fields to change, e.g. {"email":"…"}' },
251
+ },
252
+ required: ['entity', 'id', 'values'],
253
+ },
254
+ category: 'Data',
255
+ } satisfies PluginAITool,
256
+ ]
257
+ : []),
258
+ {
259
+ id: 'data.search-records',
260
+ name: 'searchRecords',
261
+ description: `Searches or lists records of ONE entity and returns matching rows with their fields. Use \`search\` for names/text and \`filters\` for exact field values (e.g. {"status":"open"}) — never put a status word in search. Entities: ${catalogLine}.${attachedRecordGuidance(keys)}`,
262
+ icon: 'Search',
263
+ mode: 'read',
264
+ parameters: {
265
+ type: 'object',
266
+ properties: {
267
+ entity: { type: 'string', description: 'Which entity to search', enum: keys },
268
+ search: { type: 'string', description: 'Name or text to match. Omit to list recent records.' },
269
+ filters: { type: 'object', description: 'Optional exact-value filters {field: value}, e.g. {"status":"open"}.' },
270
+ orderBy: { type: 'string', description: 'Field to sort by (e.g. startsAt for "next/upcoming" questions).' },
271
+ direction: { type: 'string', description: 'Sort direction', enum: ['asc', 'desc'] },
272
+ },
273
+ required: ['entity'],
274
+ },
275
+ category: 'Data',
276
+ },
277
+ {
278
+ id: 'data.query-data',
279
+ name: 'queryData',
280
+ description: `Aggregates over ONE entity: count, sum or avg of a field, with optional date range, equality filters and a group-by. Use for analytical questions (revenue this week, new clients this month, busiest professional). Entities: ${catalogLine}.`,
281
+ icon: 'Sigma',
282
+ mode: 'read',
283
+ parameters: {
284
+ type: 'object',
285
+ properties: {
286
+ entity: { type: 'string', description: 'Which entity to aggregate', enum: keys },
287
+ metric: { type: 'string', description: 'Aggregation', enum: ['count', 'sum', 'avg'] },
288
+ field: { type: 'string', description: 'Field to sum/avg (record field name). Ignored for count.' },
289
+ dateField: { type: 'string', description: "Date field to range-filter (default 'created_at')." },
290
+ from: { type: 'string', description: 'ISO date/datetime lower bound (inclusive).' },
291
+ to: { type: 'string', description: 'ISO date/datetime upper bound (inclusive).' },
292
+ groupBy: { type: 'string', description: 'Optional field to group results by.' },
293
+ filters: { type: 'object', description: 'Optional equality filters {field: value}.' },
294
+ },
295
+ required: ['entity', 'metric'],
296
+ },
297
+ category: 'Data',
298
+ },
299
+ ]
300
+ }
301
+
302
+ export function generateEntityTools(entities: RegisteredEntity[] = getAllEntities()): PluginAITool[] {
303
+ return entities
304
+ .filter((e) => !!e.entityDef)
305
+ .map((entity) => ({
306
+ id: `entity.${entity.entityKey}`,
307
+ name: entityToolName(entity.entityKey),
308
+ description: `Searches ${entity.labelPlural.toLowerCase()} and returns matching records with their fields. Use this to answer questions about a specific ${entity.label.toLowerCase()} or to count them.`,
309
+ icon: entity.icon,
310
+ mode: 'read' as const,
311
+ parameters: {
312
+ type: 'object' as const,
313
+ properties: {
314
+ search: {
315
+ type: 'string' as const,
316
+ description: `Name or text to match. Omit to list recent ${entity.labelPlural.toLowerCase()}.`,
317
+ },
318
+ },
319
+ },
320
+ category: 'Data',
321
+ }))
322
+ }
@@ -14,6 +14,23 @@ const STAGE_FILTER_MAP: Record<string, string[]> = {
14
14
  paid: ['paid'],
15
15
  }
16
16
 
17
+ /**
18
+ * A shop order's lifecycle is not in `status` (open/archived/cancelled) but in
19
+ * the payment state the shop plugin mirrors into metadata. Without this every
20
+ * storefront purchase showed up as "rascunho" on the customer's record — the
21
+ * same tab, the same table, but reading the wrong column for that kind.
22
+ */
23
+ function mapShopOrderToStage(row: { status?: string | null; metadata?: Record<string, unknown> | null }): ClientDocumentStage {
24
+ if (row.status === 'cancelled') return 'cancelled'
25
+ switch (String(row.metadata?.financial_status ?? '')) {
26
+ case 'paid': return 'paid'
27
+ case 'partially_refunded': return 'partial'
28
+ case 'refunded':
29
+ case 'voided': return 'cancelled'
30
+ default: return 'invoiced' // placed, awaiting payment
31
+ }
32
+ }
33
+
17
34
  function mapOrderStatusToStage(status: string | null | undefined): ClientDocumentStage {
18
35
  switch (status) {
19
36
  case 'scheduled':
@@ -83,12 +100,17 @@ export function createClientOrdersProvider(): ClientOrdersProvider {
83
100
  return {
84
101
  id: row.id,
85
102
  kind: row.kind as string,
86
- stage: mapOrderStatusToStage(row.status as string | null | undefined),
103
+ stage: row.kind === 'shop'
104
+ ? mapShopOrderToStage(row)
105
+ : mapOrderStatusToStage(row.status as string | null | undefined),
87
106
  referenceNumber: row.reference_number ?? undefined,
88
107
  date: startsAt?.slice(0, 10) ?? (row.created_at as string)?.slice(0, 10) ?? '',
89
108
  startsAt,
90
109
  total: Number(row.total) || 0,
91
- paidAmount: Number(meta.paidAmount) || 0,
110
+ // A paid shop order has no separate paidAmount; the total is what
111
+ // was settled.
112
+ paidAmount: Number(meta.paidAmount)
113
+ || (row.kind === 'shop' && meta.financial_status === 'paid' ? Number(row.total) || 0 : 0),
92
114
  description: (meta.itemsSummary as string) ?? (meta.serviceNames as string) ?? row.notes ?? undefined,
93
115
  createdAt: row.created_at as string,
94
116
  }
@@ -0,0 +1,56 @@
1
+ import { createFayzAgentClient, type FayzAgentClient } from '@fayz-ai/sdk/agent'
2
+
3
+ /**
4
+ * Resolves the connection to the project's Fayz agent.
5
+ *
6
+ * The container writes `VITE_FAYZ_PROJECT_ID`, `VITE_FAYZ_API_BASE_URL` and
7
+ * `VITE_FAYZ_AGENT_KEY` into every preview's `.env`, so enabling an agent in
8
+ * Fayz is enough to light up the app's assistant — no app-side config edit and
9
+ * no key to copy. `chat.agent` overrides are for self-hosted deployments where
10
+ * the app is built outside a Fayz container.
11
+ */
12
+
13
+ export interface FayzAgentConnectionConfig {
14
+ projectId?: string
15
+ apiBaseUrl?: string
16
+ publishableKey?: string
17
+ }
18
+
19
+ export interface ResolvedFayzAgentConnection {
20
+ projectId: string
21
+ apiBaseUrl: string
22
+ publishableKey: string
23
+ }
24
+
25
+ function env(key: string): string | undefined {
26
+ const value = (import.meta.env as Record<string, string | undefined>)[key]
27
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
28
+ }
29
+
30
+ export function resolveFayzAgentConnection(
31
+ override?: FayzAgentConnectionConfig | false,
32
+ ): ResolvedFayzAgentConnection | null {
33
+ if (override === false) return null
34
+
35
+ const projectId = override?.projectId ?? env('VITE_FAYZ_PROJECT_ID')
36
+ const apiBaseUrl = override?.apiBaseUrl ?? env('VITE_FAYZ_API_BASE_URL')
37
+ const publishableKey = override?.publishableKey ?? env('VITE_FAYZ_AGENT_KEY')
38
+
39
+ if (!projectId || !apiBaseUrl || !publishableKey) return null
40
+ return { projectId, apiBaseUrl, publishableKey }
41
+ }
42
+
43
+ let cached: { key: string; client: FayzAgentClient } | null = null
44
+
45
+ export function getFayzAgentClient(connection: ResolvedFayzAgentConnection): FayzAgentClient {
46
+ const key = `${connection.apiBaseUrl}|${connection.projectId}|${connection.publishableKey}`
47
+ if (cached?.key === key) return cached.client
48
+
49
+ const client = createFayzAgentClient({
50
+ baseUrl: connection.apiBaseUrl,
51
+ projectId: connection.projectId,
52
+ publishableKey: connection.publishableKey,
53
+ })
54
+ cached = { key, client }
55
+ return client
56
+ }
@@ -595,6 +595,43 @@ export const defaultTranslations: Record<string, string> = {
595
595
  'billing.paywall.requiresPlan': 'This feature requires the {{plan}} plan or higher.',
596
596
  'billing.paywall.upgradeRequired': 'Upgrade Required',
597
597
  'billing.paywall.viewPlans': 'View Plans',
598
+
599
+ // ---------------------------------------------------------------------------
600
+ // Entitlements — upgrade prompts, plan limits, soft-limit banner
601
+ // ---------------------------------------------------------------------------
602
+ 'upgrade.title': 'Premium feature',
603
+ 'upgrade.description': 'This feature is available on a higher plan. Upgrade to unlock it.',
604
+ 'upgrade.descriptionFeature': 'The {feature} feature is available on a higher plan. Upgrade to unlock it.',
605
+ 'upgrade.includes': 'Includes {feature}',
606
+ 'upgrade.cta': 'View plans',
607
+ 'upgrade.badgeTooltip': 'Available on a higher plan',
608
+ 'limit.reached': "You've reached the {label} limit of the {plan} plan.",
609
+ 'banner.overLimit': "You've exceeded the {label} limit of your plan — upgrade to keep growing.",
610
+ 'billing.featureMatrix': 'Feature comparison',
611
+ 'billing.unlimited': 'Unlimited',
612
+ 'billing.upTo': 'Up to {count}',
613
+ // Limit labels — apps/plugins may override per key; these cover the base caps.
614
+ 'limit.label.clients': 'clients',
615
+ 'limit.label.users': 'team members',
616
+ 'limit.label.locations': 'locations',
617
+ 'limit.label.bookings_month': 'monthly bookings',
618
+ 'limit.label.products': 'products',
619
+ 'limit.label.students': 'students',
620
+ 'limit.label.courses': 'courses',
621
+ 'limit.label.contacts': 'contacts',
622
+ 'limit.label.deals': 'deals',
623
+ 'limit.label.menu_items': 'menu items',
624
+ 'limit.label.orders_month': 'monthly orders',
625
+ 'limit.label.connected_banks': 'connected banks',
626
+ 'limit.label.movements_month': 'monthly movements',
627
+ 'limit.label.leads': 'leads',
628
+ 'limit.label.quotes': 'quotes',
629
+ 'limit.label.conversations_month': 'monthly conversations',
630
+ 'limit.label.recipes': 'recipes',
631
+ 'limit.label.form_templates': 'form templates',
632
+ 'limit.label.documents_month': 'monthly documents',
633
+ 'limit.label.content_posts_month': 'monthly content posts',
634
+ 'limit.label.blog_posts': 'blog posts',
598
635
  'chat.defaultTitle': 'Assistant',
599
636
  'chat.tryAsking': 'Try asking',
600
637
  'chat.fab.tryIt': 'Try it',
@@ -2345,6 +2382,40 @@ export const builtInLocales: Record<string, Record<string, string>> = {
2345
2382
  'billing.paywall.upgradeRequired': 'Upgrade Necessário',
2346
2383
  'billing.paywall.viewPlans': 'Ver Planos',
2347
2384
 
2385
+ // Entitlements — upgrade prompts, limites de plano, banner de soft-limit
2386
+ 'upgrade.title': 'Recurso premium',
2387
+ 'upgrade.description': 'Este recurso está disponível em um plano superior. Faça upgrade para liberá-lo.',
2388
+ 'upgrade.descriptionFeature': 'O recurso {feature} está disponível em um plano superior. Faça upgrade para liberá-lo.',
2389
+ 'upgrade.includes': 'Inclui {feature}',
2390
+ 'upgrade.cta': 'Ver planos',
2391
+ 'upgrade.badgeTooltip': 'Disponível em um plano superior',
2392
+ 'limit.reached': 'Você atingiu o limite de {label} do plano {plan}.',
2393
+ 'banner.overLimit': 'Você excedeu o limite de {label} do seu plano — faça upgrade para continuar crescendo.',
2394
+ 'billing.featureMatrix': 'Comparação de recursos',
2395
+ 'billing.unlimited': 'Ilimitado',
2396
+ 'billing.upTo': 'Até {count}',
2397
+ 'limit.label.clients': 'clientes',
2398
+ 'limit.label.users': 'membros da equipe',
2399
+ 'limit.label.locations': 'locais',
2400
+ 'limit.label.bookings_month': 'agendamentos mensais',
2401
+ 'limit.label.leads': 'leads',
2402
+ 'limit.label.quotes': 'orçamentos',
2403
+ 'limit.label.conversations_month': 'conversas mensais',
2404
+ 'limit.label.recipes': 'receitas',
2405
+ 'limit.label.form_templates': 'modelos de formulário',
2406
+ 'limit.label.documents_month': 'documentos mensais',
2407
+ 'limit.label.content_posts_month': 'posts de conteúdo mensais',
2408
+ 'limit.label.blog_posts': 'posts de blog',
2409
+ 'limit.label.products': 'produtos',
2410
+ 'limit.label.students': 'alunos',
2411
+ 'limit.label.courses': 'cursos',
2412
+ 'limit.label.contacts': 'contatos',
2413
+ 'limit.label.deals': 'negócios',
2414
+ 'limit.label.menu_items': 'itens do cardápio',
2415
+ 'limit.label.orders_month': 'pedidos mensais',
2416
+ 'limit.label.connected_banks': 'bancos conectados',
2417
+ 'limit.label.movements_month': 'movimentações mensais',
2418
+
2348
2419
  // auth (additional duplicates/variants)
2349
2420
  'auth.login.enterCredentials': 'Insira suas credenciais para acessar sua conta',
2350
2421
  'auth.login.failedDefault': 'Falha ao entrar. Tente novamente.',
@@ -1,10 +1,50 @@
1
1
  import { create } from 'zustand'
2
2
 
3
+ /** A record the reply concerns — rendered as a "goto" button under the
4
+ * bubble. Semantic ref only (id + archetype/kind); the SURFACE resolves the
5
+ * URL against its own route map (agents.md: a missing link is correct, an
6
+ * invented one is a dead end). */
7
+ export interface ChatRecordLink {
8
+ id: string
9
+ label: string
10
+ archetype?: string
11
+ kind?: string
12
+ }
13
+
14
+ export interface ChatToolCall {
15
+ name: string
16
+ /** Pretty-printed arguments (may be empty). */
17
+ args: string
18
+ /** Result content, truncated for display. */
19
+ result?: string
20
+ }
21
+
3
22
  export interface ChatMessage {
4
23
  id: string
5
24
  role: 'user' | 'assistant'
6
25
  content: string
7
26
  timestamp: string
27
+ /** Tool calls this assistant reply made, accumulated across rounds —
28
+ * rendered as an expandable trace under the bubble. */
29
+ toolCalls?: ChatToolCall[]
30
+ /** Records to offer as navigation buttons under the reply. */
31
+ links?: ChatRecordLink[]
32
+ }
33
+
34
+ /**
35
+ * A write the agent wants to perform, parked until the human decides. The turn
36
+ * loop awaits the promise this action's resolver settles — the chat composer
37
+ * stays frozen behind the card, mirroring the broker's PENDING_CONFIRMATION
38
+ * step for server-plane tools.
39
+ */
40
+ export interface PendingAgentAction {
41
+ id: string
42
+ toolName: string
43
+ title: string
44
+ params: Record<string, unknown>
45
+ /** 'client' = executed locally after approval; 'server' = approval is sent
46
+ * back to the broker as confirmAction. */
47
+ plane: 'client' | 'server'
8
48
  }
9
49
 
10
50
  interface ChatState {
@@ -16,13 +56,41 @@ interface ChatState {
16
56
  addMessage: (message: ChatMessage) => void
17
57
  updateLastAssistant: (content: string) => void
18
58
  setStreaming: (streaming: boolean) => void
59
+ setConversationId: (id: string | null) => void
19
60
  reset: () => void
61
+ /**
62
+ * Server-side thread id when talking to a Fayz agent. History lives in Fayz,
63
+ * so subsequent turns only send the new message plus this id.
64
+ */
65
+ conversationId: string | null
66
+ /** The write awaiting human confirmation (renders the ConfirmActionCard). */
67
+ pendingAction: PendingAgentAction | null
68
+ /** Park an action + register its decision resolver. */
69
+ requestConfirmation: (action: PendingAgentAction) => Promise<boolean>
70
+ /** Card buttons: settle the parked action. */
71
+ resolvePendingAction: (approved: boolean) => void
72
+ /** Tool names executing right now — rendered as live activity chips. */
73
+ activeTools: string[]
74
+ setActiveTools: (names: string[]) => void
75
+ /** Record the tool calls an assistant reply made (expandable per-message trace). */
76
+ appendToolCallsToLastAssistant: (calls: ChatToolCall[]) => void
77
+ /** Attach goto-record buttons to the reply (deduped by id, capped). */
78
+ appendLinksToLastAssistant: (links: ChatRecordLink[]) => void
79
+ /** The signed-in user's threads (history drawer). */
80
+ conversations: Array<{ id: string; title: string | null; updatedAt: string }>
81
+ setConversations: (rows: Array<{ id: string; title: string | null; updatedAt: string }>) => void
82
+ /** Replace the transcript wholesale (resuming a past conversation). */
83
+ setMessages: (messages: ChatMessage[]) => void
20
84
  }
21
85
 
86
+ // Module-level so the resolver never round-trips through React state.
87
+ let pendingResolver: ((approved: boolean) => void) | null = null
88
+
22
89
  export const useChatStore = create<ChatState>((set) => ({
23
90
  messages: [],
24
91
  isOpen: false,
25
92
  isStreaming: false,
93
+ conversationId: null,
26
94
 
27
95
  setOpen: (open) => set({ isOpen: open }),
28
96
  toggleOpen: () => set((s) => ({ isOpen: !s.isOpen })),
@@ -41,5 +109,54 @@ export const useChatStore = create<ChatState>((set) => ({
41
109
  }),
42
110
 
43
111
  setStreaming: (isStreaming) => set({ isStreaming }),
44
- reset: () => set({ messages: [], isStreaming: false }),
112
+ setConversationId: (conversationId) => set({ conversationId }),
113
+ reset: () => {
114
+ pendingResolver?.(false)
115
+ pendingResolver = null
116
+ set({ messages: [], isStreaming: false, conversationId: null, pendingAction: null, activeTools: [] })
117
+ },
118
+
119
+ pendingAction: null,
120
+ requestConfirmation: (action) => {
121
+ // A newer request supersedes an unanswered one (treated as declined).
122
+ pendingResolver?.(false)
123
+ return new Promise<boolean>((resolve) => {
124
+ pendingResolver = resolve
125
+ set({ pendingAction: action })
126
+ })
127
+ },
128
+ resolvePendingAction: (approved) => {
129
+ const resolve = pendingResolver
130
+ pendingResolver = null
131
+ set({ pendingAction: null })
132
+ resolve?.(approved)
133
+ },
134
+
135
+ activeTools: [],
136
+ setActiveTools: (activeTools) => set({ activeTools }),
137
+ appendToolCallsToLastAssistant: (calls) =>
138
+ set((s) => {
139
+ if (!calls.length) return s
140
+ const msgs = [...s.messages]
141
+ const lastIdx = msgs.length - 1
142
+ if (lastIdx >= 0 && msgs[lastIdx].role === 'assistant') {
143
+ msgs[lastIdx] = { ...msgs[lastIdx], toolCalls: [...(msgs[lastIdx].toolCalls ?? []), ...calls] }
144
+ }
145
+ return { messages: msgs }
146
+ }),
147
+ appendLinksToLastAssistant: (links) =>
148
+ set((s) => {
149
+ if (!links.length) return s
150
+ const msgs = [...s.messages]
151
+ const lastIdx = msgs.length - 1
152
+ if (lastIdx >= 0 && msgs[lastIdx].role === 'assistant') {
153
+ const seen = new Map((msgs[lastIdx].links ?? []).map((l) => [l.id, l]))
154
+ for (const l of links) if (!seen.has(l.id)) seen.set(l.id, l)
155
+ msgs[lastIdx] = { ...msgs[lastIdx], links: Array.from(seen.values()).slice(0, 3) }
156
+ }
157
+ return { messages: msgs }
158
+ }),
159
+ conversations: [],
160
+ setConversations: (conversations) => set({ conversations }),
161
+ setMessages: (messages) => set({ messages }),
45
162
  }))
@@ -428,6 +428,36 @@ select.bg-card,
428
428
  to { opacity: 1; transform: translateX(0); }
429
429
  }
430
430
 
431
+ /* Sidebar rail push — see @fayz-ai/ui styles.css for the rationale. */
432
+ .saas-rail-in-forward { animation: saas-rail-in-forward var(--saas-rail-duration, 260ms) var(--saas-transition-easing) both; }
433
+ .saas-rail-out-forward { animation: saas-rail-out-forward var(--saas-rail-duration, 260ms) var(--saas-transition-easing) both; }
434
+ .saas-rail-in-back { animation: saas-rail-in-back var(--saas-rail-duration, 260ms) var(--saas-transition-easing) both; }
435
+ .saas-rail-out-back { animation: saas-rail-out-back var(--saas-rail-duration, 260ms) var(--saas-transition-easing) both; }
436
+
437
+ @keyframes saas-rail-in-forward {
438
+ from { opacity: 0; transform: translateX(100%); }
439
+ to { opacity: 1; transform: translateX(0); }
440
+ }
441
+ @keyframes saas-rail-out-forward {
442
+ from { opacity: 1; transform: translateX(0); }
443
+ to { opacity: 0; transform: translateX(-35%); }
444
+ }
445
+ @keyframes saas-rail-in-back {
446
+ from { opacity: 0; transform: translateX(-100%); }
447
+ to { opacity: 1; transform: translateX(0); }
448
+ }
449
+ @keyframes saas-rail-out-back {
450
+ from { opacity: 1; transform: translateX(0); }
451
+ to { opacity: 0; transform: translateX(35%); }
452
+ }
453
+
454
+ @media (prefers-reduced-motion: reduce) {
455
+ .saas-rail-in-forward, .saas-rail-out-forward,
456
+ .saas-rail-in-back, .saas-rail-out-back {
457
+ animation-duration: 1ms;
458
+ }
459
+ }
460
+
431
461
  /* ═══ Print — intentionally left minimal here. ═══ */
432
462
  /* Runtime-injected print styles live in PrintChrome.tsx */
433
463
  /* to guarantee they bypass any build-tool reordering. */
@@ -1,3 +1,5 @@
1
+ import type { PlanEntitlements } from '@fayz-ai/core'
2
+
1
3
  export type PlanInterval = 'monthly' | 'yearly'
2
4
  export type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'paused'
3
5
 
@@ -37,6 +39,12 @@ export interface PlanConfig {
37
39
  limits?: Record<string, number>
38
40
  popular?: boolean
39
41
  verticalId?: string
42
+ /**
43
+ * Structured plan entitlements (feature gates + quantity caps) — the
44
+ * enforcement source of truth the access engine reads. Preserved verbatim by
45
+ * normalizeBillingPlan onto the runtime `Plan`.
46
+ */
47
+ entitlements?: PlanEntitlements
40
48
  }
41
49
 
42
50
  export function normalizePlanConfig(plan: PlanConfig, index?: number): Plan {