@carlonicora/nextjs-jsonapi 2.5.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-TIMGYKAA.js → BlockNoteEditor-CDTU6DRN.js} +19 -19
- package/dist/{BlockNoteEditor-TIMGYKAA.js.map → BlockNoteEditor-CDTU6DRN.js.map} +1 -1
- package/dist/{BlockNoteEditor-YFJMXJEU.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-RTOC53EZ.js → chunk-53B6NPGA.js} +34 -4
- package/dist/chunk-53B6NPGA.js.map +1 -0
- package/dist/{chunk-G4YS52SO.mjs → chunk-J54546YC.mjs} +1596 -1416
- package/dist/chunk-J54546YC.mjs.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-7HKJNUW7.js → chunk-UGNLKDI6.js} +1000 -820
- 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.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/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-VVAY73V2.mjs.map} +0 -0
- /package/dist/{chunk-DJWNNV52.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 {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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 {
|
|
11
|
-
import {
|
|
12
|
-
|
|
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 {
|
|
15
|
+
import { priceLabel, usePriceContext } from "../../../contexts/PriceContext";
|
|
16
|
+
import { StripePriceInput, StripePriceInterface } from "../../data/stripe-price.interface";
|
|
24
17
|
|
|
25
|
-
type PriceEditorProps = {
|
|
26
|
-
productId
|
|
18
|
+
export type PriceEditorProps = {
|
|
19
|
+
productId?: string;
|
|
27
20
|
price?: StripePriceInterface;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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 =
|
|
66
|
-
|
|
67
|
-
(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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: "
|
|
225
|
-
{ id: "day", text: "
|
|
226
|
-
{ id: "week", text: "
|
|
227
|
-
{ id: "month", text: "
|
|
228
|
-
{ id: "year", text: "
|
|
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: "
|
|
233
|
-
{ id: "metered", text: "
|
|
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
|
-
<
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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="
|
|
308
|
-
|
|
309
|
-
|
|
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="
|
|
315
|
-
name="
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
+
}
|