@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,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,3 @@
1
+ export * from "./coupon-places";
2
+ export * from "./format-coupon-discount";
3
+ export * from "./to-promotion-code-result";
@@ -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({ onSuccess, onCancel, isLoading = false }: PaymentMethodFormProps) {
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
- <div className="flex items-center gap-x-2">
136
- <Checkbox id="setAsDefault" checked={setAsDefault} onCheckedChange={(checked) => setSetAsDefault(!!checked)} />
137
- <Label htmlFor="setAsDefault" className="cursor-pointer font-normal">
138
- {t("billing.ui.payment_form.set_default")}
139
- </Label>
140
- </div>
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 product list.
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 `forceHeader`: ContentListTable draws its own header row with the title
17
- * and the create action, exactly as the company and user list pages do.
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
- <RoundPageContainer module={Modules.StripeProduct} fullWidth>
23
- <ProductsList fullWidth />
24
- </RoundPageContainer>
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={onRemove}
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
  }
@@ -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({ onOpenWizard, hasActiveRecurringSubscription }: SubscriptionsContainerProps) {
23
+ export function SubscriptionsContainer({
24
+ onOpenWizard,
25
+ hasActiveRecurringSubscription,
26
+ subscriptions: parentSubscriptions,
27
+ onSubscriptionsChange,
28
+ }: SubscriptionsContainerProps) {
17
29
  const t = useTranslations();
18
- const [subscriptions, setSubscriptions] = useState<StripeSubscriptionInterface[]>([]);
19
- const [loading, setLoading] = useState<boolean>(true);
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 { formatCurrency, formatDate, useBillingFormatOptions } from "../../../components/utils";
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">{amount}</TableCell>
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>
@@ -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
  }