@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.
Files changed (128) hide show
  1. package/dist/{BlockNoteEditor-7WGCH2KB.js → BlockNoteEditor-CDTU6DRN.js} +19 -19
  2. package/dist/{BlockNoteEditor-7WGCH2KB.js.map → BlockNoteEditor-CDTU6DRN.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-GWAV5ESO.mjs → BlockNoteEditor-VVAY73V2.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-V66AZWPG.js → chunk-53B6NPGA.js} +278 -8
  11. package/dist/chunk-53B6NPGA.js.map +1 -0
  12. package/dist/{chunk-M2EGUO2F.mjs → chunk-J54546YC.mjs} +1608 -1419
  13. package/dist/chunk-J54546YC.mjs.map +1 -0
  14. package/dist/{chunk-TOKHHZSP.js → chunk-JKGEJGSD.js} +7 -7
  15. package/dist/{chunk-TOKHHZSP.js.map → chunk-JKGEJGSD.js.map} +1 -1
  16. package/dist/{chunk-3LVSA7IM.mjs → chunk-PHEFWL6L.mjs} +2 -2
  17. package/dist/{chunk-LCCRUWWY.mjs → chunk-TWXKAY34.mjs} +273 -3
  18. package/dist/chunk-TWXKAY34.mjs.map +1 -0
  19. package/dist/{chunk-GYGMEDVS.js → chunk-UGNLKDI6.js} +1012 -823
  20. package/dist/chunk-UGNLKDI6.js.map +1 -0
  21. package/dist/client/index.js +4 -4
  22. package/dist/client/index.mjs +3 -3
  23. package/dist/components/index.d.mts +16 -1
  24. package/dist/components/index.d.ts +16 -1
  25. package/dist/components/index.js +4 -4
  26. package/dist/components/index.mjs +3 -3
  27. package/dist/{tokenusage-admin.module-5wJ_tZTj.d.ts → config-BIjrg5wd.d.ts} +39 -1
  28. package/dist/{tokenusage-admin.module-DQVT4O4j.d.mts → config-BRUjtCd1.d.mts} +39 -1
  29. package/dist/contexts/index.js +4 -4
  30. package/dist/contexts/index.mjs +3 -3
  31. package/dist/core/index.d.mts +22 -3
  32. package/dist/core/index.d.ts +22 -3
  33. package/dist/core/index.js +12 -2
  34. package/dist/core/index.js.map +1 -1
  35. package/dist/core/index.mjs +11 -1
  36. package/dist/features/help/index.js +37 -37
  37. package/dist/features/help/index.mjs +3 -3
  38. package/dist/features/tokenusage/index.d.mts +413 -14
  39. package/dist/features/tokenusage/index.d.ts +413 -14
  40. package/dist/features/tokenusage/index.js +481 -120
  41. package/dist/features/tokenusage/index.js.map +1 -1
  42. package/dist/features/tokenusage/index.mjs +431 -70
  43. package/dist/features/tokenusage/index.mjs.map +1 -1
  44. package/dist/index.d.mts +3 -3
  45. package/dist/index.d.ts +3 -3
  46. package/dist/index.js +13 -3
  47. package/dist/index.js.map +1 -1
  48. package/dist/index.mjs +12 -2
  49. package/dist/server/index.js +3 -3
  50. package/dist/server/index.mjs +1 -1
  51. package/package.json +1 -1
  52. package/src/client/index.ts +4 -0
  53. package/src/core/index.ts +10 -0
  54. package/src/core/registry/ModuleRegistry.ts +4 -0
  55. package/src/features/administration/components/AdminIndexContainer.tsx +1 -1
  56. package/src/features/administration/components/__tests__/AdminIndexContainer.spec.tsx +1 -1
  57. package/src/features/billing/contexts/PriceContext.tsx +202 -0
  58. package/src/features/billing/contexts/ProductContext.tsx +185 -0
  59. package/src/features/billing/contexts/__tests__/PriceContext.spec.tsx +89 -0
  60. package/src/features/billing/contexts/__tests__/ProductContext.spec.tsx +81 -0
  61. package/src/features/billing/contexts/index.ts +2 -1
  62. package/src/features/billing/i18n-keys.ts +88 -0
  63. package/src/features/billing/stripe-price/components/containers/PriceContainer.tsx +21 -0
  64. package/src/features/billing/stripe-price/components/containers/index.ts +1 -0
  65. package/src/features/billing/stripe-price/components/details/PriceDetails.tsx +131 -0
  66. package/src/features/billing/stripe-price/components/details/index.ts +1 -0
  67. package/src/features/billing/stripe-price/components/forms/PriceArchiver.tsx +55 -0
  68. package/src/features/billing/stripe-price/components/forms/PriceEditor.tsx +307 -374
  69. package/src/features/billing/stripe-price/components/forms/index.ts +3 -1
  70. package/src/features/billing/stripe-price/components/index.ts +2 -0
  71. package/src/features/billing/stripe-price/components/lists/PricesList.tsx +49 -267
  72. package/src/features/billing/stripe-price/data/index.ts +1 -0
  73. package/src/features/billing/stripe-price/data/stripe-price.fields.ts +8 -0
  74. package/src/features/billing/stripe-price/hooks/useStripePriceTableStructure.tsx +119 -0
  75. package/src/features/billing/stripe-price/stripe-price.module.ts +5 -1
  76. package/src/features/billing/stripe-product/components/containers/ProductContainer.tsx +45 -0
  77. package/src/features/billing/stripe-product/components/containers/ProductsListContainer.tsx +27 -0
  78. package/src/features/billing/stripe-product/components/containers/index.ts +2 -2
  79. package/src/features/billing/stripe-product/components/details/ProductDetails.tsx +45 -0
  80. package/src/features/billing/stripe-product/components/details/index.ts +1 -0
  81. package/src/features/billing/stripe-product/components/forms/ProductArchiver.tsx +55 -0
  82. package/src/features/billing/stripe-product/components/forms/ProductEditor.tsx +92 -81
  83. package/src/features/billing/stripe-product/components/forms/index.ts +3 -1
  84. package/src/features/billing/stripe-product/components/index.ts +1 -0
  85. package/src/features/billing/stripe-product/components/lists/ProductsList.tsx +45 -187
  86. package/src/features/billing/stripe-product/data/index.ts +1 -0
  87. package/src/features/billing/stripe-product/data/stripe-product.fields.ts +6 -0
  88. package/src/features/billing/stripe-product/hooks/useStripeProductTableStructure.tsx +80 -0
  89. package/src/features/billing/stripe-product/stripe-product.module.ts +5 -1
  90. package/src/features/tokenusage/components/TokenUsageAdminContainer.tsx +20 -4
  91. package/src/features/tokenusage/components/TokenUsageAdminTiles.tsx +31 -18
  92. package/src/features/tokenusage/components/TokenUsageBreakdownTable.tsx +13 -13
  93. package/src/features/tokenusage/components/TokenUsageRankedBar.tsx +24 -7
  94. package/src/features/tokenusage/components/TokenUsageReportContainer.tsx +102 -0
  95. package/src/features/tokenusage/components/TokenUsageReportFilterBar.tsx +34 -0
  96. package/src/features/tokenusage/components/TokenUsageReportTiles.tsx +161 -0
  97. package/src/features/tokenusage/components/TokenUsageTimelineChart.tsx +15 -13
  98. package/src/features/tokenusage/components/__tests__/TokenUsageAdminTiles.spec.tsx +4 -2
  99. package/src/features/tokenusage/components/__tests__/TokenUsageRankedBar.spec.tsx +3 -1
  100. package/src/features/tokenusage/contexts/TokenUsageAdminContext.tsx +6 -3
  101. package/src/features/tokenusage/contexts/TokenUsageReportContext.tsx +199 -0
  102. package/src/features/tokenusage/data/TokenUsageReportService.ts +65 -0
  103. package/src/features/tokenusage/data/index.ts +9 -0
  104. package/src/features/tokenusage/data/tokenusage-report-breakdown.interface.ts +12 -0
  105. package/src/features/tokenusage/data/tokenusage-report-breakdown.ts +94 -0
  106. package/src/features/tokenusage/data/tokenusage-report-summary.interface.ts +12 -0
  107. package/src/features/tokenusage/data/tokenusage-report-summary.ts +87 -0
  108. package/src/features/tokenusage/data/tokenusage-report-timeline.interface.ts +13 -0
  109. package/src/features/tokenusage/data/tokenusage-report-timeline.ts +94 -0
  110. package/src/features/tokenusage/data/tokenusage-report.types.ts +51 -0
  111. package/src/features/tokenusage/i18n-keys.ts +28 -0
  112. package/src/features/tokenusage/index.ts +18 -0
  113. package/src/features/tokenusage/lib/config.ts +22 -0
  114. package/src/features/tokenusage/lib/formatters.ts +100 -0
  115. package/src/features/tokenusage/lib/metrics.ts +8 -26
  116. package/src/features/tokenusage/lib/palette.ts +41 -14
  117. package/src/features/tokenusage/tokenusage-admin.module.ts +4 -0
  118. package/src/features/tokenusage/tokenusage.modules.ts +40 -0
  119. package/src/shadcnui/ui/chart.tsx +30 -3
  120. package/dist/chunk-GYGMEDVS.js.map +0 -1
  121. package/dist/chunk-LCCRUWWY.mjs.map +0 -1
  122. package/dist/chunk-M2EGUO2F.mjs.map +0 -1
  123. package/dist/chunk-V66AZWPG.js.map +0 -1
  124. package/src/features/billing/contexts/BillingContext.tsx +0 -95
  125. package/src/features/billing/stripe-product/components/containers/ProductsAdminContainer.tsx +0 -98
  126. package/src/features/billing/stripe-product/components/containers/ProductsAdminPageContainer.tsx +0 -27
  127. /package/dist/{BlockNoteEditor-GWAV5ESO.mjs.map → BlockNoteEditor-VVAY73V2.mjs.map} +0 -0
  128. /package/dist/{chunk-3LVSA7IM.mjs.map → chunk-PHEFWL6L.mjs.map} +0 -0
@@ -1,219 +1,118 @@
1
1
  "use client";
2
2
 
3
3
  import { zodResolver } from "@hookform/resolvers/zod";
4
- import { AlertCircle, PlusIcon, XIcon } from "lucide-react";
5
- import { useEffect, useState } from "react";
6
- import { SubmitHandler, useForm } from "react-hook-form";
4
+ import { PlusIcon, XIcon } from "lucide-react";
5
+ import { useTranslations } from "next-intl";
6
+ import { ReactNode, useCallback, useEffect, useMemo, useState } from "react";
7
+ import { useForm } from "react-hook-form";
7
8
  import { v4 } from "uuid";
8
9
  import { z } from "zod";
9
- import { FormCheckbox, FormInput, FormSelect, FormTextarea } from "../../../../../components";
10
- import { CommonEditorButtons } from "../../../../../components/forms/CommonEditorButtons";
11
- import {
12
- Button,
13
- Dialog,
14
- DialogContent,
15
- DialogDescription,
16
- DialogHeader,
17
- DialogTitle,
18
- Form,
19
- Input,
20
- Label,
21
- } from "../../../../../shadcnui";
10
+ import { EditorSheet, FieldLabel, FormCheckbox, FormInput, FormSelect, FormTextarea } from "../../../../../components";
11
+ import { Modules } from "../../../../../core";
12
+ import { useI18nRouter } from "../../../../../i18n";
13
+ import { Alert, AlertDescription, AlertTitle, Button, Checkbox, Input } from "../../../../../shadcnui";
22
14
  import { FeatureInterface, FeatureService } from "../../../../feature";
23
- import { StripePriceInterface, StripePriceService } from "../../data";
15
+ import { priceLabel, usePriceContext } from "../../../contexts/PriceContext";
16
+ import { StripePriceInput, StripePriceInterface } from "../../data/stripe-price.interface";
24
17
 
25
- type PriceEditorProps = {
26
- productId: string;
18
+ export type PriceEditorProps = {
19
+ productId?: string;
27
20
  price?: StripePriceInterface;
28
- open: boolean;
29
- onOpenChange: (open: boolean) => void;
30
- onSuccess: () => void;
21
+ propagateChanges?: (price: StripePriceInterface) => void;
22
+ onSuccess?: () => void | Promise<void>;
23
+ trigger?: ReactNode;
24
+ forceShow?: boolean;
25
+ onClose?: () => void;
26
+ dialogOpen?: boolean;
27
+ onDialogOpenChange?: (open: boolean) => void;
31
28
  };
32
29
 
33
- type PriceFormValues = {
34
- unitAmount: number;
35
- currency: string;
36
- interval: "one_time" | "day" | "week" | "month" | "year";
37
- intervalCount?: number;
38
- usageType?: "licensed" | "metered";
39
- nickname?: string;
40
- active: boolean;
41
- isTrial: boolean;
42
- description?: string;
43
- features: string[];
44
- token: string;
45
- featureIds: string[]; // Platform Feature entity IDs
46
- };
47
-
48
- export function PriceEditor({ productId, price, open, onOpenChange, onSuccess }: PriceEditorProps) {
49
- const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
30
+ function PriceEditorInternal({
31
+ productId,
32
+ price,
33
+ propagateChanges,
34
+ onSuccess,
35
+ trigger,
36
+ forceShow,
37
+ onClose,
38
+ dialogOpen,
39
+ onDialogOpenChange,
40
+ }: PriceEditorProps) {
41
+ const t = useTranslations();
42
+ const router = useI18nRouter();
43
+ const { createPrice, updatePrice, productId: contextProductId } = usePriceContext();
50
44
  const [allFeatures, setAllFeatures] = useState<FeatureInterface[]>([]);
45
+ const isEdit = !!price;
46
+ // NOT `price.productId` — that getter throws when the attribute is absent, and
47
+ // it always is (see the note in PriceContext). Only create mode needs this,
48
+ // and there it arrives as a prop or from the context.
49
+ const targetProductId = productId ?? price?.product?.id ?? contextProductId;
51
50
 
52
- // Fetch all platform features on mount
53
51
  useEffect(() => {
54
52
  const fetchFeatures = async () => {
55
- try {
56
- const features = await FeatureService.findMany({});
57
- setAllFeatures(features);
58
- } catch (error) {
59
- console.error("[PriceEditor] Failed to fetch features:", error);
60
- }
53
+ setAllFeatures(await FeatureService.findMany({}));
61
54
  };
62
- fetchFeatures();
55
+ void fetchFeatures();
63
56
  }, []);
64
57
 
65
- const formSchema = z.object({
66
- unitAmount: z.preprocess(
67
- (val) => (typeof val === "string" ? parseFloat(val) : val),
68
- z.number().min(0, { message: "Amount must be 0 or greater" }),
69
- ),
70
- currency: z.string().min(1, { message: "Currency is required" }),
71
- interval: z.enum(["one_time", "day", "week", "month", "year"]),
72
- intervalCount: z.preprocess(
73
- (val) => (val === "" || val === undefined ? undefined : typeof val === "string" ? parseInt(val, 10) : val),
74
- z.number().min(1).optional(),
75
- ),
76
- usageType: z.enum(["licensed", "metered"]).optional(),
77
- nickname: z.string().optional(),
78
- active: z.boolean(),
79
- isTrial: z.boolean(),
80
- description: z.string().optional(),
81
- features: z.array(z.string()),
82
- token: z.string(),
83
- featureIds: z.array(z.string()),
84
- });
85
-
86
- const isEditMode = !!price;
87
-
88
- // Convert cents to dollars for display
89
- const defaultUnitAmount = price?.unitAmount ? price.unitAmount / 100 : 0;
90
-
91
- // Get core feature IDs that should always be selected
92
- const coreFeatureIds = allFeatures.filter((f) => f.isCore).map((f) => f.id);
58
+ const formSchema = useMemo(
59
+ () =>
60
+ z.object({
61
+ unitAmount: z.preprocess(
62
+ (value) => (typeof value === "string" ? parseFloat(value) : value),
63
+ z.number().min(0, { message: t("billing.admin.prices.errors.amount") }),
64
+ ),
65
+ currency: z.string().min(1, { message: t("billing.admin.prices.errors.currency") }),
66
+ interval: z.enum(["one_time", "day", "week", "month", "year"]),
67
+ intervalCount: z.preprocess(
68
+ (value) =>
69
+ value === "" || value === undefined ? undefined : typeof value === "string" ? parseInt(value, 10) : value,
70
+ z.number().min(1).optional(),
71
+ ),
72
+ usageType: z.enum(["licensed", "metered"]).optional(),
73
+ nickname: z.string().optional(),
74
+ isTrial: z.boolean(),
75
+ description: z.string().optional(),
76
+ features: z.array(z.string()),
77
+ token: z.string(),
78
+ featureIds: z.array(z.string()),
79
+ }),
80
+ [t],
81
+ );
93
82
 
94
- // Combine existing price features with core features (ensure no duplicates)
95
- const defaultFeatureIds = [...new Set([...(price?.priceFeatures?.map((f) => f.id) ?? []), ...coreFeatureIds])];
83
+ type PriceFormValues = z.infer<typeof formSchema>;
84
+
85
+ // Fed to BOTH useForm and EditorSheet.onReset. The previous implementation
86
+ // reseeded with its own `useEffect(… form.reset)` on open; EditorSheet already
87
+ // owns that (EditorSheet.tsx:163-190) and running both fights over the form.
88
+ //
89
+ // `active` is absent on purpose: archiving is the single deactivation path
90
+ // (PriceArchiver), and a second toggle here would let the two disagree.
91
+ const getDefaultValues = useCallback((): PriceFormValues => {
92
+ const coreFeatureIds = allFeatures.filter((feature) => feature.isCore).map((feature) => feature.id);
93
+ return {
94
+ unitAmount: price?.unitAmount ? price.unitAmount / 100 : 0,
95
+ currency: price?.currency ?? "usd",
96
+ interval: price?.priceType === "one_time" ? "one_time" : (price?.recurring?.interval ?? "month"),
97
+ intervalCount: price?.recurring?.intervalCount ?? 1,
98
+ usageType: price?.recurring?.usageType ?? "licensed",
99
+ nickname: price?.nickname ?? "",
100
+ isTrial: price?.isTrial ?? false,
101
+ description: price?.description ?? "",
102
+ features: price?.features ?? [],
103
+ token: price?.token?.toString() ?? "",
104
+ featureIds: [...new Set([...(price?.priceFeatures?.map((f) => f.id) ?? []), ...coreFeatureIds])],
105
+ };
106
+ }, [price, allFeatures]);
96
107
 
97
108
  const form = useForm<PriceFormValues>({
98
109
  resolver: zodResolver(formSchema) as any,
99
- defaultValues: {
100
- unitAmount: defaultUnitAmount,
101
- currency: price?.currency || "usd",
102
- interval: price?.priceType === "one_time" ? "one_time" : price?.recurring?.interval || "month",
103
- intervalCount: price?.recurring?.intervalCount || 1,
104
- usageType: price?.recurring?.usageType || "licensed",
105
- nickname: price?.nickname || "",
106
- active: price?.active ?? true,
107
- isTrial: price?.isTrial ?? false,
108
- description: price?.description || "",
109
- features: price?.features || [],
110
- token: price?.token?.toString() ?? "",
111
- featureIds: defaultFeatureIds,
112
- },
110
+ defaultValues: getDefaultValues(),
113
111
  });
114
112
 
115
- // Reset form when dialog opens to ensure fresh state
116
- useEffect(() => {
117
- if (open) {
118
- // Recalculate core feature IDs with current allFeatures
119
- const currentCoreFeatureIds = allFeatures.filter((f) => f.isCore).map((f) => f.id);
120
- const resetFeatureIds = [
121
- ...new Set([...(price?.priceFeatures?.map((f) => f.id) ?? []), ...currentCoreFeatureIds]),
122
- ];
123
-
124
- form.reset({
125
- unitAmount: price?.unitAmount ? price.unitAmount / 100 : 0,
126
- currency: price?.currency || "usd",
127
- interval: price?.priceType === "one_time" ? "one_time" : price?.recurring?.interval || "month",
128
- intervalCount: price?.recurring?.intervalCount || 1,
129
- usageType: price?.recurring?.usageType || "licensed",
130
- nickname: price?.nickname || "",
131
- active: price?.active ?? true,
132
- isTrial: price?.isTrial ?? false,
133
- description: price?.description || "",
134
- features: price?.features || [],
135
- token: price?.token?.toString() ?? "",
136
- featureIds: resetFeatureIds,
137
- });
138
- }
139
- }, [open, price?.id, allFeatures]);
140
-
141
113
  const watchInterval = form.watch("interval");
142
114
  const isRecurring = watchInterval !== "one_time";
143
115
 
144
- const onSubmit: SubmitHandler<PriceFormValues> = async (values) => {
145
- setIsSubmitting(true);
146
-
147
- try {
148
- // Convert dollars to cents
149
- const unitAmountInCents = Math.round(values.unitAmount * 100);
150
-
151
- if (isEditMode) {
152
- // Update existing price (nickname, description, features, token, isTrial can be updated - Stripe fields are limited)
153
- await StripePriceService.updatePrice({
154
- id: price.id,
155
- nickname: values.nickname || undefined,
156
- description: values.description || undefined,
157
- features: values.features.filter((f) => f.trim()) || undefined,
158
- token: values.token ? parseInt(values.token, 10) : undefined,
159
- // Only include isTrial and featureIds for recurring prices
160
- ...(price?.priceType === "recurring" ? { isTrial: values.isTrial, featureIds: values.featureIds } : {}),
161
- });
162
- } else {
163
- // Create new price
164
- const createInput: any = {
165
- id: v4(),
166
- productId: productId,
167
- currency: values.currency,
168
- unitAmount: unitAmountInCents,
169
- };
170
-
171
- // Add recurring details if interval is not one_time
172
- if (isRecurring) {
173
- createInput.recurring = {
174
- interval: values.interval as "day" | "week" | "month" | "year",
175
- intervalCount: values.intervalCount || 1,
176
- usageType: values.usageType || "licensed",
177
- };
178
- }
179
-
180
- if (values.nickname) {
181
- createInput.nickname = values.nickname;
182
- }
183
-
184
- if (values.description) {
185
- createInput.description = values.description;
186
- }
187
-
188
- const filteredFeatures = values.features.filter((f) => f.trim());
189
- if (filteredFeatures.length > 0) {
190
- createInput.features = filteredFeatures;
191
- }
192
-
193
- if (values.token) {
194
- createInput.token = parseInt(values.token, 10);
195
- }
196
-
197
- // Add isTrial and platform feature IDs only for recurring prices (Neo4j only, not sent to Stripe)
198
- if (isRecurring) {
199
- createInput.isTrial = values.isTrial;
200
- if (values.featureIds.length > 0) {
201
- createInput.featureIds = values.featureIds;
202
- }
203
- }
204
-
205
- await StripePriceService.createPrice(createInput);
206
- }
207
-
208
- onSuccess();
209
- onOpenChange(false);
210
- } catch (error) {
211
- console.error("[PriceEditor] Failed to save price:", error);
212
- } finally {
213
- setIsSubmitting(false);
214
- }
215
- };
216
-
217
116
  const currencyOptions = [
218
117
  { id: "usd", text: "USD ($)" },
219
118
  { id: "eur", text: "EUR (€)" },
@@ -221,208 +120,242 @@ export function PriceEditor({ productId, price, open, onOpenChange, onSuccess }:
221
120
  ];
222
121
 
223
122
  const intervalOptions = [
224
- { id: "one_time", text: "One-time" },
225
- { id: "day", text: "Daily" },
226
- { id: "week", text: "Weekly" },
227
- { id: "month", text: "Monthly" },
228
- { id: "year", text: "Yearly" },
123
+ { id: "one_time", text: t("billing.admin.prices.interval.one_time") },
124
+ { id: "day", text: t("billing.admin.prices.interval.day") },
125
+ { id: "week", text: t("billing.admin.prices.interval.week") },
126
+ { id: "month", text: t("billing.admin.prices.interval.month") },
127
+ { id: "year", text: t("billing.admin.prices.interval.year") },
229
128
  ];
230
129
 
231
130
  const usageTypeOptions = [
232
- { id: "licensed", text: "Licensed (per unit)" },
233
- { id: "metered", text: "Metered (usage-based)" },
131
+ { id: "licensed", text: t("billing.admin.prices.usage.licensed") },
132
+ { id: "metered", text: t("billing.admin.prices.usage.metered") },
234
133
  ];
235
134
 
236
135
  return (
237
- <Dialog open={open} onOpenChange={onOpenChange}>
238
- <DialogContent className="max-w-2xl">
239
- <DialogHeader>
240
- <DialogTitle>{isEditMode ? "Edit Price" : "Create Price"}</DialogTitle>
241
- <DialogDescription>
242
- {isEditMode
243
- ? "Update the price details. Note: Only nickname and active status can be changed."
244
- : "Create a new price for this product"}
245
- </DialogDescription>
246
- </DialogHeader>
247
-
248
- {isEditMode && (
249
- <div className="bg-primary/10 border-primary/30 flex gap-x-3 rounded-lg border p-4">
250
- <AlertCircle className="text-primary mt-0.5 h-5 w-5 flex-shrink-0" />
251
- <div className="text-foreground text-sm">
252
- <p className="font-semibold mb-1">Stripe Price Immutability</p>
253
- <p>
254
- Due to Stripe's architecture, only the nickname and active status can be modified after creation. To
255
- change amount, currency, or billing interval, create a new price.
256
- </p>
257
- </div>
258
- </div>
259
- )}
260
-
261
- <Form {...form}>
262
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
263
- <div className="grid grid-cols-2 gap-x-4">
264
- <FormInput
265
- form={form}
266
- id="unitAmount"
267
- name="Amount (in dollars)"
268
- placeholder="9.99"
269
- disabled={isEditMode}
270
- isRequired
271
- />
272
-
273
- <FormSelect form={form} id="currency" name="Currency" values={currencyOptions} disabled={isEditMode} />
274
- </div>
275
-
276
- <FormSelect
277
- form={form}
278
- id="interval"
279
- name="Billing Interval"
280
- values={intervalOptions}
281
- disabled={isEditMode}
282
- />
136
+ <EditorSheet
137
+ form={form}
138
+ entityType={t("billing.admin.prices.entity")}
139
+ entityName={price ? priceLabel(price) : undefined}
140
+ isEdit={isEdit}
141
+ module={Modules.StripePrice}
142
+ size="lg"
143
+ propagateChanges={propagateChanges}
144
+ onSuccess={onSuccess}
145
+ onNavigate={(url) => router.push(url)}
146
+ onSubmit={async (values) => {
147
+ const trimmedFeatures = values.features.filter((feature) => feature.trim());
148
+
149
+ if (isEdit) {
150
+ const patch: StripePriceInput = {
151
+ id: price.id,
152
+ nickname: values.nickname || undefined,
153
+ description: values.description || undefined,
154
+ features: trimmedFeatures.length > 0 ? trimmedFeatures : undefined,
155
+ token: values.token ? parseInt(values.token, 10) : undefined,
156
+ // Stripe one-time prices carry neither a trial flag nor platform
157
+ // features, so those two only travel for recurring prices.
158
+ ...(price.priceType === "recurring" ? { isTrial: values.isTrial, featureIds: values.featureIds } : {}),
159
+ };
160
+ return await updatePrice(patch);
161
+ }
283
162
 
284
- {isRecurring && (
285
- <div className="grid grid-cols-2 gap-x-4">
286
- <FormInput
287
- form={form}
288
- id="intervalCount"
289
- name="Interval Count"
290
- placeholder="1"
291
- type="number"
292
- disabled={isEditMode}
293
- />
163
+ if (!targetProductId) throw new Error("PriceEditor requires a productId to create a price");
294
164
 
295
- <FormSelect
296
- form={form}
297
- id="usageType"
298
- name="Usage Type"
299
- values={usageTypeOptions}
300
- disabled={isEditMode}
301
- />
302
- </div>
303
- )}
165
+ const payload: StripePriceInput = {
166
+ id: v4(),
167
+ productId: targetProductId,
168
+ currency: values.currency,
169
+ unitAmount: Math.round(values.unitAmount * 100),
170
+ };
171
+ if (isRecurring) {
172
+ payload.recurring = {
173
+ interval: values.interval as "day" | "week" | "month" | "year",
174
+ intervalCount: values.intervalCount ?? 1,
175
+ usageType: values.usageType ?? "licensed",
176
+ };
177
+ payload.isTrial = values.isTrial;
178
+ if (values.featureIds.length > 0) payload.featureIds = values.featureIds;
179
+ }
180
+ if (values.nickname) payload.nickname = values.nickname;
181
+ if (values.description) payload.description = values.description;
182
+ if (trimmedFeatures.length > 0) payload.features = trimmedFeatures;
183
+ if (values.token) payload.token = parseInt(values.token, 10);
184
+
185
+ return await createPrice(payload);
186
+ }}
187
+ onReset={getDefaultValues}
188
+ trigger={trigger}
189
+ forceShow={forceShow}
190
+ onClose={onClose}
191
+ dialogOpen={dialogOpen}
192
+ onDialogOpenChange={onDialogOpenChange}
193
+ >
194
+ <div className="flex w-full flex-col gap-y-4">
195
+ {isEdit && (
196
+ <Alert>
197
+ <AlertTitle>{t("billing.admin.prices.immutability.title")}</AlertTitle>
198
+ <AlertDescription>{t("billing.admin.prices.immutability.description")}</AlertDescription>
199
+ </Alert>
200
+ )}
304
201
 
202
+ <div className="grid grid-cols-2 gap-x-4">
203
+ {/* type="decimal", NOT type="currency": the currency variant hardcodes
204
+ a Euro symbol (FormInput.tsx:105-106), which contradicts the
205
+ currency selector sitting next to it. */}
206
+ <FormInput
207
+ form={form}
208
+ id="unitAmount"
209
+ type="decimal"
210
+ name={t("billing.admin.prices.fields.amount")}
211
+ placeholder={t("billing.admin.prices.placeholders.amount")}
212
+ disabled={isEdit}
213
+ isRequired
214
+ />
215
+ <FormSelect
216
+ form={form}
217
+ id="currency"
218
+ name={t("billing.admin.prices.fields.currency")}
219
+ values={currencyOptions}
220
+ disabled={isEdit}
221
+ />
222
+ </div>
223
+
224
+ <FormSelect
225
+ form={form}
226
+ id="interval"
227
+ name={t("billing.admin.prices.fields.interval")}
228
+ values={intervalOptions}
229
+ disabled={isEdit}
230
+ />
231
+
232
+ {isRecurring && (
233
+ <div className="grid grid-cols-2 gap-x-4">
305
234
  <FormInput
306
235
  form={form}
307
- id="nickname"
308
- name="Nickname (optional)"
309
- placeholder="e.g., Standard Plan, Pro Tier"
236
+ id="intervalCount"
237
+ type="number"
238
+ name={t("billing.admin.prices.fields.intervalCount")}
239
+ placeholder={t("billing.admin.prices.placeholders.intervalCount")}
240
+ disabled={isEdit}
310
241
  />
311
-
312
- <FormTextarea
242
+ <FormSelect
313
243
  form={form}
314
- id="description"
315
- name="Description (optional)"
316
- placeholder="Describe what this price tier includes..."
317
- className="min-h-24"
244
+ id="usageType"
245
+ name={t("billing.admin.prices.fields.usageType")}
246
+ values={usageTypeOptions}
247
+ disabled={isEdit}
318
248
  />
249
+ </div>
250
+ )}
319
251
 
320
- <FormInput form={form} id="token" name="Token (optional)" placeholder="Enter token value" />
321
-
322
- {/* Features List */}
323
- <div className="space-y-2">
324
- <Label>Features (optional)</Label>
325
- <div className="space-y-2">
326
- {form.watch("features").map((_, index) => (
327
- <div key={index} className="flex gap-2">
328
- <Input
329
- {...form.register(`features.${index}`)}
330
- placeholder={`Feature ${index + 1}`}
331
- className="flex-1"
332
- />
333
- <Button
334
- type="button"
335
- variant="outline"
336
- size="icon"
337
- onClick={() => {
338
- const currentFeatures = form.getValues("features");
339
- form.setValue(
340
- "features",
341
- currentFeatures.filter((_, i) => i !== index),
342
- );
252
+ <FormInput
253
+ form={form}
254
+ id="nickname"
255
+ name={t("billing.admin.prices.fields.nickname")}
256
+ placeholder={t("billing.admin.prices.placeholders.nickname")}
257
+ />
258
+
259
+ <FormTextarea
260
+ form={form}
261
+ id="description"
262
+ name={t("billing.admin.prices.fields.description")}
263
+ placeholder={t("billing.admin.prices.placeholders.description")}
264
+ className="min-h-24"
265
+ />
266
+
267
+ <FormInput
268
+ form={form}
269
+ id="token"
270
+ type="number"
271
+ name={t("billing.admin.prices.fields.token")}
272
+ placeholder={t("billing.admin.prices.placeholders.token")}
273
+ />
274
+
275
+ <div className="flex flex-col gap-y-2">
276
+ <FieldLabel>{t("billing.admin.prices.fields.features")}</FieldLabel>
277
+ {form.watch("features").map((_, index) => (
278
+ <div key={index} className="flex gap-2">
279
+ <Input
280
+ {...form.register(`features.${index}`)}
281
+ placeholder={t("billing.admin.prices.placeholders.feature", { index: index + 1 })}
282
+ className="flex-1"
283
+ />
284
+ <Button
285
+ type="button"
286
+ variant="outline"
287
+ size="icon"
288
+ aria-label={t("billing.admin.prices.actions.removeFeature")}
289
+ onClick={() =>
290
+ form.setValue(
291
+ "features",
292
+ form.getValues("features").filter((_, i) => i !== index),
293
+ )
294
+ }
295
+ >
296
+ <XIcon />
297
+ </Button>
298
+ </div>
299
+ ))}
300
+ <Button
301
+ type="button"
302
+ variant="outline"
303
+ size="sm"
304
+ className="self-start"
305
+ onClick={() => form.setValue("features", [...form.getValues("features"), ""])}
306
+ >
307
+ <PlusIcon />
308
+ {t("billing.admin.prices.actions.addFeature")}
309
+ </Button>
310
+ </div>
311
+
312
+ {isRecurring && allFeatures.length > 0 && (
313
+ <div className="flex flex-col gap-y-2">
314
+ <FieldLabel>{t("billing.admin.prices.fields.platformFeatures")}</FieldLabel>
315
+ <div className="flex max-h-48 flex-col gap-y-2 overflow-y-auto rounded-md border p-4">
316
+ {allFeatures.map((feature) => {
317
+ const isChecked = form.watch("featureIds").includes(feature.id);
318
+ return (
319
+ <div key={feature.id} className="flex items-center gap-x-2">
320
+ <Checkbox
321
+ id={`feature-${feature.id}`}
322
+ checked={isChecked}
323
+ disabled={feature.isCore}
324
+ onCheckedChange={(checked) => {
325
+ const current = form.getValues("featureIds");
326
+ if (checked) form.setValue("featureIds", [...new Set([...current, feature.id])]);
327
+ else if (!feature.isCore)
328
+ form.setValue(
329
+ "featureIds",
330
+ current.filter((id) => id !== feature.id),
331
+ );
343
332
  }}
344
- >
345
- <XIcon className="h-4 w-4" />
346
- </Button>
333
+ />
334
+ <FieldLabel htmlFor={`feature-${feature.id}`}>{feature.name}</FieldLabel>
335
+ {/* Typography role 12 (caption) — token colour, never text-gray-*. */}
336
+ {feature.isCore && (
337
+ <span className="text-muted-foreground text-xs">{t("billing.admin.prices.help.core")}</span>
338
+ )}
347
339
  </div>
348
- ))}
349
- <Button
350
- type="button"
351
- variant="outline"
352
- size="sm"
353
- onClick={() => {
354
- const currentFeatures = form.getValues("features");
355
- form.setValue("features", [...currentFeatures, ""]);
356
- }}
357
- className="mt-2"
358
- >
359
- <PlusIcon className="h-4 w-4 mr-2" />
360
- Add Feature
361
- </Button>
362
- </div>
340
+ );
341
+ })}
363
342
  </div>
343
+ </div>
344
+ )}
364
345
 
365
- {/* Platform Features Checkbox List - Only show for recurring prices */}
366
- {isRecurring && allFeatures.length > 0 && (
367
- <div className="space-y-2">
368
- <Label>Platform Features</Label>
369
- <div className="border rounded-md p-4 space-y-2 max-h-48 overflow-y-auto">
370
- {allFeatures.map((feature) => {
371
- const isCore = feature.isCore;
372
- const isChecked = form.watch("featureIds").includes(feature.id);
373
-
374
- return (
375
- <div key={feature.id} className="flex items-center space-x-2">
376
- <input
377
- type="checkbox"
378
- id={`feature-${feature.id}`}
379
- checked={isChecked}
380
- disabled={isCore}
381
- onChange={(e) => {
382
- const currentIds = form.getValues("featureIds");
383
- if (e.target.checked) {
384
- form.setValue("featureIds", [...currentIds, feature.id]);
385
- } else {
386
- // Don't allow unchecking core features
387
- if (!isCore) {
388
- form.setValue(
389
- "featureIds",
390
- currentIds.filter((id) => id !== feature.id),
391
- );
392
- }
393
- }
394
- }}
395
- className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-50"
396
- />
397
- <label
398
- htmlFor={`feature-${feature.id}`}
399
- className={`text-sm ${isCore ? "text-muted-foreground" : ""}`}
400
- >
401
- {feature.name}
402
- {isCore && <span className="ml-2 text-xs text-muted-foreground">(Core - Required)</span>}
403
- </label>
404
- </div>
405
- );
406
- })}
407
- </div>
408
- </div>
409
- )}
410
-
411
- <FormCheckbox form={form} id="active" name="Active" />
412
-
413
- {isRecurring && (
414
- <FormCheckbox
415
- form={form}
416
- id="isTrial"
417
- name="Trial Price"
418
- description="Mark this as the trial subscription plan (only one price should be marked as trial)"
419
- />
420
- )}
421
-
422
- <CommonEditorButtons isEdit={isEditMode} form={form} disabled={isSubmitting} setOpen={onOpenChange} />
423
- </form>
424
- </Form>
425
- </DialogContent>
426
- </Dialog>
346
+ {isRecurring && (
347
+ <FormCheckbox
348
+ form={form}
349
+ id="isTrial"
350
+ name={t("billing.admin.prices.fields.trial")}
351
+ description={t("billing.admin.prices.help.trial")}
352
+ />
353
+ )}
354
+ </div>
355
+ </EditorSheet>
427
356
  );
428
357
  }
358
+
359
+ export default function PriceEditor(props: PriceEditorProps) {
360
+ return <PriceEditorInternal {...props} />;
361
+ }