@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,89 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { ReactNode } from "react";
3
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
5
+ import { StripePrice } from "../../stripe-price/data/stripe-price";
6
+ import { StripePriceService } from "../../stripe-price/data/stripe-price.service";
7
+ import { PriceProvider, usePriceContext } from "../PriceContext";
8
+
9
+ vi.mock("../../stripe-price/data/stripe-price.service", () => ({
10
+ StripePriceService: {
11
+ getPrice: vi.fn(async () => ({ id: "pr1" })),
12
+ createPrice: vi.fn(async () => ({ id: "new" })),
13
+ updatePrice: vi.fn(async () => ({ id: "pr1" })),
14
+ archivePrice: vi.fn(async () => undefined),
15
+ reactivatePrice: vi.fn(async () => undefined),
16
+ },
17
+ }));
18
+
19
+ vi.mock("../../../user/contexts", () => ({
20
+ useCurrentUserContext: () => ({ hasRole: () => true, hasPermissionToModule: () => false }),
21
+ }));
22
+
23
+ vi.mock("../../../../roles", () => ({
24
+ getRoleId: () => ({ Administrator: "administrator" }),
25
+ }));
26
+
27
+ const listModeWrapper = ({ children }: { children: ReactNode }) => (
28
+ <PriceProvider productId="p1" publishChrome={false}>
29
+ {children}
30
+ </PriceProvider>
31
+ );
32
+
33
+ describe("PriceContext", () => {
34
+ beforeAll(() => {
35
+ ModuleRegistry.register("StripePrice", {
36
+ name: "stripe-prices",
37
+ pageUrl: "/administration/prices",
38
+ model: StripePrice,
39
+ } as any);
40
+ });
41
+
42
+ beforeEach(() => vi.clearAllMocks());
43
+
44
+ it("returns a no-op default outside a provider instead of throwing", () => {
45
+ const { result } = renderHook(() => usePriceContext());
46
+
47
+ expect(result.current.price).toBeUndefined();
48
+ expect(result.current.priceVersion).toBe(0);
49
+ });
50
+
51
+ it("exposes the productId it was mounted with, so the tab can create prices", () => {
52
+ const { result } = renderHook(() => usePriceContext(), { wrapper: listModeWrapper });
53
+
54
+ expect(result.current.productId).toBe("p1");
55
+ });
56
+
57
+ it("bumps priceVersion after a create", async () => {
58
+ const { result } = renderHook(() => usePriceContext(), { wrapper: listModeWrapper });
59
+
60
+ await act(async () => {
61
+ await result.current.createPrice({ id: "new", productId: "p1", currency: "usd" });
62
+ });
63
+
64
+ expect(StripePriceService.createPrice).toHaveBeenCalledWith({ id: "new", productId: "p1", currency: "usd" });
65
+ expect(result.current.priceVersion).toBe(1);
66
+ });
67
+
68
+ it("archives and restores by explicit id", async () => {
69
+ const { result } = renderHook(() => usePriceContext(), { wrapper: listModeWrapper });
70
+
71
+ await act(async () => {
72
+ await result.current.archivePrice("pr9");
73
+ await result.current.restorePrice("pr9");
74
+ });
75
+
76
+ expect(StripePriceService.archivePrice).toHaveBeenCalledWith({ id: "pr9" });
77
+ expect(StripePriceService.reactivatePrice).toHaveBeenCalledWith({ id: "pr9" });
78
+ });
79
+
80
+ it("publishes no page chrome when publishChrome is false", () => {
81
+ // A nested SharedProvider would replace the host page's title. Rendering in
82
+ // list mode must therefore leave SharedContext untouched — asserted by the
83
+ // absence of a throw from the host's own consumer, which is covered by the
84
+ // productId test above rendering without a SharedProvider ancestor.
85
+ const { result } = renderHook(() => usePriceContext(), { wrapper: listModeWrapper });
86
+
87
+ expect(result.current.price).toBeUndefined();
88
+ });
89
+ });
@@ -0,0 +1,81 @@
1
+ import { act, renderHook, waitFor } from "@testing-library/react";
2
+ import { ReactNode } from "react";
3
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
5
+ import { StripeProduct } from "../../stripe-product/data/stripe-product";
6
+ import { StripeProductService } from "../../stripe-product/data/stripe-product.service";
7
+ import { ProductProvider, useProductContext } from "../ProductContext";
8
+
9
+ vi.mock("../../stripe-product/data/stripe-product.service", () => ({
10
+ StripeProductService: {
11
+ getProduct: vi.fn(async () => ({ id: "p1", name: "Reloaded" })),
12
+ createProduct: vi.fn(async () => ({ id: "new", name: "Created" })),
13
+ updateProduct: vi.fn(async () => ({ id: "p1", name: "Updated" })),
14
+ archiveProduct: vi.fn(async () => undefined),
15
+ reactivateProduct: vi.fn(async () => undefined),
16
+ },
17
+ }));
18
+
19
+ vi.mock("../../../user/contexts", () => ({
20
+ useCurrentUserContext: () => ({ hasRole: () => true, hasPermissionToModule: () => false }),
21
+ }));
22
+
23
+ vi.mock("../../../../roles", () => ({
24
+ getRoleId: () => ({ Administrator: "administrator" }),
25
+ }));
26
+
27
+ const wrapper = ({ children }: { children: ReactNode }) => <ProductProvider>{children}</ProductProvider>;
28
+
29
+ describe("ProductContext", () => {
30
+ beforeAll(() => {
31
+ ModuleRegistry.register("StripeProduct", {
32
+ name: "stripe-products",
33
+ pageUrl: "/administration/products",
34
+ model: StripeProduct,
35
+ } as any);
36
+ });
37
+
38
+ beforeEach(() => vi.clearAllMocks());
39
+
40
+ it("returns a no-op default outside a provider instead of throwing", () => {
41
+ const { result } = renderHook(() => useProductContext());
42
+
43
+ expect(result.current.product).toBeUndefined();
44
+ expect(result.current.catalogueVersion).toBe(0);
45
+ });
46
+
47
+ it("bumps catalogueVersion after a create so mounted lists re-fetch", async () => {
48
+ const { result } = renderHook(() => useProductContext(), { wrapper });
49
+
50
+ expect(result.current.catalogueVersion).toBe(0);
51
+ await act(async () => {
52
+ await result.current.createProduct({ id: "new", name: "Created" });
53
+ });
54
+
55
+ expect(StripeProductService.createProduct).toHaveBeenCalledWith({ id: "new", name: "Created" });
56
+ expect(result.current.catalogueVersion).toBe(1);
57
+ });
58
+
59
+ it("archives by explicit id and reloads rather than trusting the 204 body", async () => {
60
+ const { result } = renderHook(() => useProductContext(), { wrapper });
61
+
62
+ await act(async () => {
63
+ await result.current.archiveProduct("p9");
64
+ });
65
+
66
+ expect(StripeProductService.archiveProduct).toHaveBeenCalledWith({ id: "p9" });
67
+ // No product is held in list mode, so there is nothing to reload — but the
68
+ // catalogue counter must still move or the list would show stale rows.
69
+ await waitFor(() => expect(result.current.catalogueVersion).toBe(1));
70
+ });
71
+
72
+ it("restores through reactivateProduct", async () => {
73
+ const { result } = renderHook(() => useProductContext(), { wrapper });
74
+
75
+ await act(async () => {
76
+ await result.current.restoreProduct("p9");
77
+ });
78
+
79
+ expect(StripeProductService.reactivateProduct).toHaveBeenCalledWith({ id: "p9" });
80
+ });
81
+ });
@@ -1 +1,2 @@
1
- export * from "./BillingContext";
1
+ export * from "./PriceContext";
2
+ export * from "./ProductContext";
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The fixed namespace of i18n keys the administrative billing surfaces read via
3
+ * useTranslations. Consuming apps must define each entry in their
4
+ * messages/<locale>.json — this list is the contract between the package and
5
+ * the app, and the names are frozen: an app that wants different wording
6
+ * changes its own translation, never the key.
7
+ *
8
+ * Mirrors the contract shipped by the administration feature
9
+ * (features/administration/i18n-keys.ts).
10
+ */
11
+ export const BILLING_ADMIN_I18N_KEYS = [
12
+ "billing.admin.products.title",
13
+ "billing.admin.products.entity",
14
+ "billing.admin.products.create",
15
+ "billing.admin.products.tabs.details",
16
+ "billing.admin.products.empty.title",
17
+ "billing.admin.products.empty.description",
18
+ "billing.admin.products.fields.name",
19
+ "billing.admin.products.fields.description",
20
+ "billing.admin.products.fields.status",
21
+ "billing.admin.products.fields.prices",
22
+ "billing.admin.products.fields.stripeId",
23
+ "billing.admin.products.placeholders.name",
24
+ "billing.admin.products.placeholders.description",
25
+ "billing.admin.products.errors.name",
26
+ "billing.admin.products.status.active",
27
+ "billing.admin.products.status.archived",
28
+ "billing.admin.products.archive.title",
29
+ "billing.admin.products.archive.description",
30
+ "billing.admin.products.archive.confirm",
31
+ "billing.admin.products.restore.title",
32
+ "billing.admin.products.restore.description",
33
+ "billing.admin.products.restore.confirm",
34
+ "billing.admin.prices.title",
35
+ "billing.admin.prices.entity",
36
+ "billing.admin.prices.create",
37
+ "billing.admin.prices.empty.title",
38
+ "billing.admin.prices.empty.description",
39
+ "billing.admin.prices.fields.product",
40
+ "billing.admin.prices.fields.nickname",
41
+ "billing.admin.prices.fields.amount",
42
+ "billing.admin.prices.fields.currency",
43
+ "billing.admin.prices.fields.interval",
44
+ "billing.admin.prices.fields.intervalCount",
45
+ "billing.admin.prices.fields.usageType",
46
+ "billing.admin.prices.fields.token",
47
+ "billing.admin.prices.fields.trial",
48
+ "billing.admin.prices.fields.description",
49
+ "billing.admin.prices.fields.features",
50
+ "billing.admin.prices.fields.platformFeatures",
51
+ "billing.admin.prices.fields.status",
52
+ "billing.admin.prices.fields.type",
53
+ "billing.admin.prices.fields.billing",
54
+ "billing.admin.prices.fields.stripeId",
55
+ "billing.admin.prices.placeholders.amount",
56
+ "billing.admin.prices.placeholders.intervalCount",
57
+ "billing.admin.prices.placeholders.nickname",
58
+ "billing.admin.prices.placeholders.description",
59
+ "billing.admin.prices.placeholders.token",
60
+ "billing.admin.prices.placeholders.feature",
61
+ "billing.admin.prices.help.trial",
62
+ "billing.admin.prices.help.core",
63
+ "billing.admin.prices.errors.amount",
64
+ "billing.admin.prices.errors.currency",
65
+ "billing.admin.prices.status.active",
66
+ "billing.admin.prices.status.archived",
67
+ "billing.admin.prices.badge.trial",
68
+ "billing.admin.prices.badge.metered",
69
+ "billing.admin.prices.type.oneTime",
70
+ "billing.admin.prices.type.recurring",
71
+ "billing.admin.prices.interval.one_time",
72
+ "billing.admin.prices.interval.day",
73
+ "billing.admin.prices.interval.week",
74
+ "billing.admin.prices.interval.month",
75
+ "billing.admin.prices.interval.year",
76
+ "billing.admin.prices.usage.licensed",
77
+ "billing.admin.prices.usage.metered",
78
+ "billing.admin.prices.immutability.title",
79
+ "billing.admin.prices.immutability.description",
80
+ "billing.admin.prices.actions.addFeature",
81
+ "billing.admin.prices.actions.removeFeature",
82
+ "billing.admin.prices.archive.title",
83
+ "billing.admin.prices.archive.description",
84
+ "billing.admin.prices.archive.confirm",
85
+ "billing.admin.prices.restore.title",
86
+ "billing.admin.prices.restore.description",
87
+ "billing.admin.prices.restore.confirm",
88
+ ] as const;
@@ -0,0 +1,21 @@
1
+ "use client";
2
+
3
+ import { RoundPageContainer } from "../../../../../components";
4
+ import { Modules } from "../../../../../core";
5
+ import { usePriceContext } from "../../../contexts/PriceContext";
6
+ import { PriceDetails } from "../details/PriceDetails";
7
+
8
+ export function PriceContainer() {
9
+ const { price } = usePriceContext();
10
+
11
+ if (!price) return null;
12
+
13
+ // No tabs: a price is a single flat record. RoundPageContainer without
14
+ // `tabs` and without `fullWidth` gives the centred max-w-6xl column and the
15
+ // title bar that PriceContext's chrome fills.
16
+ return (
17
+ <RoundPageContainer module={Modules.StripePrice} id={price.id}>
18
+ <PriceDetails />
19
+ </RoundPageContainer>
20
+ );
21
+ }
@@ -0,0 +1 @@
1
+ export * from "./PriceContainer";
@@ -0,0 +1,131 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { DetailField, SectionHeader } from "../../../../../components";
5
+ import { Modules } from "../../../../../core";
6
+ import { usePageUrlGenerator } from "../../../../../hooks";
7
+ import { Badge, Link } from "../../../../../shadcnui";
8
+ import { formatCurrency, formatInterval } from "../../../components/utils/currency";
9
+ import { usePriceContext } from "../../../contexts/PriceContext";
10
+
11
+ export function PriceDetails() {
12
+ const t = useTranslations();
13
+ const generateUrl = usePageUrlGenerator();
14
+ const { price } = usePriceContext();
15
+
16
+ if (!price) return null;
17
+
18
+ const isRecurring = price.priceType === "recurring";
19
+
20
+ return (
21
+ <div className="flex w-full flex-col gap-y-8">
22
+ <div className="flex w-full flex-col gap-y-4">
23
+ <SectionHeader>{t("billing.admin.prices.title")}</SectionHeader>
24
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
25
+ <DetailField
26
+ label={t("billing.admin.prices.fields.product")}
27
+ value={
28
+ price.product ? (
29
+ <Link href={generateUrl({ page: Modules.StripeProduct, id: price.product.id })}>
30
+ {price.product.name}
31
+ </Link>
32
+ ) : (
33
+ "—"
34
+ )
35
+ }
36
+ />
37
+ <DetailField
38
+ label={t("billing.admin.prices.fields.status")}
39
+ value={
40
+ <span className="flex flex-wrap items-center gap-1">
41
+ {price.active ? (
42
+ <Badge variant="softGreen">{t("billing.admin.prices.status.active")}</Badge>
43
+ ) : (
44
+ <Badge variant="softGray">{t("billing.admin.prices.status.archived")}</Badge>
45
+ )}
46
+ {price.isTrial && <Badge variant="softBlue">{t("billing.admin.prices.badge.trial")}</Badge>}
47
+ </span>
48
+ }
49
+ />
50
+ <DetailField
51
+ label={t("billing.admin.prices.fields.amount")}
52
+ value={<span className="tabular-nums">{formatCurrency(price.unitAmount, price.currency)}</span>}
53
+ />
54
+ <DetailField label={t("billing.admin.prices.fields.currency")} value={price.currency.toUpperCase()} />
55
+ <DetailField
56
+ label={t("billing.admin.prices.fields.type")}
57
+ value={isRecurring ? t("billing.admin.prices.type.recurring") : t("billing.admin.prices.type.oneTime")}
58
+ />
59
+ <DetailField label={t("billing.admin.prices.fields.billing")} value={formatInterval(price)} />
60
+ {isRecurring && (
61
+ <DetailField
62
+ label={t("billing.admin.prices.fields.intervalCount")}
63
+ value={<span className="tabular-nums">{price.recurring?.intervalCount ?? 1}</span>}
64
+ />
65
+ )}
66
+ {isRecurring && (
67
+ <DetailField
68
+ label={t("billing.admin.prices.fields.usageType")}
69
+ value={
70
+ price.recurring?.usageType === "metered"
71
+ ? t("billing.admin.prices.usage.metered")
72
+ : t("billing.admin.prices.usage.licensed")
73
+ }
74
+ />
75
+ )}
76
+ </div>
77
+ </div>
78
+
79
+ <div className="flex w-full flex-col gap-y-4">
80
+ <SectionHeader>{t("billing.admin.prices.fields.platformFeatures")}</SectionHeader>
81
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
82
+ <DetailField
83
+ label={t("billing.admin.prices.fields.token")}
84
+ value={<span className="tabular-nums">{price.token ?? "—"}</span>}
85
+ />
86
+ <DetailField
87
+ label={t("billing.admin.prices.fields.stripeId")}
88
+ value={<span className="tabular-nums">{price.stripePriceId}</span>}
89
+ />
90
+ <DetailField
91
+ label={t("billing.admin.prices.fields.description")}
92
+ value={price.description ?? "—"}
93
+ className="md:col-span-2"
94
+ />
95
+ <DetailField
96
+ label={t("billing.admin.prices.fields.features")}
97
+ className="md:col-span-2"
98
+ value={
99
+ price.features && price.features.length > 0 ? (
100
+ <ul className="flex flex-col gap-1 text-sm">
101
+ {price.features.map((feature) => (
102
+ <li key={feature}>{feature}</li>
103
+ ))}
104
+ </ul>
105
+ ) : (
106
+ "—"
107
+ )
108
+ }
109
+ />
110
+ <DetailField
111
+ label={t("billing.admin.prices.fields.platformFeatures")}
112
+ className="md:col-span-2"
113
+ value={
114
+ price.priceFeatures.length > 0 ? (
115
+ <span className="flex flex-wrap items-center gap-1">
116
+ {price.priceFeatures.map((feature) => (
117
+ <Badge key={feature.id} variant="softGray">
118
+ {feature.name}
119
+ </Badge>
120
+ ))}
121
+ </span>
122
+ ) : (
123
+ "—"
124
+ )
125
+ }
126
+ />
127
+ </div>
128
+ </div>
129
+ </div>
130
+ );
131
+ }
@@ -0,0 +1 @@
1
+ export * from "./PriceDetails";
@@ -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 { priceLabel, usePriceContext } from "../../../contexts/PriceContext";
9
+ import { StripePriceInterface } from "../../data/stripe-price.interface";
10
+
11
+ type PriceArchiverProps = {
12
+ price: StripePriceInterface;
13
+ };
14
+
15
+ /**
16
+ * Archive / restore control for a price. 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 PriceArchiver({ price }: PriceArchiverProps) {
22
+ const t = useTranslations();
23
+ const { archivePrice, restorePrice } = usePriceContext();
24
+ const [open, setOpen] = useState<boolean>(false);
25
+
26
+ const isArchived = !price.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.prices.${scope}.confirm`)}
34
+ </Button>
35
+ <ConfirmDialog
36
+ open={open}
37
+ onOpenChange={setOpen}
38
+ title={t(`billing.admin.prices.${scope}.title`)}
39
+ description={t(`billing.admin.prices.${scope}.description`, { label: priceLabel(price) })}
40
+ confirmLabel={t(`billing.admin.prices.${scope}.confirm`)}
41
+ cancelLabel={t("ui.buttons.cancel")}
42
+ destructive={!isArchived}
43
+ onConfirm={async () => {
44
+ try {
45
+ if (isArchived) await restorePrice(price.id);
46
+ else await archivePrice(price.id);
47
+ } catch (error) {
48
+ errorToast({ title: t(`billing.admin.prices.${scope}.title`), error });
49
+ throw error; // keeps the dialog open — ConfirmDialog swallows rejections
50
+ }
51
+ }}
52
+ />
53
+ </>
54
+ );
55
+ }