@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
@@ -0,0 +1,55 @@
1
+ "use client";
2
+
3
+ import { ArchiveIcon, RotateCcwIcon } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { useState } from "react";
6
+ import { errorToast } from "../../../../../components/errors";
7
+ import { Button, ConfirmDialog } from "../../../../../shadcnui";
8
+ import { useProductContext } from "../../../contexts/ProductContext";
9
+ import { StripeProductInterface } from "../../data/stripe-product.interface";
10
+
11
+ type ProductArchiverProps = {
12
+ product: StripeProductInterface;
13
+ };
14
+
15
+ /**
16
+ * Archive / restore control for a product. Built on ConfirmDialog, which is
17
+ * controlled and trigger-less by design — the caller owns the trigger button,
18
+ * so none of Base UI's trigger-composition pitfalls apply (no asChild, no
19
+ * nested <button>).
20
+ */
21
+ export function ProductArchiver({ product }: ProductArchiverProps) {
22
+ const t = useTranslations();
23
+ const { archiveProduct, restoreProduct } = useProductContext();
24
+ const [open, setOpen] = useState<boolean>(false);
25
+
26
+ const isArchived = !product.active;
27
+ const scope = isArchived ? "restore" : "archive";
28
+
29
+ return (
30
+ <>
31
+ <Button variant="outline" size="sm" onClick={() => setOpen(true)}>
32
+ {isArchived ? <RotateCcwIcon /> : <ArchiveIcon />}
33
+ {t(`billing.admin.products.${scope}.confirm`)}
34
+ </Button>
35
+ <ConfirmDialog
36
+ open={open}
37
+ onOpenChange={setOpen}
38
+ title={t(`billing.admin.products.${scope}.title`)}
39
+ description={t(`billing.admin.products.${scope}.description`, { name: product.name })}
40
+ confirmLabel={t(`billing.admin.products.${scope}.confirm`)}
41
+ cancelLabel={t("ui.buttons.cancel")}
42
+ destructive={!isArchived}
43
+ onConfirm={async () => {
44
+ try {
45
+ if (isArchived) await restoreProduct(product.id);
46
+ else await archiveProduct(product.id);
47
+ } catch (error) {
48
+ errorToast({ title: t(`billing.admin.products.${scope}.title`), error });
49
+ throw error; // keeps the dialog open — ConfirmDialog swallows rejections
50
+ }
51
+ }}
52
+ />
53
+ </>
54
+ );
55
+ }
@@ -1,100 +1,111 @@
1
1
  "use client";
2
2
 
3
3
  import { zodResolver } from "@hookform/resolvers/zod";
4
- import { useState } from "react";
5
- import { SubmitHandler, useForm } from "react-hook-form";
4
+ import { useTranslations } from "next-intl";
5
+ import { ReactNode, useCallback, useMemo } from "react";
6
+ import { useForm } from "react-hook-form";
6
7
  import { v4 } from "uuid";
7
8
  import { z } from "zod";
8
- import { FormCheckbox, FormInput, FormTextarea } from "../../../../../components";
9
- import { CommonEditorButtons } from "../../../../../components/forms/CommonEditorButtons";
10
- import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Form } from "../../../../../shadcnui";
9
+ import { EditorSheet, FormInput, FormTextarea } from "../../../../../components";
10
+ import { Modules } from "../../../../../core";
11
+ import { useI18nRouter } from "../../../../../i18n";
12
+ import { useProductContext } from "../../../contexts/ProductContext";
11
13
  import { StripeProductInterface } from "../../data/stripe-product.interface";
12
- import { StripeProductService } from "../../data/stripe-product.service";
13
14
 
14
- type ProductEditorProps = {
15
+ export type ProductEditorProps = {
15
16
  product?: StripeProductInterface;
16
- open: boolean;
17
- onOpenChange: (open: boolean) => void;
18
- onSuccess: () => void;
17
+ propagateChanges?: (product: StripeProductInterface) => void;
18
+ onSuccess?: () => void | Promise<void>;
19
+ trigger?: ReactNode;
20
+ forceShow?: boolean;
21
+ onClose?: () => void;
22
+ dialogOpen?: boolean;
23
+ onDialogOpenChange?: (open: boolean) => void;
19
24
  };
20
25
 
21
- export function ProductEditor({ product, open, onOpenChange, onSuccess }: ProductEditorProps) {
22
- const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
26
+ function ProductEditorInternal({
27
+ product,
28
+ propagateChanges,
29
+ onSuccess,
30
+ trigger,
31
+ forceShow,
32
+ onClose,
33
+ dialogOpen,
34
+ onDialogOpenChange,
35
+ }: ProductEditorProps) {
36
+ const t = useTranslations();
37
+ const router = useI18nRouter();
38
+ const { createProduct, updateProduct } = useProductContext();
39
+ const isEdit = !!product;
23
40
 
24
- const formSchema = z.object({
25
- name: z.string().min(1, { message: "Product name is required" }),
26
- description: z.string().min(1, { message: "Description is required" }),
27
- active: z.boolean(),
28
- });
41
+ const formSchema = useMemo(
42
+ () =>
43
+ z.object({
44
+ id: z.uuidv4(),
45
+ name: z.string().min(1, { message: t("billing.admin.products.errors.name") }),
46
+ description: z.string().optional(),
47
+ }),
48
+ [t],
49
+ );
50
+
51
+ // `active` is deliberately absent: archiving is the single deactivation path
52
+ // (ProductArchiver), and a second toggle here would let the two disagree.
53
+ const getDefaultValues = useCallback(
54
+ () => ({
55
+ id: product?.id ?? v4(),
56
+ name: product?.name ?? "",
57
+ description: product?.description ?? "",
58
+ }),
59
+ [product],
60
+ );
29
61
 
30
62
  const form = useForm<z.infer<typeof formSchema>>({
31
63
  resolver: zodResolver(formSchema),
32
- defaultValues: {
33
- name: product?.name || "",
34
- description: product?.description || "",
35
- active: product?.active ?? true,
36
- },
64
+ defaultValues: getDefaultValues(),
37
65
  });
38
66
 
39
- const onSubmit: SubmitHandler<z.infer<typeof formSchema>> = async (values) => {
40
- setIsSubmitting(true);
41
-
42
- try {
43
- if (product) {
44
- // Update existing product
45
- await StripeProductService.updateProduct({
46
- id: product.id,
47
- name: values.name,
48
- description: values.description,
49
- active: values.active,
50
- });
51
- } else {
52
- // Create new product
53
- await StripeProductService.createProduct({
54
- id: v4(),
55
- name: values.name,
56
- description: values.description,
57
- active: values.active,
58
- });
59
- }
60
-
61
- onSuccess();
62
- onOpenChange(false);
63
- } catch (error) {
64
- console.error("[ProductEditor] Failed to save product:", error);
65
- } finally {
66
- setIsSubmitting(false);
67
- }
68
- };
69
-
70
67
  return (
71
- <Dialog open={open} onOpenChange={onOpenChange}>
72
- <DialogContent className="max-w-2xl">
73
- <DialogHeader>
74
- <DialogTitle>{product ? "Edit Product" : "Create Product"}</DialogTitle>
75
- <DialogDescription>
76
- {product ? `Update the details for ${product.name}` : "Create a new product to offer to your customers"}
77
- </DialogDescription>
78
- </DialogHeader>
79
-
80
- <Form {...form}>
81
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
82
- <FormInput form={form} id="name" name="Product Name" placeholder="Enter product name" isRequired />
83
-
84
- <FormTextarea
85
- form={form}
86
- id="description"
87
- name="Description"
88
- placeholder="Enter product description"
89
- className="min-h-32"
90
- />
91
-
92
- <FormCheckbox form={form} id="active" name="Active" />
93
-
94
- <CommonEditorButtons isEdit={!!product} form={form} disabled={isSubmitting} setOpen={onOpenChange} />
95
- </form>
96
- </Form>
97
- </DialogContent>
98
- </Dialog>
68
+ <EditorSheet
69
+ form={form}
70
+ entityType={t("billing.admin.products.entity")}
71
+ entityName={product?.name}
72
+ isEdit={isEdit}
73
+ module={Modules.StripeProduct}
74
+ size="md"
75
+ propagateChanges={propagateChanges}
76
+ onSuccess={onSuccess}
77
+ onNavigate={(url) => router.push(url)}
78
+ onSubmit={async (values) => {
79
+ const payload = { id: values.id, name: values.name, description: values.description };
80
+ return isEdit ? await updateProduct(payload) : await createProduct(payload);
81
+ }}
82
+ onReset={getDefaultValues}
83
+ trigger={trigger}
84
+ forceShow={forceShow}
85
+ onClose={onClose}
86
+ dialogOpen={dialogOpen}
87
+ onDialogOpenChange={onDialogOpenChange}
88
+ >
89
+ <div className="flex w-full flex-col gap-y-4">
90
+ <FormInput
91
+ form={form}
92
+ id="name"
93
+ name={t("billing.admin.products.fields.name")}
94
+ placeholder={t("billing.admin.products.placeholders.name")}
95
+ isRequired
96
+ />
97
+ <FormTextarea
98
+ form={form}
99
+ id="description"
100
+ name={t("billing.admin.products.fields.description")}
101
+ placeholder={t("billing.admin.products.placeholders.description")}
102
+ className="min-h-32"
103
+ />
104
+ </div>
105
+ </EditorSheet>
99
106
  );
100
107
  }
108
+
109
+ export default function ProductEditor(props: ProductEditorProps) {
110
+ return <ProductEditorInternal {...props} />;
111
+ }
@@ -1 +1,3 @@
1
- export * from "./ProductEditor";
1
+ export * from "./ProductArchiver";
2
+ export { default as ProductEditor } from "./ProductEditor";
3
+ export type { ProductEditorProps } from "./ProductEditor";
@@ -1,3 +1,4 @@
1
1
  export * from "./containers";
2
+ export * from "./details";
2
3
  export * from "./forms";
3
4
  export * from "./lists";
@@ -1,203 +1,61 @@
1
1
  "use client";
2
2
 
3
- import { Archive, ChevronDown, ChevronUp, Edit, Package, RefreshCw } from "lucide-react";
4
- import { 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 { PricesList } from "../../../stripe-price/components/lists/PricesList";
3
+ import { PackageIcon } 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 { useProductContext } from "../../../contexts/ProductContext";
11
+ import { StripeProductFields } from "../../data/stripe-product.fields";
18
12
  import { StripeProductInterface } from "../../data/stripe-product.interface";
19
13
  import { StripeProductService } from "../../data/stripe-product.service";
20
- import { ProductEditor } from "../forms/ProductEditor";
14
+ import ProductEditor from "../forms/ProductEditor";
21
15
 
22
16
  type ProductsListProps = {
23
- products: StripeProductInterface[];
24
- onProductsChange: () => void;
17
+ /**
18
+ * Pass this whenever the list is the page's own content inside a
19
+ * `RoundPageContainer fullWidth`. Without it ContentListTable wraps itself in
20
+ * `rounded-md border`, drawing a second bordered card inside the page's
21
+ * rounded shell.
22
+ */
23
+ fullWidth?: boolean;
25
24
  };
26
25
 
27
- export function ProductsList({ products, onProductsChange }: ProductsListProps) {
28
- const [expandedProductId, setExpandedProductId] = useState<string | null>(null);
29
- const [editingProduct, setEditingProduct] = useState<StripeProductInterface | null>(null);
30
- const [archivingProductId, setArchivingProductId] = useState<string | null>(null);
31
- const [reactivatingProductId, setReactivatingProductId] = useState<string | null>(null);
32
- const [productToArchive, setProductToArchive] = useState<StripeProductInterface | null>(null);
33
- const [productToReactivate, setProductToReactivate] = useState<StripeProductInterface | null>(null);
26
+ export function ProductsList({ fullWidth }: ProductsListProps = {}) {
27
+ const t = useTranslations();
28
+ const { catalogueVersion } = useProductContext();
34
29
 
35
- const handleArchive = async () => {
36
- if (!productToArchive) {
37
- return;
38
- }
30
+ const data: DataListRetriever<StripeProductInterface> = useDataListRetriever({
31
+ retriever: (params) => StripeProductService.listProducts(params),
32
+ retrieverParams: {},
33
+ module: Modules.StripeProduct,
34
+ });
39
35
 
40
- setArchivingProductId(productToArchive.id);
41
- try {
42
- const _archivedProduct = await StripeProductService.archiveProduct({ id: productToArchive.id });
43
- setProductToArchive(null); // Close dialog on success
44
- onProductsChange();
45
- } catch (_error) {
46
- console.error("[ProductsList] Failed to archive product:", _error);
47
- // Keep dialog open on error so user can retry or cancel
48
- } finally {
49
- setArchivingProductId(null);
50
- }
51
- };
36
+ // Archiving and restoring happen on the detail page, so the list has to learn
37
+ // about them through the provider's counter rather than a direct callback.
38
+ useEffect(() => {
39
+ if (catalogueVersion > 0) void data.refresh();
40
+ }, [catalogueVersion]);
52
41
 
53
- const handleReactivate = async () => {
54
- if (!productToReactivate) {
55
- return;
56
- }
57
-
58
- setReactivatingProductId(productToReactivate.id);
59
- try {
60
- const _reactivatedProduct = await StripeProductService.reactivateProduct({ id: productToReactivate.id });
61
- setProductToReactivate(null); // Close dialog on success
62
- onProductsChange();
63
- } catch (_error) {
64
- // Keep dialog open on error so user can retry or cancel
65
- } finally {
66
- setReactivatingProductId(null);
67
- }
68
- };
69
-
70
- const toggleExpand = (productId: string) => {
71
- setExpandedProductId(expandedProductId === productId ? null : productId);
72
- };
42
+ const functions: ReactNode[] = [<ProductEditor key="create-product" />];
73
43
 
74
44
  return (
75
- <div className="flex flex-col gap-y-4">
76
- {products.map((product) => {
77
- const isExpanded = expandedProductId === product.id;
78
- const isArchiving = archivingProductId === product.id;
79
- const isReactivating = reactivatingProductId === product.id;
80
-
81
- return (
82
- <div key={product.id} className="border rounded-lg bg-white shadow-sm hover:shadow-md transition-shadow">
83
- {/* Product Card Header */}
84
- <div className="flex items-center justify-between p-6">
85
- <div className="flex items-center gap-x-4 flex-1">
86
- <Package className="h-6 w-6 text-primary" />
87
- <div className="flex-1">
88
- <div className="flex items-center gap-x-3">
89
- <h3 className="text-sm font-medium">{product.name}</h3>
90
- {product.active ? (
91
- <Badge variant="softGreen">Active</Badge>
92
- ) : (
93
- <Badge variant="softGray">Inactive</Badge>
94
- )}
95
- </div>
96
- {product.description && <p className="text-muted-foreground text-sm mt-1">{product.description}</p>}
97
- </div>
98
- </div>
99
-
100
- {/* Action Buttons */}
101
- <div className="flex items-center gap-x-2">
102
- <Button variant="outline" size="sm" onClick={() => setEditingProduct(product)}>
103
- <Edit className="h-4 w-4 mr-1" />
104
- Edit
105
- </Button>
106
- {product.active ? (
107
- <Button
108
- variant="outline"
109
- size="sm"
110
- onClick={() => setProductToArchive(product)}
111
- disabled={isArchiving}
112
- >
113
- <Archive className="h-4 w-4 mr-1" />
114
- {isArchiving ? "Archiving..." : "Archive"}
115
- </Button>
116
- ) : (
117
- <Button
118
- variant="outline"
119
- size="sm"
120
- onClick={() => setProductToReactivate(product)}
121
- disabled={isReactivating}
122
- >
123
- <RefreshCw className="h-4 w-4 mr-1" />
124
- {isReactivating ? "Reactivating..." : "Reactivate"}
125
- </Button>
126
- )}
127
- <Button variant="ghost" size="sm" onClick={() => toggleExpand(product.id)}>
128
- {isExpanded ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />}
129
- </Button>
130
- </div>
131
- </div>
132
-
133
- {/* Expandable Prices Section */}
134
- {isExpanded && (
135
- <div className="border-t bg-muted/30 p-6">
136
- <PricesList productId={product.id} onPricesChange={onProductsChange} />
137
- </div>
138
- )}
139
- </div>
140
- );
141
- })}
142
-
143
- {/* Edit Product Modal */}
144
- {editingProduct && (
145
- <ProductEditor
146
- product={editingProduct}
147
- open={!!editingProduct}
148
- onOpenChange={(open) => !open && setEditingProduct(null)}
149
- onSuccess={() => {
150
- onProductsChange();
151
- setEditingProduct(null);
152
- }}
45
+ <ContentListTable
46
+ data={data}
47
+ fields={[StripeProductFields.name, StripeProductFields.status, StripeProductFields.prices]}
48
+ tableGeneratorType={Modules.StripeProduct}
49
+ functions={functions}
50
+ fullWidth={fullWidth}
51
+ title={t("billing.admin.products.title")}
52
+ emptyState={
53
+ <EmptyState
54
+ icon={PackageIcon}
55
+ title={t("billing.admin.products.empty.title")}
56
+ description={t("billing.admin.products.empty.description")}
153
57
  />
154
- )}
155
-
156
- {/* Archive Product Confirmation Dialog */}
157
- <AlertDialog open={!!productToArchive} onOpenChange={(open) => !open && setProductToArchive(null)}>
158
- <AlertDialogContent>
159
- <AlertDialogHeader>
160
- <AlertDialogTitle>Archive Product</AlertDialogTitle>
161
- <AlertDialogDescription>
162
- Are you sure you want to archive &quot;{productToArchive?.name}&quot;? This will deactivate it and it will
163
- no longer be available for new subscriptions.
164
- </AlertDialogDescription>
165
- </AlertDialogHeader>
166
- <AlertDialogFooter>
167
- <AlertDialogCancel disabled={!!archivingProductId}>Cancel</AlertDialogCancel>
168
- <AlertDialogAction
169
- onClick={handleArchive}
170
- disabled={!!archivingProductId}
171
- className="bg-red-600 hover:bg-red-700"
172
- >
173
- {archivingProductId ? "Archiving..." : "Archive"}
174
- </AlertDialogAction>
175
- </AlertDialogFooter>
176
- </AlertDialogContent>
177
- </AlertDialog>
178
-
179
- {/* Reactivate Product Confirmation Dialog */}
180
- <AlertDialog open={!!productToReactivate} onOpenChange={(open) => !open && setProductToReactivate(null)}>
181
- <AlertDialogContent>
182
- <AlertDialogHeader>
183
- <AlertDialogTitle>Reactivate Product</AlertDialogTitle>
184
- <AlertDialogDescription>
185
- Are you sure you want to reactivate &quot;{productToReactivate?.name}&quot;? This will make it available
186
- for new subscriptions again.
187
- </AlertDialogDescription>
188
- </AlertDialogHeader>
189
- <AlertDialogFooter>
190
- <AlertDialogCancel disabled={!!reactivatingProductId}>Cancel</AlertDialogCancel>
191
- <AlertDialogAction
192
- onClick={handleReactivate}
193
- disabled={!!reactivatingProductId}
194
- className="bg-green-600 hover:bg-green-700"
195
- >
196
- {reactivatingProductId ? "Reactivating..." : "Reactivate"}
197
- </AlertDialogAction>
198
- </AlertDialogFooter>
199
- </AlertDialogContent>
200
- </AlertDialog>
201
- </div>
58
+ }
59
+ />
202
60
  );
203
61
  }
@@ -1,3 +1,4 @@
1
1
  export * from "./stripe-product";
2
+ export * from "./stripe-product.fields";
2
3
  export * from "./stripe-product.interface";
3
4
  export * from "./stripe-product.service";
@@ -0,0 +1,6 @@
1
+ export enum StripeProductFields {
2
+ stripeProductId = "stripeProductId",
3
+ name = "name",
4
+ status = "status",
5
+ prices = "prices",
6
+ }
@@ -0,0 +1,80 @@
1
+ "use client";
2
+
3
+ import { ColumnDef } from "@tanstack/react-table";
4
+ import { useTranslations } from "next-intl";
5
+ import { useMemo } from "react";
6
+ import { Badge, Link } from "../../../../shadcnui";
7
+ import { Modules } from "../../../../core";
8
+ import { registerTableGenerator, TableContent, usePageUrlGenerator, UseTableStructureHook } from "../../../../hooks";
9
+ import { StripeProductFields } from "../data/stripe-product.fields";
10
+ import { StripeProductInterface } from "../data/stripe-product.interface";
11
+
12
+ export const useStripeProductTableStructure: UseTableStructureHook<StripeProductInterface, StripeProductFields> = (
13
+ params,
14
+ ) => {
15
+ const t = useTranslations();
16
+ const generateUrl = usePageUrlGenerator();
17
+
18
+ const tableData = useMemo(() => {
19
+ return params.data.map((product: StripeProductInterface) => {
20
+ const entry: TableContent<StripeProductInterface> = { jsonApiData: product };
21
+ entry[StripeProductFields.stripeProductId] = product.id;
22
+ params.fields.forEach((field) => {
23
+ entry[field] = (product as any)[field as keyof StripeProductInterface];
24
+ });
25
+ return entry;
26
+ });
27
+ }, [params.data, params.fields]);
28
+
29
+ const fieldColumnMap: Partial<Record<StripeProductFields, () => any>> = {
30
+ [StripeProductFields.name]: () => ({
31
+ id: "name",
32
+ accessorKey: "name",
33
+ header: t("billing.admin.products.fields.name"),
34
+ cell: ({ row }: { row: TableContent<StripeProductInterface> }) => {
35
+ const product: StripeProductInterface = row.original.jsonApiData;
36
+ return <Link href={generateUrl({ page: Modules.StripeProduct, id: product.id })}>{product.name}</Link>;
37
+ },
38
+ enableSorting: false,
39
+ enableHiding: false,
40
+ }),
41
+ [StripeProductFields.status]: () => ({
42
+ id: "status",
43
+ accessorKey: "status",
44
+ header: t("billing.admin.products.fields.status"),
45
+ cell: ({ row }: { row: TableContent<StripeProductInterface> }) => {
46
+ const product: StripeProductInterface = row.original.jsonApiData;
47
+ return product.active ? (
48
+ <Badge variant="softGreen">{t("billing.admin.products.status.active")}</Badge>
49
+ ) : (
50
+ <Badge variant="softGray">{t("billing.admin.products.status.archived")}</Badge>
51
+ );
52
+ },
53
+ enableSorting: false,
54
+ enableHiding: false,
55
+ }),
56
+ [StripeProductFields.prices]: () => ({
57
+ id: "prices",
58
+ accessorKey: "prices",
59
+ header: t("billing.admin.products.fields.prices"),
60
+ cell: ({ row }: { row: TableContent<StripeProductInterface> }) => {
61
+ const product: StripeProductInterface = row.original.jsonApiData;
62
+ // Typography role 15 (numeric): tabular-nums, right-aligned. NEVER
63
+ // font-mono — no monospace font is loaded in these apps.
64
+ return <span className="block text-xs tabular-nums text-right">{product.stripePrices.length}</span>;
65
+ },
66
+ enableSorting: false,
67
+ enableHiding: false,
68
+ }),
69
+ };
70
+
71
+ const columns = useMemo(() => {
72
+ return params.fields.map((field) => fieldColumnMap[field]?.()).filter((col) => col !== undefined) as ColumnDef<
73
+ TableContent<StripeProductInterface>
74
+ >[];
75
+ }, [params.fields, fieldColumnMap, t, generateUrl]);
76
+
77
+ return useMemo(() => ({ data: tableData, columns: columns }), [tableData, columns]);
78
+ };
79
+
80
+ registerTableGenerator("stripe-products", useStripeProductTableStructure);
@@ -4,7 +4,11 @@ import { StripeProduct } from "./data";
4
4
  export const StripeProductModule = (factory: ModuleFactory) =>
5
5
  factory({
6
6
  name: "stripe-products",
7
- pageUrl: "/stripe-products",
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/products",
8
12
  model: StripeProduct,
9
13
  moduleId: "f8c4a1e9-3b2d-4f7a-9e5c-1d8a6b3c9f2e",
10
14
  });
@@ -108,7 +108,12 @@ function UserEditorInternal({
108
108
  const formSchema = useMemo(
109
109
  () =>
110
110
  z.object({
111
- id: z.uuidv4(),
111
+ // Any UUID version: on edit this is the PERSISTED id, and seeded/legacy
112
+ // users can carry a non-v4 uuid (the migration-created Administrator is
113
+ // v1). `z.uuidv4()` rejected those, and because `id` has no rendered
114
+ // field the failure was invisible — handleSubmit simply never fired.
115
+ // New users still get a v4 from `v4()` in getDefaultValues.
116
+ id: z.uuid(),
112
117
  name: z.string().min(1, { message: t(`user.fields.name.error`) }),
113
118
  email: z.string().min(1, { message: t(`common.fields.email.error`) }),
114
119
  password: z.string().optional(),
@@ -205,7 +210,11 @@ function UserEditorInternal({
205
210
  avatar: resetImage ? undefined : values.avatar,
206
211
  roleIds: values.roleIds,
207
212
  sendInvitationEmail: values.sendInvitationEmail,
208
- companyId: company!.id,
213
+ // Optional: `companyId` only sets the `x-companyid` header, and a user
214
+ // can legitimately have no company (the seeded Administrator does), in
215
+ // which case `company` is null here. `company!.id` threw a TypeError
216
+ // and surfaced as a generic "update failed" toast.
217
+ companyId: company?.id,
209
218
  adminCreated: adminCreated,
210
219
  };
211
220