@carlonicora/nextjs-jsonapi 5.6.0 → 5.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/dist/{BlockNoteEditor-AE3ZTEEH.js → BlockNoteEditor-ARUXU5AV.js} +19 -19
  2. package/dist/{BlockNoteEditor-AE3ZTEEH.js.map → BlockNoteEditor-ARUXU5AV.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-NEYG5L6W.mjs → BlockNoteEditor-MNNISNVN.mjs} +4 -4
  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 +591 -574
  10. package/dist/billing/index.js.map +1 -1
  11. package/dist/billing/index.mjs +30 -13
  12. package/dist/billing/index.mjs.map +1 -1
  13. package/dist/{chunk-SFYPXCK4.mjs → chunk-2FNSDBJG.mjs} +641 -536
  14. package/dist/chunk-2FNSDBJG.mjs.map +1 -0
  15. package/dist/{chunk-3I5AAJXV.js → chunk-2TXE57LE.js} +3 -3
  16. package/dist/{chunk-3I5AAJXV.js.map → chunk-2TXE57LE.js.map} +1 -1
  17. package/dist/{chunk-LQ4DK4JF.js → chunk-2WK35GO2.js} +1317 -1212
  18. package/dist/chunk-2WK35GO2.js.map +1 -0
  19. package/dist/{chunk-HKZAESS6.js → chunk-5TH2DSAH.js} +17 -12
  20. package/dist/chunk-5TH2DSAH.js.map +1 -0
  21. package/dist/{chunk-EYA2F6AN.mjs → chunk-FK26NX3S.mjs} +16 -11
  22. package/dist/chunk-FK26NX3S.mjs.map +1 -0
  23. package/dist/{chunk-53BAULDP.js → chunk-IJME5SAM.js} +4 -4
  24. package/dist/{chunk-53BAULDP.js.map → chunk-IJME5SAM.js.map} +1 -1
  25. package/dist/{chunk-KQHBONL4.mjs → chunk-OKCV4GPJ.mjs} +2 -2
  26. package/dist/{chunk-BTPUYUQ3.mjs → chunk-RGQYFSJF.mjs} +2 -2
  27. package/dist/{chunk-Y7RR57EW.mjs → chunk-WCHLCOZG.mjs} +2 -2
  28. package/dist/{chunk-RL3PL3KV.js → chunk-XOSC3FFP.js} +8 -8
  29. package/dist/{chunk-RL3PL3KV.js.map → chunk-XOSC3FFP.js.map} +1 -1
  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/containers/CouponContainer.tsx +2 -2
  68. package/src/features/billing/stripe-coupon/components/details/CouponDetails.tsx +5 -2
  69. package/src/features/billing/stripe-coupon/components/forms/CouponCodeEditor.tsx +6 -5
  70. package/src/features/billing/stripe-coupon/components/forms/CouponCodeFields.tsx +67 -45
  71. package/src/features/billing/stripe-coupon/components/forms/CouponEditor.tsx +294 -212
  72. package/src/features/billing/stripe-coupon/components/widgets/CouponCodeActions.tsx +4 -3
  73. package/src/features/billing/stripe-coupon/data/stripe-coupon.interface.ts +11 -7
  74. package/src/features/billing/stripe-coupon/data/stripe-coupon.ts +6 -4
  75. package/src/features/billing/stripe-coupon/hooks/useStripeCouponCodeTableStructure.tsx +14 -7
  76. package/src/features/billing/stripe-coupon/hooks/useStripeCouponTableStructure.tsx +2 -2
  77. package/src/features/billing/stripe-coupon/stripe-coupon-code.module.ts +3 -2
  78. package/src/features/billing/stripe-coupon/stripe-coupon.module.ts +11 -4
  79. package/src/features/billing/stripe-coupon/utils/__tests__/coupon-page-module.spec.ts +32 -0
  80. package/src/features/billing/stripe-coupon/utils/coupon-page-module.ts +13 -0
  81. package/src/features/billing/stripe-promotion-code/components/PromoCodeInput.tsx +1 -1
  82. package/src/features/billing/stripe-subscription/hooks/wizard-promotion.ts +15 -8
  83. package/dist/chunk-EYA2F6AN.mjs.map +0 -1
  84. package/dist/chunk-HKZAESS6.js.map +0 -1
  85. package/dist/chunk-LQ4DK4JF.js.map +0 -1
  86. package/dist/chunk-SFYPXCK4.mjs.map +0 -1
  87. /package/dist/{BlockNoteEditor-NEYG5L6W.mjs.map → BlockNoteEditor-MNNISNVN.mjs.map} +0 -0
  88. /package/dist/{BlockNoteViewer-3OUT5UFY.mjs.map → BlockNoteViewer-BVVN6D3T.mjs.map} +0 -0
  89. /package/dist/{chunk-KQHBONL4.mjs.map → chunk-OKCV4GPJ.mjs.map} +0 -0
  90. /package/dist/{chunk-BTPUYUQ3.mjs.map → chunk-RGQYFSJF.mjs.map} +0 -0
  91. /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 { couponPageModule } from "../../utils/coupon-page-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,105 @@ 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
+ function CouponRenameEditor({
49
155
  coupon,
50
156
  propagateChanges,
51
157
  onSuccess,
@@ -54,85 +160,116 @@ function CouponEditorInternal({
54
160
  onClose,
55
161
  dialogOpen,
56
162
  onDialogOpenChange,
57
- }: CouponEditorProps) {
163
+ }: CouponEditorProps & { coupon: StripeCouponInterface }) {
58
164
  const t = useTranslations();
59
165
  const fmt = useBillingFormatOptions();
60
166
  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]);
167
+ const { renameCoupon } = useCouponContext();
168
+
169
+ const formSchema = useMemo(() => couponRenameSchema(t), [t]);
170
+
171
+ const getDefaultValues = useCallback((): CouponRenameValues => ({ id: coupon.id, name: coupon.name }), [coupon]);
172
+
173
+ const form = useForm<CouponRenameValues>({
174
+ resolver: zodResolver(formSchema),
175
+ defaultValues: getDefaultValues(),
176
+ });
177
+
178
+ return (
179
+ <EditorSheet
180
+ form={form}
181
+ entityType={t("billing.admin.coupons.entity")}
182
+ entityName={coupon.name}
183
+ isEdit
184
+ module={couponPageModule()}
185
+ size="lg"
186
+ propagateChanges={propagateChanges}
187
+ onSuccess={onSuccess}
188
+ onNavigate={(url) => router.push(url)}
189
+ onSubmit={async (values) => await renameCoupon({ id: coupon.id, name: values.name })}
190
+ onReset={getDefaultValues}
191
+ trigger={trigger}
192
+ forceShow={forceShow}
193
+ onClose={onClose}
194
+ dialogOpen={dialogOpen}
195
+ onDialogOpenChange={onDialogOpenChange}
196
+ >
197
+ <div className="flex w-full flex-col gap-y-4">
198
+ <FormInput
199
+ form={form}
200
+ id="name"
201
+ name={t("billing.admin.coupons.fields.name")}
202
+ placeholder={t("billing.admin.coupons.placeholders.name")}
203
+ isRequired
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={<span className="tabular-nums">{formatCouponDiscount(coupon, t, fmt)}</span>}
209
+ />
210
+ <DetailField
211
+ label={t("billing.admin.coupons.fields.redeemBy")}
212
+ value={<span className="tabular-nums">{coupon.redeemBy ? formatDate(coupon.redeemBy, fmt) : "—"}</span>}
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
+ </div>
230
+ </EditorSheet>
231
+ );
232
+ }
233
+
234
+ function CouponCreateEditor({
235
+ propagateChanges,
236
+ onSuccess,
237
+ trigger,
238
+ forceShow,
239
+ onClose,
240
+ dialogOpen,
241
+ onDialogOpenChange,
242
+ }: Omit<CouponEditorProps, "coupon">) {
243
+ const t = useTranslations();
244
+ const router = useI18nRouter();
245
+ const { createCoupon } = useCouponContext();
246
+ // Read once on mount: render stays pure.
247
+ const [now] = useState(() => new Date());
248
+
249
+ const formSchema = useMemo(() => couponCreateSchema(t), [t]);
110
250
 
111
251
  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],
252
+ (): CouponCreateValues => ({
253
+ id: v4(),
254
+ name: "",
255
+ stripeCouponId: "",
256
+ discountType: "percent_off",
257
+ percentOff: "",
258
+ amountOff: "",
259
+ currency: "eur",
260
+ duration: "forever",
261
+ // "" not undefined for every FormInput: see couponCodeDefaults.
262
+ durationInMonths: "",
263
+ products: [],
264
+ redeemBy: undefined,
265
+ maxRedemptions: "",
266
+ firstCode: couponCodeDefaults(),
267
+ }),
268
+ [],
132
269
  );
133
270
 
134
- const form = useForm<any>({
135
- resolver: zodResolver(formSchema as any) as any,
271
+ const form = useForm<CouponCreateValues>({
272
+ resolver: zodResolver(formSchema),
136
273
  defaultValues: getDefaultValues(),
137
274
  });
138
275
 
@@ -154,36 +291,13 @@ function CouponEditorInternal({
154
291
  <EditorSheet
155
292
  form={form}
156
293
  entityType={t("billing.admin.coupons.entity")}
157
- entityName={coupon?.name}
158
- isEdit={isEdit}
159
- module={Modules.StripeCoupon}
294
+ isEdit={false}
295
+ module={couponPageModule()}
160
296
  size="lg"
161
297
  propagateChanges={propagateChanges}
162
298
  onSuccess={onSuccess}
163
299
  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
- }}
300
+ onSubmit={async (values) => await createCoupon(toCouponInput(values))}
187
301
  onReset={getDefaultValues}
188
302
  trigger={trigger}
189
303
  forceShow={forceShow}
@@ -199,135 +313,103 @@ function CouponEditorInternal({
199
313
  placeholder={t("billing.admin.coupons.placeholders.name")}
200
314
  isRequired
201
315
  />
316
+ <FormInput
317
+ form={form}
318
+ id="stripeCouponId"
319
+ name={t("billing.admin.coupons.fields.stripeCouponId")}
320
+ placeholder={t("billing.admin.coupons.placeholders.stripeCouponId")}
321
+ />
202
322
 
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
- </>
323
+ <FormSelect
324
+ form={form}
325
+ id="discountType"
326
+ name={t("billing.admin.coupons.fields.discountType")}
327
+ values={discountTypeOptions}
328
+ />
329
+
330
+ {watchDiscountType === "percent_off" ? (
331
+ <FormInput
332
+ form={form}
333
+ id="percentOff"
334
+ type="decimal"
335
+ name={t("billing.admin.coupons.fields.percentOff")}
336
+ placeholder={t("billing.admin.coupons.placeholders.percentOff")}
337
+ isRequired
338
+ />
230
339
  ) : (
231
- <>
340
+ <div className="grid grid-cols-2 gap-x-4">
341
+ {/* type="decimal", NOT type="currency": the currency variant hardcodes
342
+ a Euro symbol (FormInput.tsx:105-106), which contradicts the
343
+ currency selector sitting next to it. */}
232
344
  <FormInput
233
345
  form={form}
234
- id="stripeCouponId"
235
- name={t("billing.admin.coupons.fields.stripeCouponId")}
236
- placeholder={t("billing.admin.coupons.placeholders.stripeCouponId")}
346
+ id="amountOff"
347
+ type="decimal"
348
+ name={t("billing.admin.coupons.fields.amountOff")}
349
+ placeholder={t("billing.admin.coupons.placeholders.amountOff")}
350
+ isRequired
237
351
  />
238
-
239
352
  <FormSelect
240
353
  form={form}
241
- id="discountType"
242
- name={t("billing.admin.coupons.fields.discountType")}
243
- values={discountTypeOptions}
354
+ id="currency"
355
+ name={t("billing.admin.coupons.fields.currency")}
356
+ values={couponCurrencyOptions}
357
+ isRequired
244
358
  />
359
+ </div>
360
+ )}
245
361
 
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
- )}
362
+ <FormSelect
363
+ form={form}
364
+ id="duration"
365
+ name={t("billing.admin.coupons.fields.duration")}
366
+ values={durationOptions}
367
+ />
277
368
 
278
- <FormSelect
279
- form={form}
280
- id="duration"
281
- name={t("billing.admin.coupons.fields.duration")}
282
- values={durationOptions}
283
- />
369
+ {watchDuration === "repeating" && (
370
+ <FormInput
371
+ form={form}
372
+ id="durationInMonths"
373
+ type="number"
374
+ name={t("billing.admin.coupons.fields.durationInMonths")}
375
+ placeholder={t("billing.admin.coupons.placeholders.durationInMonths")}
376
+ isRequired
377
+ />
378
+ )}
284
379
 
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
- />
380
+ <EntityMultiSelector<StripeProductInterface>
381
+ id="products"
382
+ form={form}
383
+ label={t("billing.admin.coupons.fields.products")}
384
+ description={t("billing.admin.coupons.help.products")}
385
+ retriever={(params) => StripeProductService.listProducts(params)}
386
+ module={Modules.StripeProduct}
387
+ getLabel={(product) => product.name}
388
+ />
305
389
 
306
- <FormDateTime
307
- form={form}
308
- id="redeemBy"
309
- name={t("billing.admin.coupons.fields.redeemBy")}
310
- minDate={new Date()}
311
- allowEmpty
312
- />
390
+ <FormDateTime
391
+ form={form}
392
+ id="redeemBy"
393
+ name={t("billing.admin.coupons.fields.redeemBy")}
394
+ minDate={now}
395
+ allowEmpty
396
+ />
313
397
 
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
- />
398
+ <FormInput
399
+ form={form}
400
+ id="maxRedemptions"
401
+ type="number"
402
+ name={t("billing.admin.coupons.fields.maxRedemptions")}
403
+ placeholder={t("billing.admin.coupons.placeholders.maxRedemptions")}
404
+ />
321
405
 
322
- <SectionHeader>{t("billing.admin.coupons.first_code")}</SectionHeader>
323
- <CouponCodeFields form={form} prefix="firstCode." />
324
- </>
325
- )}
406
+ <SectionHeader>{t("billing.admin.coupons.first_code")}</SectionHeader>
407
+ <CouponCodeFields form={form} prefix="firstCode." />
326
408
  </div>
327
409
  </EditorSheet>
328
410
  );
329
411
  }
330
412
 
331
413
  export default function CouponEditor(props: CouponEditorProps) {
332
- return <CouponEditorInternal {...props} />;
414
+ return props.coupon ? <CouponRenameEditor {...props} coupon={props.coupon} /> : <CouponCreateEditor {...props} />;
333
415
  }
@@ -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;
@@ -124,10 +124,12 @@ export class StripeCoupon extends AbstractApiData implements StripeCouponInterfa
124
124
 
125
125
  const attributes = response.data.attributes;
126
126
  if (data.stripeCouponId) attributes.stripeCouponId = data.stripeCouponId;
127
- if (data.percentOff !== undefined) attributes.percentOff = data.percentOff;
128
- if (data.amountOff !== undefined) attributes.amountOff = data.amountOff;
129
- if (data.currency) attributes.currency = data.currency;
130
- if (data.durationInMonths !== undefined) attributes.durationInMonths = data.durationInMonths;
127
+ if (data.discountType === "percent_off") attributes.percentOff = data.percentOff;
128
+ else {
129
+ attributes.amountOff = data.amountOff;
130
+ attributes.currency = data.currency;
131
+ }
132
+ if (data.duration === "repeating") attributes.durationInMonths = data.durationInMonths;
131
133
  if (data.productIds && data.productIds.length > 0) attributes.productIds = data.productIds;
132
134
  if (data.redeemBy !== undefined) attributes.redeemBy = data.redeemBy.toISOString();
133
135
  if (data.maxRedemptions !== undefined) attributes.maxRedemptions = data.maxRedemptions;