@carlonicora/nextjs-jsonapi 2.5.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/dist/{BlockNoteEditor-TIMGYKAA.js → BlockNoteEditor-3IZ7PMFM.js} +19 -19
  2. package/dist/{BlockNoteEditor-TIMGYKAA.js.map → BlockNoteEditor-3IZ7PMFM.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-YFJMXJEU.mjs → BlockNoteEditor-G4XNDV2O.mjs} +4 -4
  4. package/dist/billing/index.d.mts +127 -46
  5. package/dist/billing/index.d.ts +127 -46
  6. package/dist/billing/index.js +1243 -1084
  7. package/dist/billing/index.js.map +1 -1
  8. package/dist/billing/index.mjs +983 -824
  9. package/dist/billing/index.mjs.map +1 -1
  10. package/dist/{chunk-RTOC53EZ.js → chunk-53B6NPGA.js} +34 -4
  11. package/dist/chunk-53B6NPGA.js.map +1 -0
  12. package/dist/{chunk-7HKJNUW7.js → chunk-5GVBUTS4.js} +1142 -953
  13. package/dist/chunk-5GVBUTS4.js.map +1 -0
  14. package/dist/{chunk-OTEDZ6YW.js → chunk-JKGEJGSD.js} +7 -7
  15. package/dist/{chunk-OTEDZ6YW.js.map → chunk-JKGEJGSD.js.map} +1 -1
  16. package/dist/{chunk-DJWNNV52.mjs → chunk-PHEFWL6L.mjs} +2 -2
  17. package/dist/{chunk-FS4RVV3K.mjs → chunk-TWXKAY34.mjs} +33 -3
  18. package/dist/chunk-TWXKAY34.mjs.map +1 -0
  19. package/dist/{chunk-G4YS52SO.mjs → chunk-WCX4CLRJ.mjs} +1607 -1418
  20. package/dist/chunk-WCX4CLRJ.mjs.map +1 -0
  21. package/dist/client/index.js +4 -4
  22. package/dist/client/index.mjs +3 -3
  23. package/dist/components/index.js +4 -4
  24. package/dist/components/index.mjs +3 -3
  25. package/dist/contexts/index.js +4 -4
  26. package/dist/contexts/index.mjs +3 -3
  27. package/dist/core/index.d.mts +17 -1
  28. package/dist/core/index.d.ts +17 -1
  29. package/dist/core/index.js +6 -2
  30. package/dist/core/index.js.map +1 -1
  31. package/dist/core/index.mjs +5 -1
  32. package/dist/features/help/index.js +37 -37
  33. package/dist/features/help/index.mjs +3 -3
  34. package/dist/features/tokenusage/index.js +101 -101
  35. package/dist/features/tokenusage/index.mjs +3 -3
  36. package/dist/index.d.mts +1 -1
  37. package/dist/index.d.ts +1 -1
  38. package/dist/index.js +7 -3
  39. package/dist/index.js.map +1 -1
  40. package/dist/index.mjs +6 -2
  41. package/dist/server/index.js +3 -3
  42. package/dist/server/index.mjs +1 -1
  43. package/package.json +1 -1
  44. package/src/client/index.ts +4 -0
  45. package/src/features/administration/components/AdminIndexContainer.tsx +1 -1
  46. package/src/features/administration/components/__tests__/AdminIndexContainer.spec.tsx +1 -1
  47. package/src/features/billing/contexts/PriceContext.tsx +202 -0
  48. package/src/features/billing/contexts/ProductContext.tsx +185 -0
  49. package/src/features/billing/contexts/__tests__/PriceContext.spec.tsx +89 -0
  50. package/src/features/billing/contexts/__tests__/ProductContext.spec.tsx +81 -0
  51. package/src/features/billing/contexts/index.ts +2 -1
  52. package/src/features/billing/i18n-keys.ts +88 -0
  53. package/src/features/billing/stripe-price/components/containers/PriceContainer.tsx +21 -0
  54. package/src/features/billing/stripe-price/components/containers/index.ts +1 -0
  55. package/src/features/billing/stripe-price/components/details/PriceDetails.tsx +131 -0
  56. package/src/features/billing/stripe-price/components/details/index.ts +1 -0
  57. package/src/features/billing/stripe-price/components/forms/PriceArchiver.tsx +55 -0
  58. package/src/features/billing/stripe-price/components/forms/PriceEditor.tsx +307 -374
  59. package/src/features/billing/stripe-price/components/forms/index.ts +3 -1
  60. package/src/features/billing/stripe-price/components/index.ts +2 -0
  61. package/src/features/billing/stripe-price/components/lists/PricesList.tsx +49 -267
  62. package/src/features/billing/stripe-price/data/index.ts +1 -0
  63. package/src/features/billing/stripe-price/data/stripe-price.fields.ts +8 -0
  64. package/src/features/billing/stripe-price/hooks/useStripePriceTableStructure.tsx +119 -0
  65. package/src/features/billing/stripe-price/stripe-price.module.ts +5 -1
  66. package/src/features/billing/stripe-product/components/containers/ProductContainer.tsx +45 -0
  67. package/src/features/billing/stripe-product/components/containers/ProductsListContainer.tsx +27 -0
  68. package/src/features/billing/stripe-product/components/containers/index.ts +2 -2
  69. package/src/features/billing/stripe-product/components/details/ProductDetails.tsx +45 -0
  70. package/src/features/billing/stripe-product/components/details/index.ts +1 -0
  71. package/src/features/billing/stripe-product/components/forms/ProductArchiver.tsx +55 -0
  72. package/src/features/billing/stripe-product/components/forms/ProductEditor.tsx +92 -81
  73. package/src/features/billing/stripe-product/components/forms/index.ts +3 -1
  74. package/src/features/billing/stripe-product/components/index.ts +1 -0
  75. package/src/features/billing/stripe-product/components/lists/ProductsList.tsx +45 -187
  76. package/src/features/billing/stripe-product/data/index.ts +1 -0
  77. package/src/features/billing/stripe-product/data/stripe-product.fields.ts +6 -0
  78. package/src/features/billing/stripe-product/hooks/useStripeProductTableStructure.tsx +80 -0
  79. package/src/features/billing/stripe-product/stripe-product.module.ts +5 -1
  80. package/src/features/user/components/forms/UserEditor.tsx +11 -2
  81. package/dist/chunk-7HKJNUW7.js.map +0 -1
  82. package/dist/chunk-FS4RVV3K.mjs.map +0 -1
  83. package/dist/chunk-G4YS52SO.mjs.map +0 -1
  84. package/dist/chunk-RTOC53EZ.js.map +0 -1
  85. package/src/features/billing/contexts/BillingContext.tsx +0 -95
  86. package/src/features/billing/stripe-product/components/containers/ProductsAdminContainer.tsx +0 -98
  87. package/src/features/billing/stripe-product/components/containers/ProductsAdminPageContainer.tsx +0 -27
  88. /package/dist/{BlockNoteEditor-YFJMXJEU.mjs.map → BlockNoteEditor-G4XNDV2O.mjs.map} +0 -0
  89. /package/dist/{chunk-DJWNNV52.mjs.map → chunk-PHEFWL6L.mjs.map} +0 -0
@@ -1 +1,3 @@
1
- export * from "./PriceEditor";
1
+ export * from "./PriceArchiver";
2
+ export { default as PriceEditor } from "./PriceEditor";
3
+ export type { PriceEditorProps } from "./PriceEditor";
@@ -1,2 +1,4 @@
1
+ export * from "./containers";
2
+ export * from "./details";
1
3
  export * from "./forms";
2
4
  export * from "./lists";
@@ -1,284 +1,66 @@
1
1
  "use client";
2
2
 
3
- import { Archive, DollarSign, Edit, RotateCcw } from "lucide-react";
4
- import { useEffect, useState } from "react";
5
- import {
6
- AlertDialog,
7
- AlertDialogAction,
8
- AlertDialogCancel,
9
- AlertDialogContent,
10
- AlertDialogDescription,
11
- AlertDialogFooter,
12
- AlertDialogHeader,
13
- AlertDialogTitle,
14
- Badge,
15
- Button,
16
- } from "../../../../../shadcnui";
17
- import { formatCurrency } from "../../../components/utils/currency";
18
- import { StripePriceService } from "../../data";
3
+ import { DollarSignIcon } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { ReactNode, useEffect } from "react";
6
+ import { ContentListTable } from "../../../../../components";
7
+ import { Modules } from "../../../../../core";
8
+ import { DataListRetriever, useDataListRetriever } from "../../../../../hooks";
9
+ import { EmptyState } from "../../../../../shadcnui";
10
+ import { usePriceContext } from "../../../contexts/PriceContext";
11
+ import { StripePriceFields } from "../../data/stripe-price.fields";
19
12
  import { StripePriceInterface } from "../../data/stripe-price.interface";
20
- import { PriceEditor } from "../forms/PriceEditor";
13
+ import { StripePriceService } from "../../data/stripe-price.service";
14
+ import PriceEditor from "../forms/PriceEditor";
21
15
 
22
16
  type PricesListProps = {
23
17
  productId: string;
24
- onPricesChange: () => void;
18
+ /**
19
+ * Only when the list IS the page's own content inside a `RoundPageContainer
20
+ * fullWidth`. Inside a tab panel leave it unset, or ContentListTable drops its
21
+ * own `rounded-md border` and the table floats borderless in the panel.
22
+ * Mirrors ThreatList, which is rendered both ways.
23
+ */
24
+ fullWidth?: boolean;
25
25
  };
26
26
 
27
- export function PricesList({ productId, onPricesChange }: PricesListProps) {
28
- const [prices, setPrices] = useState<StripePriceInterface[]>([]);
29
- const [loading, setLoading] = useState<boolean>(true);
30
- const [showCreatePrice, setShowCreatePrice] = useState<boolean>(false);
31
- const [editingPrice, setEditingPrice] = useState<StripePriceInterface | null>(null);
32
- const [priceToArchive, setPriceToArchive] = useState<StripePriceInterface | null>(null);
33
- const [priceToReactivate, setPriceToReactivate] = useState<StripePriceInterface | null>(null);
34
- const [archivingPriceId, setArchivingPriceId] = useState<string | null>(null);
35
- const [reactivatingPriceId, setReactivatingPriceId] = useState<string | null>(null);
27
+ export function PricesList({ productId, fullWidth }: PricesListProps) {
28
+ const t = useTranslations();
29
+ const { priceVersion } = usePriceContext();
36
30
 
37
- const loadPrices = async () => {
38
- setLoading(true);
39
- try {
40
- const fetchedPrices = await StripePriceService.listPrices({ productId });
41
- setPrices(fetchedPrices);
42
- } catch (error) {
43
- console.error("[PricesList] Failed to load prices:", error);
44
- } finally {
45
- setLoading(false);
46
- }
47
- };
31
+ const data: DataListRetriever<StripePriceInterface> = useDataListRetriever({
32
+ retriever: (params) => StripePriceService.listPrices({ ...params, productId }),
33
+ retrieverParams: {},
34
+ module: Modules.StripePrice,
35
+ });
48
36
 
49
37
  useEffect(() => {
50
- loadPrices();
51
- }, [productId]);
38
+ if (priceVersion > 0) void data.refresh();
39
+ }, [priceVersion]);
52
40
 
53
- const handleArchive = async () => {
54
- if (!priceToArchive) {
55
- return;
56
- }
57
-
58
- setArchivingPriceId(priceToArchive.id);
59
- try {
60
- await StripePriceService.archivePrice({ id: priceToArchive.id });
61
- setPriceToArchive(null); // Close dialog on success
62
- await loadPrices();
63
- onPricesChange();
64
- } catch (error) {
65
- console.error("[PricesList] Failed to archive price:", error);
66
- // Keep dialog open on error so user can retry or cancel
67
- } finally {
68
- setArchivingPriceId(null);
69
- }
70
- };
71
-
72
- const handleReactivate = async () => {
73
- if (!priceToReactivate) {
74
- return;
75
- }
76
-
77
- setReactivatingPriceId(priceToReactivate.id);
78
- try {
79
- await StripePriceService.reactivatePrice({ id: priceToReactivate.id });
80
- setPriceToReactivate(null); // Close dialog on success
81
- await loadPrices();
82
- onPricesChange();
83
- } catch (error) {
84
- console.error("[PricesList] Failed to reactivate price:", error);
85
- // Keep dialog open on error so user can retry or cancel
86
- } finally {
87
- setReactivatingPriceId(null);
88
- }
89
- };
90
-
91
- const formatInterval = (price: StripePriceInterface): string => {
92
- if (price.priceType === "one_time") {
93
- return "one-time";
94
- }
95
-
96
- if (price.recurring) {
97
- const count = price.recurring.intervalCount;
98
- const interval = price.recurring.interval;
99
-
100
- if (count === 1) {
101
- return `/ ${interval}`;
102
- } else {
103
- return `/ ${count} ${interval}s`;
104
- }
105
- }
106
-
107
- return "";
108
- };
109
-
110
- if (loading) {
111
- return (
112
- <div className="flex items-center justify-center py-8">
113
- <p className="text-muted-foreground">Loading prices...</p>
114
- </div>
115
- );
116
- }
41
+ const functions: ReactNode[] = [<PriceEditor key="create-price" productId={productId} />];
117
42
 
118
43
  return (
119
- <div className="flex flex-col gap-y-4">
120
- <div className="flex items-center justify-between mb-4">
121
- <h4 className="text-lg font-semibold">Prices</h4>
122
- <Button size="sm" onClick={() => setShowCreatePrice(true)}>
123
- Add Price
124
- </Button>
125
- </div>
126
-
127
- {/* Empty State */}
128
- {prices.length === 0 && (
129
- <div className="bg-background flex flex-col items-center justify-center gap-y-3 rounded-lg border border-dashed p-8">
130
- <DollarSign className="text-muted-foreground h-12 w-12" />
131
- <p className="text-muted-foreground text-sm">No prices yet. Add a price to enable subscriptions.</p>
132
- <Button size="sm" onClick={() => setShowCreatePrice(true)}>
133
- Add Price
134
- </Button>
135
- </div>
136
- )}
137
-
138
- {/* Prices Grid */}
139
- {prices.length > 0 && (
140
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
141
- {prices.map((price) => {
142
- const isArchiving = archivingPriceId === price.id;
143
- const isReactivating = reactivatingPriceId === price.id;
144
-
145
- return (
146
- <div key={price.id} className="border rounded-lg bg-white p-4 hover:shadow-sm transition-shadow">
147
- <div className="flex items-start justify-between mb-3">
148
- <DollarSign className="h-5 w-5 text-primary" />
149
- <div className="flex gap-1">
150
- <Button variant="ghost" size="sm" onClick={() => setEditingPrice(price)} className="h-8 w-8 p-0">
151
- <Edit className="h-4 w-4" />
152
- </Button>
153
- {price.active ? (
154
- <Button
155
- variant="ghost"
156
- size="sm"
157
- onClick={() => setPriceToArchive(price)}
158
- className="h-8 w-8 p-0"
159
- disabled={isArchiving}
160
- >
161
- <Archive className="h-4 w-4" />
162
- </Button>
163
- ) : (
164
- <Button
165
- variant="ghost"
166
- size="sm"
167
- onClick={() => setPriceToReactivate(price)}
168
- className="h-8 w-8 p-0"
169
- disabled={isReactivating}
170
- >
171
- <RotateCcw className="h-4 w-4" />
172
- </Button>
173
- )}
174
- </div>
175
- </div>
176
-
177
- <div className="mb-2">
178
- <div className="text-2xl font-bold">
179
- {formatCurrency(price.unitAmount, price.currency)}{" "}
180
- <span className="text-muted-foreground text-sm font-normal">{formatInterval(price)}</span>
181
- </div>
182
- </div>
183
-
184
- {price.metadata?.nickname && <p className="text-sm font-medium mb-2">{price.metadata.nickname}</p>}
185
-
186
- <div className="flex flex-wrap gap-2">
187
- {price.active ? (
188
- <Badge variant="softGreen">Active</Badge>
189
- ) : (
190
- <Badge variant="softGray">Inactive</Badge>
191
- )}
192
-
193
- {price.recurring?.usageType === "metered" && <Badge variant="softBlue">Metered</Badge>}
194
-
195
- <Badge variant="softGray" className="uppercase">
196
- {price.currency}
197
- </Badge>
198
- </div>
199
- </div>
200
- );
201
- })}
202
- </div>
203
- )}
204
-
205
- {/* Create Price Modal */}
206
- {showCreatePrice && (
207
- <PriceEditor
208
- productId={productId}
209
- open={showCreatePrice}
210
- onOpenChange={setShowCreatePrice}
211
- onSuccess={() => {
212
- loadPrices();
213
- onPricesChange();
214
- }}
44
+ <ContentListTable
45
+ data={data}
46
+ fields={[
47
+ StripePriceFields.nickname,
48
+ StripePriceFields.amount,
49
+ StripePriceFields.interval,
50
+ StripePriceFields.token,
51
+ StripePriceFields.status,
52
+ ]}
53
+ tableGeneratorType={Modules.StripePrice}
54
+ functions={functions}
55
+ fullWidth={fullWidth}
56
+ title={t("billing.admin.prices.title")}
57
+ emptyState={
58
+ <EmptyState
59
+ icon={DollarSignIcon}
60
+ title={t("billing.admin.prices.empty.title")}
61
+ description={t("billing.admin.prices.empty.description")}
215
62
  />
216
- )}
217
-
218
- {/* Edit Price Modal */}
219
- {editingPrice && (
220
- <PriceEditor
221
- productId={productId}
222
- price={editingPrice}
223
- open={!!editingPrice}
224
- onOpenChange={(open) => !open && setEditingPrice(null)}
225
- onSuccess={() => {
226
- loadPrices();
227
- onPricesChange();
228
- setEditingPrice(null);
229
- }}
230
- />
231
- )}
232
-
233
- {/* Archive Price Confirmation Dialog */}
234
- <AlertDialog open={!!priceToArchive} onOpenChange={(open) => !open && setPriceToArchive(null)}>
235
- <AlertDialogContent>
236
- <AlertDialogHeader>
237
- <AlertDialogTitle>Archive Price</AlertDialogTitle>
238
- <AlertDialogDescription>
239
- Are you sure you want to archive the price for{" "}
240
- {priceToArchive &&
241
- `${formatCurrency(priceToArchive.unitAmount, priceToArchive.currency)} ${formatInterval(priceToArchive)}`}
242
- ? This will prevent new subscriptions but existing ones will continue.
243
- </AlertDialogDescription>
244
- </AlertDialogHeader>
245
- <AlertDialogFooter>
246
- <AlertDialogCancel disabled={!!archivingPriceId}>Cancel</AlertDialogCancel>
247
- <AlertDialogAction
248
- onClick={handleArchive}
249
- disabled={!!archivingPriceId}
250
- className="bg-red-600 hover:bg-red-700"
251
- >
252
- {archivingPriceId ? "Archiving..." : "Archive"}
253
- </AlertDialogAction>
254
- </AlertDialogFooter>
255
- </AlertDialogContent>
256
- </AlertDialog>
257
-
258
- {/* Reactivate Price Confirmation Dialog */}
259
- <AlertDialog open={!!priceToReactivate} onOpenChange={(open) => !open && setPriceToReactivate(null)}>
260
- <AlertDialogContent>
261
- <AlertDialogHeader>
262
- <AlertDialogTitle>Reactivate Price</AlertDialogTitle>
263
- <AlertDialogDescription>
264
- Are you sure you want to reactivate the price for{" "}
265
- {priceToReactivate &&
266
- `${formatCurrency(priceToReactivate.unitAmount, priceToReactivate.currency)} ${formatInterval(priceToReactivate)}`}
267
- ? This will allow new subscriptions again.
268
- </AlertDialogDescription>
269
- </AlertDialogHeader>
270
- <AlertDialogFooter>
271
- <AlertDialogCancel disabled={!!reactivatingPriceId}>Cancel</AlertDialogCancel>
272
- <AlertDialogAction
273
- onClick={handleReactivate}
274
- disabled={!!reactivatingPriceId}
275
- className="bg-green-600 hover:bg-green-700"
276
- >
277
- {reactivatingPriceId ? "Reactivating..." : "Reactivate"}
278
- </AlertDialogAction>
279
- </AlertDialogFooter>
280
- </AlertDialogContent>
281
- </AlertDialog>
282
- </div>
63
+ }
64
+ />
283
65
  );
284
66
  }
@@ -1,3 +1,4 @@
1
1
  export * from "./stripe-price";
2
+ export * from "./stripe-price.fields";
2
3
  export * from "./stripe-price.interface";
3
4
  export * from "./stripe-price.service";
@@ -0,0 +1,8 @@
1
+ export enum StripePriceFields {
2
+ stripePriceId = "stripePriceId",
3
+ nickname = "nickname",
4
+ amount = "amount",
5
+ interval = "interval",
6
+ token = "token",
7
+ status = "status",
8
+ }
@@ -0,0 +1,119 @@
1
+ "use client";
2
+
3
+ import { ColumnDef } from "@tanstack/react-table";
4
+ import { useTranslations } from "next-intl";
5
+ import { useMemo } from "react";
6
+ import { Modules } from "../../../../core";
7
+ import { registerTableGenerator, TableContent, usePageUrlGenerator, UseTableStructureHook } from "../../../../hooks";
8
+ import { Badge, Link } from "../../../../shadcnui";
9
+ import { formatCurrency, formatInterval } from "../../components/utils/currency";
10
+ import { StripePriceFields } from "../data/stripe-price.fields";
11
+ import { StripePriceInterface } from "../data/stripe-price.interface";
12
+
13
+ export const useStripePriceTableStructure: UseTableStructureHook<StripePriceInterface, StripePriceFields> = (
14
+ params,
15
+ ) => {
16
+ const t = useTranslations();
17
+ const generateUrl = usePageUrlGenerator();
18
+
19
+ const tableData = useMemo(() => {
20
+ return params.data.map((price: StripePriceInterface) => {
21
+ const entry: TableContent<StripePriceInterface> = { jsonApiData: price };
22
+ entry[StripePriceFields.stripePriceId] = price.id;
23
+ params.fields.forEach((field) => {
24
+ entry[field] = (price as any)[field as keyof StripePriceInterface];
25
+ });
26
+ return entry;
27
+ });
28
+ }, [params.data, params.fields]);
29
+
30
+ const fieldColumnMap: Partial<Record<StripePriceFields, () => any>> = {
31
+ [StripePriceFields.nickname]: () => ({
32
+ id: "nickname",
33
+ accessorKey: "nickname",
34
+ header: t("billing.admin.prices.fields.nickname"),
35
+ cell: ({ row }: { row: TableContent<StripePriceInterface> }) => {
36
+ const price: StripePriceInterface = row.original.jsonApiData;
37
+ return (
38
+ <Link href={generateUrl({ page: Modules.StripePrice, id: price.id })}>
39
+ {price.nickname ?? formatCurrency(price.unitAmount, price.currency)}
40
+ </Link>
41
+ );
42
+ },
43
+ enableSorting: false,
44
+ enableHiding: false,
45
+ }),
46
+ [StripePriceFields.amount]: () => ({
47
+ id: "amount",
48
+ accessorKey: "amount",
49
+ header: t("billing.admin.prices.fields.amount"),
50
+ cell: ({ row }: { row: TableContent<StripePriceInterface> }) => {
51
+ const price: StripePriceInterface = row.original.jsonApiData;
52
+ // Typography role 15 (numeric): tabular-nums, right-aligned. NEVER font-mono.
53
+ return (
54
+ <span className="block text-xs tabular-nums text-right">
55
+ {formatCurrency(price.unitAmount, price.currency)}
56
+ </span>
57
+ );
58
+ },
59
+ enableSorting: false,
60
+ enableHiding: false,
61
+ }),
62
+ [StripePriceFields.interval]: () => ({
63
+ id: "interval",
64
+ accessorKey: "interval",
65
+ header: t("billing.admin.prices.fields.billing"),
66
+ cell: ({ row }: { row: TableContent<StripePriceInterface> }) => {
67
+ const price: StripePriceInterface = row.original.jsonApiData;
68
+ // Typography role 8 (UI chrome): single-line, plain text-xs.
69
+ return <span className="text-xs">{formatInterval(price)}</span>;
70
+ },
71
+ enableSorting: false,
72
+ enableHiding: false,
73
+ }),
74
+ [StripePriceFields.token]: () => ({
75
+ id: "token",
76
+ accessorKey: "token",
77
+ header: t("billing.admin.prices.fields.token"),
78
+ cell: ({ row }: { row: TableContent<StripePriceInterface> }) => {
79
+ const price: StripePriceInterface = row.original.jsonApiData;
80
+ return <span className="block text-xs tabular-nums text-right">{price.token ?? ""}</span>;
81
+ },
82
+ enableSorting: false,
83
+ enableHiding: false,
84
+ }),
85
+ [StripePriceFields.status]: () => ({
86
+ id: "status",
87
+ accessorKey: "status",
88
+ header: t("billing.admin.prices.fields.status"),
89
+ cell: ({ row }: { row: TableContent<StripePriceInterface> }) => {
90
+ const price: StripePriceInterface = row.original.jsonApiData;
91
+ return (
92
+ <span className="flex flex-wrap items-center gap-1">
93
+ {price.active ? (
94
+ <Badge variant="softGreen">{t("billing.admin.prices.status.active")}</Badge>
95
+ ) : (
96
+ <Badge variant="softGray">{t("billing.admin.prices.status.archived")}</Badge>
97
+ )}
98
+ {price.isTrial && <Badge variant="softBlue">{t("billing.admin.prices.badge.trial")}</Badge>}
99
+ {price.recurring?.usageType === "metered" && (
100
+ <Badge variant="softBlue">{t("billing.admin.prices.badge.metered")}</Badge>
101
+ )}
102
+ </span>
103
+ );
104
+ },
105
+ enableSorting: false,
106
+ enableHiding: false,
107
+ }),
108
+ };
109
+
110
+ const columns = useMemo(() => {
111
+ return params.fields.map((field) => fieldColumnMap[field]?.()).filter((col) => col !== undefined) as ColumnDef<
112
+ TableContent<StripePriceInterface>
113
+ >[];
114
+ }, [params.fields, fieldColumnMap, t, generateUrl]);
115
+
116
+ return useMemo(() => ({ data: tableData, columns: columns }), [tableData, columns]);
117
+ };
118
+
119
+ registerTableGenerator("stripe-prices", useStripePriceTableStructure);
@@ -4,7 +4,11 @@ import { StripePrice } from "./data/stripe-price";
4
4
  export const StripePriceModule = (factory: ModuleFactory) =>
5
5
  factory({
6
6
  name: "stripe-prices",
7
- pageUrl: "/stripe-prices",
7
+ // The administration route this entity is served at. `name` above stays the
8
+ // API endpoint; this drives generateUrl / rewriteUrl / EditorSheet
9
+ // navigation, so it must match the real Next.js route or RoundPageContainer
10
+ // rewrites the URL to a 404 on the first tab change.
11
+ pageUrl: "/administration/prices",
8
12
  model: StripePrice,
9
13
  moduleId: "a7d3e5f1-8c9b-4a2e-b6d7-3f1c8e9a4b5d",
10
14
  });
@@ -0,0 +1,45 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { RoundPageContainer } from "../../../../../components";
5
+ import { Modules } from "../../../../../core";
6
+ import { PriceProvider } from "../../../contexts/PriceContext";
7
+ import { useProductContext } from "../../../contexts/ProductContext";
8
+ import { PricesList } from "../../../stripe-price/components/lists/PricesList";
9
+ import { ProductDetails } from "../details/ProductDetails";
10
+
11
+ export function ProductContainer() {
12
+ const t = useTranslations();
13
+ const { product } = useProductContext();
14
+
15
+ if (!product) return null;
16
+
17
+ return (
18
+ <RoundPageContainer
19
+ module={Modules.StripeProduct}
20
+ id={product.id}
21
+ tabs={[
22
+ {
23
+ sectionKey: "details",
24
+ label: t("billing.admin.products.tabs.details"),
25
+ content: <ProductDetails />,
26
+ },
27
+ {
28
+ key: Modules.StripePrice,
29
+ label: t("billing.admin.prices.title"),
30
+ // publishChrome={false}: SharedProvider REPLACES its value for the
31
+ // subtree (SharedContext.tsx:35-37), so a nested provider publishing
32
+ // chrome here would blank this page's own title.
33
+ // No `fillHeight` and no `fullWidth` on the list: a ContentListTable in
34
+ // a tab keeps its own bordered card and flows with the panel, exactly
35
+ // as ThreatList does inside GoalContainer.
36
+ content: (
37
+ <PriceProvider productId={product.id} publishChrome={false}>
38
+ <PricesList productId={product.id} />
39
+ </PriceProvider>
40
+ ),
41
+ },
42
+ ]}
43
+ />
44
+ );
45
+ }
@@ -0,0 +1,27 @@
1
+ "use client";
2
+
3
+ import { RoundPageContainer } from "../../../../../components";
4
+ import { Modules } from "../../../../../core";
5
+ import { ProductProvider } from "../../../contexts/ProductContext";
6
+ import { ProductsList } from "../lists/ProductsList";
7
+
8
+ /**
9
+ * Page container for the administrative product list.
10
+ *
11
+ * Client component by necessity: `module={Modules.StripeProduct}` is a registry
12
+ * entry carrying an icon component and methods, which a Server Component cannot
13
+ * pass across the boundary. Routes mount this container instead — the same
14
+ * shape as every other list page.
15
+ *
16
+ * No `forceHeader`: ContentListTable draws its own header row with the title
17
+ * and the create action, exactly as the company and user list pages do.
18
+ */
19
+ export function ProductsListContainer() {
20
+ return (
21
+ <ProductProvider>
22
+ <RoundPageContainer module={Modules.StripeProduct} fullWidth>
23
+ <ProductsList fullWidth />
24
+ </RoundPageContainer>
25
+ </ProductProvider>
26
+ );
27
+ }
@@ -1,2 +1,2 @@
1
- export * from "./ProductsAdminContainer";
2
- export * from "./ProductsAdminPageContainer";
1
+ export * from "./ProductContainer";
2
+ export * from "./ProductsListContainer";
@@ -0,0 +1,45 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { DetailField, SectionHeader } from "../../../../../components";
5
+ import { Badge } from "../../../../../shadcnui";
6
+ import { useProductContext } from "../../../contexts/ProductContext";
7
+
8
+ export function ProductDetails() {
9
+ const t = useTranslations();
10
+ const { product } = useProductContext();
11
+
12
+ if (!product) return null;
13
+
14
+ return (
15
+ <div className="flex w-full flex-col gap-y-4">
16
+ <SectionHeader>{t("billing.admin.products.tabs.details")}</SectionHeader>
17
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
18
+ <DetailField label={t("billing.admin.products.fields.name")} value={product.name} />
19
+ <DetailField
20
+ label={t("billing.admin.products.fields.status")}
21
+ value={
22
+ product.active ? (
23
+ <Badge variant="softGreen">{t("billing.admin.products.status.active")}</Badge>
24
+ ) : (
25
+ <Badge variant="softGray">{t("billing.admin.products.status.archived")}</Badge>
26
+ )
27
+ }
28
+ />
29
+ <DetailField
30
+ label={t("billing.admin.products.fields.description")}
31
+ value={product.description ?? "—"}
32
+ className="md:col-span-2"
33
+ />
34
+ <DetailField
35
+ label={t("billing.admin.products.fields.prices")}
36
+ value={<span className="tabular-nums">{product.stripePrices.length}</span>}
37
+ />
38
+ <DetailField
39
+ label={t("billing.admin.products.fields.stripeId")}
40
+ value={<span className="tabular-nums">{product.stripeProductId}</span>}
41
+ />
42
+ </div>
43
+ </div>
44
+ );
45
+ }
@@ -0,0 +1 @@
1
+ export * from "./ProductDetails";