@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,15 @@
|
|
|
1
|
+
import { ModuleFactory } from "../../../permissions";
|
|
2
|
+
import { StripeCouponCode } from "./data/stripe-coupon-code";
|
|
3
|
+
|
|
4
|
+
/** The code endpoint's segment that returns the automatic code for a price. */
|
|
5
|
+
export const STRIPE_COUPON_CODE_AUTOMATIC_SEGMENT = "automatic";
|
|
6
|
+
|
|
7
|
+
export const StripeCouponCodeModule = (factory: ModuleFactory) =>
|
|
8
|
+
factory({
|
|
9
|
+
name: "stripe-coupon-codes",
|
|
10
|
+
// Codes are listed on the products page (Codes tab), same value as
|
|
11
|
+
// StripeProductModule. A code has no page of its own.
|
|
12
|
+
pageUrl: "/administration/products",
|
|
13
|
+
model: StripeCouponCode,
|
|
14
|
+
moduleId: "85feb4c7-ff22-416a-b856-8dafcf28ff26",
|
|
15
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ModuleFactory } from "../../../permissions";
|
|
2
|
+
import { StripeCouponOffer } from "./data/stripe-coupon-offer";
|
|
3
|
+
|
|
4
|
+
export const StripeCouponOfferModule = (factory: ModuleFactory) =>
|
|
5
|
+
factory({
|
|
6
|
+
name: "stripe-coupon-offers",
|
|
7
|
+
pageUrl: "/pricing",
|
|
8
|
+
model: StripeCouponOffer,
|
|
9
|
+
moduleId: "6859d6cc-b94c-4cf7-8a02-b71ad59d75d2",
|
|
10
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { TicketPercentIcon } from "lucide-react";
|
|
2
|
+
import { ModuleFactory } from "../../../permissions";
|
|
3
|
+
import { StripeCoupon } from "./data/stripe-coupon";
|
|
4
|
+
|
|
5
|
+
/** The coupon endpoint's non-resource segment that re-reads its counts from Stripe. */
|
|
6
|
+
export const STRIPE_COUPON_REFRESH_SEGMENT = "refresh";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Route of a coupon's own page (`/administration/coupons/<id>`). It is not the
|
|
10
|
+
* module's pageUrl: there is no coupons list route, the list is the Codes tab of
|
|
11
|
+
* the products page. Links to one coupon build on this instead.
|
|
12
|
+
*/
|
|
13
|
+
export const STRIPE_COUPON_DETAIL_PAGE_URL = "/administration/coupons";
|
|
14
|
+
|
|
15
|
+
export const StripeCouponModule = (factory: ModuleFactory) =>
|
|
16
|
+
factory({
|
|
17
|
+
name: "stripe-coupons",
|
|
18
|
+
// The coupons list lives on the products page (Codes tab), so the module
|
|
19
|
+
// points there, same value as StripeProductModule. Links to a single coupon
|
|
20
|
+
// use STRIPE_COUPON_DETAIL_PAGE_URL.
|
|
21
|
+
pageUrl: "/administration/products",
|
|
22
|
+
model: StripeCoupon,
|
|
23
|
+
moduleId: "50bdb0d2-539c-4358-b609-d6beab248bdb",
|
|
24
|
+
icon: TicketPercentIcon,
|
|
25
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { couponPlacesLeft, couponPlacesTotal } from "../coupon-places";
|
|
3
|
+
|
|
4
|
+
describe("coupon places", () => {
|
|
5
|
+
it("returns null when neither limit is set", () =>
|
|
6
|
+
expect(couponPlacesLeft({ timesRedeemed: 3 }, { timesRedeemed: 3 })).toBeNull());
|
|
7
|
+
it("uses the coupon limit alone", () =>
|
|
8
|
+
expect(couponPlacesLeft({ timesRedeemed: 0 }, { maxRedemptions: 50, timesRedeemed: 13 })).toBe(37));
|
|
9
|
+
it("takes the smaller of coupon and code", () =>
|
|
10
|
+
expect(couponPlacesLeft({ maxRedemptions: 10, timesRedeemed: 8 }, { maxRedemptions: 50, timesRedeemed: 13 })).toBe(
|
|
11
|
+
2,
|
|
12
|
+
));
|
|
13
|
+
it("never goes below zero", () =>
|
|
14
|
+
expect(couponPlacesLeft({ timesRedeemed: 0 }, { maxRedemptions: 50, timesRedeemed: 51 })).toBe(0));
|
|
15
|
+
it("placesTotal is the smaller set limit", () =>
|
|
16
|
+
expect(couponPlacesTotal({ maxRedemptions: 10, timesRedeemed: 0 }, { maxRedemptions: 50, timesRedeemed: 0 })).toBe(
|
|
17
|
+
10,
|
|
18
|
+
));
|
|
19
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { StripeCouponCodeInterface } from "../../data/stripe-coupon-code.interface";
|
|
3
|
+
import { StripeCouponInterface } from "../../data/stripe-coupon.interface";
|
|
4
|
+
import { toPromotionCodeResult } from "../to-promotion-code-result";
|
|
5
|
+
|
|
6
|
+
const codeWith = (coupon: Partial<StripeCouponInterface>): StripeCouponCodeInterface =>
|
|
7
|
+
({ stripePromotionCodeId: "promo_1", code: "FOUNDER", coupon }) as unknown as StripeCouponCodeInterface;
|
|
8
|
+
|
|
9
|
+
describe("toPromotionCodeResult", () => {
|
|
10
|
+
it("maps a percentage coupon", () => {
|
|
11
|
+
expect(
|
|
12
|
+
toPromotionCodeResult(codeWith({ discountType: "percent_off", percentOff: 20, duration: "forever" })),
|
|
13
|
+
).toEqual({
|
|
14
|
+
valid: true,
|
|
15
|
+
automatic: true,
|
|
16
|
+
promotionCodeId: "promo_1",
|
|
17
|
+
code: "FOUNDER",
|
|
18
|
+
discountType: "percent_off",
|
|
19
|
+
discountValue: 20,
|
|
20
|
+
duration: "forever",
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("maps a fixed-amount coupon to amountOff and its currency", () => {
|
|
25
|
+
const result = toPromotionCodeResult(
|
|
26
|
+
codeWith({
|
|
27
|
+
discountType: "amount_off",
|
|
28
|
+
amountOff: 500,
|
|
29
|
+
currency: "eur",
|
|
30
|
+
duration: "repeating",
|
|
31
|
+
durationInMonths: 3,
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
expect(result.discountType).toBe("amount_off");
|
|
35
|
+
expect(result.discountValue).toBe(500);
|
|
36
|
+
expect(result.currency).toBe("eur");
|
|
37
|
+
expect(result.duration).toBe("repeating");
|
|
38
|
+
expect(result.durationInMonths).toBe(3);
|
|
39
|
+
expect(result.automatic).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Places left on a code: the smaller of (coupon limit - coupon redeemed) and
|
|
3
|
+
* (code limit - code redeemed), counting only the limits that are set, never
|
|
4
|
+
* below zero. `null` = unlimited. Same rule as the backend `placesLeft`.
|
|
5
|
+
*/
|
|
6
|
+
type Redeemable = { maxRedemptions?: number; timesRedeemed: number };
|
|
7
|
+
|
|
8
|
+
export function couponPlacesLeft(code: Redeemable, coupon: Redeemable): number | null {
|
|
9
|
+
const remaining = [code, coupon]
|
|
10
|
+
.filter((item) => typeof item.maxRedemptions === "number")
|
|
11
|
+
.map((item) => Math.max(0, (item.maxRedemptions as number) - (item.timesRedeemed ?? 0)));
|
|
12
|
+
|
|
13
|
+
return remaining.length === 0 ? null : Math.min(...remaining);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The smaller of the set limits, or `null` when neither is set. */
|
|
17
|
+
export function couponPlacesTotal(code: Redeemable, coupon: Redeemable): number | null {
|
|
18
|
+
const limits = [code.maxRedemptions, coupon.maxRedemptions].filter(
|
|
19
|
+
(limit): limit is number => typeof limit === "number",
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
return limits.length === 0 ? null : Math.min(...limits);
|
|
23
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { formatCurrency } from "../../components/utils/currency";
|
|
2
|
+
import { BillingFormatOptions, BillingTranslator } from "../../components/utils/format-options";
|
|
3
|
+
import { StripeCouponDiscountType, StripeCouponDurationType } from "../data/stripe-coupon.interface";
|
|
4
|
+
|
|
5
|
+
export type CouponDiscountShape = {
|
|
6
|
+
discountType: StripeCouponDiscountType;
|
|
7
|
+
percentOff?: number;
|
|
8
|
+
amountOff?: number; // minor units
|
|
9
|
+
currency?: string;
|
|
10
|
+
duration: StripeCouponDurationType;
|
|
11
|
+
durationInMonths?: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** "20% off forever", "€5.00 off for 3 months", "€5.00 off once". */
|
|
15
|
+
export function formatCouponDiscount(
|
|
16
|
+
coupon: CouponDiscountShape,
|
|
17
|
+
t: BillingTranslator,
|
|
18
|
+
fmt: BillingFormatOptions = {},
|
|
19
|
+
): string {
|
|
20
|
+
const amount =
|
|
21
|
+
coupon.discountType === "percent_off"
|
|
22
|
+
? t("billing.admin.coupons.discount.percent", { value: String(coupon.percentOff ?? 0) })
|
|
23
|
+
: t("billing.admin.coupons.discount.amount", {
|
|
24
|
+
amount: formatCurrency(coupon.amountOff, coupon.currency ?? "eur", fmt),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Literal keys (no template) so the message validator sees every one in use.
|
|
28
|
+
const duration =
|
|
29
|
+
coupon.duration === "repeating"
|
|
30
|
+
? t("billing.admin.coupons.discount.repeating", { months: String(coupon.durationInMonths ?? 0) })
|
|
31
|
+
: coupon.duration === "once"
|
|
32
|
+
? t("billing.admin.coupons.discount.once")
|
|
33
|
+
: t("billing.admin.coupons.discount.forever");
|
|
34
|
+
|
|
35
|
+
return `${amount} ${duration}`;
|
|
36
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { PromotionCodeValidationResult } from "../../stripe-promotion-code/data/stripe-promotion-code.interface";
|
|
2
|
+
import { StripeCouponCodeInterface } from "../data/stripe-coupon-code.interface";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The automatic code as the wizard's promotion-code state, exactly as if the
|
|
6
|
+
* GM had typed and validated it.
|
|
7
|
+
*/
|
|
8
|
+
export function toPromotionCodeResult(code: StripeCouponCodeInterface): PromotionCodeValidationResult {
|
|
9
|
+
const coupon = code.coupon;
|
|
10
|
+
const discountType = coupon?.discountType;
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
valid: true,
|
|
14
|
+
automatic: true,
|
|
15
|
+
promotionCodeId: code.stripePromotionCodeId,
|
|
16
|
+
code: code.code,
|
|
17
|
+
discountType,
|
|
18
|
+
discountValue: discountType === "amount_off" ? coupon?.amountOff : coupon?.percentOff,
|
|
19
|
+
currency: discountType === "amount_off" ? coupon?.currency : undefined,
|
|
20
|
+
duration: coupon?.duration,
|
|
21
|
+
durationInMonths: coupon?.durationInMonths,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -10,9 +10,19 @@ type PaymentMethodFormProps = {
|
|
|
10
10
|
onSuccess: () => void;
|
|
11
11
|
onCancel: () => void;
|
|
12
12
|
isLoading?: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Always make the new card the customer's default and hide the checkbox. The
|
|
15
|
+
* subscription wizard needs this: the next attempt must charge the card just added.
|
|
16
|
+
*/
|
|
17
|
+
alwaysSetDefault?: boolean;
|
|
13
18
|
};
|
|
14
19
|
|
|
15
|
-
export function PaymentMethodForm({
|
|
20
|
+
export function PaymentMethodForm({
|
|
21
|
+
onSuccess,
|
|
22
|
+
onCancel,
|
|
23
|
+
isLoading = false,
|
|
24
|
+
alwaysSetDefault = false,
|
|
25
|
+
}: PaymentMethodFormProps) {
|
|
16
26
|
const t = useTranslations();
|
|
17
27
|
const stripe = useStripe();
|
|
18
28
|
const elements = useElements();
|
|
@@ -75,7 +85,7 @@ export function PaymentMethodForm({ onSuccess, onCancel, isLoading = false }: Pa
|
|
|
75
85
|
}
|
|
76
86
|
|
|
77
87
|
// Set as default if checkbox is checked
|
|
78
|
-
if (setAsDefault && confirmedSetupIntent?.payment_method) {
|
|
88
|
+
if ((alwaysSetDefault || setAsDefault) && confirmedSetupIntent?.payment_method) {
|
|
79
89
|
await StripeCustomerService.setDefaultPaymentMethod({
|
|
80
90
|
paymentMethodId:
|
|
81
91
|
typeof confirmedSetupIntent.payment_method === "string"
|
|
@@ -132,12 +142,18 @@ export function PaymentMethodForm({ onSuccess, onCancel, isLoading = false }: Pa
|
|
|
132
142
|
</div>
|
|
133
143
|
|
|
134
144
|
{/* Set as Default Checkbox */}
|
|
135
|
-
|
|
136
|
-
<
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
145
|
+
{!alwaysSetDefault && (
|
|
146
|
+
<div className="flex items-center gap-x-2">
|
|
147
|
+
<Checkbox
|
|
148
|
+
id="setAsDefault"
|
|
149
|
+
checked={setAsDefault}
|
|
150
|
+
onCheckedChange={(checked) => setSetAsDefault(!!checked)}
|
|
151
|
+
/>
|
|
152
|
+
<Label htmlFor="setAsDefault" className="cursor-pointer font-normal">
|
|
153
|
+
{t("billing.ui.payment_form.set_default")}
|
|
154
|
+
</Label>
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
141
157
|
|
|
142
158
|
{/* Error Alert */}
|
|
143
159
|
{error && (
|
|
@@ -1,27 +1,42 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
3
4
|
import { RoundPageContainer } from "../../../../../components";
|
|
4
5
|
import { Modules } from "../../../../../core";
|
|
6
|
+
import { CouponProvider } from "../../../contexts/CouponContext";
|
|
5
7
|
import { ProductProvider } from "../../../contexts/ProductContext";
|
|
8
|
+
import { CouponsList } from "../../../stripe-coupon/components/lists/CouponsList";
|
|
6
9
|
import { ProductsList } from "../lists/ProductsList";
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
|
-
* Page container for the administrative
|
|
12
|
+
* Page container for the administrative products page: two tabs, Products and
|
|
13
|
+
* Codes, with `sectionKey`s so the URL keeps the tab (`?section=codes`).
|
|
10
14
|
*
|
|
11
15
|
* Client component by necessity: `module={Modules.StripeProduct}` is a registry
|
|
12
16
|
* entry carrying an icon component and methods, which a Server Component cannot
|
|
13
17
|
* pass across the boundary. Routes mount this container instead — the same
|
|
14
18
|
* shape as every other list page.
|
|
15
19
|
*
|
|
16
|
-
* No `
|
|
17
|
-
*
|
|
20
|
+
* No `fullWidth`, on the page or the lists: the page keeps its title bar and
|
|
21
|
+
* each ContentListTable keeps its own bordered card in the tab (see ProductContainer). CouponProvider
|
|
22
|
+
* is nested with `publishChrome={false}`: SharedProvider REPLACES its value for
|
|
23
|
+
* the subtree (SharedContext.tsx:35-37), so a nested provider publishing chrome
|
|
24
|
+
* would blank the products page's own title.
|
|
18
25
|
*/
|
|
19
26
|
export function ProductsListContainer() {
|
|
27
|
+
const t = useTranslations();
|
|
28
|
+
|
|
20
29
|
return (
|
|
21
30
|
<ProductProvider>
|
|
22
|
-
<
|
|
23
|
-
<
|
|
24
|
-
|
|
31
|
+
<CouponProvider publishChrome={false}>
|
|
32
|
+
<RoundPageContainer
|
|
33
|
+
module={Modules.StripeProduct}
|
|
34
|
+
tabs={[
|
|
35
|
+
{ sectionKey: "products", label: t("billing.admin.products.tabs.products"), content: <ProductsList /> },
|
|
36
|
+
{ sectionKey: "codes", label: t("billing.admin.products.tabs.codes"), content: <CouponsList /> },
|
|
37
|
+
]}
|
|
38
|
+
/>
|
|
39
|
+
</CouponProvider>
|
|
25
40
|
</ProductProvider>
|
|
26
41
|
);
|
|
27
42
|
}
|
|
@@ -29,6 +29,25 @@ export function PromoCodeInput({
|
|
|
29
29
|
const locale = useI18nLocale();
|
|
30
30
|
const [code, setCode] = useState("");
|
|
31
31
|
|
|
32
|
+
// Clear the typed text whenever a code becomes applied (typed or automatic):
|
|
33
|
+
// the box then reads empty after the applied code is removed, instead of
|
|
34
|
+
// still holding the removed code. Adjusting state during render on a prop
|
|
35
|
+
// change (React's "storing information from previous renders" pattern) —
|
|
36
|
+
// a failed validation keeps the text so the user can fix a typo.
|
|
37
|
+
const appliedKey = appliedCode?.valid
|
|
38
|
+
? `${appliedCode.automatic ? "auto" : "typed"}:${appliedCode.code ?? ""}`
|
|
39
|
+
: null;
|
|
40
|
+
const [previousAppliedKey, setPreviousAppliedKey] = useState(appliedKey);
|
|
41
|
+
if (appliedKey !== previousAppliedKey) {
|
|
42
|
+
setPreviousAppliedKey(appliedKey);
|
|
43
|
+
if (appliedKey !== null) setCode("");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const handleRemove = () => {
|
|
47
|
+
setCode("");
|
|
48
|
+
onRemove();
|
|
49
|
+
};
|
|
50
|
+
|
|
32
51
|
const handleApply = () => {
|
|
33
52
|
if (code.trim()) {
|
|
34
53
|
onApply(code.trim().toUpperCase());
|
|
@@ -67,6 +86,45 @@ export function PromoCodeInput({
|
|
|
67
86
|
}
|
|
68
87
|
};
|
|
69
88
|
|
|
89
|
+
const codeInput = (
|
|
90
|
+
<div className="space-y-2">
|
|
91
|
+
<div className="flex gap-2">
|
|
92
|
+
<Input
|
|
93
|
+
placeholder={t("billing.ui.promo_code.placeholder")}
|
|
94
|
+
value={code}
|
|
95
|
+
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
|
96
|
+
onKeyDown={handleKeyDown}
|
|
97
|
+
disabled={disabled || isValidating}
|
|
98
|
+
className="flex-1"
|
|
99
|
+
/>
|
|
100
|
+
<Button variant="outline" onClick={handleApply} disabled={disabled || isValidating || !code.trim()}>
|
|
101
|
+
{isValidating ? <Loader2 className="h-4 w-4 animate-spin" /> : t("billing.ui.promo_code.apply")}
|
|
102
|
+
</Button>
|
|
103
|
+
</div>
|
|
104
|
+
{error && <p className="text-destructive text-xs/relaxed">{error}</p>}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Show the automatic code as applied, with the option to type a different one
|
|
109
|
+
if (appliedCode?.valid && appliedCode.automatic) {
|
|
110
|
+
return (
|
|
111
|
+
<div className="space-y-3">
|
|
112
|
+
<div className="space-y-2">
|
|
113
|
+
<div className="bg-success/15 border-success/30 flex items-center gap-2 rounded-lg border p-3">
|
|
114
|
+
<Check className="text-success h-4 w-4" />
|
|
115
|
+
<span className="text-success font-medium">{t("billing.ui.promo_code.automatic_label")}</span>
|
|
116
|
+
<span className="text-success text-sm">{formatDiscount(appliedCode)}</span>
|
|
117
|
+
</div>
|
|
118
|
+
<p className="text-success text-sm">{formatDuration(appliedCode)}</p>
|
|
119
|
+
</div>
|
|
120
|
+
<div className="space-y-2">
|
|
121
|
+
<p className="text-muted-foreground text-xs/relaxed">{t("billing.ui.promo_code.use_different")}</p>
|
|
122
|
+
{codeInput}
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
70
128
|
// Show applied code state
|
|
71
129
|
if (appliedCode?.valid) {
|
|
72
130
|
return (
|
|
@@ -80,7 +138,7 @@ export function PromoCodeInput({
|
|
|
80
138
|
<Button
|
|
81
139
|
variant="ghost"
|
|
82
140
|
size="sm"
|
|
83
|
-
onClick={
|
|
141
|
+
onClick={handleRemove}
|
|
84
142
|
disabled={disabled}
|
|
85
143
|
className="text-success hover:bg-success/25 hover:text-success"
|
|
86
144
|
>
|
|
@@ -92,22 +150,5 @@ export function PromoCodeInput({
|
|
|
92
150
|
);
|
|
93
151
|
}
|
|
94
152
|
|
|
95
|
-
return
|
|
96
|
-
<div className="space-y-2">
|
|
97
|
-
<div className="flex gap-2">
|
|
98
|
-
<Input
|
|
99
|
-
placeholder={t("billing.ui.promo_code.placeholder")}
|
|
100
|
-
value={code}
|
|
101
|
-
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
|
102
|
-
onKeyDown={handleKeyDown}
|
|
103
|
-
disabled={disabled || isValidating}
|
|
104
|
-
className="flex-1"
|
|
105
|
-
/>
|
|
106
|
-
<Button variant="outline" onClick={handleApply} disabled={disabled || isValidating || !code.trim()}>
|
|
107
|
-
{isValidating ? <Loader2 className="h-4 w-4 animate-spin" /> : t("billing.ui.promo_code.apply")}
|
|
108
|
-
</Button>
|
|
109
|
-
</div>
|
|
110
|
-
{error && <p className="text-destructive text-xs/relaxed">{error}</p>}
|
|
111
|
-
</div>
|
|
112
|
-
);
|
|
153
|
+
return codeInput;
|
|
113
154
|
}
|
|
@@ -11,4 +11,6 @@ export interface PromotionCodeValidationResult {
|
|
|
11
11
|
duration?: "forever" | "once" | "repeating";
|
|
12
12
|
durationInMonths?: number;
|
|
13
13
|
errorMessage?: string;
|
|
14
|
+
/** True when the server applies this code by itself (no code typed by the GM). */
|
|
15
|
+
automatic?: boolean;
|
|
14
16
|
}
|
package/src/features/billing/stripe-subscription/components/containers/SubscriptionsContainer.tsx
CHANGED
|
@@ -11,14 +11,33 @@ import { SubscriptionsList } from "../lists";
|
|
|
11
11
|
type SubscriptionsContainerProps = {
|
|
12
12
|
onOpenWizard?: (subscription?: StripeSubscriptionInterface) => void;
|
|
13
13
|
hasActiveRecurringSubscription?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* The list a parent already holds (the billing dashboard). When given, the container
|
|
16
|
+
* shows it instead of loading its own, and a change asks the parent to refresh through
|
|
17
|
+
* `onSubscriptionsChange`, so the dialog and the cards behind it never disagree.
|
|
18
|
+
*/
|
|
19
|
+
subscriptions?: StripeSubscriptionInterface[];
|
|
20
|
+
onSubscriptionsChange?: () => void;
|
|
14
21
|
};
|
|
15
22
|
|
|
16
|
-
export function SubscriptionsContainer({
|
|
23
|
+
export function SubscriptionsContainer({
|
|
24
|
+
onOpenWizard,
|
|
25
|
+
hasActiveRecurringSubscription,
|
|
26
|
+
subscriptions: parentSubscriptions,
|
|
27
|
+
onSubscriptionsChange,
|
|
28
|
+
}: SubscriptionsContainerProps) {
|
|
17
29
|
const t = useTranslations();
|
|
18
|
-
const
|
|
19
|
-
const [
|
|
30
|
+
const isControlled = parentSubscriptions !== undefined;
|
|
31
|
+
const [ownSubscriptions, setSubscriptions] = useState<StripeSubscriptionInterface[]>([]);
|
|
32
|
+
const [ownLoading, setLoading] = useState<boolean>(!isControlled);
|
|
33
|
+
const subscriptions = parentSubscriptions ?? ownSubscriptions;
|
|
34
|
+
const loading = isControlled ? false : ownLoading;
|
|
20
35
|
|
|
21
36
|
const loadSubscriptions = useCallback(async () => {
|
|
37
|
+
if (isControlled) {
|
|
38
|
+
onSubscriptionsChange?.();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
22
41
|
setLoading(true);
|
|
23
42
|
try {
|
|
24
43
|
const fetchedSubscriptions = await StripeSubscriptionService.listSubscriptions();
|
|
@@ -28,10 +47,10 @@ export function SubscriptionsContainer({ onOpenWizard, hasActiveRecurringSubscri
|
|
|
28
47
|
} finally {
|
|
29
48
|
setLoading(false);
|
|
30
49
|
}
|
|
31
|
-
}, []);
|
|
50
|
+
}, [isControlled, onSubscriptionsChange]);
|
|
32
51
|
|
|
33
52
|
useEffect(() => {
|
|
34
|
-
loadSubscriptions();
|
|
53
|
+
if (!isControlled) loadSubscriptions();
|
|
35
54
|
}, []);
|
|
36
55
|
|
|
37
56
|
// Detect critical subscriptions
|
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
import { useTranslations } from "next-intl";
|
|
4
4
|
import { useState } from "react";
|
|
5
5
|
import { Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../../../../shadcnui";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
applyDiscount,
|
|
8
|
+
formatCurrency,
|
|
9
|
+
formatDate,
|
|
10
|
+
subscriptionDiscount,
|
|
11
|
+
useBillingFormatOptions,
|
|
12
|
+
} from "../../../components/utils";
|
|
7
13
|
import { StripePriceInterface } from "../../../stripe-price/data/stripe-price.interface";
|
|
8
14
|
import { StripeSubscriptionInterface, SubscriptionStatus } from "../../data";
|
|
9
15
|
import { SubscriptionDetails } from "../details/SubscriptionDetails";
|
|
@@ -71,6 +77,10 @@ export function SubscriptionsList({ subscriptions, onSubscriptionsChange, onChan
|
|
|
71
77
|
{subscriptions.map((subscription) => {
|
|
72
78
|
const price = subscription.price;
|
|
73
79
|
const amount = price?.unitAmount ? formatCurrency(price.unitAmount, price.currency, fmt) : "0";
|
|
80
|
+
// What Stripe bills: the price after the discount the subscription carries.
|
|
81
|
+
const discountedAmount = price
|
|
82
|
+
? applyDiscount(price.unitAmount, subscriptionDiscount(subscription))
|
|
83
|
+
: null;
|
|
74
84
|
const period = `${formatDate(subscription.currentPeriodStart, fmt)} - ${formatDate(subscription.currentPeriodEnd, fmt)}`;
|
|
75
85
|
|
|
76
86
|
return (
|
|
@@ -87,7 +97,16 @@ export function SubscriptionsList({ subscriptions, onSubscriptionsChange, onChan
|
|
|
87
97
|
</TableCell>
|
|
88
98
|
<TableCell className="font-medium">{formatPlanName(price, t)}</TableCell>
|
|
89
99
|
<TableCell className="text-muted-foreground text-sm">{period}</TableCell>
|
|
90
|
-
<TableCell className="text-end font-medium">
|
|
100
|
+
<TableCell className="text-end font-medium">
|
|
101
|
+
{discountedAmount !== null && price ? (
|
|
102
|
+
<>
|
|
103
|
+
<span className="line-through text-muted-foreground me-2">{amount}</span>
|
|
104
|
+
<span className="text-success">{formatCurrency(discountedAmount, price.currency, fmt)}</span>
|
|
105
|
+
</>
|
|
106
|
+
) : (
|
|
107
|
+
amount
|
|
108
|
+
)}
|
|
109
|
+
</TableCell>
|
|
91
110
|
<TableCell className="text-end">
|
|
92
111
|
{(subscription.status === SubscriptionStatus.ACTIVE ||
|
|
93
112
|
subscription.status === SubscriptionStatus.TRIALING) &&
|
|
@@ -72,7 +72,7 @@ export function SubscriptionWizard({
|
|
|
72
72
|
|
|
73
73
|
return (
|
|
74
74
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
75
|
-
<DialogContent className="max-w-2xl">
|
|
75
|
+
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
76
76
|
<DialogHeader>
|
|
77
77
|
<DialogTitle>{dialogTitle}</DialogTitle>
|
|
78
78
|
<DialogDescription>{dialogDescription}</DialogDescription>
|
package/src/features/billing/stripe-subscription/components/wizards/WizardStepPaymentMethod.tsx
CHANGED
|
@@ -19,7 +19,7 @@ export function WizardStepPaymentMethod({ onBack, onSuccess, isProcessing }: Wiz
|
|
|
19
19
|
<p className="text-sm text-muted-foreground">{t("billing.ui.wizard.payment_description")}</p>
|
|
20
20
|
</div>
|
|
21
21
|
|
|
22
|
-
<PaymentMethodForm onSuccess={onSuccess} onCancel={onBack} isLoading={isProcessing} />
|
|
22
|
+
<PaymentMethodForm onSuccess={onSuccess} onCancel={onBack} isLoading={isProcessing} alwaysSetDefault />
|
|
23
23
|
</div>
|
|
24
24
|
);
|
|
25
25
|
}
|