@carlonicora/nextjs-jsonapi 5.5.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 (152) hide show
  1. package/dist/{BlockNoteEditor-GVQCKRMU.mjs → BlockNoteEditor-R3WXUFHX.mjs} +4 -4
  2. package/dist/{BlockNoteEditor-THBJH2UR.js → BlockNoteEditor-ZHXXFWUJ.js} +19 -19
  3. package/dist/{BlockNoteEditor-THBJH2UR.js.map → BlockNoteEditor-ZHXXFWUJ.js.map} +1 -1
  4. package/dist/{BlockNoteViewer-U6E3DS4F.mjs → BlockNoteViewer-BVVN6D3T.mjs} +2 -2
  5. package/dist/{BlockNoteViewer-VZOVKTFO.js → BlockNoteViewer-SD3ZFIXD.js} +3 -3
  6. package/dist/{BlockNoteViewer-VZOVKTFO.js.map → BlockNoteViewer-SD3ZFIXD.js.map} +1 -1
  7. package/dist/billing/index.d.mts +197 -46
  8. package/dist/billing/index.d.ts +197 -46
  9. package/dist/billing/index.js +1166 -589
  10. package/dist/billing/index.js.map +1 -1
  11. package/dist/billing/index.mjs +803 -226
  12. package/dist/billing/index.mjs.map +1 -1
  13. package/dist/{chunk-WPYT4UKJ.js → chunk-2TXE57LE.js} +3 -3
  14. package/dist/{chunk-WPYT4UKJ.js.map → chunk-2TXE57LE.js.map} +1 -1
  15. package/dist/{chunk-G6UE6MEP.js → chunk-5TH2DSAH.js} +901 -204
  16. package/dist/chunk-5TH2DSAH.js.map +1 -0
  17. package/dist/{chunk-X5WIUUNG.mjs → chunk-CFB6FHIY.mjs} +3342 -2377
  18. package/dist/chunk-CFB6FHIY.mjs.map +1 -0
  19. package/dist/{chunk-VC5A3AUZ.mjs → chunk-FK26NX3S.mjs} +701 -4
  20. package/dist/chunk-FK26NX3S.mjs.map +1 -0
  21. package/dist/{chunk-YDYVJM4I.js → chunk-IJME5SAM.js} +4 -4
  22. package/dist/{chunk-YDYVJM4I.js.map → chunk-IJME5SAM.js.map} +1 -1
  23. package/dist/{chunk-W7F5BDSV.mjs → chunk-OKCV4GPJ.mjs} +2 -2
  24. package/dist/{chunk-3CGROPF3.mjs → chunk-RGQYFSJF.mjs} +2 -2
  25. package/dist/{chunk-6SQYXUVP.mjs → chunk-WCHLCOZG.mjs} +2 -2
  26. package/dist/{chunk-S4YJ5WCW.js → chunk-XOSC3FFP.js} +8 -8
  27. package/dist/{chunk-S4YJ5WCW.js.map → chunk-XOSC3FFP.js.map} +1 -1
  28. package/dist/{chunk-TY5FYP3G.js → chunk-ZCOPOO7T.js} +2024 -1059
  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 +248 -3
  37. package/dist/core/index.d.ts +248 -3
  38. package/dist/core/index.js +42 -2
  39. package/dist/core/index.js.map +1 -1
  40. package/dist/core/index.mjs +41 -1
  41. package/dist/{stripe-promotion-code.interface-ClZ7DxS9.d.mts → currency-DT_dveBx.d.mts} +137 -1
  42. package/dist/{stripe-promotion-code.interface-C3qqh3mi.d.ts → currency-pK9eRYRC.d.ts} +137 -1
  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 +43 -3
  60. package/dist/index.js.map +1 -1
  61. package/dist/index.mjs +42 -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 +4 -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 +209 -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 +72 -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 +80 -0
  88. package/src/features/billing/stripe-coupon/components/forms/CouponCodeFields.tsx +214 -0
  89. package/src/features/billing/stripe-coupon/components/forms/CouponEditor.tsx +425 -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 +81 -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 +55 -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 +167 -0
  110. package/src/features/billing/stripe-coupon/hooks/useStripeCouponCodeTableStructure.tsx +161 -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 +15 -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 +25 -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 +61 -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-R3WXUFHX.mjs.map} +0 -0
  149. /package/dist/{BlockNoteViewer-U6E3DS4F.mjs.map → BlockNoteViewer-BVVN6D3T.mjs.map} +0 -0
  150. /package/dist/{chunk-W7F5BDSV.mjs.map → chunk-OKCV4GPJ.mjs.map} +0 -0
  151. /package/dist/{chunk-3CGROPF3.mjs.map → chunk-RGQYFSJF.mjs.map} +0 -0
  152. /package/dist/{chunk-6SQYXUVP.mjs.map → chunk-WCHLCOZG.mjs.map} +0 -0
@@ -0,0 +1,214 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { useState } from "react";
5
+ import { FieldValues, UseFormReturn } from "react-hook-form";
6
+ import { z } from "zod";
7
+ import { FormCheckbox, FormDateTime, FormInput, FormSelect, FormSwitch } from "../../../../../components";
8
+ import { FieldDescription } from "../../../../../shadcnui";
9
+ import { StripeCouponCodeInput } from "../../data/stripe-coupon-code.interface";
10
+
11
+ /**
12
+ * What a number or decimal `<FormInput>` holds: "" until something is typed, a
13
+ * number from `type="number"` (`+""` is 0 when cleared, NaN on garbage), a
14
+ * string from `type="decimal"` (may use a comma as the separator).
15
+ *
16
+ * The schemas below validate this raw value and never transform it, so the
17
+ * form's values have one type before and after validation. The `parse*`
18
+ * functions turn it into the number that is sent, on submit.
19
+ */
20
+ type RawNumber = string | number | null | undefined;
21
+
22
+ const rawNumber = () => z.union([z.string(), z.number()]).nullish();
23
+
24
+ /** The issues `schema` reports for `value`, as plain messages. */
25
+ function issueMessages(schema: z.ZodType, value: unknown): string[] {
26
+ const result = schema.safeParse(value);
27
+ return result.success ? [] : result.error.issues.map((issue) => issue.message);
28
+ }
29
+
30
+ /**
31
+ * Optional whole number of at least 1. 0, "" and NaN all mean "not set": every
32
+ * limit here has a floor of 1.
33
+ */
34
+ export function parseOptionalPositiveInt(value: RawNumber): number | undefined {
35
+ if (value === undefined || value === null || value === "" || value === 0) return undefined;
36
+ if (typeof value === "number") return Number.isNaN(value) ? undefined : value;
37
+ return parseInt(value, 10);
38
+ }
39
+
40
+ /**
41
+ * Optional decimal in MAJOR units. Converted to minor units on submit, exactly
42
+ * as PriceEditor does (`Math.round(value * 100)`).
43
+ */
44
+ export function parseOptionalDecimal(value: RawNumber): number | undefined {
45
+ if (typeof value === "string") return value.trim() === "" ? undefined : parseFloat(value.replace(",", "."));
46
+ if (value === undefined || value === null || Number.isNaN(value)) return undefined;
47
+ return value;
48
+ }
49
+
50
+ const positiveIntCheck = z.number().int().min(1).optional();
51
+ const decimalCheck = z.number().optional();
52
+
53
+ export const optionalPositiveInt = () =>
54
+ rawNumber().superRefine((value, ctx) => {
55
+ for (const message of issueMessages(positiveIntCheck, parseOptionalPositiveInt(value)))
56
+ ctx.addIssue({ code: "custom", message });
57
+ });
58
+
59
+ export const optionalDecimal = () =>
60
+ rawNumber().superRefine((value, ctx) => {
61
+ for (const message of issueMessages(decimalCheck, parseOptionalDecimal(value)))
62
+ ctx.addIssue({ code: "custom", message });
63
+ });
64
+
65
+ /**
66
+ * Optional point in time from `<FormDateTime allowEmpty>`, which writes "" when
67
+ * the value is cleared (FormDateTime.tsx:136, :241).
68
+ */
69
+ export const optionalDate = () => z.union([z.date(), z.literal("")]).nullish();
70
+
71
+ /** Mirrors StripeCouponCodePostAttributesDTO. */
72
+ export const couponCodeSchema = (t: (key: string) => string) =>
73
+ z
74
+ .object({
75
+ code: z.string().optional(),
76
+ firstTimeTransaction: z.boolean(),
77
+ minimumAmount: optionalDecimal(),
78
+ minimumAmountCurrency: z.string().min(1),
79
+ stripeCustomerId: z.string().optional(),
80
+ expiresAt: optionalDate(),
81
+ maxRedemptions: optionalPositiveInt(),
82
+ automatic: z.boolean(),
83
+ })
84
+ .superRefine((values, ctx) => {
85
+ const minimumAmount = parseOptionalDecimal(values.minimumAmount);
86
+ if (minimumAmount !== undefined && minimumAmount <= 0)
87
+ ctx.addIssue({
88
+ code: "custom",
89
+ path: ["minimumAmount"],
90
+ message: t("billing.admin.coupons.errors.minimumAmount"),
91
+ });
92
+ });
93
+
94
+ export type CouponCodeFormValues = z.infer<ReturnType<typeof couponCodeSchema>>;
95
+
96
+ /**
97
+ * Every text/number input starts as "" (never undefined): FormInput spreads
98
+ * `field.value` onto a Base UI input, and undefined → value flips it from
99
+ * uncontrolled to controlled (console warning). The parse functions map ""
100
+ * to undefined, so empty fields are still sent as absent.
101
+ */
102
+ export const couponCodeDefaults = (): CouponCodeFormValues => ({
103
+ code: "",
104
+ firstTimeTransaction: false,
105
+ minimumAmount: "",
106
+ minimumAmountCurrency: "eur",
107
+ stripeCustomerId: "",
108
+ expiresAt: undefined,
109
+ maxRedemptions: "",
110
+ automatic: false,
111
+ });
112
+
113
+ /** Form values → the code attributes the models serialise. Unset fields are left out. */
114
+ export const toCouponCodeInput = (values: CouponCodeFormValues): Omit<StripeCouponCodeInput, "id" | "couponId"> => {
115
+ const input: Omit<StripeCouponCodeInput, "id" | "couponId"> = {
116
+ firstTimeTransaction: values.firstTimeTransaction,
117
+ automatic: values.automatic,
118
+ };
119
+ if (values.code?.trim()) input.code = values.code.trim();
120
+ const minimumAmount = parseOptionalDecimal(values.minimumAmount);
121
+ if (minimumAmount !== undefined) {
122
+ input.minimumAmount = Math.round(minimumAmount * 100);
123
+ input.minimumAmountCurrency = values.minimumAmountCurrency;
124
+ }
125
+ if (values.stripeCustomerId?.trim()) input.stripeCustomerId = values.stripeCustomerId.trim();
126
+ if (values.expiresAt) input.expiresAt = values.expiresAt;
127
+ const maxRedemptions = parseOptionalPositiveInt(values.maxRedemptions);
128
+ if (maxRedemptions !== undefined) input.maxRedemptions = maxRedemptions;
129
+ return input;
130
+ };
131
+
132
+ export const couponCurrencyOptions = [
133
+ { id: "usd", text: "USD ($)" },
134
+ { id: "eur", text: "EUR (€)" },
135
+ { id: "gbp", text: "GBP (£)" },
136
+ ];
137
+
138
+ type CouponCodeFieldsProps<T extends FieldValues> = {
139
+ form: UseFormReturn<T>;
140
+ /** "" for CouponCodeEditor, "firstCode." when nested inside CouponEditor. */
141
+ prefix?: string;
142
+ };
143
+
144
+ /** The fields of a customer-facing code, shared by CouponEditor (first code) and CouponCodeEditor. */
145
+ export function CouponCodeFields<T extends FieldValues>({ form, prefix = "" }: CouponCodeFieldsProps<T>) {
146
+ const t = useTranslations();
147
+ // Read once on mount: render stays pure.
148
+ const [now] = useState(() => new Date());
149
+
150
+ return (
151
+ <div className="flex w-full flex-col gap-y-4">
152
+ <FormInput
153
+ form={form}
154
+ id={`${prefix}code`}
155
+ name={t("billing.admin.coupons.fields.code")}
156
+ placeholder={t("billing.admin.coupons.placeholders.code")}
157
+ />
158
+ {/* The warning sits visibly under the option (spec), not in FormCheckbox's
159
+ hover tooltip — same layout as FormSwitch's description. */}
160
+ <div className="flex w-full flex-col">
161
+ <FormCheckbox
162
+ form={form}
163
+ id={`${prefix}firstTimeTransaction`}
164
+ name={t("billing.admin.coupons.fields.firstTimeTransaction")}
165
+ />
166
+ <FieldDescription>{t("billing.admin.coupons.fields.first_time_warning")}</FieldDescription>
167
+ </div>
168
+ <div className="grid grid-cols-2 gap-x-4">
169
+ {/* type="decimal", NOT type="currency": the currency variant hardcodes
170
+ a Euro symbol (FormInput.tsx:105-106), which contradicts the
171
+ currency selector sitting next to it. */}
172
+ <FormInput
173
+ form={form}
174
+ id={`${prefix}minimumAmount`}
175
+ type="decimal"
176
+ name={t("billing.admin.coupons.fields.minimumAmount")}
177
+ placeholder={t("billing.admin.coupons.placeholders.minimumAmount")}
178
+ />
179
+ <FormSelect
180
+ form={form}
181
+ id={`${prefix}minimumAmountCurrency`}
182
+ name={t("billing.admin.coupons.fields.currency")}
183
+ values={couponCurrencyOptions}
184
+ />
185
+ </div>
186
+ <FormInput
187
+ form={form}
188
+ id={`${prefix}stripeCustomerId`}
189
+ name={t("billing.admin.coupons.fields.stripeCustomerId")}
190
+ placeholder="cus_…"
191
+ />
192
+ <FormDateTime
193
+ form={form}
194
+ id={`${prefix}expiresAt`}
195
+ name={t("billing.admin.coupons.fields.expiresAt")}
196
+ minDate={now}
197
+ allowEmpty
198
+ />
199
+ <FormInput
200
+ form={form}
201
+ id={`${prefix}maxRedemptions`}
202
+ type="number"
203
+ name={t("billing.admin.coupons.fields.codeMaxRedemptions")}
204
+ placeholder={t("billing.admin.coupons.placeholders.maxRedemptions")}
205
+ />
206
+ <FormSwitch
207
+ form={form}
208
+ id={`${prefix}automatic`}
209
+ name={t("billing.admin.coupons.fields.automatic")}
210
+ description={t("billing.admin.coupons.help.automatic")}
211
+ />
212
+ </div>
213
+ );
214
+ }
@@ -0,0 +1,425 @@
1
+ "use client";
2
+
3
+ import { zodResolver } from "@hookform/resolvers/zod";
4
+ import { useTranslations } from "next-intl";
5
+ import { ReactNode, useCallback, useMemo, useState } 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 {
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";
31
+ import { formatCouponDiscount } from "../../utils/format-coupon-discount";
32
+ import {
33
+ CouponCodeFields,
34
+ couponCodeDefaults,
35
+ couponCodeSchema,
36
+ couponCurrencyOptions,
37
+ optionalDate,
38
+ optionalDecimal,
39
+ optionalPositiveInt,
40
+ parseOptionalDecimal,
41
+ parseOptionalPositiveInt,
42
+ toCouponCodeInput,
43
+ } from "./CouponCodeFields";
44
+
45
+ export type CouponEditorProps = {
46
+ coupon?: StripeCouponInterface;
47
+ propagateChanges?: (coupon: StripeCouponInterface) => void;
48
+ onSuccess?: () => void | Promise<void>;
49
+ trigger?: ReactNode;
50
+ forceShow?: boolean;
51
+ onClose?: () => void;
52
+ dialogOpen?: boolean;
53
+ onDialogOpenChange?: (open: boolean) => void;
54
+ };
55
+
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({
165
+ coupon,
166
+ propagateChanges,
167
+ onSuccess,
168
+ trigger,
169
+ forceShow,
170
+ onClose,
171
+ dialogOpen,
172
+ onDialogOpenChange,
173
+ }: CouponEditorProps & { coupon: StripeCouponInterface }) {
174
+ const t = useTranslations();
175
+ const fmt = useBillingFormatOptions();
176
+ const router = useI18nRouter();
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]);
260
+
261
+ const getDefaultValues = useCallback(
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
+ [],
279
+ );
280
+
281
+ const form = useForm<CouponCreateValues>({
282
+ resolver: zodResolver(formSchema),
283
+ defaultValues: getDefaultValues(),
284
+ });
285
+
286
+ const watchDiscountType = form.watch("discountType");
287
+ const watchDuration = form.watch("duration");
288
+
289
+ const discountTypeOptions = [
290
+ { id: "percent_off", text: t("billing.admin.coupons.type.percent_off") },
291
+ { id: "amount_off", text: t("billing.admin.coupons.type.amount_off") },
292
+ ];
293
+
294
+ const durationOptions = [
295
+ { id: "once", text: t("billing.admin.coupons.duration.once") },
296
+ { id: "repeating", text: t("billing.admin.coupons.duration.repeating") },
297
+ { id: "forever", text: t("billing.admin.coupons.duration.forever") },
298
+ ];
299
+
300
+ return (
301
+ <EditorSheet
302
+ form={form}
303
+ entityType={t("billing.admin.coupons.entity")}
304
+ isEdit={false}
305
+ module={couponPageModule()}
306
+ size="lg"
307
+ propagateChanges={propagateChanges}
308
+ onSuccess={onSuccess}
309
+ onNavigate={(url) => router.push(url)}
310
+ onSubmit={async (values) => await createCoupon(toCouponInput(values))}
311
+ onReset={getDefaultValues}
312
+ trigger={trigger}
313
+ forceShow={forceShow}
314
+ onClose={onClose}
315
+ dialogOpen={dialogOpen}
316
+ onDialogOpenChange={onDialogOpenChange}
317
+ >
318
+ <div className="flex w-full flex-col gap-y-4">
319
+ <FormInput
320
+ form={form}
321
+ id="name"
322
+ name={t("billing.admin.coupons.fields.name")}
323
+ placeholder={t("billing.admin.coupons.placeholders.name")}
324
+ isRequired
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
+ />
332
+
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
+ />
349
+ ) : (
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. */}
354
+ <FormInput
355
+ form={form}
356
+ id="amountOff"
357
+ type="decimal"
358
+ name={t("billing.admin.coupons.fields.amountOff")}
359
+ placeholder={t("billing.admin.coupons.placeholders.amountOff")}
360
+ isRequired
361
+ />
362
+ <FormSelect
363
+ form={form}
364
+ id="currency"
365
+ name={t("billing.admin.coupons.fields.currency")}
366
+ values={couponCurrencyOptions}
367
+ isRequired
368
+ />
369
+ </div>
370
+ )}
371
+
372
+ <FormSelect
373
+ form={form}
374
+ id="duration"
375
+ name={t("billing.admin.coupons.fields.duration")}
376
+ values={durationOptions}
377
+ />
378
+
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
+ )}
389
+
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
+ />
399
+
400
+ <FormDateTime
401
+ form={form}
402
+ id="redeemBy"
403
+ name={t("billing.admin.coupons.fields.redeemBy")}
404
+ minDate={now}
405
+ allowEmpty
406
+ />
407
+
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
+ />
415
+
416
+ <SectionHeader>{t("billing.admin.coupons.first_code")}</SectionHeader>
417
+ <CouponCodeFields form={form} prefix="firstCode." />
418
+ </div>
419
+ </EditorSheet>
420
+ );
421
+ }
422
+
423
+ export default function CouponEditor(props: CouponEditorProps) {
424
+ return props.coupon ? <CouponRenameEditor {...props} coupon={props.coupon} /> : <CouponCreateEditor {...props} />;
425
+ }
@@ -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";