@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.
- package/dist/{BlockNoteEditor-GVQCKRMU.mjs → BlockNoteEditor-R3WXUFHX.mjs} +4 -4
- package/dist/{BlockNoteEditor-THBJH2UR.js → BlockNoteEditor-ZHXXFWUJ.js} +19 -19
- package/dist/{BlockNoteEditor-THBJH2UR.js.map → BlockNoteEditor-ZHXXFWUJ.js.map} +1 -1
- package/dist/{BlockNoteViewer-U6E3DS4F.mjs → BlockNoteViewer-BVVN6D3T.mjs} +2 -2
- package/dist/{BlockNoteViewer-VZOVKTFO.js → BlockNoteViewer-SD3ZFIXD.js} +3 -3
- package/dist/{BlockNoteViewer-VZOVKTFO.js.map → BlockNoteViewer-SD3ZFIXD.js.map} +1 -1
- package/dist/billing/index.d.mts +197 -46
- package/dist/billing/index.d.ts +197 -46
- package/dist/billing/index.js +1166 -589
- package/dist/billing/index.js.map +1 -1
- package/dist/billing/index.mjs +803 -226
- package/dist/billing/index.mjs.map +1 -1
- package/dist/{chunk-WPYT4UKJ.js → chunk-2TXE57LE.js} +3 -3
- package/dist/{chunk-WPYT4UKJ.js.map → chunk-2TXE57LE.js.map} +1 -1
- package/dist/{chunk-G6UE6MEP.js → chunk-5TH2DSAH.js} +901 -204
- package/dist/chunk-5TH2DSAH.js.map +1 -0
- package/dist/{chunk-X5WIUUNG.mjs → chunk-CFB6FHIY.mjs} +3342 -2377
- package/dist/chunk-CFB6FHIY.mjs.map +1 -0
- package/dist/{chunk-VC5A3AUZ.mjs → chunk-FK26NX3S.mjs} +701 -4
- package/dist/chunk-FK26NX3S.mjs.map +1 -0
- package/dist/{chunk-YDYVJM4I.js → chunk-IJME5SAM.js} +4 -4
- package/dist/{chunk-YDYVJM4I.js.map → chunk-IJME5SAM.js.map} +1 -1
- package/dist/{chunk-W7F5BDSV.mjs → chunk-OKCV4GPJ.mjs} +2 -2
- package/dist/{chunk-3CGROPF3.mjs → chunk-RGQYFSJF.mjs} +2 -2
- package/dist/{chunk-6SQYXUVP.mjs → chunk-WCHLCOZG.mjs} +2 -2
- package/dist/{chunk-S4YJ5WCW.js → chunk-XOSC3FFP.js} +8 -8
- package/dist/{chunk-S4YJ5WCW.js.map → chunk-XOSC3FFP.js.map} +1 -1
- package/dist/{chunk-TY5FYP3G.js → chunk-ZCOPOO7T.js} +2024 -1059
- package/dist/chunk-ZCOPOO7T.js.map +1 -0
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.js +4 -4
- package/dist/components/index.mjs +3 -3
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +248 -3
- package/dist/core/index.d.ts +248 -3
- package/dist/core/index.js +42 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +41 -1
- package/dist/{stripe-promotion-code.interface-ClZ7DxS9.d.mts → currency-DT_dveBx.d.mts} +137 -1
- package/dist/{stripe-promotion-code.interface-C3qqh3mi.d.ts → currency-pK9eRYRC.d.ts} +137 -1
- package/dist/email-automations/index.js +92 -92
- package/dist/email-automations/index.mjs +3 -3
- package/dist/features/analytics/index.js +121 -121
- package/dist/features/analytics/index.mjs +4 -4
- package/dist/features/analytics/server.js +4 -4
- package/dist/features/analytics/server.mjs +2 -2
- package/dist/features/blog/index.js +150 -150
- package/dist/features/blog/index.mjs +3 -3
- package/dist/features/blog/server.js +16 -16
- package/dist/features/blog/server.mjs +1 -1
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +124 -124
- package/dist/features/tokenusage/index.mjs +4 -4
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +43 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +42 -2
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +2 -2
- package/messages/en.json +125 -3
- package/messages/it.json +125 -3
- package/package.json +1 -1
- package/scripts/validate-messages.mjs +1 -0
- package/src/billing/index.ts +1 -0
- package/src/client/index.ts +4 -0
- package/src/core/index.ts +4 -0
- package/src/core/registry/ModuleRegistry.ts +3 -0
- package/src/features/billing/components/cards/InvoicesSummaryCard.tsx +3 -2
- package/src/features/billing/components/cards/SubscriptionSummaryCard.tsx +4 -3
- package/src/features/billing/components/containers/BillingDashboardContainer.tsx +22 -5
- package/src/features/billing/components/utils/__tests__/discount.spec.ts +59 -0
- package/src/features/billing/components/utils/__tests__/url.spec.ts +32 -0
- package/src/features/billing/components/utils/discount.ts +38 -0
- package/src/features/billing/components/utils/index.ts +2 -0
- package/src/features/billing/components/utils/url.ts +12 -0
- package/src/features/billing/contexts/CouponContext.tsx +209 -0
- package/src/features/billing/contexts/index.ts +1 -0
- package/src/features/billing/i18n-keys.ts +79 -0
- package/src/features/billing/stripe-coupon/components/containers/CouponContainer.tsx +38 -0
- package/src/features/billing/stripe-coupon/components/containers/index.ts +1 -0
- package/src/features/billing/stripe-coupon/components/details/CouponDetails.tsx +72 -0
- package/src/features/billing/stripe-coupon/components/details/index.ts +1 -0
- package/src/features/billing/stripe-coupon/components/forms/CouponCodeEditor.tsx +80 -0
- package/src/features/billing/stripe-coupon/components/forms/CouponCodeFields.tsx +214 -0
- package/src/features/billing/stripe-coupon/components/forms/CouponEditor.tsx +425 -0
- package/src/features/billing/stripe-coupon/components/forms/CouponRefresher.tsx +45 -0
- package/src/features/billing/stripe-coupon/components/forms/index.ts +6 -0
- package/src/features/billing/stripe-coupon/components/index.ts +5 -0
- package/src/features/billing/stripe-coupon/components/lists/CouponCodesList.tsx +61 -0
- package/src/features/billing/stripe-coupon/components/lists/CouponsList.tsx +67 -0
- package/src/features/billing/stripe-coupon/components/lists/index.ts +2 -0
- package/src/features/billing/stripe-coupon/components/widgets/CouponCodeActions.tsx +81 -0
- package/src/features/billing/stripe-coupon/components/widgets/index.ts +1 -0
- package/src/features/billing/stripe-coupon/data/__tests__/stripe-coupon-models.spec.ts +114 -0
- package/src/features/billing/stripe-coupon/data/index.ts +10 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon-code.fields.ts +8 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon-code.interface.ts +44 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon-code.service.ts +92 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon-code.ts +132 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon-offer.interface.ts +17 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon-offer.ts +70 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon.fields.ts +7 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon.interface.ts +55 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon.service.ts +93 -0
- package/src/features/billing/stripe-coupon/data/stripe-coupon.ts +167 -0
- package/src/features/billing/stripe-coupon/hooks/useStripeCouponCodeTableStructure.tsx +161 -0
- package/src/features/billing/stripe-coupon/hooks/useStripeCouponTableStructure.tsx +113 -0
- package/src/features/billing/stripe-coupon/index.ts +5 -0
- package/src/features/billing/stripe-coupon/stripe-coupon-code.module.ts +15 -0
- package/src/features/billing/stripe-coupon/stripe-coupon-offer.module.ts +10 -0
- package/src/features/billing/stripe-coupon/stripe-coupon.module.ts +25 -0
- package/src/features/billing/stripe-coupon/utils/__tests__/coupon-places.spec.ts +19 -0
- package/src/features/billing/stripe-coupon/utils/__tests__/to-promotion-code-result.spec.ts +41 -0
- package/src/features/billing/stripe-coupon/utils/coupon-places.ts +23 -0
- package/src/features/billing/stripe-coupon/utils/format-coupon-discount.ts +36 -0
- package/src/features/billing/stripe-coupon/utils/index.ts +3 -0
- package/src/features/billing/stripe-coupon/utils/to-promotion-code-result.ts +23 -0
- package/src/features/billing/stripe-customer/components/forms/PaymentMethodForm.tsx +24 -8
- package/src/features/billing/stripe-product/components/containers/ProductsListContainer.tsx +21 -6
- package/src/features/billing/stripe-promotion-code/components/PromoCodeInput.tsx +60 -19
- package/src/features/billing/stripe-promotion-code/data/stripe-promotion-code.interface.ts +2 -0
- package/src/features/billing/stripe-subscription/components/containers/SubscriptionsContainer.tsx +24 -5
- package/src/features/billing/stripe-subscription/components/lists/SubscriptionsList.tsx +21 -2
- package/src/features/billing/stripe-subscription/components/wizards/SubscriptionWizard.tsx +1 -1
- package/src/features/billing/stripe-subscription/components/wizards/WizardStepPaymentMethod.tsx +1 -1
- package/src/features/billing/stripe-subscription/components/wizards/WizardStepReview.tsx +60 -49
- package/src/features/billing/stripe-subscription/components/wizards/__tests__/review-card.spec.ts +34 -0
- package/src/features/billing/stripe-subscription/components/wizards/review-card.ts +24 -0
- package/src/features/billing/stripe-subscription/data/__tests__/stripe-subscription.discount.spec.ts +67 -0
- package/src/features/billing/stripe-subscription/data/stripe-subscription.interface.ts +14 -0
- package/src/features/billing/stripe-subscription/data/stripe-subscription.service.ts +39 -0
- package/src/features/billing/stripe-subscription/data/stripe-subscription.ts +21 -0
- package/src/features/billing/stripe-subscription/hooks/__tests__/wizard-payment.spec.ts +48 -0
- package/src/features/billing/stripe-subscription/hooks/__tests__/wizard-promotion.spec.ts +132 -0
- package/src/features/billing/stripe-subscription/hooks/useSubscriptionWizard.ts +138 -12
- package/src/features/billing/stripe-subscription/hooks/wizard-payment.ts +30 -0
- package/src/features/billing/stripe-subscription/hooks/wizard-promotion.ts +61 -0
- package/src/features/user/contexts/CurrentUserContext.tsx +25 -0
- package/src/features/user/contexts/__tests__/CurrentUserContext.spec.tsx +121 -2
- package/dist/chunk-G6UE6MEP.js.map +0 -1
- package/dist/chunk-TY5FYP3G.js.map +0 -1
- package/dist/chunk-VC5A3AUZ.mjs.map +0 -1
- package/dist/chunk-X5WIUUNG.mjs.map +0 -1
- /package/dist/{BlockNoteEditor-GVQCKRMU.mjs.map → BlockNoteEditor-R3WXUFHX.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-U6E3DS4F.mjs.map → BlockNoteViewer-BVVN6D3T.mjs.map} +0 -0
- /package/dist/{chunk-W7F5BDSV.mjs.map → chunk-OKCV4GPJ.mjs.map} +0 -0
- /package/dist/{chunk-3CGROPF3.mjs.map → chunk-RGQYFSJF.mjs.map} +0 -0
- /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";
|