@carlonicora/nextjs-jsonapi 5.5.0 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/dist/{BlockNoteEditor-THBJH2UR.js → BlockNoteEditor-AE3ZTEEH.js} +19 -19
  2. package/dist/{BlockNoteEditor-THBJH2UR.js.map → BlockNoteEditor-AE3ZTEEH.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-GVQCKRMU.mjs → BlockNoteEditor-NEYG5L6W.mjs} +4 -4
  4. package/dist/{BlockNoteViewer-U6E3DS4F.mjs → BlockNoteViewer-3OUT5UFY.mjs} +2 -2
  5. package/dist/{BlockNoteViewer-VZOVKTFO.js → BlockNoteViewer-4CFKQM7U.js} +3 -3
  6. package/dist/{BlockNoteViewer-VZOVKTFO.js.map → BlockNoteViewer-4CFKQM7U.js.map} +1 -1
  7. package/dist/billing/index.d.mts +186 -25
  8. package/dist/billing/index.d.ts +186 -25
  9. package/dist/billing/index.js +1148 -587
  10. package/dist/billing/index.js.map +1 -1
  11. package/dist/billing/index.mjs +785 -224
  12. package/dist/billing/index.mjs.map +1 -1
  13. package/dist/{chunk-WPYT4UKJ.js → chunk-3I5AAJXV.js} +3 -3
  14. package/dist/{chunk-WPYT4UKJ.js.map → chunk-3I5AAJXV.js.map} +1 -1
  15. package/dist/{chunk-YDYVJM4I.js → chunk-53BAULDP.js} +4 -4
  16. package/dist/{chunk-YDYVJM4I.js.map → chunk-53BAULDP.js.map} +1 -1
  17. package/dist/{chunk-3CGROPF3.mjs → chunk-BTPUYUQ3.mjs} +2 -2
  18. package/dist/{chunk-VC5A3AUZ.mjs → chunk-EYA2F6AN.mjs} +696 -4
  19. package/dist/chunk-EYA2F6AN.mjs.map +1 -0
  20. package/dist/{chunk-G6UE6MEP.js → chunk-HKZAESS6.js} +896 -204
  21. package/dist/chunk-HKZAESS6.js.map +1 -0
  22. package/dist/{chunk-W7F5BDSV.mjs → chunk-KQHBONL4.mjs} +2 -2
  23. package/dist/{chunk-TY5FYP3G.js → chunk-LQ4DK4JF.js} +1919 -1056
  24. package/dist/chunk-LQ4DK4JF.js.map +1 -0
  25. package/dist/{chunk-S4YJ5WCW.js → chunk-RL3PL3KV.js} +8 -8
  26. package/dist/{chunk-S4YJ5WCW.js.map → chunk-RL3PL3KV.js.map} +1 -1
  27. package/dist/{chunk-X5WIUUNG.mjs → chunk-SFYPXCK4.mjs} +3237 -2374
  28. package/dist/chunk-SFYPXCK4.mjs.map +1 -0
  29. package/dist/{chunk-6SQYXUVP.mjs → chunk-Y7RR57EW.mjs} +2 -2
  30. package/dist/client/index.js +4 -4
  31. package/dist/client/index.mjs +3 -3
  32. package/dist/components/index.js +4 -4
  33. package/dist/components/index.mjs +3 -3
  34. package/dist/contexts/index.js +4 -4
  35. package/dist/contexts/index.mjs +3 -3
  36. package/dist/core/index.d.mts +242 -3
  37. package/dist/core/index.d.ts +242 -3
  38. package/dist/core/index.js +34 -2
  39. package/dist/core/index.js.map +1 -1
  40. package/dist/core/index.mjs +33 -1
  41. package/dist/email-automations/index.js +92 -92
  42. package/dist/email-automations/index.mjs +3 -3
  43. package/dist/features/analytics/index.js +121 -121
  44. package/dist/features/analytics/index.mjs +4 -4
  45. package/dist/features/analytics/server.js +4 -4
  46. package/dist/features/analytics/server.mjs +2 -2
  47. package/dist/features/blog/index.js +150 -150
  48. package/dist/features/blog/index.mjs +3 -3
  49. package/dist/features/blog/server.js +16 -16
  50. package/dist/features/blog/server.mjs +1 -1
  51. package/dist/features/help/index.js +37 -37
  52. package/dist/features/help/index.mjs +3 -3
  53. package/dist/features/tokenusage/index.js +124 -124
  54. package/dist/features/tokenusage/index.mjs +4 -4
  55. package/dist/{stripe-promotion-code.interface-C3qqh3mi.d.ts → format-options-BtX3o0Yu.d.ts} +104 -1
  56. package/dist/{stripe-promotion-code.interface-ClZ7DxS9.d.mts → format-options-D6c7FNDi.d.mts} +104 -1
  57. package/dist/index.d.mts +2 -2
  58. package/dist/index.d.ts +2 -2
  59. package/dist/index.js +35 -3
  60. package/dist/index.js.map +1 -1
  61. package/dist/index.mjs +34 -2
  62. package/dist/server/index.js +3 -3
  63. package/dist/server/index.mjs +2 -2
  64. package/messages/en.json +125 -3
  65. package/messages/it.json +125 -3
  66. package/package.json +1 -1
  67. package/scripts/validate-messages.mjs +1 -0
  68. package/src/billing/index.ts +1 -0
  69. package/src/client/index.ts +4 -0
  70. package/src/core/index.ts +1 -0
  71. package/src/core/registry/ModuleRegistry.ts +3 -0
  72. package/src/features/billing/components/cards/InvoicesSummaryCard.tsx +3 -2
  73. package/src/features/billing/components/cards/SubscriptionSummaryCard.tsx +4 -3
  74. package/src/features/billing/components/containers/BillingDashboardContainer.tsx +22 -5
  75. package/src/features/billing/components/utils/__tests__/discount.spec.ts +59 -0
  76. package/src/features/billing/components/utils/__tests__/url.spec.ts +32 -0
  77. package/src/features/billing/components/utils/discount.ts +38 -0
  78. package/src/features/billing/components/utils/index.ts +2 -0
  79. package/src/features/billing/components/utils/url.ts +12 -0
  80. package/src/features/billing/contexts/CouponContext.tsx +211 -0
  81. package/src/features/billing/contexts/index.ts +1 -0
  82. package/src/features/billing/i18n-keys.ts +79 -0
  83. package/src/features/billing/stripe-coupon/components/containers/CouponContainer.tsx +38 -0
  84. package/src/features/billing/stripe-coupon/components/containers/index.ts +1 -0
  85. package/src/features/billing/stripe-coupon/components/details/CouponDetails.tsx +69 -0
  86. package/src/features/billing/stripe-coupon/components/details/index.ts +1 -0
  87. package/src/features/billing/stripe-coupon/components/forms/CouponCodeEditor.tsx +79 -0
  88. package/src/features/billing/stripe-coupon/components/forms/CouponCodeFields.tsx +192 -0
  89. package/src/features/billing/stripe-coupon/components/forms/CouponEditor.tsx +333 -0
  90. package/src/features/billing/stripe-coupon/components/forms/CouponRefresher.tsx +45 -0
  91. package/src/features/billing/stripe-coupon/components/forms/index.ts +6 -0
  92. package/src/features/billing/stripe-coupon/components/index.ts +5 -0
  93. package/src/features/billing/stripe-coupon/components/lists/CouponCodesList.tsx +61 -0
  94. package/src/features/billing/stripe-coupon/components/lists/CouponsList.tsx +67 -0
  95. package/src/features/billing/stripe-coupon/components/lists/index.ts +2 -0
  96. package/src/features/billing/stripe-coupon/components/widgets/CouponCodeActions.tsx +80 -0
  97. package/src/features/billing/stripe-coupon/components/widgets/index.ts +1 -0
  98. package/src/features/billing/stripe-coupon/data/__tests__/stripe-coupon-models.spec.ts +114 -0
  99. package/src/features/billing/stripe-coupon/data/index.ts +10 -0
  100. package/src/features/billing/stripe-coupon/data/stripe-coupon-code.fields.ts +8 -0
  101. package/src/features/billing/stripe-coupon/data/stripe-coupon-code.interface.ts +44 -0
  102. package/src/features/billing/stripe-coupon/data/stripe-coupon-code.service.ts +92 -0
  103. package/src/features/billing/stripe-coupon/data/stripe-coupon-code.ts +132 -0
  104. package/src/features/billing/stripe-coupon/data/stripe-coupon-offer.interface.ts +17 -0
  105. package/src/features/billing/stripe-coupon/data/stripe-coupon-offer.ts +70 -0
  106. package/src/features/billing/stripe-coupon/data/stripe-coupon.fields.ts +7 -0
  107. package/src/features/billing/stripe-coupon/data/stripe-coupon.interface.ts +51 -0
  108. package/src/features/billing/stripe-coupon/data/stripe-coupon.service.ts +93 -0
  109. package/src/features/billing/stripe-coupon/data/stripe-coupon.ts +165 -0
  110. package/src/features/billing/stripe-coupon/hooks/useStripeCouponCodeTableStructure.tsx +154 -0
  111. package/src/features/billing/stripe-coupon/hooks/useStripeCouponTableStructure.tsx +113 -0
  112. package/src/features/billing/stripe-coupon/index.ts +5 -0
  113. package/src/features/billing/stripe-coupon/stripe-coupon-code.module.ts +14 -0
  114. package/src/features/billing/stripe-coupon/stripe-coupon-offer.module.ts +10 -0
  115. package/src/features/billing/stripe-coupon/stripe-coupon.module.ts +18 -0
  116. package/src/features/billing/stripe-coupon/utils/__tests__/coupon-places.spec.ts +19 -0
  117. package/src/features/billing/stripe-coupon/utils/__tests__/to-promotion-code-result.spec.ts +41 -0
  118. package/src/features/billing/stripe-coupon/utils/coupon-places.ts +23 -0
  119. package/src/features/billing/stripe-coupon/utils/format-coupon-discount.ts +36 -0
  120. package/src/features/billing/stripe-coupon/utils/index.ts +3 -0
  121. package/src/features/billing/stripe-coupon/utils/to-promotion-code-result.ts +23 -0
  122. package/src/features/billing/stripe-customer/components/forms/PaymentMethodForm.tsx +24 -8
  123. package/src/features/billing/stripe-product/components/containers/ProductsListContainer.tsx +21 -6
  124. package/src/features/billing/stripe-promotion-code/components/PromoCodeInput.tsx +60 -19
  125. package/src/features/billing/stripe-promotion-code/data/stripe-promotion-code.interface.ts +2 -0
  126. package/src/features/billing/stripe-subscription/components/containers/SubscriptionsContainer.tsx +24 -5
  127. package/src/features/billing/stripe-subscription/components/lists/SubscriptionsList.tsx +21 -2
  128. package/src/features/billing/stripe-subscription/components/wizards/SubscriptionWizard.tsx +1 -1
  129. package/src/features/billing/stripe-subscription/components/wizards/WizardStepPaymentMethod.tsx +1 -1
  130. package/src/features/billing/stripe-subscription/components/wizards/WizardStepReview.tsx +60 -49
  131. package/src/features/billing/stripe-subscription/components/wizards/__tests__/review-card.spec.ts +34 -0
  132. package/src/features/billing/stripe-subscription/components/wizards/review-card.ts +24 -0
  133. package/src/features/billing/stripe-subscription/data/__tests__/stripe-subscription.discount.spec.ts +67 -0
  134. package/src/features/billing/stripe-subscription/data/stripe-subscription.interface.ts +14 -0
  135. package/src/features/billing/stripe-subscription/data/stripe-subscription.service.ts +39 -0
  136. package/src/features/billing/stripe-subscription/data/stripe-subscription.ts +21 -0
  137. package/src/features/billing/stripe-subscription/hooks/__tests__/wizard-payment.spec.ts +48 -0
  138. package/src/features/billing/stripe-subscription/hooks/__tests__/wizard-promotion.spec.ts +132 -0
  139. package/src/features/billing/stripe-subscription/hooks/useSubscriptionWizard.ts +138 -12
  140. package/src/features/billing/stripe-subscription/hooks/wizard-payment.ts +30 -0
  141. package/src/features/billing/stripe-subscription/hooks/wizard-promotion.ts +54 -0
  142. package/src/features/user/contexts/CurrentUserContext.tsx +25 -0
  143. package/src/features/user/contexts/__tests__/CurrentUserContext.spec.tsx +121 -2
  144. package/dist/chunk-G6UE6MEP.js.map +0 -1
  145. package/dist/chunk-TY5FYP3G.js.map +0 -1
  146. package/dist/chunk-VC5A3AUZ.mjs.map +0 -1
  147. package/dist/chunk-X5WIUUNG.mjs.map +0 -1
  148. /package/dist/{BlockNoteEditor-GVQCKRMU.mjs.map → BlockNoteEditor-NEYG5L6W.mjs.map} +0 -0
  149. /package/dist/{BlockNoteViewer-U6E3DS4F.mjs.map → BlockNoteViewer-3OUT5UFY.mjs.map} +0 -0
  150. /package/dist/{chunk-3CGROPF3.mjs.map → chunk-BTPUYUQ3.mjs.map} +0 -0
  151. /package/dist/{chunk-W7F5BDSV.mjs.map → chunk-KQHBONL4.mjs.map} +0 -0
  152. /package/dist/{chunk-6SQYXUVP.mjs.map → chunk-Y7RR57EW.mjs.map} +0 -0
@@ -0,0 +1,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-sm">{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
  }
@@ -2,14 +2,23 @@
2
2
 
3
3
  import { useTranslations } from "next-intl";
4
4
  import { AlertCircle } from "lucide-react";
5
+ import { useEffect, useState } from "react";
5
6
  import { Alert, AlertDescription, Badge, Button } from "../../../../../shadcnui";
6
7
  import { SectionHeader } from "../../../../../components/typography";
7
- import { formatCurrency, useBillingFormatOptions } from "../../../components/utils";
8
+ import {
9
+ applyDiscount,
10
+ formatCurrency,
11
+ promotionCodeDiscount,
12
+ subscriptionDiscount,
13
+ useBillingFormatOptions,
14
+ } from "../../../components/utils";
8
15
  import { StripePriceInterface } from "../../../stripe-price/data/stripe-price.interface";
9
16
  import { ProrationPreviewInterface } from "../../../stripe-invoice/data/stripe-invoice.interface";
10
17
  import { PromotionCodeValidationResult } from "../../../stripe-promotion-code";
11
18
  import { PromoCodeInput } from "../../../stripe-promotion-code/components/PromoCodeInput";
12
19
  import { StripeSubscriptionInterface } from "../../data";
20
+ import { StripeCustomerService } from "../../../stripe-customer/data/stripe-customer.service";
21
+ import { ReviewCard, reviewCard } from "./review-card";
13
22
 
14
23
  type WizardStepReviewProps = {
15
24
  selectedPrice: StripePriceInterface | null;
@@ -50,6 +59,29 @@ export function WizardStepReview({
50
59
  }: WizardStepReviewProps) {
51
60
  const t = useTranslations();
52
61
  const fmt = useBillingFormatOptions();
62
+ const [card, setCard] = useState<ReviewCard | null>(null);
63
+
64
+ // Show which card is on file. The step remounts after the payment step, so a
65
+ // card just added is read again here.
66
+ useEffect(() => {
67
+ if (!hasPaymentMethod) {
68
+ setCard(null);
69
+ return;
70
+ }
71
+ let cancelled = false;
72
+ Promise.all([StripeCustomerService.listPaymentMethods(), StripeCustomerService.getCustomer()])
73
+ .then(([methods, customer]) => {
74
+ if (!cancelled) setCard(reviewCard(methods, customer?.defaultPaymentMethodId));
75
+ })
76
+ .catch((err) => {
77
+ console.error("[WizardStepReview] Failed to load the card on file:", err);
78
+ if (!cancelled) setCard(null);
79
+ });
80
+ return () => {
81
+ cancelled = true;
82
+ };
83
+ }, [hasPaymentMethod]);
84
+
53
85
  if (!selectedPrice) {
54
86
  return <div className="text-center py-8 text-muted-foreground">{t("billing.ui.wizard.review.no_plan")}</div>;
55
87
  }
@@ -64,60 +96,28 @@ export function WizardStepReview({
64
96
  : t("billing.ui.wizard.review.interval_monthly");
65
97
  };
66
98
 
67
- // Calculate discounted price if promotion code is applied
68
- const calculateDiscountedPrice = (): number | null => {
69
- if (!promotionCode?.valid || !selectedPrice.unitAmount) return null;
70
-
71
- const originalPrice = selectedPrice.unitAmount;
72
-
73
- if (promotionCode.discountType === "percent_off" && promotionCode.discountValue) {
74
- return originalPrice * (1 - promotionCode.discountValue / 100);
75
- }
76
-
77
- if (promotionCode.discountType === "amount_off" && promotionCode.discountValue) {
78
- // amount_off is in cents, same as unitAmount
79
- return Math.max(0, originalPrice - promotionCode.discountValue);
80
- }
99
+ // A code (typed or automatic) replaces the subscription's discount in Stripe.
100
+ // Without one, a plan change keeps the discount the subscription already carries.
101
+ const codeDiscount = promotionCodeDiscount(promotionCode);
102
+ const carriedDiscount = isChangingPlan && !codeDiscount ? subscriptionDiscount(subscription) : null;
103
+ const discount = codeDiscount ?? carriedDiscount;
81
104
 
82
- return null;
83
- };
84
-
85
- const discountedPrice = calculateDiscountedPrice();
105
+ const discountedPrice = applyDiscount(selectedPrice.unitAmount, discount);
86
106
 
87
107
  // Calculate discounted immediate charge for proration preview
88
- const calculateDiscountedImmediateCharge = (): number | null => {
89
- if (!promotionCode?.valid || !prorationPreview?.immediateCharge) return null;
90
-
91
- const originalCharge = prorationPreview.immediateCharge;
92
-
93
- if (promotionCode.discountType === "percent_off" && promotionCode.discountValue) {
94
- return originalCharge * (1 - promotionCode.discountValue / 100);
95
- }
96
-
97
- if (promotionCode.discountType === "amount_off" && promotionCode.discountValue) {
98
- return Math.max(0, originalCharge - promotionCode.discountValue);
99
- }
100
-
101
- return null;
102
- };
103
-
104
- const discountedImmediateCharge = calculateDiscountedImmediateCharge();
108
+ const discountedImmediateCharge = applyDiscount(prorationPreview?.immediateCharge, discount);
105
109
 
106
110
  // Format the discount description
107
111
  const getDiscountDescription = (): string | null => {
108
- if (!promotionCode?.valid) return null;
112
+ if (!discount) return null;
109
113
 
110
- if (promotionCode.discountType === "percent_off" && promotionCode.discountValue) {
111
- return t("billing.ui.wizard.review.percent_off", { value: promotionCode.discountValue });
114
+ if (discount.discountType === "percent_off") {
115
+ return t("billing.ui.wizard.review.percent_off", { value: discount.discountValue });
112
116
  }
113
117
 
114
- if (promotionCode.discountType === "amount_off" && promotionCode.discountValue) {
115
- return t("billing.ui.wizard.review.amount_off", {
116
- amount: formatCurrency(promotionCode.discountValue, promotionCode.currency || selectedPrice.currency, fmt),
117
- });
118
- }
119
-
120
- return null;
118
+ return t("billing.ui.wizard.review.amount_off", {
119
+ amount: formatCurrency(discount.discountValue, discount.currency || selectedPrice.currency, fmt),
120
+ });
121
121
  };
122
122
 
123
123
  const discountDescription = getDiscountDescription();
@@ -142,6 +142,9 @@ export function WizardStepReview({
142
142
  {formatCurrency(discountedPrice, selectedPrice.currency, fmt)}
143
143
  </p>
144
144
  {discountDescription && <Badge variant="softGreen">{discountDescription}</Badge>}
145
+ {carriedDiscount && (
146
+ <p className="text-sm text-muted-foreground">{t("billing.ui.wizard.review.discount_carries_over")}</p>
147
+ )}
145
148
  </>
146
149
  ) : (
147
150
  <p className="font-semibold text-lg">
@@ -165,7 +168,9 @@ export function WizardStepReview({
165
168
  </h4>
166
169
  <p className={`text-sm ${isTrialUpgrade ? "text-warning" : "text-foreground"}`}>
167
170
  {isTrialUpgrade
168
- ? t("billing.ui.wizard.review.trial_upgrade_description")
171
+ ? discountedImmediateCharge !== null
172
+ ? t("billing.ui.wizard.review.trial_upgrade_description_discounted")
173
+ : t("billing.ui.wizard.review.trial_upgrade_description")
169
174
  : t("billing.ui.wizard.review.proration_description")}
170
175
  </p>
171
176
  <div className="flex justify-between text-sm">
@@ -210,11 +215,17 @@ export function WizardStepReview({
210
215
  <h4 className="font-medium">{t("billing.ui.payment_method.title")}</h4>
211
216
  <p className="text-sm text-muted-foreground">
212
217
  {hasPaymentMethod
213
- ? t("billing.ui.wizard.review.payment_on_file")
218
+ ? card
219
+ ? t("billing.ui.wizard.review.card_on_file", { brand: card.brand, last4: card.last4 })
220
+ : t("billing.ui.wizard.review.payment_on_file")
214
221
  : t("billing.ui.wizard.review.no_payment_on_file")}
215
222
  </p>
216
223
  </div>
217
- {!hasPaymentMethod && (
224
+ {hasPaymentMethod ? (
225
+ <Button variant="outline" onClick={onAddPaymentMethod} disabled={isProcessing}>
226
+ {t("billing.ui.wizard.review.use_another_card")}
227
+ </Button>
228
+ ) : (
218
229
  <Button variant="outline" onClick={onAddPaymentMethod}>
219
230
  {t("billing.ui.payment_methods.add")}
220
231
  </Button>