@carlonicora/nextjs-jsonapi 2.5.0 → 3.0.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.
- package/dist/{BlockNoteEditor-TIMGYKAA.js → BlockNoteEditor-CDTU6DRN.js} +19 -19
- package/dist/{BlockNoteEditor-TIMGYKAA.js.map → BlockNoteEditor-CDTU6DRN.js.map} +1 -1
- package/dist/{BlockNoteEditor-YFJMXJEU.mjs → BlockNoteEditor-VVAY73V2.mjs} +4 -4
- package/dist/billing/index.d.mts +127 -46
- package/dist/billing/index.d.ts +127 -46
- package/dist/billing/index.js +1243 -1084
- package/dist/billing/index.js.map +1 -1
- package/dist/billing/index.mjs +983 -824
- package/dist/billing/index.mjs.map +1 -1
- package/dist/{chunk-RTOC53EZ.js → chunk-53B6NPGA.js} +34 -4
- package/dist/chunk-53B6NPGA.js.map +1 -0
- package/dist/{chunk-G4YS52SO.mjs → chunk-J54546YC.mjs} +1596 -1416
- package/dist/chunk-J54546YC.mjs.map +1 -0
- package/dist/{chunk-OTEDZ6YW.js → chunk-JKGEJGSD.js} +7 -7
- package/dist/{chunk-OTEDZ6YW.js.map → chunk-JKGEJGSD.js.map} +1 -1
- package/dist/{chunk-DJWNNV52.mjs → chunk-PHEFWL6L.mjs} +2 -2
- package/dist/{chunk-FS4RVV3K.mjs → chunk-TWXKAY34.mjs} +33 -3
- package/dist/chunk-TWXKAY34.mjs.map +1 -0
- package/dist/{chunk-7HKJNUW7.js → chunk-UGNLKDI6.js} +1000 -820
- package/dist/chunk-UGNLKDI6.js.map +1 -0
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.js +4 -4
- package/dist/components/index.mjs +3 -3
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +17 -1
- package/dist/core/index.d.ts +17 -1
- package/dist/core/index.js +6 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +5 -1
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +101 -101
- package/dist/features/tokenusage/index.mjs +3 -3
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +6 -2
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +4 -0
- package/src/features/administration/components/AdminIndexContainer.tsx +1 -1
- package/src/features/administration/components/__tests__/AdminIndexContainer.spec.tsx +1 -1
- package/src/features/billing/contexts/PriceContext.tsx +202 -0
- package/src/features/billing/contexts/ProductContext.tsx +185 -0
- package/src/features/billing/contexts/__tests__/PriceContext.spec.tsx +89 -0
- package/src/features/billing/contexts/__tests__/ProductContext.spec.tsx +81 -0
- package/src/features/billing/contexts/index.ts +2 -1
- package/src/features/billing/i18n-keys.ts +88 -0
- package/src/features/billing/stripe-price/components/containers/PriceContainer.tsx +21 -0
- package/src/features/billing/stripe-price/components/containers/index.ts +1 -0
- package/src/features/billing/stripe-price/components/details/PriceDetails.tsx +131 -0
- package/src/features/billing/stripe-price/components/details/index.ts +1 -0
- package/src/features/billing/stripe-price/components/forms/PriceArchiver.tsx +55 -0
- package/src/features/billing/stripe-price/components/forms/PriceEditor.tsx +307 -374
- package/src/features/billing/stripe-price/components/forms/index.ts +3 -1
- package/src/features/billing/stripe-price/components/index.ts +2 -0
- package/src/features/billing/stripe-price/components/lists/PricesList.tsx +49 -267
- package/src/features/billing/stripe-price/data/index.ts +1 -0
- package/src/features/billing/stripe-price/data/stripe-price.fields.ts +8 -0
- package/src/features/billing/stripe-price/hooks/useStripePriceTableStructure.tsx +119 -0
- package/src/features/billing/stripe-price/stripe-price.module.ts +5 -1
- package/src/features/billing/stripe-product/components/containers/ProductContainer.tsx +45 -0
- package/src/features/billing/stripe-product/components/containers/ProductsListContainer.tsx +27 -0
- package/src/features/billing/stripe-product/components/containers/index.ts +2 -2
- package/src/features/billing/stripe-product/components/details/ProductDetails.tsx +45 -0
- package/src/features/billing/stripe-product/components/details/index.ts +1 -0
- package/src/features/billing/stripe-product/components/forms/ProductArchiver.tsx +55 -0
- package/src/features/billing/stripe-product/components/forms/ProductEditor.tsx +92 -81
- package/src/features/billing/stripe-product/components/forms/index.ts +3 -1
- package/src/features/billing/stripe-product/components/index.ts +1 -0
- package/src/features/billing/stripe-product/components/lists/ProductsList.tsx +45 -187
- package/src/features/billing/stripe-product/data/index.ts +1 -0
- package/src/features/billing/stripe-product/data/stripe-product.fields.ts +6 -0
- package/src/features/billing/stripe-product/hooks/useStripeProductTableStructure.tsx +80 -0
- package/src/features/billing/stripe-product/stripe-product.module.ts +5 -1
- package/dist/chunk-7HKJNUW7.js.map +0 -1
- package/dist/chunk-FS4RVV3K.mjs.map +0 -1
- package/dist/chunk-G4YS52SO.mjs.map +0 -1
- package/dist/chunk-RTOC53EZ.js.map +0 -1
- package/src/features/billing/contexts/BillingContext.tsx +0 -95
- package/src/features/billing/stripe-product/components/containers/ProductsAdminContainer.tsx +0 -98
- package/src/features/billing/stripe-product/components/containers/ProductsAdminPageContainer.tsx +0 -27
- /package/dist/{BlockNoteEditor-YFJMXJEU.mjs.map → BlockNoteEditor-VVAY73V2.mjs.map} +0 -0
- /package/dist/{chunk-DJWNNV52.mjs.map → chunk-PHEFWL6L.mjs.map} +0 -0
|
@@ -1,284 +1,66 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
28
|
-
const
|
|
29
|
-
const
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
51
|
-
}, [
|
|
38
|
+
if (priceVersion > 0) void data.refresh();
|
|
39
|
+
}, [priceVersion]);
|
|
52
40
|
|
|
53
|
-
const
|
|
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
|
-
<
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
{
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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 "./
|
|
2
|
-
export * from "./
|
|
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";
|