@carlonicora/nextjs-jsonapi 5.5.0 → 5.6.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 (152) hide show
  1. package/dist/{BlockNoteEditor-THBJH2UR.js → BlockNoteEditor-AE3ZTEEH.js} +19 -19
  2. package/dist/{BlockNoteEditor-THBJH2UR.js.map → BlockNoteEditor-AE3ZTEEH.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-GVQCKRMU.mjs → BlockNoteEditor-NEYG5L6W.mjs} +4 -4
  4. package/dist/{BlockNoteViewer-U6E3DS4F.mjs → BlockNoteViewer-3OUT5UFY.mjs} +2 -2
  5. package/dist/{BlockNoteViewer-VZOVKTFO.js → BlockNoteViewer-4CFKQM7U.js} +3 -3
  6. package/dist/{BlockNoteViewer-VZOVKTFO.js.map → BlockNoteViewer-4CFKQM7U.js.map} +1 -1
  7. package/dist/billing/index.d.mts +186 -25
  8. package/dist/billing/index.d.ts +186 -25
  9. package/dist/billing/index.js +1148 -587
  10. package/dist/billing/index.js.map +1 -1
  11. package/dist/billing/index.mjs +785 -224
  12. package/dist/billing/index.mjs.map +1 -1
  13. package/dist/{chunk-WPYT4UKJ.js → chunk-3I5AAJXV.js} +3 -3
  14. package/dist/{chunk-WPYT4UKJ.js.map → chunk-3I5AAJXV.js.map} +1 -1
  15. package/dist/{chunk-YDYVJM4I.js → chunk-53BAULDP.js} +4 -4
  16. package/dist/{chunk-YDYVJM4I.js.map → chunk-53BAULDP.js.map} +1 -1
  17. package/dist/{chunk-3CGROPF3.mjs → chunk-BTPUYUQ3.mjs} +2 -2
  18. package/dist/{chunk-VC5A3AUZ.mjs → chunk-EYA2F6AN.mjs} +696 -4
  19. package/dist/chunk-EYA2F6AN.mjs.map +1 -0
  20. package/dist/{chunk-G6UE6MEP.js → chunk-HKZAESS6.js} +896 -204
  21. package/dist/chunk-HKZAESS6.js.map +1 -0
  22. package/dist/{chunk-W7F5BDSV.mjs → chunk-KQHBONL4.mjs} +2 -2
  23. package/dist/{chunk-TY5FYP3G.js → chunk-LQ4DK4JF.js} +1919 -1056
  24. package/dist/chunk-LQ4DK4JF.js.map +1 -0
  25. package/dist/{chunk-S4YJ5WCW.js → chunk-RL3PL3KV.js} +8 -8
  26. package/dist/{chunk-S4YJ5WCW.js.map → chunk-RL3PL3KV.js.map} +1 -1
  27. package/dist/{chunk-X5WIUUNG.mjs → chunk-SFYPXCK4.mjs} +3237 -2374
  28. package/dist/chunk-SFYPXCK4.mjs.map +1 -0
  29. package/dist/{chunk-6SQYXUVP.mjs → chunk-Y7RR57EW.mjs} +2 -2
  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 +242 -3
  37. package/dist/core/index.d.ts +242 -3
  38. package/dist/core/index.js +34 -2
  39. package/dist/core/index.js.map +1 -1
  40. package/dist/core/index.mjs +33 -1
  41. package/dist/email-automations/index.js +92 -92
  42. package/dist/email-automations/index.mjs +3 -3
  43. package/dist/features/analytics/index.js +121 -121
  44. package/dist/features/analytics/index.mjs +4 -4
  45. package/dist/features/analytics/server.js +4 -4
  46. package/dist/features/analytics/server.mjs +2 -2
  47. package/dist/features/blog/index.js +150 -150
  48. package/dist/features/blog/index.mjs +3 -3
  49. package/dist/features/blog/server.js +16 -16
  50. package/dist/features/blog/server.mjs +1 -1
  51. package/dist/features/help/index.js +37 -37
  52. package/dist/features/help/index.mjs +3 -3
  53. package/dist/features/tokenusage/index.js +124 -124
  54. package/dist/features/tokenusage/index.mjs +4 -4
  55. package/dist/{stripe-promotion-code.interface-C3qqh3mi.d.ts → format-options-BtX3o0Yu.d.ts} +104 -1
  56. package/dist/{stripe-promotion-code.interface-ClZ7DxS9.d.mts → format-options-D6c7FNDi.d.mts} +104 -1
  57. package/dist/index.d.mts +2 -2
  58. package/dist/index.d.ts +2 -2
  59. package/dist/index.js +35 -3
  60. package/dist/index.js.map +1 -1
  61. package/dist/index.mjs +34 -2
  62. package/dist/server/index.js +3 -3
  63. package/dist/server/index.mjs +2 -2
  64. package/messages/en.json +125 -3
  65. package/messages/it.json +125 -3
  66. package/package.json +1 -1
  67. package/scripts/validate-messages.mjs +1 -0
  68. package/src/billing/index.ts +1 -0
  69. package/src/client/index.ts +4 -0
  70. package/src/core/index.ts +1 -0
  71. package/src/core/registry/ModuleRegistry.ts +3 -0
  72. package/src/features/billing/components/cards/InvoicesSummaryCard.tsx +3 -2
  73. package/src/features/billing/components/cards/SubscriptionSummaryCard.tsx +4 -3
  74. package/src/features/billing/components/containers/BillingDashboardContainer.tsx +22 -5
  75. package/src/features/billing/components/utils/__tests__/discount.spec.ts +59 -0
  76. package/src/features/billing/components/utils/__tests__/url.spec.ts +32 -0
  77. package/src/features/billing/components/utils/discount.ts +38 -0
  78. package/src/features/billing/components/utils/index.ts +2 -0
  79. package/src/features/billing/components/utils/url.ts +12 -0
  80. package/src/features/billing/contexts/CouponContext.tsx +211 -0
  81. package/src/features/billing/contexts/index.ts +1 -0
  82. package/src/features/billing/i18n-keys.ts +79 -0
  83. package/src/features/billing/stripe-coupon/components/containers/CouponContainer.tsx +38 -0
  84. package/src/features/billing/stripe-coupon/components/containers/index.ts +1 -0
  85. package/src/features/billing/stripe-coupon/components/details/CouponDetails.tsx +69 -0
  86. package/src/features/billing/stripe-coupon/components/details/index.ts +1 -0
  87. package/src/features/billing/stripe-coupon/components/forms/CouponCodeEditor.tsx +79 -0
  88. package/src/features/billing/stripe-coupon/components/forms/CouponCodeFields.tsx +192 -0
  89. package/src/features/billing/stripe-coupon/components/forms/CouponEditor.tsx +333 -0
  90. package/src/features/billing/stripe-coupon/components/forms/CouponRefresher.tsx +45 -0
  91. package/src/features/billing/stripe-coupon/components/forms/index.ts +6 -0
  92. package/src/features/billing/stripe-coupon/components/index.ts +5 -0
  93. package/src/features/billing/stripe-coupon/components/lists/CouponCodesList.tsx +61 -0
  94. package/src/features/billing/stripe-coupon/components/lists/CouponsList.tsx +67 -0
  95. package/src/features/billing/stripe-coupon/components/lists/index.ts +2 -0
  96. package/src/features/billing/stripe-coupon/components/widgets/CouponCodeActions.tsx +80 -0
  97. package/src/features/billing/stripe-coupon/components/widgets/index.ts +1 -0
  98. package/src/features/billing/stripe-coupon/data/__tests__/stripe-coupon-models.spec.ts +114 -0
  99. package/src/features/billing/stripe-coupon/data/index.ts +10 -0
  100. package/src/features/billing/stripe-coupon/data/stripe-coupon-code.fields.ts +8 -0
  101. package/src/features/billing/stripe-coupon/data/stripe-coupon-code.interface.ts +44 -0
  102. package/src/features/billing/stripe-coupon/data/stripe-coupon-code.service.ts +92 -0
  103. package/src/features/billing/stripe-coupon/data/stripe-coupon-code.ts +132 -0
  104. package/src/features/billing/stripe-coupon/data/stripe-coupon-offer.interface.ts +17 -0
  105. package/src/features/billing/stripe-coupon/data/stripe-coupon-offer.ts +70 -0
  106. package/src/features/billing/stripe-coupon/data/stripe-coupon.fields.ts +7 -0
  107. package/src/features/billing/stripe-coupon/data/stripe-coupon.interface.ts +51 -0
  108. package/src/features/billing/stripe-coupon/data/stripe-coupon.service.ts +93 -0
  109. package/src/features/billing/stripe-coupon/data/stripe-coupon.ts +165 -0
  110. package/src/features/billing/stripe-coupon/hooks/useStripeCouponCodeTableStructure.tsx +154 -0
  111. package/src/features/billing/stripe-coupon/hooks/useStripeCouponTableStructure.tsx +113 -0
  112. package/src/features/billing/stripe-coupon/index.ts +5 -0
  113. package/src/features/billing/stripe-coupon/stripe-coupon-code.module.ts +14 -0
  114. package/src/features/billing/stripe-coupon/stripe-coupon-offer.module.ts +10 -0
  115. package/src/features/billing/stripe-coupon/stripe-coupon.module.ts +18 -0
  116. package/src/features/billing/stripe-coupon/utils/__tests__/coupon-places.spec.ts +19 -0
  117. package/src/features/billing/stripe-coupon/utils/__tests__/to-promotion-code-result.spec.ts +41 -0
  118. package/src/features/billing/stripe-coupon/utils/coupon-places.ts +23 -0
  119. package/src/features/billing/stripe-coupon/utils/format-coupon-discount.ts +36 -0
  120. package/src/features/billing/stripe-coupon/utils/index.ts +3 -0
  121. package/src/features/billing/stripe-coupon/utils/to-promotion-code-result.ts +23 -0
  122. package/src/features/billing/stripe-customer/components/forms/PaymentMethodForm.tsx +24 -8
  123. package/src/features/billing/stripe-product/components/containers/ProductsListContainer.tsx +21 -6
  124. package/src/features/billing/stripe-promotion-code/components/PromoCodeInput.tsx +60 -19
  125. package/src/features/billing/stripe-promotion-code/data/stripe-promotion-code.interface.ts +2 -0
  126. package/src/features/billing/stripe-subscription/components/containers/SubscriptionsContainer.tsx +24 -5
  127. package/src/features/billing/stripe-subscription/components/lists/SubscriptionsList.tsx +21 -2
  128. package/src/features/billing/stripe-subscription/components/wizards/SubscriptionWizard.tsx +1 -1
  129. package/src/features/billing/stripe-subscription/components/wizards/WizardStepPaymentMethod.tsx +1 -1
  130. package/src/features/billing/stripe-subscription/components/wizards/WizardStepReview.tsx +60 -49
  131. package/src/features/billing/stripe-subscription/components/wizards/__tests__/review-card.spec.ts +34 -0
  132. package/src/features/billing/stripe-subscription/components/wizards/review-card.ts +24 -0
  133. package/src/features/billing/stripe-subscription/data/__tests__/stripe-subscription.discount.spec.ts +67 -0
  134. package/src/features/billing/stripe-subscription/data/stripe-subscription.interface.ts +14 -0
  135. package/src/features/billing/stripe-subscription/data/stripe-subscription.service.ts +39 -0
  136. package/src/features/billing/stripe-subscription/data/stripe-subscription.ts +21 -0
  137. package/src/features/billing/stripe-subscription/hooks/__tests__/wizard-payment.spec.ts +48 -0
  138. package/src/features/billing/stripe-subscription/hooks/__tests__/wizard-promotion.spec.ts +132 -0
  139. package/src/features/billing/stripe-subscription/hooks/useSubscriptionWizard.ts +138 -12
  140. package/src/features/billing/stripe-subscription/hooks/wizard-payment.ts +30 -0
  141. package/src/features/billing/stripe-subscription/hooks/wizard-promotion.ts +54 -0
  142. package/src/features/user/contexts/CurrentUserContext.tsx +25 -0
  143. package/src/features/user/contexts/__tests__/CurrentUserContext.spec.tsx +121 -2
  144. package/dist/chunk-G6UE6MEP.js.map +0 -1
  145. package/dist/chunk-TY5FYP3G.js.map +0 -1
  146. package/dist/chunk-VC5A3AUZ.mjs.map +0 -1
  147. package/dist/chunk-X5WIUUNG.mjs.map +0 -1
  148. /package/dist/{BlockNoteEditor-GVQCKRMU.mjs.map → BlockNoteEditor-NEYG5L6W.mjs.map} +0 -0
  149. /package/dist/{BlockNoteViewer-U6E3DS4F.mjs.map → BlockNoteViewer-3OUT5UFY.mjs.map} +0 -0
  150. /package/dist/{chunk-3CGROPF3.mjs.map → chunk-BTPUYUQ3.mjs.map} +0 -0
  151. /package/dist/{chunk-W7F5BDSV.mjs.map → chunk-KQHBONL4.mjs.map} +0 -0
  152. /package/dist/{chunk-6SQYXUVP.mjs.map → chunk-Y7RR57EW.mjs.map} +0 -0
@@ -0,0 +1,192 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { z } from "zod";
5
+ import { FormCheckbox, FormDateTime, FormInput, FormSelect, FormSwitch } from "../../../../../components";
6
+ import { FieldDescription } from "../../../../../shadcnui";
7
+ import { StripeCouponCodeInput } from "../../data/stripe-coupon-code.interface";
8
+
9
+ /**
10
+ * Optional whole number of at least 1. The fields render as
11
+ * `<FormInput type="number">`, which hands back `+""` (0) for a cleared input,
12
+ * so 0, "" and NaN all mean "not set" — every limit here has a floor of 1.
13
+ */
14
+ export const optionalPositiveInt = () =>
15
+ z.preprocess(
16
+ (value) =>
17
+ value === undefined ||
18
+ value === null ||
19
+ value === "" ||
20
+ value === 0 ||
21
+ (typeof value === "number" && Number.isNaN(value))
22
+ ? undefined
23
+ : typeof value === "string"
24
+ ? parseInt(value, 10)
25
+ : value,
26
+ z.number().int().min(1).optional(),
27
+ );
28
+
29
+ /**
30
+ * Optional decimal in MAJOR units, from `<FormInput type="decimal">` (a string
31
+ * that may use a comma as the separator). Converted to minor units on submit,
32
+ * exactly as PriceEditor does (`Math.round(value * 100)`).
33
+ */
34
+ export const optionalDecimal = () =>
35
+ z.preprocess(
36
+ (value) =>
37
+ typeof value === "string"
38
+ ? value.trim() === ""
39
+ ? undefined
40
+ : parseFloat(value.replace(",", "."))
41
+ : value === null || (typeof value === "number" && Number.isNaN(value))
42
+ ? undefined
43
+ : value,
44
+ z.number().optional(),
45
+ );
46
+
47
+ /** Mirrors StripeCouponCodePostAttributesDTO. */
48
+ /**
49
+ * Optional point in time from `<FormDateTime allowEmpty>`, which writes "" when
50
+ * the value is cleared (FormDateTime.tsx:136, :241).
51
+ */
52
+ export const optionalDate = () =>
53
+ z.preprocess((value) => (value === "" || value === null ? undefined : value), z.date().optional());
54
+
55
+ export const couponCodeSchema = (t: (key: string) => string) =>
56
+ z
57
+ .object({
58
+ code: z.string().optional(),
59
+ firstTimeTransaction: z.boolean(),
60
+ minimumAmount: optionalDecimal(),
61
+ minimumAmountCurrency: z.string().min(1),
62
+ stripeCustomerId: z.string().optional(),
63
+ expiresAt: optionalDate(),
64
+ maxRedemptions: optionalPositiveInt(),
65
+ automatic: z.boolean(),
66
+ })
67
+ .superRefine((values, ctx) => {
68
+ if (values.minimumAmount !== undefined && values.minimumAmount <= 0)
69
+ ctx.addIssue({
70
+ code: "custom",
71
+ path: ["minimumAmount"],
72
+ message: t("billing.admin.coupons.errors.minimumAmount"),
73
+ });
74
+ });
75
+
76
+ export type CouponCodeFormValues = z.infer<ReturnType<typeof couponCodeSchema>>;
77
+
78
+ /**
79
+ * Every text/number input starts as "" (never undefined): FormInput spreads
80
+ * `field.value` onto a Base UI input, and undefined → value flips it from
81
+ * uncontrolled to controlled (console warning). The schema preprocessors map
82
+ * "" back to undefined, so empty fields are still sent as absent.
83
+ */
84
+ export const couponCodeDefaults = (): z.input<ReturnType<typeof couponCodeSchema>> => ({
85
+ code: "",
86
+ firstTimeTransaction: false,
87
+ minimumAmount: "",
88
+ minimumAmountCurrency: "eur",
89
+ stripeCustomerId: "",
90
+ expiresAt: undefined,
91
+ maxRedemptions: "",
92
+ automatic: false,
93
+ });
94
+
95
+ /** Form values → the code attributes the models serialise. Unset fields are left out. */
96
+ export const toCouponCodeInput = (values: CouponCodeFormValues): Omit<StripeCouponCodeInput, "id" | "couponId"> => {
97
+ const input: Omit<StripeCouponCodeInput, "id" | "couponId"> = {
98
+ firstTimeTransaction: values.firstTimeTransaction,
99
+ automatic: values.automatic,
100
+ };
101
+ if (values.code?.trim()) input.code = values.code.trim();
102
+ if (values.minimumAmount !== undefined) {
103
+ input.minimumAmount = Math.round(values.minimumAmount * 100);
104
+ input.minimumAmountCurrency = values.minimumAmountCurrency;
105
+ }
106
+ if (values.stripeCustomerId?.trim()) input.stripeCustomerId = values.stripeCustomerId.trim();
107
+ if (values.expiresAt) input.expiresAt = values.expiresAt;
108
+ if (values.maxRedemptions !== undefined) input.maxRedemptions = values.maxRedemptions;
109
+ return input;
110
+ };
111
+
112
+ export const couponCurrencyOptions = [
113
+ { id: "usd", text: "USD ($)" },
114
+ { id: "eur", text: "EUR (€)" },
115
+ { id: "gbp", text: "GBP (£)" },
116
+ ];
117
+
118
+ type CouponCodeFieldsProps = {
119
+ form: any;
120
+ /** "" for CouponCodeEditor, "firstCode." when nested inside CouponEditor. */
121
+ prefix?: string;
122
+ };
123
+
124
+ /** The fields of a customer-facing code, shared by CouponEditor (first code) and CouponCodeEditor. */
125
+ export function CouponCodeFields({ form, prefix = "" }: CouponCodeFieldsProps) {
126
+ const t = useTranslations();
127
+
128
+ return (
129
+ <div className="flex w-full flex-col gap-y-4">
130
+ <FormInput
131
+ form={form}
132
+ id={`${prefix}code`}
133
+ name={t("billing.admin.coupons.fields.code")}
134
+ placeholder={t("billing.admin.coupons.placeholders.code")}
135
+ />
136
+ {/* The warning sits visibly under the option (spec), not in FormCheckbox's
137
+ hover tooltip — same layout as FormSwitch's description. */}
138
+ <div className="flex w-full flex-col">
139
+ <FormCheckbox
140
+ form={form}
141
+ id={`${prefix}firstTimeTransaction`}
142
+ name={t("billing.admin.coupons.fields.firstTimeTransaction")}
143
+ />
144
+ <FieldDescription>{t("billing.admin.coupons.fields.first_time_warning")}</FieldDescription>
145
+ </div>
146
+ <div className="grid grid-cols-2 gap-x-4">
147
+ {/* type="decimal", NOT type="currency": the currency variant hardcodes
148
+ a Euro symbol (FormInput.tsx:105-106), which contradicts the
149
+ currency selector sitting next to it. */}
150
+ <FormInput
151
+ form={form}
152
+ id={`${prefix}minimumAmount`}
153
+ type="decimal"
154
+ name={t("billing.admin.coupons.fields.minimumAmount")}
155
+ placeholder={t("billing.admin.coupons.placeholders.minimumAmount")}
156
+ />
157
+ <FormSelect
158
+ form={form}
159
+ id={`${prefix}minimumAmountCurrency`}
160
+ name={t("billing.admin.coupons.fields.currency")}
161
+ values={couponCurrencyOptions}
162
+ />
163
+ </div>
164
+ <FormInput
165
+ form={form}
166
+ id={`${prefix}stripeCustomerId`}
167
+ name={t("billing.admin.coupons.fields.stripeCustomerId")}
168
+ placeholder="cus_…"
169
+ />
170
+ <FormDateTime
171
+ form={form}
172
+ id={`${prefix}expiresAt`}
173
+ name={t("billing.admin.coupons.fields.expiresAt")}
174
+ minDate={new Date()}
175
+ allowEmpty
176
+ />
177
+ <FormInput
178
+ form={form}
179
+ id={`${prefix}maxRedemptions`}
180
+ type="number"
181
+ name={t("billing.admin.coupons.fields.codeMaxRedemptions")}
182
+ placeholder={t("billing.admin.coupons.placeholders.maxRedemptions")}
183
+ />
184
+ <FormSwitch
185
+ form={form}
186
+ id={`${prefix}automatic`}
187
+ name={t("billing.admin.coupons.fields.automatic")}
188
+ description={t("billing.admin.coupons.help.automatic")}
189
+ />
190
+ </div>
191
+ );
192
+ }
@@ -0,0 +1,333 @@
1
+ "use client";
2
+
3
+ import { zodResolver } from "@hookform/resolvers/zod";
4
+ import { useTranslations } from "next-intl";
5
+ import { ReactNode, useCallback, useMemo } from "react";
6
+ import { useForm } from "react-hook-form";
7
+ import { v4 } from "uuid";
8
+ import { z } from "zod";
9
+ import {
10
+ DetailField,
11
+ EditorSheet,
12
+ EntityMultiSelector,
13
+ FormDateTime,
14
+ FormInput,
15
+ FormSelect,
16
+ SectionHeader,
17
+ } from "../../../../../components";
18
+ import { Modules } from "../../../../../core";
19
+ import { useI18nRouter } from "../../../../../i18n";
20
+ import { formatDate, useBillingFormatOptions } from "../../../components/utils";
21
+ import { useCouponContext } from "../../../contexts/CouponContext";
22
+ import { StripeProductInterface } from "../../../stripe-product/data/stripe-product.interface";
23
+ import { StripeProductService } from "../../../stripe-product/data/stripe-product.service";
24
+ import { StripeCouponInput, StripeCouponInterface } from "../../data/stripe-coupon.interface";
25
+ import { formatCouponDiscount } from "../../utils/format-coupon-discount";
26
+ import {
27
+ CouponCodeFields,
28
+ couponCodeDefaults,
29
+ couponCodeSchema,
30
+ couponCurrencyOptions,
31
+ optionalDate,
32
+ optionalDecimal,
33
+ optionalPositiveInt,
34
+ toCouponCodeInput,
35
+ } from "./CouponCodeFields";
36
+
37
+ export type CouponEditorProps = {
38
+ coupon?: StripeCouponInterface;
39
+ propagateChanges?: (coupon: StripeCouponInterface) => void;
40
+ onSuccess?: () => void | Promise<void>;
41
+ trigger?: ReactNode;
42
+ forceShow?: boolean;
43
+ onClose?: () => void;
44
+ dialogOpen?: boolean;
45
+ onDialogOpenChange?: (open: boolean) => void;
46
+ };
47
+
48
+ function CouponEditorInternal({
49
+ coupon,
50
+ propagateChanges,
51
+ onSuccess,
52
+ trigger,
53
+ forceShow,
54
+ onClose,
55
+ dialogOpen,
56
+ onDialogOpenChange,
57
+ }: CouponEditorProps) {
58
+ const t = useTranslations();
59
+ const fmt = useBillingFormatOptions();
60
+ 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]);
110
+
111
+ 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],
132
+ );
133
+
134
+ const form = useForm<any>({
135
+ resolver: zodResolver(formSchema as any) as any,
136
+ defaultValues: getDefaultValues(),
137
+ });
138
+
139
+ const watchDiscountType = form.watch("discountType");
140
+ const watchDuration = form.watch("duration");
141
+
142
+ const discountTypeOptions = [
143
+ { id: "percent_off", text: t("billing.admin.coupons.type.percent_off") },
144
+ { id: "amount_off", text: t("billing.admin.coupons.type.amount_off") },
145
+ ];
146
+
147
+ const durationOptions = [
148
+ { id: "once", text: t("billing.admin.coupons.duration.once") },
149
+ { id: "repeating", text: t("billing.admin.coupons.duration.repeating") },
150
+ { id: "forever", text: t("billing.admin.coupons.duration.forever") },
151
+ ];
152
+
153
+ return (
154
+ <EditorSheet
155
+ form={form}
156
+ entityType={t("billing.admin.coupons.entity")}
157
+ entityName={coupon?.name}
158
+ isEdit={isEdit}
159
+ module={Modules.StripeCoupon}
160
+ size="lg"
161
+ propagateChanges={propagateChanges}
162
+ onSuccess={onSuccess}
163
+ 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
+ }}
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
+ <FormInput
196
+ form={form}
197
+ id="name"
198
+ name={t("billing.admin.coupons.fields.name")}
199
+ placeholder={t("billing.admin.coupons.placeholders.name")}
200
+ isRequired
201
+ />
202
+
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
+ </>
230
+ ) : (
231
+ <>
232
+ <FormInput
233
+ form={form}
234
+ id="stripeCouponId"
235
+ name={t("billing.admin.coupons.fields.stripeCouponId")}
236
+ placeholder={t("billing.admin.coupons.placeholders.stripeCouponId")}
237
+ />
238
+
239
+ <FormSelect
240
+ form={form}
241
+ id="discountType"
242
+ name={t("billing.admin.coupons.fields.discountType")}
243
+ values={discountTypeOptions}
244
+ />
245
+
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
+ )}
277
+
278
+ <FormSelect
279
+ form={form}
280
+ id="duration"
281
+ name={t("billing.admin.coupons.fields.duration")}
282
+ values={durationOptions}
283
+ />
284
+
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
+ />
305
+
306
+ <FormDateTime
307
+ form={form}
308
+ id="redeemBy"
309
+ name={t("billing.admin.coupons.fields.redeemBy")}
310
+ minDate={new Date()}
311
+ allowEmpty
312
+ />
313
+
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
+ />
321
+
322
+ <SectionHeader>{t("billing.admin.coupons.first_code")}</SectionHeader>
323
+ <CouponCodeFields form={form} prefix="firstCode." />
324
+ </>
325
+ )}
326
+ </div>
327
+ </EditorSheet>
328
+ );
329
+ }
330
+
331
+ export default function CouponEditor(props: CouponEditorProps) {
332
+ return <CouponEditorInternal {...props} />;
333
+ }
@@ -0,0 +1,45 @@
1
+ "use client";
2
+
3
+ import { RefreshCwIcon } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { useState } from "react";
6
+ import { errorToast } from "../../../../../components/errors";
7
+ import { Button } from "../../../../../shadcnui";
8
+ import { useCouponContext } from "../../../contexts/CouponContext";
9
+ import { StripeCouponInterface } from "../../data/stripe-coupon.interface";
10
+
11
+ type CouponRefresherProps = {
12
+ coupon: StripeCouponInterface;
13
+ };
14
+
15
+ /**
16
+ * Re-reads the coupon and its codes from Stripe and writes the usage counts to
17
+ * our copy. Counts go stale only for subscriptions made outside our endpoints
18
+ * (the Stripe dashboard); this button fixes that.
19
+ */
20
+ export function CouponRefresher({ coupon }: CouponRefresherProps) {
21
+ const t = useTranslations();
22
+ const { refreshCounts } = useCouponContext();
23
+ const [pending, setPending] = useState<boolean>(false);
24
+
25
+ return (
26
+ <Button
27
+ variant="outline"
28
+ size="sm"
29
+ disabled={pending}
30
+ onClick={async () => {
31
+ setPending(true);
32
+ try {
33
+ await refreshCounts(coupon.id);
34
+ } catch (error) {
35
+ errorToast({ title: t("billing.admin.coupons.refresh.title"), error });
36
+ } finally {
37
+ setPending(false);
38
+ }
39
+ }}
40
+ >
41
+ <RefreshCwIcon />
42
+ {t("billing.admin.coupons.refresh.title")}
43
+ </Button>
44
+ );
45
+ }
@@ -0,0 +1,6 @@
1
+ export * from "./CouponCodeFields";
2
+ export * from "./CouponRefresher";
3
+ export { default as CouponEditor } from "./CouponEditor";
4
+ export type { CouponEditorProps } from "./CouponEditor";
5
+ export { default as CouponCodeEditor } from "./CouponCodeEditor";
6
+ export type { CouponCodeEditorProps } from "./CouponCodeEditor";
@@ -0,0 +1,5 @@
1
+ export * from "./containers";
2
+ export * from "./details";
3
+ export * from "./forms";
4
+ export * from "./lists";
5
+ export * from "./widgets";
@@ -0,0 +1,61 @@
1
+ "use client";
2
+
3
+ import { TicketIcon } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { ReactNode, useEffect } from "react";
6
+ import { ContentListTable } from "../../../../../components";
7
+ import { Modules } from "../../../../../core";
8
+ import { DataListRetriever, useDataListRetriever } from "../../../../../hooks";
9
+ import { EmptyState } from "../../../../../shadcnui";
10
+ import { useCouponContext } from "../../../contexts/CouponContext";
11
+ import { StripeCouponCodeFields } from "../../data/stripe-coupon-code.fields";
12
+ import { StripeCouponCodeInterface } from "../../data/stripe-coupon-code.interface";
13
+ import { StripeCouponCodeService } from "../../data/stripe-coupon-code.service";
14
+ import CouponCodeEditor from "../forms/CouponCodeEditor";
15
+
16
+ type CouponCodesListProps = {
17
+ couponId: string;
18
+ };
19
+
20
+ export function CouponCodesList({ couponId }: CouponCodesListProps) {
21
+ const t = useTranslations();
22
+ const { catalogueVersion } = useCouponContext();
23
+
24
+ const data: DataListRetriever<StripeCouponCodeInterface> = useDataListRetriever({
25
+ retriever: (params) => StripeCouponCodeService.listByCoupon({ ...params, couponId }),
26
+ retrieverParams: {},
27
+ module: Modules.StripeCouponCode,
28
+ });
29
+
30
+ // Switching "Apply automatically" on one code clears it on the others in the
31
+ // same server write, so every flag change re-reads the whole list.
32
+ useEffect(() => {
33
+ if (catalogueVersion > 0) void data.refresh();
34
+ }, [catalogueVersion]);
35
+
36
+ const functions: ReactNode[] = [<CouponCodeEditor key="create-code" couponId={couponId} />];
37
+
38
+ return (
39
+ <ContentListTable
40
+ data={data}
41
+ fields={[
42
+ StripeCouponCodeFields.code,
43
+ StripeCouponCodeFields.places,
44
+ StripeCouponCodeFields.expiresAt,
45
+ StripeCouponCodeFields.automatic,
46
+ StripeCouponCodeFields.status,
47
+ StripeCouponCodeFields.actions,
48
+ ]}
49
+ tableGeneratorType={Modules.StripeCouponCode}
50
+ functions={functions}
51
+ title={t("billing.admin.coupons.codes.title")}
52
+ emptyState={
53
+ <EmptyState
54
+ icon={TicketIcon}
55
+ title={t("billing.admin.coupons.codes.empty.title")}
56
+ description={t("billing.admin.coupons.codes.empty.description")}
57
+ />
58
+ }
59
+ />
60
+ );
61
+ }