@carlonicora/nextjs-jsonapi 5.6.0 → 5.7.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 (87) hide show
  1. package/dist/{BlockNoteEditor-NEYG5L6W.mjs → BlockNoteEditor-R3WXUFHX.mjs} +4 -4
  2. package/dist/{BlockNoteEditor-AE3ZTEEH.js → BlockNoteEditor-ZHXXFWUJ.js} +19 -19
  3. package/dist/{BlockNoteEditor-AE3ZTEEH.js.map → BlockNoteEditor-ZHXXFWUJ.js.map} +1 -1
  4. package/dist/{BlockNoteViewer-3OUT5UFY.mjs → BlockNoteViewer-BVVN6D3T.mjs} +2 -2
  5. package/dist/{BlockNoteViewer-4CFKQM7U.js → BlockNoteViewer-SD3ZFIXD.js} +3 -3
  6. package/dist/{BlockNoteViewer-4CFKQM7U.js.map → BlockNoteViewer-SD3ZFIXD.js.map} +1 -1
  7. package/dist/billing/index.d.mts +33 -43
  8. package/dist/billing/index.d.ts +33 -43
  9. package/dist/billing/index.js +590 -574
  10. package/dist/billing/index.js.map +1 -1
  11. package/dist/billing/index.mjs +28 -12
  12. package/dist/billing/index.mjs.map +1 -1
  13. package/dist/{chunk-3I5AAJXV.js → chunk-2TXE57LE.js} +3 -3
  14. package/dist/{chunk-3I5AAJXV.js.map → chunk-2TXE57LE.js.map} +1 -1
  15. package/dist/{chunk-HKZAESS6.js → chunk-5TH2DSAH.js} +17 -12
  16. package/dist/chunk-5TH2DSAH.js.map +1 -0
  17. package/dist/{chunk-SFYPXCK4.mjs → chunk-CFB6FHIY.mjs} +638 -536
  18. package/dist/{chunk-SFYPXCK4.mjs.map → chunk-CFB6FHIY.mjs.map} +1 -1
  19. package/dist/{chunk-EYA2F6AN.mjs → chunk-FK26NX3S.mjs} +16 -11
  20. package/dist/chunk-FK26NX3S.mjs.map +1 -0
  21. package/dist/{chunk-53BAULDP.js → chunk-IJME5SAM.js} +4 -4
  22. package/dist/{chunk-53BAULDP.js.map → chunk-IJME5SAM.js.map} +1 -1
  23. package/dist/{chunk-KQHBONL4.mjs → chunk-OKCV4GPJ.mjs} +2 -2
  24. package/dist/{chunk-BTPUYUQ3.mjs → chunk-RGQYFSJF.mjs} +2 -2
  25. package/dist/{chunk-Y7RR57EW.mjs → chunk-WCHLCOZG.mjs} +2 -2
  26. package/dist/{chunk-RL3PL3KV.js → chunk-XOSC3FFP.js} +8 -8
  27. package/dist/{chunk-RL3PL3KV.js.map → chunk-XOSC3FFP.js.map} +1 -1
  28. package/dist/{chunk-LQ4DK4JF.js → chunk-ZCOPOO7T.js} +1314 -1212
  29. package/dist/chunk-ZCOPOO7T.js.map +1 -0
  30. package/dist/client/index.js +4 -4
  31. package/dist/client/index.mjs +3 -3
  32. package/dist/components/index.js +4 -4
  33. package/dist/components/index.mjs +3 -3
  34. package/dist/contexts/index.js +4 -4
  35. package/dist/contexts/index.mjs +3 -3
  36. package/dist/core/index.d.mts +9 -3
  37. package/dist/core/index.d.ts +9 -3
  38. package/dist/core/index.js +10 -2
  39. package/dist/core/index.js.map +1 -1
  40. package/dist/core/index.mjs +9 -1
  41. package/dist/{format-options-D6c7FNDi.d.mts → currency-DT_dveBx.d.mts} +41 -8
  42. package/dist/{format-options-BtX3o0Yu.d.ts → currency-pK9eRYRC.d.ts} +41 -8
  43. package/dist/email-automations/index.js +92 -92
  44. package/dist/email-automations/index.mjs +3 -3
  45. package/dist/features/analytics/index.js +121 -121
  46. package/dist/features/analytics/index.mjs +4 -4
  47. package/dist/features/analytics/server.js +4 -4
  48. package/dist/features/analytics/server.mjs +2 -2
  49. package/dist/features/blog/index.js +150 -150
  50. package/dist/features/blog/index.mjs +3 -3
  51. package/dist/features/blog/server.js +16 -16
  52. package/dist/features/blog/server.mjs +1 -1
  53. package/dist/features/help/index.js +37 -37
  54. package/dist/features/help/index.mjs +3 -3
  55. package/dist/features/tokenusage/index.js +124 -124
  56. package/dist/features/tokenusage/index.mjs +4 -4
  57. package/dist/index.d.mts +2 -2
  58. package/dist/index.d.ts +2 -2
  59. package/dist/index.js +11 -3
  60. package/dist/index.js.map +1 -1
  61. package/dist/index.mjs +10 -2
  62. package/dist/server/index.js +3 -3
  63. package/dist/server/index.mjs +2 -2
  64. package/package.json +1 -1
  65. package/src/core/index.ts +3 -0
  66. package/src/features/billing/contexts/CouponContext.tsx +0 -2
  67. package/src/features/billing/stripe-coupon/components/details/CouponDetails.tsx +5 -2
  68. package/src/features/billing/stripe-coupon/components/forms/CouponCodeEditor.tsx +6 -5
  69. package/src/features/billing/stripe-coupon/components/forms/CouponCodeFields.tsx +67 -45
  70. package/src/features/billing/stripe-coupon/components/forms/CouponEditor.tsx +304 -212
  71. package/src/features/billing/stripe-coupon/components/widgets/CouponCodeActions.tsx +4 -3
  72. package/src/features/billing/stripe-coupon/data/stripe-coupon.interface.ts +11 -7
  73. package/src/features/billing/stripe-coupon/data/stripe-coupon.ts +6 -4
  74. package/src/features/billing/stripe-coupon/hooks/useStripeCouponCodeTableStructure.tsx +14 -7
  75. package/src/features/billing/stripe-coupon/hooks/useStripeCouponTableStructure.tsx +2 -2
  76. package/src/features/billing/stripe-coupon/stripe-coupon-code.module.ts +3 -2
  77. package/src/features/billing/stripe-coupon/stripe-coupon.module.ts +11 -4
  78. package/src/features/billing/stripe-promotion-code/components/PromoCodeInput.tsx +1 -1
  79. package/src/features/billing/stripe-subscription/hooks/wizard-promotion.ts +15 -8
  80. package/dist/chunk-EYA2F6AN.mjs.map +0 -1
  81. package/dist/chunk-HKZAESS6.js.map +0 -1
  82. package/dist/chunk-LQ4DK4JF.js.map +0 -1
  83. /package/dist/{BlockNoteEditor-NEYG5L6W.mjs.map → BlockNoteEditor-R3WXUFHX.mjs.map} +0 -0
  84. /package/dist/{BlockNoteViewer-3OUT5UFY.mjs.map → BlockNoteViewer-BVVN6D3T.mjs.map} +0 -0
  85. /package/dist/{chunk-KQHBONL4.mjs.map → chunk-OKCV4GPJ.mjs.map} +0 -0
  86. /package/dist/{chunk-BTPUYUQ3.mjs.map → chunk-RGQYFSJF.mjs.map} +0 -0
  87. /package/dist/{chunk-Y7RR57EW.mjs.map → chunk-WCHLCOZG.mjs.map} +0 -0
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { zodResolver } from "@hookform/resolvers/zod";
4
4
  import { useTranslations } from "next-intl";
5
- import { ReactNode, useCallback, useMemo } from "react";
5
+ import { ReactNode, useCallback, useMemo, useState } from "react";
6
6
  import { useForm } from "react-hook-form";
7
7
  import { v4 } from "uuid";
8
8
  import { z } from "zod";
@@ -21,7 +21,13 @@ import { formatDate, useBillingFormatOptions } from "../../../components/utils";
21
21
  import { useCouponContext } from "../../../contexts/CouponContext";
22
22
  import { StripeProductInterface } from "../../../stripe-product/data/stripe-product.interface";
23
23
  import { StripeProductService } from "../../../stripe-product/data/stripe-product.service";
24
- import { StripeCouponInput, StripeCouponInterface } from "../../data/stripe-coupon.interface";
24
+ import {
25
+ StripeCouponDiscountInput,
26
+ StripeCouponDurationInput,
27
+ StripeCouponInput,
28
+ StripeCouponInterface,
29
+ } from "../../data/stripe-coupon.interface";
30
+ import { STRIPE_COUPON_DETAIL_PAGE_URL } from "../../stripe-coupon.module";
25
31
  import { formatCouponDiscount } from "../../utils/format-coupon-discount";
26
32
  import {
27
33
  CouponCodeFields,
@@ -31,6 +37,8 @@ import {
31
37
  optionalDate,
32
38
  optionalDecimal,
33
39
  optionalPositiveInt,
40
+ parseOptionalDecimal,
41
+ parseOptionalPositiveInt,
34
42
  toCouponCodeInput,
35
43
  } from "./CouponCodeFields";
36
44
 
@@ -45,7 +53,115 @@ export type CouponEditorProps = {
45
53
  onDialogOpenChange?: (open: boolean) => void;
46
54
  };
47
55
 
48
- function CouponEditorInternal({
56
+ type Translator = (key: string) => string;
57
+
58
+ /**
59
+ * Edit mode is a rename: Stripe allows changing only a coupon's name after
60
+ * creation, so the schema is the name alone.
61
+ */
62
+ const couponRenameSchema = (t: Translator) =>
63
+ z.object({ id: z.string(), name: z.string().min(1, { message: t("billing.admin.coupons.errors.name") }) });
64
+
65
+ type CouponRenameValues = z.infer<ReturnType<typeof couponRenameSchema>>;
66
+
67
+ /** Mirrors StripeCouponPostAttributesDTO. */
68
+ const couponCreateSchema = (t: Translator) =>
69
+ z
70
+ .object({
71
+ id: z.uuidv4(),
72
+ name: z.string().min(1, { message: t("billing.admin.coupons.errors.name") }),
73
+ stripeCouponId: z.string().optional(),
74
+ discountType: z.enum(["percent_off", "amount_off"]),
75
+ percentOff: optionalDecimal(),
76
+ amountOff: optionalDecimal(),
77
+ currency: z.string().min(1),
78
+ duration: z.enum(["once", "repeating", "forever"]),
79
+ durationInMonths: optionalPositiveInt(),
80
+ products: z.array(z.object({ id: z.string(), name: z.string().optional() })),
81
+ redeemBy: optionalDate(),
82
+ maxRedemptions: optionalPositiveInt(),
83
+ firstCode: couponCodeSchema(t),
84
+ })
85
+ .superRefine((values, ctx) => {
86
+ const percentOff = parseOptionalDecimal(values.percentOff);
87
+ if (values.discountType === "percent_off" && (percentOff === undefined || percentOff < 0.01 || percentOff > 100))
88
+ ctx.addIssue({
89
+ code: "custom",
90
+ path: ["percentOff"],
91
+ message: t("billing.admin.coupons.errors.percentOff"),
92
+ });
93
+ const amountOff = parseOptionalDecimal(values.amountOff);
94
+ if (values.discountType === "amount_off" && (amountOff === undefined || amountOff <= 0))
95
+ ctx.addIssue({
96
+ code: "custom",
97
+ path: ["amountOff"],
98
+ message: t("billing.admin.coupons.errors.amountOff"),
99
+ });
100
+ if (values.duration === "repeating" && parseOptionalPositiveInt(values.durationInMonths) === undefined)
101
+ ctx.addIssue({
102
+ code: "custom",
103
+ path: ["durationInMonths"],
104
+ message: t("billing.admin.coupons.errors.durationInMonths"),
105
+ });
106
+ });
107
+
108
+ type CouponCreateValues = z.infer<ReturnType<typeof couponCreateSchema>>;
109
+
110
+ /** The schema guarantees the value; a missing one is a bug, not user input. */
111
+ function required<T>(value: T | undefined, field: string): T {
112
+ if (value === undefined) throw new Error(`${field} is not set`);
113
+ return value;
114
+ }
115
+
116
+ function toDiscountInput(values: CouponCreateValues): StripeCouponDiscountInput {
117
+ if (values.discountType === "percent_off")
118
+ return { discountType: "percent_off", percentOff: required(parseOptionalDecimal(values.percentOff), "percentOff") };
119
+
120
+ return {
121
+ discountType: "amount_off",
122
+ amountOff: Math.round(required(parseOptionalDecimal(values.amountOff), "amountOff") * 100),
123
+ currency: values.currency,
124
+ };
125
+ }
126
+
127
+ function toDurationInput(values: CouponCreateValues): StripeCouponDurationInput {
128
+ if (values.duration === "repeating")
129
+ return {
130
+ duration: "repeating",
131
+ durationInMonths: required(parseOptionalPositiveInt(values.durationInMonths), "durationInMonths"),
132
+ };
133
+
134
+ return { duration: values.duration };
135
+ }
136
+
137
+ /** Form values → the coupon the model serialises. Unset fields are left out. */
138
+ function toCouponInput(values: CouponCreateValues): StripeCouponInput {
139
+ const payload: StripeCouponInput = {
140
+ id: values.id,
141
+ name: values.name,
142
+ firstCode: toCouponCodeInput(values.firstCode),
143
+ ...toDiscountInput(values),
144
+ ...toDurationInput(values),
145
+ };
146
+ if (values.stripeCouponId?.trim()) payload.stripeCouponId = values.stripeCouponId.trim();
147
+ if (values.products.length > 0) payload.productIds = values.products.map((product) => product.id);
148
+ if (values.redeemBy) payload.redeemBy = values.redeemBy;
149
+ const maxRedemptions = parseOptionalPositiveInt(values.maxRedemptions);
150
+ if (maxRedemptions !== undefined) payload.maxRedemptions = maxRedemptions;
151
+ return payload;
152
+ }
153
+
154
+ /**
155
+ * The module with the coupon's own route as pageUrl: the module's pageUrl is the
156
+ * products page (the coupons list is its Codes tab), so EditorSheet's
157
+ * navigate / revalidate after a save would otherwise land on
158
+ * `/administration/products/<couponId>`.
159
+ */
160
+ function couponPageModule() {
161
+ return { ...Modules.StripeCoupon, pageUrl: STRIPE_COUPON_DETAIL_PAGE_URL };
162
+ }
163
+
164
+ function CouponRenameEditor({
49
165
  coupon,
50
166
  propagateChanges,
51
167
  onSuccess,
@@ -54,85 +170,116 @@ function CouponEditorInternal({
54
170
  onClose,
55
171
  dialogOpen,
56
172
  onDialogOpenChange,
57
- }: CouponEditorProps) {
173
+ }: CouponEditorProps & { coupon: StripeCouponInterface }) {
58
174
  const t = useTranslations();
59
175
  const fmt = useBillingFormatOptions();
60
176
  const router = useI18nRouter();
61
- const { createCoupon, renameCoupon } = useCouponContext();
62
- const isEdit = !!coupon;
63
-
64
- // Mirrors StripeCouponPostAttributesDTO. Edit mode is a rename: Stripe allows
65
- // changing only a coupon's name after creation, so the schema is the name alone.
66
- const formSchema = useMemo(() => {
67
- const name = z.string().min(1, { message: t("billing.admin.coupons.errors.name") });
68
- if (isEdit) return z.object({ id: z.string(), name });
69
-
70
- return z
71
- .object({
72
- id: z.uuidv4(),
73
- name,
74
- stripeCouponId: z.string().optional(),
75
- discountType: z.enum(["percent_off", "amount_off"]),
76
- percentOff: optionalDecimal(),
77
- amountOff: optionalDecimal(),
78
- currency: z.string().min(1),
79
- duration: z.enum(["once", "repeating", "forever"]),
80
- durationInMonths: optionalPositiveInt(),
81
- products: z.array(z.object({ id: z.string(), name: z.string().optional() })),
82
- redeemBy: optionalDate(),
83
- maxRedemptions: optionalPositiveInt(),
84
- firstCode: couponCodeSchema(t),
85
- })
86
- .superRefine((values, ctx) => {
87
- if (
88
- values.discountType === "percent_off" &&
89
- (values.percentOff === undefined || values.percentOff < 0.01 || values.percentOff > 100)
90
- )
91
- ctx.addIssue({
92
- code: "custom",
93
- path: ["percentOff"],
94
- message: t("billing.admin.coupons.errors.percentOff"),
95
- });
96
- if (values.discountType === "amount_off" && (values.amountOff === undefined || values.amountOff <= 0))
97
- ctx.addIssue({
98
- code: "custom",
99
- path: ["amountOff"],
100
- message: t("billing.admin.coupons.errors.amountOff"),
101
- });
102
- if (values.duration === "repeating" && values.durationInMonths === undefined)
103
- ctx.addIssue({
104
- code: "custom",
105
- path: ["durationInMonths"],
106
- message: t("billing.admin.coupons.errors.durationInMonths"),
107
- });
108
- });
109
- }, [t, isEdit]);
177
+ const { renameCoupon } = useCouponContext();
178
+
179
+ const formSchema = useMemo(() => couponRenameSchema(t), [t]);
180
+
181
+ const getDefaultValues = useCallback((): CouponRenameValues => ({ id: coupon.id, name: coupon.name }), [coupon]);
182
+
183
+ const form = useForm<CouponRenameValues>({
184
+ resolver: zodResolver(formSchema),
185
+ defaultValues: getDefaultValues(),
186
+ });
187
+
188
+ return (
189
+ <EditorSheet
190
+ form={form}
191
+ entityType={t("billing.admin.coupons.entity")}
192
+ entityName={coupon.name}
193
+ isEdit
194
+ module={couponPageModule()}
195
+ size="lg"
196
+ propagateChanges={propagateChanges}
197
+ onSuccess={onSuccess}
198
+ onNavigate={(url) => router.push(url)}
199
+ onSubmit={async (values) => await renameCoupon({ id: coupon.id, name: values.name })}
200
+ onReset={getDefaultValues}
201
+ trigger={trigger}
202
+ forceShow={forceShow}
203
+ onClose={onClose}
204
+ dialogOpen={dialogOpen}
205
+ onDialogOpenChange={onDialogOpenChange}
206
+ >
207
+ <div className="flex w-full flex-col gap-y-4">
208
+ <FormInput
209
+ form={form}
210
+ id="name"
211
+ name={t("billing.admin.coupons.fields.name")}
212
+ placeholder={t("billing.admin.coupons.placeholders.name")}
213
+ isRequired
214
+ />
215
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
216
+ <DetailField
217
+ label={t("billing.admin.coupons.fields.discount")}
218
+ value={<span className="tabular-nums">{formatCouponDiscount(coupon, t, fmt)}</span>}
219
+ />
220
+ <DetailField
221
+ label={t("billing.admin.coupons.fields.redeemBy")}
222
+ value={<span className="tabular-nums">{coupon.redeemBy ? formatDate(coupon.redeemBy, fmt) : "—"}</span>}
223
+ />
224
+ <DetailField
225
+ label={t("billing.admin.coupons.fields.maxRedemptions")}
226
+ value={
227
+ <span className="tabular-nums">
228
+ {coupon.maxRedemptions ?? t("billing.admin.coupons.places.no_limit")}
229
+ </span>
230
+ }
231
+ />
232
+ <DetailField
233
+ label={t("billing.admin.coupons.fields.stripeId")}
234
+ value={<span className="tabular-nums">{coupon.stripeCouponId}</span>}
235
+ />
236
+ </div>
237
+ {/* Typography role 10 (helper text). */}
238
+ <p className="text-muted-foreground text-xs/relaxed">{t("billing.admin.coupons.fixed_notice")}</p>
239
+ </div>
240
+ </EditorSheet>
241
+ );
242
+ }
243
+
244
+ function CouponCreateEditor({
245
+ propagateChanges,
246
+ onSuccess,
247
+ trigger,
248
+ forceShow,
249
+ onClose,
250
+ dialogOpen,
251
+ onDialogOpenChange,
252
+ }: Omit<CouponEditorProps, "coupon">) {
253
+ const t = useTranslations();
254
+ const router = useI18nRouter();
255
+ const { createCoupon } = useCouponContext();
256
+ // Read once on mount: render stays pure.
257
+ const [now] = useState(() => new Date());
258
+
259
+ const formSchema = useMemo(() => couponCreateSchema(t), [t]);
110
260
 
111
261
  const getDefaultValues = useCallback(
112
- (): any =>
113
- coupon
114
- ? { id: coupon.id, name: coupon.name }
115
- : {
116
- id: v4(),
117
- name: "",
118
- stripeCouponId: "",
119
- discountType: "percent_off",
120
- percentOff: "",
121
- amountOff: "",
122
- currency: "eur",
123
- duration: "forever",
124
- // "" not undefined for every FormInput: see couponCodeDefaults.
125
- durationInMonths: "",
126
- products: [],
127
- redeemBy: undefined,
128
- maxRedemptions: "",
129
- firstCode: couponCodeDefaults(),
130
- },
131
- [coupon],
262
+ (): CouponCreateValues => ({
263
+ id: v4(),
264
+ name: "",
265
+ stripeCouponId: "",
266
+ discountType: "percent_off",
267
+ percentOff: "",
268
+ amountOff: "",
269
+ currency: "eur",
270
+ duration: "forever",
271
+ // "" not undefined for every FormInput: see couponCodeDefaults.
272
+ durationInMonths: "",
273
+ products: [],
274
+ redeemBy: undefined,
275
+ maxRedemptions: "",
276
+ firstCode: couponCodeDefaults(),
277
+ }),
278
+ [],
132
279
  );
133
280
 
134
- const form = useForm<any>({
135
- resolver: zodResolver(formSchema as any) as any,
281
+ const form = useForm<CouponCreateValues>({
282
+ resolver: zodResolver(formSchema),
136
283
  defaultValues: getDefaultValues(),
137
284
  });
138
285
 
@@ -154,36 +301,13 @@ function CouponEditorInternal({
154
301
  <EditorSheet
155
302
  form={form}
156
303
  entityType={t("billing.admin.coupons.entity")}
157
- entityName={coupon?.name}
158
- isEdit={isEdit}
159
- module={Modules.StripeCoupon}
304
+ isEdit={false}
305
+ module={couponPageModule()}
160
306
  size="lg"
161
307
  propagateChanges={propagateChanges}
162
308
  onSuccess={onSuccess}
163
309
  onNavigate={(url) => router.push(url)}
164
- onSubmit={async (values: any) => {
165
- if (isEdit) return await renameCoupon({ id: coupon.id, name: values.name });
166
-
167
- const payload: StripeCouponInput = {
168
- id: values.id,
169
- name: values.name,
170
- discountType: values.discountType,
171
- duration: values.duration,
172
- firstCode: toCouponCodeInput(values.firstCode),
173
- };
174
- if (values.stripeCouponId?.trim()) payload.stripeCouponId = values.stripeCouponId.trim();
175
- if (values.discountType === "percent_off") payload.percentOff = values.percentOff;
176
- else {
177
- payload.amountOff = Math.round(values.amountOff * 100);
178
- payload.currency = values.currency;
179
- }
180
- if (values.duration === "repeating") payload.durationInMonths = values.durationInMonths;
181
- if (values.products.length > 0) payload.productIds = values.products.map((p: { id: string }) => p.id);
182
- if (values.redeemBy) payload.redeemBy = values.redeemBy;
183
- if (values.maxRedemptions !== undefined) payload.maxRedemptions = values.maxRedemptions;
184
-
185
- return await createCoupon(payload);
186
- }}
310
+ onSubmit={async (values) => await createCoupon(toCouponInput(values))}
187
311
  onReset={getDefaultValues}
188
312
  trigger={trigger}
189
313
  forceShow={forceShow}
@@ -199,135 +323,103 @@ function CouponEditorInternal({
199
323
  placeholder={t("billing.admin.coupons.placeholders.name")}
200
324
  isRequired
201
325
  />
326
+ <FormInput
327
+ form={form}
328
+ id="stripeCouponId"
329
+ name={t("billing.admin.coupons.fields.stripeCouponId")}
330
+ placeholder={t("billing.admin.coupons.placeholders.stripeCouponId")}
331
+ />
202
332
 
203
- {isEdit ? (
204
- <>
205
- <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
206
- <DetailField
207
- label={t("billing.admin.coupons.fields.discount")}
208
- value={formatCouponDiscount(coupon, t, fmt)}
209
- />
210
- <DetailField
211
- label={t("billing.admin.coupons.fields.redeemBy")}
212
- value={coupon.redeemBy ? formatDate(coupon.redeemBy, fmt) : "—"}
213
- />
214
- <DetailField
215
- label={t("billing.admin.coupons.fields.maxRedemptions")}
216
- value={
217
- <span className="tabular-nums">
218
- {coupon.maxRedemptions ?? t("billing.admin.coupons.places.no_limit")}
219
- </span>
220
- }
221
- />
222
- <DetailField
223
- label={t("billing.admin.coupons.fields.stripeId")}
224
- value={<span className="tabular-nums">{coupon.stripeCouponId}</span>}
225
- />
226
- </div>
227
- {/* Typography role 10 (helper text). */}
228
- <p className="text-muted-foreground text-xs/relaxed">{t("billing.admin.coupons.fixed_notice")}</p>
229
- </>
333
+ <FormSelect
334
+ form={form}
335
+ id="discountType"
336
+ name={t("billing.admin.coupons.fields.discountType")}
337
+ values={discountTypeOptions}
338
+ />
339
+
340
+ {watchDiscountType === "percent_off" ? (
341
+ <FormInput
342
+ form={form}
343
+ id="percentOff"
344
+ type="decimal"
345
+ name={t("billing.admin.coupons.fields.percentOff")}
346
+ placeholder={t("billing.admin.coupons.placeholders.percentOff")}
347
+ isRequired
348
+ />
230
349
  ) : (
231
- <>
350
+ <div className="grid grid-cols-2 gap-x-4">
351
+ {/* type="decimal", NOT type="currency": the currency variant hardcodes
352
+ a Euro symbol (FormInput.tsx:105-106), which contradicts the
353
+ currency selector sitting next to it. */}
232
354
  <FormInput
233
355
  form={form}
234
- id="stripeCouponId"
235
- name={t("billing.admin.coupons.fields.stripeCouponId")}
236
- placeholder={t("billing.admin.coupons.placeholders.stripeCouponId")}
356
+ id="amountOff"
357
+ type="decimal"
358
+ name={t("billing.admin.coupons.fields.amountOff")}
359
+ placeholder={t("billing.admin.coupons.placeholders.amountOff")}
360
+ isRequired
237
361
  />
238
-
239
362
  <FormSelect
240
363
  form={form}
241
- id="discountType"
242
- name={t("billing.admin.coupons.fields.discountType")}
243
- values={discountTypeOptions}
364
+ id="currency"
365
+ name={t("billing.admin.coupons.fields.currency")}
366
+ values={couponCurrencyOptions}
367
+ isRequired
244
368
  />
369
+ </div>
370
+ )}
245
371
 
246
- {watchDiscountType === "percent_off" ? (
247
- <FormInput
248
- form={form}
249
- id="percentOff"
250
- type="decimal"
251
- name={t("billing.admin.coupons.fields.percentOff")}
252
- placeholder={t("billing.admin.coupons.placeholders.percentOff")}
253
- isRequired
254
- />
255
- ) : (
256
- <div className="grid grid-cols-2 gap-x-4">
257
- {/* type="decimal", NOT type="currency": the currency variant hardcodes
258
- a Euro symbol (FormInput.tsx:105-106), which contradicts the
259
- currency selector sitting next to it. */}
260
- <FormInput
261
- form={form}
262
- id="amountOff"
263
- type="decimal"
264
- name={t("billing.admin.coupons.fields.amountOff")}
265
- placeholder={t("billing.admin.coupons.placeholders.amountOff")}
266
- isRequired
267
- />
268
- <FormSelect
269
- form={form}
270
- id="currency"
271
- name={t("billing.admin.coupons.fields.currency")}
272
- values={couponCurrencyOptions}
273
- isRequired
274
- />
275
- </div>
276
- )}
372
+ <FormSelect
373
+ form={form}
374
+ id="duration"
375
+ name={t("billing.admin.coupons.fields.duration")}
376
+ values={durationOptions}
377
+ />
277
378
 
278
- <FormSelect
279
- form={form}
280
- id="duration"
281
- name={t("billing.admin.coupons.fields.duration")}
282
- values={durationOptions}
283
- />
379
+ {watchDuration === "repeating" && (
380
+ <FormInput
381
+ form={form}
382
+ id="durationInMonths"
383
+ type="number"
384
+ name={t("billing.admin.coupons.fields.durationInMonths")}
385
+ placeholder={t("billing.admin.coupons.placeholders.durationInMonths")}
386
+ isRequired
387
+ />
388
+ )}
284
389
 
285
- {watchDuration === "repeating" && (
286
- <FormInput
287
- form={form}
288
- id="durationInMonths"
289
- type="number"
290
- name={t("billing.admin.coupons.fields.durationInMonths")}
291
- placeholder={t("billing.admin.coupons.placeholders.durationInMonths")}
292
- isRequired
293
- />
294
- )}
295
-
296
- <EntityMultiSelector<StripeProductInterface>
297
- id="products"
298
- form={form}
299
- label={t("billing.admin.coupons.fields.products")}
300
- description={t("billing.admin.coupons.help.products")}
301
- retriever={(params) => StripeProductService.listProducts(params)}
302
- module={Modules.StripeProduct}
303
- getLabel={(product) => product.name}
304
- />
390
+ <EntityMultiSelector<StripeProductInterface>
391
+ id="products"
392
+ form={form}
393
+ label={t("billing.admin.coupons.fields.products")}
394
+ description={t("billing.admin.coupons.help.products")}
395
+ retriever={(params) => StripeProductService.listProducts(params)}
396
+ module={Modules.StripeProduct}
397
+ getLabel={(product) => product.name}
398
+ />
305
399
 
306
- <FormDateTime
307
- form={form}
308
- id="redeemBy"
309
- name={t("billing.admin.coupons.fields.redeemBy")}
310
- minDate={new Date()}
311
- allowEmpty
312
- />
400
+ <FormDateTime
401
+ form={form}
402
+ id="redeemBy"
403
+ name={t("billing.admin.coupons.fields.redeemBy")}
404
+ minDate={now}
405
+ allowEmpty
406
+ />
313
407
 
314
- <FormInput
315
- form={form}
316
- id="maxRedemptions"
317
- type="number"
318
- name={t("billing.admin.coupons.fields.maxRedemptions")}
319
- placeholder={t("billing.admin.coupons.placeholders.maxRedemptions")}
320
- />
408
+ <FormInput
409
+ form={form}
410
+ id="maxRedemptions"
411
+ type="number"
412
+ name={t("billing.admin.coupons.fields.maxRedemptions")}
413
+ placeholder={t("billing.admin.coupons.placeholders.maxRedemptions")}
414
+ />
321
415
 
322
- <SectionHeader>{t("billing.admin.coupons.first_code")}</SectionHeader>
323
- <CouponCodeFields form={form} prefix="firstCode." />
324
- </>
325
- )}
416
+ <SectionHeader>{t("billing.admin.coupons.first_code")}</SectionHeader>
417
+ <CouponCodeFields form={form} prefix="firstCode." />
326
418
  </div>
327
419
  </EditorSheet>
328
420
  );
329
421
  }
330
422
 
331
423
  export default function CouponEditor(props: CouponEditorProps) {
332
- return <CouponEditorInternal {...props} />;
424
+ return props.coupon ? <CouponRenameEditor {...props} coupon={props.coupon} /> : <CouponCreateEditor {...props} />;
333
425
  }
@@ -4,7 +4,7 @@ import { PowerIcon, RotateCcwIcon } from "lucide-react";
4
4
  import { useTranslations } from "next-intl";
5
5
  import { useState } from "react";
6
6
  import { errorToast } from "../../../../../components/errors";
7
- import { Button, ConfirmDialog, Switch } from "../../../../../shadcnui";
7
+ import { Button, ConfirmDialog, FieldLabel, Switch } from "../../../../../shadcnui";
8
8
  import { useCouponContext } from "../../../contexts/CouponContext";
9
9
  import { StripeCouponCodeInterface } from "../../data/stripe-coupon-code.interface";
10
10
  import type { CouponCodeStatus } from "../../hooks/useStripeCouponCodeTableStructure";
@@ -33,7 +33,8 @@ export function CouponCodeActions({ code, status }: CouponCodeActionsProps) {
33
33
 
34
34
  return (
35
35
  <div className="flex items-center justify-end gap-x-4">
36
- <label className="flex items-center gap-x-2 text-xs">
36
+ {/* Typography role 9 (field label): FieldLabel wraps the switch, so a click on the text toggles it. */}
37
+ <FieldLabel>
37
38
  <Switch
38
39
  checked={code.automatic}
39
40
  // Switching off is always allowed; switching on only for a live code.
@@ -53,7 +54,7 @@ export function CouponCodeActions({ code, status }: CouponCodeActionsProps) {
53
54
  }}
54
55
  />
55
56
  {t("billing.admin.coupons.fields.automatic")}
56
- </label>
57
+ </FieldLabel>
57
58
  <Button variant="outline" size="sm" disabled={isDead} onClick={() => setOpen(true)}>
58
59
  {isInactive ? <RotateCcwIcon /> : <PowerIcon />}
59
60
  {t(`billing.admin.coupons.codes.${scope}.confirm`)}
@@ -29,21 +29,25 @@ export interface StripeCouponInterface extends ApiDataInterface {
29
29
  // Stripe Coupon Input DTOs
30
30
  // ============================================================================
31
31
 
32
+ /** The discount of a new coupon: a percentage, or a fixed amount in one currency. */
33
+ export type StripeCouponDiscountInput =
34
+ | { discountType: "percent_off"; percentOff: number }
35
+ | { discountType: "amount_off"; amountOff: number; currency: string }; // amountOff in minor units
36
+
37
+ /** How long a new coupon's discount lasts: months only when it repeats. */
38
+ export type StripeCouponDurationInput =
39
+ { duration: "once" } | { duration: "forever" } | { duration: "repeating"; durationInMonths: number };
40
+
32
41
  export type StripeCouponInput = {
33
42
  id: string;
34
43
  name: string;
35
44
  stripeCouponId?: string;
36
- discountType: StripeCouponDiscountType;
37
- percentOff?: number;
38
- amountOff?: number;
39
- currency?: string;
40
- duration: StripeCouponDurationType;
41
- durationInMonths?: number;
42
45
  productIds?: string[]; // our StripeProduct ids
43
46
  redeemBy?: Date;
44
47
  maxRedemptions?: number;
45
48
  firstCode: Omit<StripeCouponCodeInput, "id" | "couponId">;
46
- };
49
+ } & StripeCouponDiscountInput &
50
+ StripeCouponDurationInput;
47
51
 
48
52
  export type StripeCouponRenameInput = {
49
53
  id: string;