@carlonicora/nextjs-jsonapi 2.4.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-7WGCH2KB.js → BlockNoteEditor-CDTU6DRN.js} +19 -19
- package/dist/{BlockNoteEditor-7WGCH2KB.js.map → BlockNoteEditor-CDTU6DRN.js.map} +1 -1
- package/dist/{BlockNoteEditor-GWAV5ESO.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-V66AZWPG.js → chunk-53B6NPGA.js} +278 -8
- package/dist/chunk-53B6NPGA.js.map +1 -0
- package/dist/{chunk-M2EGUO2F.mjs → chunk-J54546YC.mjs} +1608 -1419
- package/dist/chunk-J54546YC.mjs.map +1 -0
- package/dist/{chunk-TOKHHZSP.js → chunk-JKGEJGSD.js} +7 -7
- package/dist/{chunk-TOKHHZSP.js.map → chunk-JKGEJGSD.js.map} +1 -1
- package/dist/{chunk-3LVSA7IM.mjs → chunk-PHEFWL6L.mjs} +2 -2
- package/dist/{chunk-LCCRUWWY.mjs → chunk-TWXKAY34.mjs} +273 -3
- package/dist/chunk-TWXKAY34.mjs.map +1 -0
- package/dist/{chunk-GYGMEDVS.js → chunk-UGNLKDI6.js} +1012 -823
- 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.d.mts +16 -1
- package/dist/components/index.d.ts +16 -1
- package/dist/components/index.js +4 -4
- package/dist/components/index.mjs +3 -3
- package/dist/{tokenusage-admin.module-5wJ_tZTj.d.ts → config-BIjrg5wd.d.ts} +39 -1
- package/dist/{tokenusage-admin.module-DQVT4O4j.d.mts → config-BRUjtCd1.d.mts} +39 -1
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +22 -3
- package/dist/core/index.d.ts +22 -3
- package/dist/core/index.js +12 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +11 -1
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.d.mts +413 -14
- package/dist/features/tokenusage/index.d.ts +413 -14
- package/dist/features/tokenusage/index.js +481 -120
- package/dist/features/tokenusage/index.js.map +1 -1
- package/dist/features/tokenusage/index.mjs +431 -70
- package/dist/features/tokenusage/index.mjs.map +1 -1
- package/dist/index.d.mts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +13 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +12 -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/core/index.ts +10 -0
- package/src/core/registry/ModuleRegistry.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/tokenusage/components/TokenUsageAdminContainer.tsx +20 -4
- package/src/features/tokenusage/components/TokenUsageAdminTiles.tsx +31 -18
- package/src/features/tokenusage/components/TokenUsageBreakdownTable.tsx +13 -13
- package/src/features/tokenusage/components/TokenUsageRankedBar.tsx +24 -7
- package/src/features/tokenusage/components/TokenUsageReportContainer.tsx +102 -0
- package/src/features/tokenusage/components/TokenUsageReportFilterBar.tsx +34 -0
- package/src/features/tokenusage/components/TokenUsageReportTiles.tsx +161 -0
- package/src/features/tokenusage/components/TokenUsageTimelineChart.tsx +15 -13
- package/src/features/tokenusage/components/__tests__/TokenUsageAdminTiles.spec.tsx +4 -2
- package/src/features/tokenusage/components/__tests__/TokenUsageRankedBar.spec.tsx +3 -1
- package/src/features/tokenusage/contexts/TokenUsageAdminContext.tsx +6 -3
- package/src/features/tokenusage/contexts/TokenUsageReportContext.tsx +199 -0
- package/src/features/tokenusage/data/TokenUsageReportService.ts +65 -0
- package/src/features/tokenusage/data/index.ts +9 -0
- package/src/features/tokenusage/data/tokenusage-report-breakdown.interface.ts +12 -0
- package/src/features/tokenusage/data/tokenusage-report-breakdown.ts +94 -0
- package/src/features/tokenusage/data/tokenusage-report-summary.interface.ts +12 -0
- package/src/features/tokenusage/data/tokenusage-report-summary.ts +87 -0
- package/src/features/tokenusage/data/tokenusage-report-timeline.interface.ts +13 -0
- package/src/features/tokenusage/data/tokenusage-report-timeline.ts +94 -0
- package/src/features/tokenusage/data/tokenusage-report.types.ts +51 -0
- package/src/features/tokenusage/i18n-keys.ts +28 -0
- package/src/features/tokenusage/index.ts +18 -0
- package/src/features/tokenusage/lib/config.ts +22 -0
- package/src/features/tokenusage/lib/formatters.ts +100 -0
- package/src/features/tokenusage/lib/metrics.ts +8 -26
- package/src/features/tokenusage/lib/palette.ts +41 -14
- package/src/features/tokenusage/tokenusage-admin.module.ts +4 -0
- package/src/features/tokenusage/tokenusage.modules.ts +40 -0
- package/src/shadcnui/ui/chart.tsx +30 -3
- package/dist/chunk-GYGMEDVS.js.map +0 -1
- package/dist/chunk-LCCRUWWY.mjs.map +0 -1
- package/dist/chunk-M2EGUO2F.mjs.map +0 -1
- package/dist/chunk-V66AZWPG.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-GWAV5ESO.mjs.map → BlockNoteEditor-VVAY73V2.mjs.map} +0 -0
- /package/dist/{chunk-3LVSA7IM.mjs.map → chunk-PHEFWL6L.mjs.map} +0 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { createContext, ReactNode, useCallback, useContext, useMemo, useState } from "react";
|
|
5
|
+
import { SharedProvider } from "../../../contexts/SharedContext";
|
|
6
|
+
import { JsonApiHydratedDataInterface, Modules, rehydrate } from "../../../core";
|
|
7
|
+
import { usePageUrlGenerator } from "../../../hooks";
|
|
8
|
+
import { BreadcrumbItemData } from "../../../interfaces";
|
|
9
|
+
import { getRoleId } from "../../../roles";
|
|
10
|
+
import { useCurrentUserContext } from "../../user/contexts";
|
|
11
|
+
import { ProductArchiver } from "../stripe-product/components/forms/ProductArchiver";
|
|
12
|
+
import ProductEditor from "../stripe-product/components/forms/ProductEditor";
|
|
13
|
+
import { StripeProductInput, StripeProductInterface } from "../stripe-product/data/stripe-product.interface";
|
|
14
|
+
import { StripeProductService } from "../stripe-product/data/stripe-product.service";
|
|
15
|
+
|
|
16
|
+
export interface ProductContextType {
|
|
17
|
+
product: StripeProductInterface | undefined;
|
|
18
|
+
setProduct: (value: StripeProductInterface | undefined) => void;
|
|
19
|
+
reloadProduct: () => Promise<void>;
|
|
20
|
+
createProduct: (input: StripeProductInput) => Promise<StripeProductInterface>;
|
|
21
|
+
updateProduct: (input: StripeProductInput) => Promise<StripeProductInterface>;
|
|
22
|
+
archiveProduct: (id?: string) => Promise<void>;
|
|
23
|
+
restoreProduct: (id?: string) => Promise<void>;
|
|
24
|
+
/** Bumped by every mutation. Mounted lists re-fetch on it. */
|
|
25
|
+
catalogueVersion: number;
|
|
26
|
+
refreshCatalogue: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ProductContext = createContext<ProductContextType | undefined>(undefined);
|
|
30
|
+
|
|
31
|
+
export type ProductProviderProps = {
|
|
32
|
+
children: ReactNode;
|
|
33
|
+
dehydratedProduct?: JsonApiHydratedDataInterface;
|
|
34
|
+
/**
|
|
35
|
+
* Publish breadcrumbs + title into SharedProvider. Pass `false` when this
|
|
36
|
+
* provider is mounted INSIDE another entity's page (the prices tab mounts
|
|
37
|
+
* PriceProvider that way): SharedProvider replaces its value for the subtree
|
|
38
|
+
* rather than merging (SharedContext.tsx:35-37), so a nested provider that
|
|
39
|
+
* publishes chrome would blank the host page's title.
|
|
40
|
+
*/
|
|
41
|
+
publishChrome?: boolean;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const defaultContextValue: ProductContextType = {
|
|
45
|
+
product: undefined,
|
|
46
|
+
setProduct: () => {},
|
|
47
|
+
reloadProduct: async () => {},
|
|
48
|
+
createProduct: async () => {
|
|
49
|
+
throw new Error("createProduct requires a ProductProvider");
|
|
50
|
+
},
|
|
51
|
+
updateProduct: async () => {
|
|
52
|
+
throw new Error("updateProduct requires a ProductProvider");
|
|
53
|
+
},
|
|
54
|
+
archiveProduct: async () => {},
|
|
55
|
+
restoreProduct: async () => {},
|
|
56
|
+
catalogueVersion: 0,
|
|
57
|
+
refreshCatalogue: () => {},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const ProductProvider = ({ children, dehydratedProduct, publishChrome = true }: ProductProviderProps) => {
|
|
61
|
+
const t = useTranslations();
|
|
62
|
+
const generateUrl = usePageUrlGenerator();
|
|
63
|
+
const { hasRole } = useCurrentUserContext();
|
|
64
|
+
|
|
65
|
+
const [product, setProduct] = useState<StripeProductInterface | undefined>(
|
|
66
|
+
dehydratedProduct ? rehydrate<StripeProductInterface>(Modules.StripeProduct, dehydratedProduct) : undefined,
|
|
67
|
+
);
|
|
68
|
+
const [catalogueVersion, setCatalogueVersion] = useState<number>(0);
|
|
69
|
+
|
|
70
|
+
const refreshCatalogue = useCallback(() => setCatalogueVersion((value) => value + 1), []);
|
|
71
|
+
|
|
72
|
+
const reloadProduct = useCallback(async () => {
|
|
73
|
+
if (!product?.id) return;
|
|
74
|
+
setProduct(await StripeProductService.getProduct({ id: product.id }));
|
|
75
|
+
}, [product?.id]);
|
|
76
|
+
|
|
77
|
+
const createProduct = useCallback(
|
|
78
|
+
async (input: StripeProductInput) => {
|
|
79
|
+
const saved = await StripeProductService.createProduct(input);
|
|
80
|
+
refreshCatalogue();
|
|
81
|
+
return saved;
|
|
82
|
+
},
|
|
83
|
+
[refreshCatalogue],
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const updateProduct = useCallback(
|
|
87
|
+
async (input: StripeProductInput) => {
|
|
88
|
+
const saved = await StripeProductService.updateProduct(input);
|
|
89
|
+
setProduct(saved);
|
|
90
|
+
refreshCatalogue();
|
|
91
|
+
return saved;
|
|
92
|
+
},
|
|
93
|
+
[refreshCatalogue],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// The archive/reactivate routes answer 204 NO_CONTENT with an empty body
|
|
97
|
+
// (stripe-product.controller.ts:99-115) even though the service signature
|
|
98
|
+
// claims it returns a product — so the response is discarded and the entity
|
|
99
|
+
// is re-read instead.
|
|
100
|
+
const archiveProduct = useCallback(
|
|
101
|
+
async (id?: string) => {
|
|
102
|
+
const target = id ?? product?.id;
|
|
103
|
+
if (!target) return;
|
|
104
|
+
await StripeProductService.archiveProduct({ id: target });
|
|
105
|
+
await reloadProduct();
|
|
106
|
+
refreshCatalogue();
|
|
107
|
+
},
|
|
108
|
+
[product?.id, reloadProduct, refreshCatalogue],
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const restoreProduct = useCallback(
|
|
112
|
+
async (id?: string) => {
|
|
113
|
+
const target = id ?? product?.id;
|
|
114
|
+
if (!target) return;
|
|
115
|
+
await StripeProductService.reactivateProduct({ id: target });
|
|
116
|
+
await reloadProduct();
|
|
117
|
+
refreshCatalogue();
|
|
118
|
+
},
|
|
119
|
+
[product?.id, reloadProduct, refreshCatalogue],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const value = useMemo<ProductContextType>(
|
|
123
|
+
() => ({
|
|
124
|
+
product,
|
|
125
|
+
setProduct,
|
|
126
|
+
reloadProduct,
|
|
127
|
+
createProduct,
|
|
128
|
+
updateProduct,
|
|
129
|
+
archiveProduct,
|
|
130
|
+
restoreProduct,
|
|
131
|
+
catalogueVersion,
|
|
132
|
+
refreshCatalogue,
|
|
133
|
+
}),
|
|
134
|
+
[
|
|
135
|
+
product,
|
|
136
|
+
reloadProduct,
|
|
137
|
+
createProduct,
|
|
138
|
+
updateProduct,
|
|
139
|
+
archiveProduct,
|
|
140
|
+
restoreProduct,
|
|
141
|
+
catalogueVersion,
|
|
142
|
+
refreshCatalogue,
|
|
143
|
+
],
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
if (!publishChrome) return <ProductContext.Provider value={value}>{children}</ProductContext.Provider>;
|
|
147
|
+
|
|
148
|
+
const breadcrumb = (): BreadcrumbItemData[] => {
|
|
149
|
+
const items: BreadcrumbItemData[] = [
|
|
150
|
+
{
|
|
151
|
+
name: t("billing.admin.products.title"),
|
|
152
|
+
// Linked only when it is NOT the current page.
|
|
153
|
+
...(product ? { href: generateUrl({ page: Modules.StripeProduct }) } : {}),
|
|
154
|
+
},
|
|
155
|
+
];
|
|
156
|
+
if (product) items.push({ name: product.name });
|
|
157
|
+
return items;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const title = () => {
|
|
161
|
+
const response: any = { type: t("billing.admin.products.title") };
|
|
162
|
+
if (!product) return response;
|
|
163
|
+
|
|
164
|
+
response.element = product.name;
|
|
165
|
+
|
|
166
|
+
// narr8 note: gate on hasRole ONLY. hasPermissionToModule always returns
|
|
167
|
+
// false in this app, so gating an editor on it hides the editor outright.
|
|
168
|
+
if (hasRole(getRoleId().Administrator)) {
|
|
169
|
+
response.functions = [
|
|
170
|
+
<ProductArchiver key="productArchiver" product={product} />,
|
|
171
|
+
<ProductEditor key="productEditor" product={product} propagateChanges={setProduct} />,
|
|
172
|
+
];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return response;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<SharedProvider value={{ breadcrumbs: breadcrumb(), title: title() }}>
|
|
180
|
+
<ProductContext.Provider value={value}>{children}</ProductContext.Provider>
|
|
181
|
+
</SharedProvider>
|
|
182
|
+
);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export const useProductContext = (): ProductContextType => useContext(ProductContext) ?? defaultContextValue;
|
|
@@ -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
|
+
}
|