@carlonicora/nextjs-jsonapi 3.11.0 → 3.13.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.
Files changed (156) hide show
  1. package/dist/{AssistantInterface-DwdBzS9f.d.ts → AssistantInterface-RvZzlcA_.d.ts} +2 -0
  2. package/dist/{AssistantInterface-DMh-zApr.d.mts → AssistantInterface-jL56dyNc.d.mts} +2 -0
  3. package/dist/{AssistantMessageInterface-0zyVingf.d.ts → AssistantMessageInterface-CuaMDs2i.d.ts} +1 -1
  4. package/dist/{AssistantMessageInterface-ddyG114R.d.mts → AssistantMessageInterface-Du3ShThp.d.mts} +1 -1
  5. package/dist/{BlockNoteEditor-XOVNTXDY.js → BlockNoteEditor-CFVSHS46.js} +19 -19
  6. package/dist/{BlockNoteEditor-XOVNTXDY.js.map → BlockNoteEditor-CFVSHS46.js.map} +1 -1
  7. package/dist/{BlockNoteEditor-NBHDEZ7W.mjs → BlockNoteEditor-SERY337S.mjs} +4 -4
  8. package/dist/{BlockNoteViewer-C2UUOBAF.js → BlockNoteViewer-CWFPYYIU.js} +3 -3
  9. package/dist/{BlockNoteViewer-C2UUOBAF.js.map → BlockNoteViewer-CWFPYYIU.js.map} +1 -1
  10. package/dist/{BlockNoteViewer-TYLVTIUD.mjs → BlockNoteViewer-P2VKEFRN.mjs} +2 -2
  11. package/dist/{ai-connection.module-28f-hux0.d.mts → HandbookSectionInterface-BOxIdBdd.d.mts} +54 -1
  12. package/dist/{ai-connection.module-Bqwh_DnR.d.ts → HandbookSectionInterface-CAXxbDw0.d.ts} +54 -1
  13. package/dist/{auth.interface-B6ZQLGjS.d.ts → auth.interface-BC1jp_ni.d.ts} +1 -1
  14. package/dist/{auth.interface-jYD8Gc4P.d.mts → auth.interface-dR2-6gPM.d.mts} +1 -1
  15. package/dist/billing/index.d.mts +27 -0
  16. package/dist/billing/index.d.ts +27 -0
  17. package/dist/billing/index.js +697 -450
  18. package/dist/billing/index.js.map +1 -1
  19. package/dist/billing/index.mjs +282 -35
  20. package/dist/billing/index.mjs.map +1 -1
  21. package/dist/{chunk-6J2SQZ3G.js → chunk-IB7DTXWG.js} +847 -129
  22. package/dist/chunk-IB7DTXWG.js.map +1 -0
  23. package/dist/{chunk-YN4LOVL4.js → chunk-LVE3BJ6K.js} +2106 -1109
  24. package/dist/chunk-LVE3BJ6K.js.map +1 -0
  25. package/dist/{chunk-M5QAHSCY.mjs → chunk-OSFYCSTW.mjs} +725 -7
  26. package/dist/chunk-OSFYCSTW.mjs.map +1 -0
  27. package/dist/{chunk-XNGI4HDZ.mjs → chunk-UQXE3SME.mjs} +2100 -1103
  28. package/dist/chunk-UQXE3SME.mjs.map +1 -0
  29. package/dist/{chunk-IMSIVIIC.mjs → chunk-VZKJIAND.mjs} +2 -2
  30. package/dist/{chunk-V6CGCARD.js → chunk-XH4IURUD.js} +7 -7
  31. package/dist/{chunk-V6CGCARD.js.map → chunk-XH4IURUD.js.map} +1 -1
  32. package/dist/client/index.d.mts +2 -2
  33. package/dist/client/index.d.ts +2 -2
  34. package/dist/client/index.js +4 -4
  35. package/dist/client/index.mjs +3 -3
  36. package/dist/components/index.d.mts +258 -7
  37. package/dist/components/index.d.ts +258 -7
  38. package/dist/components/index.js +30 -4
  39. package/dist/components/index.js.map +1 -1
  40. package/dist/components/index.mjs +29 -3
  41. package/dist/contexts/index.d.mts +17 -5
  42. package/dist/contexts/index.d.ts +17 -5
  43. package/dist/contexts/index.js +4 -4
  44. package/dist/contexts/index.mjs +3 -3
  45. package/dist/core/index.d.mts +493 -12
  46. package/dist/core/index.d.ts +493 -12
  47. package/dist/core/index.js +38 -2
  48. package/dist/core/index.js.map +1 -1
  49. package/dist/core/index.mjs +37 -1
  50. package/dist/features/help/index.js +37 -37
  51. package/dist/features/help/index.mjs +3 -3
  52. package/dist/features/tokenusage/index.js +101 -101
  53. package/dist/features/tokenusage/index.mjs +3 -3
  54. package/dist/index.d.mts +7 -7
  55. package/dist/index.d.ts +7 -7
  56. package/dist/index.js +39 -3
  57. package/dist/index.js.map +1 -1
  58. package/dist/index.mjs +38 -2
  59. package/dist/{notification.interface-DYF56CtE.d.ts → notification.interface-CjzV_KII.d.ts} +1 -1
  60. package/dist/{notification.interface-BWOCvKRP.d.mts → notification.interface-MnRt1Tjm.d.mts} +1 -1
  61. package/dist/{s3.service-D_dswjgz.d.mts → s3.service-C0Yq2Sx3.d.mts} +2 -2
  62. package/dist/{s3.service-DserU9VK.d.ts → s3.service-CUGarZs3.d.ts} +2 -2
  63. package/dist/server/index.d.mts +3 -3
  64. package/dist/server/index.d.ts +3 -3
  65. package/dist/server/index.js +3 -3
  66. package/dist/server/index.mjs +1 -1
  67. package/dist/{useSocket-BZG9oX-L.d.mts → useSocket-BcH7l7B8.d.mts} +1 -1
  68. package/dist/{useSocket-CiyTtYFj.d.ts → useSocket-DCZd_eJk.d.ts} +1 -1
  69. package/package.json +1 -1
  70. package/src/components/containers/ReactMarkdownContainer.tsx +61 -11
  71. package/src/components/index.ts +1 -0
  72. package/src/core/index.ts +5 -0
  73. package/src/core/registry/ModuleRegistry.ts +4 -0
  74. package/src/features/assistant/components/parts/AssistantComposer.tsx +22 -3
  75. package/src/features/assistant/components/parts/AssistantSidebar.tsx +13 -3
  76. package/src/features/assistant/components/parts/AssistantThreadHeader.tsx +10 -3
  77. package/src/features/assistant/contexts/AssistantContext.tsx +30 -3
  78. package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +32 -0
  79. package/src/features/assistant/data/Assistant.ts +2 -0
  80. package/src/features/assistant/data/AssistantInterface.ts +2 -0
  81. package/src/features/assistant/data/AssistantService.ts +4 -0
  82. package/src/features/assistant-message/components/parts/MessageSourcesContainer.tsx +5 -1
  83. package/src/features/assistant-message/data/AssistantMessage.ts +27 -2
  84. package/src/features/billing/i18n-keys.ts +7 -0
  85. package/src/features/billing/stripe-price/components/forms/PriceEditor.tsx +108 -2
  86. package/src/features/billing/stripe-price/components/forms/__tests__/PriceEditor.spec.tsx +182 -0
  87. package/src/features/billing/stripe-price/components/lists/PricesList.tsx +152 -25
  88. package/src/features/billing/stripe-price/components/lists/__tests__/PricesList.spec.tsx +268 -0
  89. package/src/features/billing/stripe-price/data/__tests__/stripe-price.transfer.spec.ts +265 -0
  90. package/src/features/billing/stripe-price/data/stripe-price.fields.ts +1 -0
  91. package/src/features/billing/stripe-price/data/stripe-price.transfer.ts +194 -0
  92. package/src/features/billing/stripe-price/hooks/useStripePriceTableStructure.tsx +66 -2
  93. package/src/features/content/data/content.interface.ts +1 -1
  94. package/src/features/handbook/HandbookPageModule.ts +41 -0
  95. package/src/features/handbook/HandbookSectionModule.ts +35 -0
  96. package/src/features/handbook/HandbookThreadMessageModule.ts +21 -0
  97. package/src/features/handbook/HandbookThreadModule.ts +29 -0
  98. package/src/features/handbook/components/containers/HandbookAdminContainer.tsx +17 -0
  99. package/src/features/handbook/components/containers/HandbookAskContainer.tsx +142 -0
  100. package/src/features/handbook/components/containers/HandbookAskSheet.tsx +149 -0
  101. package/src/features/handbook/components/containers/HandbookPageContainer.tsx +143 -0
  102. package/src/features/handbook/components/containers/__tests__/HandbookAskContainer.spec.tsx +268 -0
  103. package/src/features/handbook/components/containers/__tests__/HandbookAskSheet.spec.tsx +143 -0
  104. package/src/features/handbook/components/containers/__tests__/HandbookPageContainer.spec.tsx +288 -0
  105. package/src/features/handbook/components/details/HandbookPageDetails.tsx +27 -0
  106. package/src/features/handbook/components/details/HandbookPageNeighbours.tsx +72 -0
  107. package/src/features/handbook/components/details/HandbookPageToc.tsx +165 -0
  108. package/src/features/handbook/components/forms/HandbookSyncButton.tsx +43 -0
  109. package/src/features/handbook/components/forms/__tests__/HandbookSyncButton.spec.tsx +42 -0
  110. package/src/features/handbook/components/index.ts +12 -0
  111. package/src/features/handbook/components/lists/HandbookContents.tsx +196 -0
  112. package/src/features/handbook/components/lists/HandbookSectionGroup.tsx +153 -0
  113. package/src/features/handbook/components/lists/__tests__/HandbookContents.spec.tsx +247 -0
  114. package/src/features/handbook/components/parts/HandbookIndexStatus.tsx +62 -0
  115. package/src/features/handbook/components/parts/HandbookPageNavigator.tsx +143 -0
  116. package/src/features/handbook/contexts/HandbookContext.tsx +109 -0
  117. package/src/features/handbook/contexts/__tests__/HandbookContext.spec.tsx +131 -0
  118. package/src/features/handbook/data/HandbookPage.spec.ts +136 -0
  119. package/src/features/handbook/data/HandbookPage.ts +99 -0
  120. package/src/features/handbook/data/HandbookPageFields.ts +31 -0
  121. package/src/features/handbook/data/HandbookPageInput.ts +8 -0
  122. package/src/features/handbook/data/HandbookPageInterface.ts +29 -0
  123. package/src/features/handbook/data/HandbookPageService.ts +60 -0
  124. package/src/features/handbook/data/HandbookSection.spec.ts +58 -0
  125. package/src/features/handbook/data/HandbookSection.ts +73 -0
  126. package/src/features/handbook/data/HandbookSectionInput.ts +12 -0
  127. package/src/features/handbook/data/HandbookSectionInterface.ts +26 -0
  128. package/src/features/handbook/data/HandbookSectionService.ts +26 -0
  129. package/src/features/handbook/data/HandbookThread.ts +114 -0
  130. package/src/features/handbook/data/HandbookThreadInterface.ts +31 -0
  131. package/src/features/handbook/data/HandbookThreadMessage.ts +144 -0
  132. package/src/features/handbook/data/HandbookThreadMessageInterface.ts +35 -0
  133. package/src/features/handbook/data/HandbookThreadMessageService.ts +30 -0
  134. package/src/features/handbook/data/HandbookThreadService.ts +86 -0
  135. package/src/features/handbook/data/__tests__/HandbookThreadService.spec.ts +112 -0
  136. package/src/features/handbook/data/handbookDisplay.spec.ts +92 -0
  137. package/src/features/handbook/data/handbookDisplay.ts +55 -0
  138. package/src/features/handbook/data/index.ts +16 -0
  139. package/src/features/handbook/hooks/__tests__/useHandbookAsk.spec.ts +198 -0
  140. package/src/features/handbook/hooks/useHandbookAsk.ts +213 -0
  141. package/src/features/handbook/i18n-keys.ts +46 -0
  142. package/src/features/handbook/index.ts +7 -0
  143. package/src/features/how-to/components/forms/HowToEditor.tsx +0 -1
  144. package/src/features/how-to/components/forms/HowToReindexButton.spec.tsx +84 -0
  145. package/src/features/how-to/components/forms/HowToReindexButton.tsx +46 -0
  146. package/src/features/how-to/components/index.ts +1 -0
  147. package/src/features/how-to/components/lists/HowToList.tsx +5 -1
  148. package/src/features/how-to/data/HowTo.spec.ts +61 -1
  149. package/src/features/how-to/data/HowToService.ts +13 -0
  150. package/dist/chunk-6J2SQZ3G.js.map +0 -1
  151. package/dist/chunk-M5QAHSCY.mjs.map +0 -1
  152. package/dist/chunk-XNGI4HDZ.mjs.map +0 -1
  153. package/dist/chunk-YN4LOVL4.js.map +0 -1
  154. /package/dist/{BlockNoteEditor-NBHDEZ7W.mjs.map → BlockNoteEditor-SERY337S.mjs.map} +0 -0
  155. /package/dist/{BlockNoteViewer-TYLVTIUD.mjs.map → BlockNoteViewer-P2VKEFRN.mjs.map} +0 -0
  156. /package/dist/{chunk-IMSIVIIC.mjs.map → chunk-VZKJIAND.mjs.map} +0 -0
@@ -0,0 +1,265 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { StripePriceInterface } from "../stripe-price.interface";
3
+ import {
4
+ STRIPE_PRICE_TRANSFER_KIND,
5
+ STRIPE_PRICE_TRANSFER_VERSION,
6
+ buildStripePriceTransferDocument,
7
+ parseStripePriceTransferDocument,
8
+ priceToSeed,
9
+ stripePriceTransferFilename,
10
+ } from "../stripe-price.transfer";
11
+
12
+ // A plain object, never the real model: the model's `currency` / `priceType`
13
+ // getters throw when the attribute is absent, and the point of these tests is
14
+ // the transformation, not the hydration.
15
+ const makePrice = (overrides: Partial<Record<keyof StripePriceInterface, unknown>> = {}): StripePriceInterface =>
16
+ ({
17
+ id: "price-node-1",
18
+ stripePriceId: "price_1SourceEnv",
19
+ productId: "product-node-1",
20
+ active: true,
21
+ currency: "eur",
22
+ unitAmount: 5900,
23
+ priceType: "recurring",
24
+ recurring: { interval: "month", intervalCount: 1, usageType: "licensed" },
25
+ nickname: "Standard",
26
+ lookupKey: "standard_monthly",
27
+ metadata: { env: "production" },
28
+ description: "The standard plan",
29
+ features: ["Unlimited projects", "Priority support"],
30
+ token: 1000,
31
+ isTrial: false,
32
+ priceFeatures: [{ id: "feature-node-9", name: "AI assistant" }],
33
+ ...overrides,
34
+ }) as unknown as StripePriceInterface;
35
+
36
+ describe("stripe-price.transfer", () => {
37
+ describe("priceToSeed", () => {
38
+ it("carries no identity across environments", () => {
39
+ const seed = priceToSeed(makePrice());
40
+
41
+ expect(seed).not.toHaveProperty("id");
42
+ expect(seed).not.toHaveProperty("stripePriceId");
43
+ expect(seed).not.toHaveProperty("productId");
44
+ expect(seed).not.toHaveProperty("active");
45
+ expect(seed).not.toHaveProperty("lookupKey");
46
+ expect(seed).not.toHaveProperty("metadata");
47
+ });
48
+
49
+ it("keeps unitAmount in minor units", () => {
50
+ expect(priceToSeed(makePrice({ unitAmount: 5900 })).unitAmount).toBe(5900);
51
+ });
52
+
53
+ it("defaults a missing unitAmount to 0", () => {
54
+ expect(priceToSeed(makePrice({ unitAmount: undefined })).unitAmount).toBe(0);
55
+ });
56
+
57
+ it("exports platform features by NAME, never by id", () => {
58
+ const seed = priceToSeed(
59
+ makePrice({
60
+ priceFeatures: [
61
+ { id: "feature-node-9", name: "AI assistant" },
62
+ { id: "feature-node-3", name: "Custom domains" },
63
+ ],
64
+ }),
65
+ );
66
+
67
+ expect(seed.platformFeatures).toEqual(["AI assistant", "Custom domains"]);
68
+ });
69
+
70
+ it("omits platformFeatures entirely when the price has none", () => {
71
+ const seed = priceToSeed(makePrice({ priceFeatures: [] }));
72
+
73
+ expect(seed).not.toHaveProperty("platformFeatures");
74
+ });
75
+
76
+ it("exports interval 'one_time' for a one-time price", () => {
77
+ const seed = priceToSeed(makePrice({ priceType: "one_time", recurring: undefined }));
78
+
79
+ expect(seed.interval).toBe("one_time");
80
+ expect(seed).not.toHaveProperty("intervalCount");
81
+ expect(seed).not.toHaveProperty("usageType");
82
+ });
83
+
84
+ it("falls back to a monthly interval when a recurring price has no recurring block", () => {
85
+ const seed = priceToSeed(makePrice({ priceType: "recurring", recurring: undefined }));
86
+
87
+ expect(seed.interval).toBe("month");
88
+ });
89
+
90
+ it("keeps a token of 0, which means 'no AI' rather than 'unset'", () => {
91
+ expect(priceToSeed(makePrice({ token: 0 })).token).toBe(0);
92
+ });
93
+
94
+ it("omits optional keys instead of writing undefined", () => {
95
+ const seed = priceToSeed(
96
+ makePrice({
97
+ nickname: undefined,
98
+ description: "",
99
+ features: [],
100
+ token: undefined,
101
+ isTrial: undefined,
102
+ }),
103
+ );
104
+
105
+ expect(Object.keys(seed).sort()).toEqual(
106
+ ["currency", "interval", "intervalCount", "platformFeatures", "unitAmount", "usageType"].sort(),
107
+ );
108
+ expect(JSON.stringify(seed)).not.toContain("undefined");
109
+ });
110
+
111
+ it("survives a model whose currency and priceType getters throw", () => {
112
+ const throwing = {
113
+ get currency(): string {
114
+ throw new Error("currency is not defined");
115
+ },
116
+ get priceType(): "one_time" | "recurring" {
117
+ throw new Error("priceType is not defined");
118
+ },
119
+ unitAmount: 1500,
120
+ recurring: { interval: "year", intervalCount: 1 },
121
+ priceFeatures: [],
122
+ } as unknown as StripePriceInterface;
123
+
124
+ expect(priceToSeed(throwing)).toEqual({ unitAmount: 1500, currency: "usd", interval: "year", intervalCount: 1 });
125
+ });
126
+ });
127
+
128
+ describe("buildStripePriceTransferDocument", () => {
129
+ it("round-trips through JSON unchanged", () => {
130
+ const document = buildStripePriceTransferDocument(makePrice());
131
+
132
+ const parsed = parseStripePriceTransferDocument(JSON.stringify(document));
133
+
134
+ expect(parsed).toEqual(document);
135
+ expect(parsed.price).toEqual(document.price);
136
+ expect(parsed.kind).toBe(STRIPE_PRICE_TRANSFER_KIND);
137
+ expect(parsed.version).toBe(STRIPE_PRICE_TRANSFER_VERSION);
138
+ });
139
+
140
+ it("serialises no identity at all", () => {
141
+ const serialised = JSON.stringify(buildStripePriceTransferDocument(makePrice()));
142
+
143
+ expect(serialised).not.toContain("stripePriceId");
144
+ expect(serialised).not.toContain("price_1SourceEnv");
145
+ expect(serialised).not.toContain("productId");
146
+ expect(serialised).not.toContain("product-node-1");
147
+ expect(serialised).not.toContain("price-node-1");
148
+ expect(serialised).not.toContain("feature-node-9");
149
+ expect(serialised).not.toContain("lookupKey");
150
+ expect(serialised).not.toContain("metadata");
151
+ expect(serialised).not.toMatch(/"id"/);
152
+ });
153
+ });
154
+
155
+ describe("stripePriceTransferFilename", () => {
156
+ it("slugs the nickname", () => {
157
+ expect(stripePriceTransferFilename(makePrice({ nickname: "Standard" }))).toBe("price-standard.json");
158
+ });
159
+
160
+ it("collapses non-alphanumerics and trims the edges", () => {
161
+ expect(stripePriceTransferFilename(makePrice({ nickname: " Pro // Yearly (EU)! " }))).toBe(
162
+ "price-pro-yearly-eu.json",
163
+ );
164
+ });
165
+
166
+ it("falls back to amount and currency without a nickname", () => {
167
+ expect(stripePriceTransferFilename(makePrice({ nickname: undefined, unitAmount: 5900, currency: "eur" }))).toBe(
168
+ "price-5900-eur.json",
169
+ );
170
+ });
171
+
172
+ it("never emits a path separator", () => {
173
+ const filename = stripePriceTransferFilename(makePrice({ nickname: "../../etc/passwd" }));
174
+
175
+ expect(filename).not.toContain("/");
176
+ expect(filename).not.toContain("\\");
177
+ expect(filename).toBe("price-etc-passwd.json");
178
+ });
179
+ });
180
+
181
+ describe("parseStripePriceTransferDocument", () => {
182
+ const validDocument = () => ({
183
+ kind: STRIPE_PRICE_TRANSFER_KIND,
184
+ version: STRIPE_PRICE_TRANSFER_VERSION,
185
+ price: { unitAmount: 5900, currency: "eur", interval: "month" },
186
+ });
187
+
188
+ it("rejects text that is not JSON", () => {
189
+ expect(() => parseStripePriceTransferDocument("not json at all")).toThrow(/not valid JSON/i);
190
+ });
191
+
192
+ it("rejects a wrong kind", () => {
193
+ const raw = JSON.stringify({ ...validDocument(), kind: "stripe-product" });
194
+
195
+ expect(() => parseStripePriceTransferDocument(raw)).toThrow(/kind/i);
196
+ });
197
+
198
+ it("rejects a future version", () => {
199
+ const raw = JSON.stringify({ ...validDocument(), version: 99 });
200
+
201
+ expect(() => parseStripePriceTransferDocument(raw)).toThrow(/version/i);
202
+ });
203
+
204
+ it("rejects a missing unitAmount", () => {
205
+ const raw = JSON.stringify({ ...validDocument(), price: { currency: "eur", interval: "month" } });
206
+
207
+ expect(() => parseStripePriceTransferDocument(raw)).toThrow(/unitAmount/);
208
+ });
209
+
210
+ it("rejects a negative unitAmount and an unknown interval", () => {
211
+ expect(() =>
212
+ parseStripePriceTransferDocument(
213
+ JSON.stringify({ ...validDocument(), price: { unitAmount: -1, currency: "eur", interval: "month" } }),
214
+ ),
215
+ ).toThrow(/unitAmount/);
216
+
217
+ expect(() =>
218
+ parseStripePriceTransferDocument(
219
+ JSON.stringify({ ...validDocument(), price: { unitAmount: 100, currency: "eur", interval: "fortnight" } }),
220
+ ),
221
+ ).toThrow(/interval/);
222
+ });
223
+
224
+ it("strips unknown keys instead of failing on them", () => {
225
+ const raw = JSON.stringify({
226
+ ...validDocument(),
227
+ price: {
228
+ unitAmount: 5900,
229
+ currency: "eur",
230
+ interval: "month",
231
+ stripePriceId: "price_1OtherEnv",
232
+ productId: "product-node-2",
233
+ id: "price-node-2",
234
+ },
235
+ });
236
+
237
+ const parsed = parseStripePriceTransferDocument(raw);
238
+
239
+ expect(parsed.price).toEqual({ unitAmount: 5900, currency: "eur", interval: "month" });
240
+ expect(parsed.price).not.toHaveProperty("stripePriceId");
241
+ expect(parsed.price).not.toHaveProperty("productId");
242
+ expect(parsed.price).not.toHaveProperty("id");
243
+ });
244
+
245
+ it("keeps every optional key it recognises", () => {
246
+ const price = {
247
+ unitAmount: 5900,
248
+ currency: "eur",
249
+ interval: "year",
250
+ intervalCount: 2,
251
+ usageType: "metered",
252
+ nickname: "Standard",
253
+ description: "The standard plan",
254
+ features: ["Unlimited projects"],
255
+ token: 0,
256
+ isTrial: true,
257
+ platformFeatures: ["AI assistant"],
258
+ };
259
+
260
+ const parsed = parseStripePriceTransferDocument(JSON.stringify({ ...validDocument(), price }));
261
+
262
+ expect(parsed.price).toEqual(price);
263
+ });
264
+ });
265
+ });
@@ -5,4 +5,5 @@ export enum StripePriceFields {
5
5
  interval = "interval",
6
6
  token = "token",
7
7
  status = "status",
8
+ actions = "actions",
8
9
  }
@@ -0,0 +1,194 @@
1
+ import { z } from "zod";
2
+ import { StripePriceInterface } from "./stripe-price.interface";
3
+
4
+ // ============================================================================
5
+ // Stripe Price Transfer Document
6
+ // ----------------------------------------------------------------------------
7
+ // Pure, DOM-free: no React, no window, no Blob, no fetch. Everything here is a
8
+ // plain data transformation so it can be unit-tested without a renderer, and so
9
+ // both the export (download) and the clone (prefill an editor) paths can share
10
+ // one definition of "the editable shape of a price".
11
+ // ============================================================================
12
+
13
+ export const STRIPE_PRICE_TRANSFER_KIND = "stripe-price";
14
+ export const STRIPE_PRICE_TRANSFER_VERSION = 1;
15
+
16
+ /** Versions this build can still read. Older documents stay parseable here. */
17
+ const SUPPORTED_VERSIONS: number[] = [STRIPE_PRICE_TRANSFER_VERSION];
18
+
19
+ /** The editable shape of a price, portable between environments. */
20
+ export type StripePriceSeed = {
21
+ unitAmount: number; // MINOR units (cents), exactly as Stripe stores it
22
+ currency: string;
23
+ interval: "one_time" | "day" | "week" | "month" | "year";
24
+ intervalCount?: number;
25
+ usageType?: "licensed" | "metered";
26
+ nickname?: string;
27
+ description?: string;
28
+ features?: string[]; // free-text marketing bullets
29
+ token?: number;
30
+ isTrial?: boolean;
31
+ platformFeatures?: string[]; // Feature entity NAMES (never ids — ids differ per environment)
32
+ };
33
+
34
+ export type StripePriceTransferDocument = {
35
+ kind: typeof STRIPE_PRICE_TRANSFER_KIND;
36
+ version: typeof STRIPE_PRICE_TRANSFER_VERSION;
37
+ price: StripePriceSeed;
38
+ };
39
+
40
+ const seedSchema = z.object({
41
+ unitAmount: z.number().min(0),
42
+ currency: z.string().min(1),
43
+ interval: z.enum(["one_time", "day", "week", "month", "year"]),
44
+ intervalCount: z.number().min(1).optional(),
45
+ usageType: z.enum(["licensed", "metered"]).optional(),
46
+ nickname: z.string().optional(),
47
+ description: z.string().optional(),
48
+ features: z.array(z.string()).optional(),
49
+ token: z.number().optional(),
50
+ isTrial: z.boolean().optional(),
51
+ platformFeatures: z.array(z.string()).optional(),
52
+ });
53
+
54
+ /**
55
+ * `currency` and `priceType` are getters that THROW when the attribute is
56
+ * absent (stripe-price.ts:42-58), and `productId` demonstrably is absent on a
57
+ * hydrated price (see the note in PriceEditor). Read anything model-backed
58
+ * through here so a partially-hydrated price degrades to a default instead of
59
+ * exploding mid-export.
60
+ */
61
+ function readOptional<T>(read: () => T): T | undefined {
62
+ try {
63
+ return read();
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ }
68
+
69
+ function nonEmptyStrings(values: string[] | undefined): string[] {
70
+ if (!Array.isArray(values)) return [];
71
+ return values.map((value) => value.trim()).filter((value) => value.length > 0);
72
+ }
73
+
74
+ /**
75
+ * Clone path: a live price -> the editable seed.
76
+ *
77
+ * Carries NO identity: no id, no stripePriceId, no productId, no active, no
78
+ * lookupKey, no metadata. A clone is a brand-new Stripe price and Stripe mints
79
+ * its own id on create; copying any of those across would either collide or
80
+ * silently re-point the new price at the source environment's objects.
81
+ */
82
+ export function priceToSeed(price: StripePriceInterface): StripePriceSeed {
83
+ const priceType = readOptional(() => price.priceType);
84
+ const currency = readOptional(() => price.currency);
85
+ const recurring = price.recurring;
86
+
87
+ // Same derivation as PriceEditor's getDefaultValues, so a cloned seed and the
88
+ // form it prefills never disagree about what interval a price has.
89
+ const interval: StripePriceSeed["interval"] =
90
+ priceType === "one_time" ? "one_time" : (recurring?.interval ?? "month");
91
+
92
+ // MINOR units on purpose. PriceEditor divides by 100 for display; the
93
+ // document stays in the unit Stripe itself stores.
94
+ const seed: StripePriceSeed = {
95
+ unitAmount: price.unitAmount ?? 0,
96
+ currency: currency ?? "usd",
97
+ interval,
98
+ };
99
+
100
+ if (interval !== "one_time") {
101
+ if (typeof recurring?.intervalCount === "number") seed.intervalCount = recurring.intervalCount;
102
+ if (recurring?.usageType) seed.usageType = recurring.usageType;
103
+ }
104
+
105
+ const nickname = price.nickname?.trim();
106
+ if (nickname) seed.nickname = nickname;
107
+
108
+ const description = price.description?.trim();
109
+ if (description) seed.description = description;
110
+
111
+ const features = nonEmptyStrings(price.features);
112
+ if (features.length > 0) seed.features = features;
113
+
114
+ // `0` is a meaningful token value ("no AI"), so test for the type, never for
115
+ // truthiness.
116
+ if (typeof price.token === "number" && !Number.isNaN(price.token)) seed.token = price.token;
117
+
118
+ if (typeof price.isTrial === "boolean") seed.isTrial = price.isTrial;
119
+
120
+ // NAMES, never ids: Feature ids differ per environment, names are the stable
121
+ // handle an importing environment can resolve against its own Feature rows.
122
+ const platformFeatures = nonEmptyStrings(price.priceFeatures?.map((feature) => feature.name));
123
+ if (platformFeatures.length > 0) seed.platformFeatures = platformFeatures;
124
+
125
+ return seed;
126
+ }
127
+
128
+ /** Export path. */
129
+ export function buildStripePriceTransferDocument(price: StripePriceInterface): StripePriceTransferDocument {
130
+ return {
131
+ kind: STRIPE_PRICE_TRANSFER_KIND,
132
+ version: STRIPE_PRICE_TRANSFER_VERSION,
133
+ price: priceToSeed(price),
134
+ };
135
+ }
136
+
137
+ function slugify(value: string): string {
138
+ return value
139
+ .toLowerCase()
140
+ .replace(/[^a-z0-9]+/g, "-")
141
+ .replace(/^-+|-+$/g, "");
142
+ }
143
+
144
+ /** Export path: a safe download filename, e.g. "price-standard.json". */
145
+ export function stripePriceTransferFilename(price: StripePriceInterface): string {
146
+ const seed = priceToSeed(price);
147
+ const fromNickname = seed.nickname ? slugify(seed.nickname) : "";
148
+ // Fallback keeps two exports of different prices from colliding when neither
149
+ // is nicknamed. Both halves are slugified, so no separator can survive.
150
+ const slug = fromNickname || slugify(`${seed.unitAmount}-${seed.currency}`);
151
+
152
+ return `price-${slug || "export"}.json`;
153
+ }
154
+
155
+ /** Import path. Throws an Error when the text is not a valid document. */
156
+ export function parseStripePriceTransferDocument(raw: string): StripePriceTransferDocument {
157
+ let candidate: unknown;
158
+ try {
159
+ candidate = JSON.parse(raw);
160
+ } catch {
161
+ throw new Error("The file is not valid JSON.");
162
+ }
163
+
164
+ if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate))
165
+ throw new Error("The file is not a price transfer document.");
166
+
167
+ const document = candidate as Record<string, unknown>;
168
+
169
+ if (document.kind !== STRIPE_PRICE_TRANSFER_KIND)
170
+ throw new Error(
171
+ `The file is not a price transfer document: expected kind "${STRIPE_PRICE_TRANSFER_KIND}", found ${JSON.stringify(document.kind)}.`,
172
+ );
173
+
174
+ if (typeof document.version !== "number" || !SUPPORTED_VERSIONS.includes(document.version))
175
+ throw new Error(
176
+ `Unsupported price transfer version ${JSON.stringify(document.version)}. Supported: ${SUPPORTED_VERSIONS.join(", ")}.`,
177
+ );
178
+
179
+ // Unknown keys are stripped rather than rejected, so a document written by a
180
+ // newer build of the same version still imports.
181
+ const parsed = seedSchema.safeParse(document.price);
182
+ if (!parsed.success) {
183
+ const details = parsed.error.issues
184
+ .map((issue) => `${issue.path.join(".") || "price"}: ${issue.message}`)
185
+ .join("; ");
186
+ throw new Error(`The price in the file is not valid. ${details}`);
187
+ }
188
+
189
+ return {
190
+ kind: STRIPE_PRICE_TRANSFER_KIND,
191
+ version: STRIPE_PRICE_TRANSFER_VERSION,
192
+ price: parsed.data,
193
+ };
194
+ }
@@ -1,21 +1,41 @@
1
1
  "use client";
2
2
 
3
3
  import { ColumnDef } from "@tanstack/react-table";
4
+ import { CopyIcon, DownloadIcon } from "lucide-react";
4
5
  import { useTranslations } from "next-intl";
5
6
  import { useMemo } from "react";
6
7
  import { Modules } from "../../../../core";
7
8
  import { registerTableGenerator, TableContent, usePageUrlGenerator, UseTableStructureHook } from "../../../../hooks";
8
- import { Badge, Link } from "../../../../shadcnui";
9
+ import { Badge, Button, Link } from "../../../../shadcnui";
9
10
  import { formatCurrency, formatInterval } from "../../components/utils/currency";
10
11
  import { StripePriceFields } from "../data/stripe-price.fields";
11
12
  import { StripePriceInterface } from "../data/stripe-price.interface";
12
13
 
14
+ /**
15
+ * Row-level handlers the ACTIONS column calls. They reach this hook through
16
+ * `ContentListTable`'s `context` prop, which the table forwards verbatim as
17
+ * `params.context` (ContentListTable.tsx:88-95, hooks/types.ts:27) — the table
18
+ * generator registry hands the hook nothing else from the call site.
19
+ *
20
+ * Both are optional on purpose: the same generator serves lists that render the
21
+ * actions column without wiring anything (or that omit the column entirely), so
22
+ * each button is rendered only when its handler is actually present rather than
23
+ * rendering a dead control.
24
+ */
25
+ type StripePriceRowActions = {
26
+ onClone?: (price: StripePriceInterface) => void;
27
+ onExport?: (price: StripePriceInterface) => void;
28
+ };
29
+
13
30
  export const useStripePriceTableStructure: UseTableStructureHook<StripePriceInterface, StripePriceFields> = (
14
31
  params,
15
32
  ) => {
16
33
  const t = useTranslations();
17
34
  const generateUrl = usePageUrlGenerator();
18
35
 
36
+ const onClone = params.context?.onClone as StripePriceRowActions["onClone"];
37
+ const onExport = params.context?.onExport as StripePriceRowActions["onExport"];
38
+
19
39
  const tableData = useMemo(() => {
20
40
  return params.data.map((price: StripePriceInterface) => {
21
41
  const entry: TableContent<StripePriceInterface> = { jsonApiData: price };
@@ -105,13 +125,57 @@ export const useStripePriceTableStructure: UseTableStructureHook<StripePriceInte
105
125
  enableSorting: false,
106
126
  enableHiding: false,
107
127
  }),
128
+ [StripePriceFields.actions]: () => ({
129
+ id: "actions",
130
+ accessorKey: "actions",
131
+ header: t("billing.admin.prices.fields.actions"),
132
+ cell: ({ row }: { row: TableContent<StripePriceInterface> }) => {
133
+ const price: StripePriceInterface = row.original.jsonApiData;
134
+ return (
135
+ <span className="flex items-center justify-end gap-x-1">
136
+ {onClone && (
137
+ <Button
138
+ type="button"
139
+ variant="ghost"
140
+ size="icon"
141
+ aria-label={t("billing.admin.prices.actions.clone")}
142
+ onClick={(event) => {
143
+ // The row itself can be clickable (ContentListTable's
144
+ // `onRowClick`), so a row action must never bubble into it.
145
+ event.stopPropagation();
146
+ onClone(price);
147
+ }}
148
+ >
149
+ <CopyIcon />
150
+ </Button>
151
+ )}
152
+ {onExport && (
153
+ <Button
154
+ type="button"
155
+ variant="ghost"
156
+ size="icon"
157
+ aria-label={t("billing.admin.prices.actions.export")}
158
+ onClick={(event) => {
159
+ event.stopPropagation();
160
+ onExport(price);
161
+ }}
162
+ >
163
+ <DownloadIcon />
164
+ </Button>
165
+ )}
166
+ </span>
167
+ );
168
+ },
169
+ enableSorting: false,
170
+ enableHiding: false,
171
+ }),
108
172
  };
109
173
 
110
174
  const columns = useMemo(() => {
111
175
  return params.fields.map((field) => fieldColumnMap[field]?.()).filter((col) => col !== undefined) as ColumnDef<
112
176
  TableContent<StripePriceInterface>
113
177
  >[];
114
- }, [params.fields, fieldColumnMap, t, generateUrl]);
178
+ }, [params.fields, fieldColumnMap, t, generateUrl, onClone, onExport]);
115
179
 
116
180
  return useMemo(() => ({ data: tableData, columns: columns }), [tableData, columns]);
117
181
  };
@@ -5,7 +5,7 @@ export type ContentInput = {
5
5
  id: string;
6
6
  name?: string;
7
7
 
8
- authorId: string;
8
+ authorId?: string;
9
9
  editorIds?: string[];
10
10
  };
11
11
 
@@ -0,0 +1,41 @@
1
+ import { BookOpenIcon } from "lucide-react";
2
+ import { createJsonApiInclusion } from "../../core";
3
+ import { ModuleFactory } from "../../permissions";
4
+ import { HandbookPage } from "./data/HandbookPage";
5
+
6
+ export const HandbookPageModule = (factory: ModuleFactory) =>
7
+ factory({
8
+ moduleId: "b1f1c2d6-6a0e-4e93-9b6d-3f5f4a2c81e7",
9
+ pageUrl: "/administration/handbook",
10
+ name: "handbookpages",
11
+ model: HandbookPage,
12
+ icon: BookOpenIcon,
13
+ inclusions: {
14
+ lists: {
15
+ // `content` is deliberately omitted: the list renders titles and
16
+ // status, and a thousand pages of markdown do not belong in a list
17
+ // payload. `section`, `order` and `summary` are the index fields the
18
+ // contents page groups, orders and describes pages by, so they are the
19
+ // one part of the payload the list surface cannot do without.
20
+ //
21
+ // `displayTitle` and `displaySummary` are the translated halves of the
22
+ // two strings the contents page prints, and a sparse fieldset that
23
+ // omitted them would leave every row in English. `displayContent` is
24
+ // NOT here for the same reason `content` is not: it is the whole
25
+ // markdown body, and it belongs to the detail read alone.
26
+ fields: [
27
+ createJsonApiInclusion("handbookpages", [
28
+ "path",
29
+ "title",
30
+ "displayTitle",
31
+ "section",
32
+ "order",
33
+ "summary",
34
+ "displaySummary",
35
+ "wordCount",
36
+ "aiStatus",
37
+ ]),
38
+ ],
39
+ },
40
+ },
41
+ });
@@ -0,0 +1,35 @@
1
+ import { LibraryIcon } from "lucide-react";
2
+ import { createJsonApiInclusion } from "../../core";
3
+ import { ModuleFactory } from "../../permissions";
4
+ import { HandbookSection } from "./data/HandbookSection";
5
+
6
+ /**
7
+ * One top-level directory of the documentation tree. Like the thread message,
8
+ * it has no `pageUrl` — a section has no page of its own; it is a heading on
9
+ * the contents surface and nothing more.
10
+ */
11
+ export const HandbookSectionModule = (factory: ModuleFactory) =>
12
+ factory({
13
+ moduleId: "6b0a2f74-1c53-4a0e-9c1d-2e7b4a9d6f30",
14
+ name: "handbooksections",
15
+ model: HandbookSection,
16
+ icon: LibraryIcon,
17
+ inclusions: {
18
+ lists: {
19
+ // `HandbookSectionService.findMany` is the ONLY read of this resource and
20
+ // it limits to these fields, so the translated halves have to be here:
21
+ // without them every section heading on the contents page and in the
22
+ // reader's navigator would stay English however good the translation is.
23
+ fields: [
24
+ createJsonApiInclusion("handbooksections", [
25
+ "key",
26
+ "title",
27
+ "displayTitle",
28
+ "summary",
29
+ "displaySummary",
30
+ "order",
31
+ ]),
32
+ ],
33
+ },
34
+ },
35
+ });
@@ -0,0 +1,21 @@
1
+ import { MessageCircleQuestionIcon } from "lucide-react";
2
+ import { createJsonApiInclusion } from "../../core";
3
+ import { ModuleFactory } from "../../permissions";
4
+ import { HandbookThreadMessage } from "./data/HandbookThreadMessage";
5
+
6
+ /**
7
+ * One turn of a handbook conversation. Like the thread, it has no `pageUrl` —
8
+ * a message is only ever read as part of its thread.
9
+ */
10
+ export const HandbookThreadMessageModule = (factory: ModuleFactory) =>
11
+ factory({
12
+ moduleId: "bef354f2-70e1-4f4f-9953-10d3d74cad0c",
13
+ name: "handbookthreadmessages",
14
+ model: HandbookThreadMessage,
15
+ icon: MessageCircleQuestionIcon,
16
+ inclusions: {
17
+ lists: {
18
+ fields: [createJsonApiInclusion("handbookthreadmessages", ["role", "content", "position", "sources"])],
19
+ },
20
+ },
21
+ });