@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.
- package/dist/{BlockNoteEditor-TIMGYKAA.js → BlockNoteEditor-3IZ7PMFM.js} +19 -19
- package/dist/{BlockNoteEditor-TIMGYKAA.js.map → BlockNoteEditor-3IZ7PMFM.js.map} +1 -1
- package/dist/{BlockNoteEditor-YFJMXJEU.mjs → BlockNoteEditor-G4XNDV2O.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-7HKJNUW7.js → chunk-5GVBUTS4.js} +1142 -953
- package/dist/chunk-5GVBUTS4.js.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-G4YS52SO.mjs → chunk-WCX4CLRJ.mjs} +1607 -1418
- package/dist/chunk-WCX4CLRJ.mjs.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/src/features/user/components/forms/UserEditor.tsx +11 -2
- 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-G4XNDV2O.mjs.map} +0 -0
- /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 "./
|
|
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
|
+
}
|