@carlonicora/nextjs-jsonapi 6.1.0 → 6.3.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 (127) hide show
  1. package/dist/{AssistantMessageInterface-S7oyERTT.d.mts → AssistantMessageInterface-0ZQ1EJKO.d.mts} +1 -1
  2. package/dist/{AssistantMessageInterface-DHJJO_Z_.d.ts → AssistantMessageInterface-CA-hZ5Wj.d.ts} +1 -1
  3. package/dist/{BlockNoteEditor-SAU3G6H7.js → BlockNoteEditor-CT4XUXPC.js} +19 -19
  4. package/dist/{BlockNoteEditor-SAU3G6H7.js.map → BlockNoteEditor-CT4XUXPC.js.map} +1 -1
  5. package/dist/{BlockNoteEditor-FZNU4YQT.mjs → BlockNoteEditor-M6UQRVRK.mjs} +4 -4
  6. package/dist/{BlockNoteViewer-7BTV7QQA.js → BlockNoteViewer-MFLWF3Y2.js} +3 -3
  7. package/dist/{BlockNoteViewer-7BTV7QQA.js.map → BlockNoteViewer-MFLWF3Y2.js.map} +1 -1
  8. package/dist/{BlockNoteViewer-6XDDVTN6.mjs → BlockNoteViewer-XZJ5Y7WN.mjs} +2 -2
  9. package/dist/{BlogPostInterface-BB_4wcDR.d.mts → BlogPostInterface-BN2mXc46.d.mts} +1 -1
  10. package/dist/{BlogPostInterface-bWAT1rfR.d.ts → BlogPostInterface-CTGfsR1I.d.ts} +1 -1
  11. package/dist/{auth.interface-CSzJ1oP1.d.ts → auth.interface-D5606g19.d.ts} +1 -1
  12. package/dist/{auth.interface-DD4uQv2K.d.mts → auth.interface-tsOuc0Fi.d.mts} +1 -1
  13. package/dist/billing/index.d.mts +3 -3
  14. package/dist/billing/index.d.ts +3 -3
  15. package/dist/billing/index.js +501 -493
  16. package/dist/billing/index.js.map +1 -1
  17. package/dist/billing/index.mjs +13 -5
  18. package/dist/billing/index.mjs.map +1 -1
  19. package/dist/{chunk-USYZNWKS.mjs → chunk-3FD6Y734.mjs} +74 -15
  20. package/dist/chunk-3FD6Y734.mjs.map +1 -0
  21. package/dist/{chunk-F4JB7YAP.js → chunk-DSGP6GC3.js} +8 -8
  22. package/dist/{chunk-F4JB7YAP.js.map → chunk-DSGP6GC3.js.map} +1 -1
  23. package/dist/{chunk-GFGKMUPZ.mjs → chunk-O2TEUH5S.mjs} +2 -2
  24. package/dist/{chunk-MTI2UXDA.js → chunk-QJIDORHM.js} +4 -4
  25. package/dist/{chunk-MTI2UXDA.js.map → chunk-QJIDORHM.js.map} +1 -1
  26. package/dist/{chunk-JVJS5RHB.mjs → chunk-RUJCWOJE.mjs} +2 -2
  27. package/dist/{chunk-U7DR5VDT.mjs → chunk-TIT4CJYQ.mjs} +1233 -1181
  28. package/dist/chunk-TIT4CJYQ.mjs.map +1 -0
  29. package/dist/{chunk-TPZS46IM.js → chunk-V4I3XKOM.js} +3 -3
  30. package/dist/{chunk-TPZS46IM.js.map → chunk-V4I3XKOM.js.map} +1 -1
  31. package/dist/{chunk-T3YQ47EF.js → chunk-XZCCIKX3.js} +152 -93
  32. package/dist/chunk-XZCCIKX3.js.map +1 -0
  33. package/dist/{chunk-IPYXT7MH.js → chunk-Y7JIQNM6.js} +922 -870
  34. package/dist/chunk-Y7JIQNM6.js.map +1 -0
  35. package/dist/{chunk-RYZYKG6W.mjs → chunk-ZDUT5JPT.mjs} +2 -2
  36. package/dist/client/index.d.mts +4 -4
  37. package/dist/client/index.d.ts +4 -4
  38. package/dist/client/index.js +4 -4
  39. package/dist/client/index.mjs +3 -3
  40. package/dist/components/index.d.mts +22 -6
  41. package/dist/components/index.d.ts +22 -6
  42. package/dist/components/index.js +10 -4
  43. package/dist/components/index.js.map +1 -1
  44. package/dist/components/index.mjs +10 -4
  45. package/dist/contexts/index.d.mts +5 -5
  46. package/dist/contexts/index.d.ts +5 -5
  47. package/dist/contexts/index.js +4 -4
  48. package/dist/contexts/index.mjs +3 -3
  49. package/dist/core/index.d.mts +23 -13
  50. package/dist/core/index.d.ts +23 -13
  51. package/dist/core/index.js +8 -2
  52. package/dist/core/index.js.map +1 -1
  53. package/dist/core/index.mjs +7 -1
  54. package/dist/{currency-DrEVd_Xs.d.mts → currency-BhuEoBOn.d.mts} +2 -115
  55. package/dist/{currency-8ErEC39n.d.ts → currency-CBgZkS0u.d.ts} +2 -115
  56. package/dist/email-automations/index.js +92 -92
  57. package/dist/email-automations/index.mjs +3 -3
  58. package/dist/features/analytics/index.d.mts +11 -3
  59. package/dist/features/analytics/index.d.ts +11 -3
  60. package/dist/features/analytics/index.js +129 -127
  61. package/dist/features/analytics/index.js.map +1 -1
  62. package/dist/features/analytics/index.mjs +12 -10
  63. package/dist/features/analytics/index.mjs.map +1 -1
  64. package/dist/features/analytics/server.js +4 -4
  65. package/dist/features/analytics/server.mjs +2 -2
  66. package/dist/features/blog/index.d.mts +4 -4
  67. package/dist/features/blog/index.d.ts +4 -4
  68. package/dist/features/blog/index.js +150 -150
  69. package/dist/features/blog/index.mjs +3 -3
  70. package/dist/features/blog/server.d.mts +4 -4
  71. package/dist/features/blog/server.d.ts +4 -4
  72. package/dist/features/blog/server.js +16 -16
  73. package/dist/features/blog/server.mjs +1 -1
  74. package/dist/features/help/index.js +37 -37
  75. package/dist/features/help/index.mjs +3 -3
  76. package/dist/features/tokenusage/index.js +127 -127
  77. package/dist/features/tokenusage/index.mjs +4 -4
  78. package/dist/index.d.mts +8 -8
  79. package/dist/index.d.ts +8 -8
  80. package/dist/index.js +9 -3
  81. package/dist/index.js.map +1 -1
  82. package/dist/index.mjs +8 -2
  83. package/dist/{notification.interface-BB6GPpZY.d.mts → notification.interface-CF5h90U2.d.mts} +1 -1
  84. package/dist/{notification.interface-CmAGrijJ.d.ts → notification.interface-DQReXAD7.d.ts} +1 -1
  85. package/dist/{s3.service-Bz_K-tjz.d.ts → s3.service-BTCpmp5g.d.ts} +11 -4
  86. package/dist/{s3.service-D5zNCEW3.d.mts → s3.service-CzFBDEuQ.d.mts} +11 -4
  87. package/dist/server/index.d.mts +5 -5
  88. package/dist/server/index.d.ts +5 -5
  89. package/dist/server/index.js +3 -3
  90. package/dist/server/index.mjs +2 -2
  91. package/dist/stripe-price.interface-D6mKjAdr.d.mts +132 -0
  92. package/dist/stripe-price.interface-DGqcklFJ.d.ts +132 -0
  93. package/dist/{useSocket-B0H9pmmH.d.ts → useSocket-BM3q9A0C.d.ts} +1 -1
  94. package/dist/{useSocket-B5RQb6v2.d.mts → useSocket-CwSoq6VS.d.mts} +1 -1
  95. package/dist/{user.interface-CIWGlbqM.d.mts → user.interface-BYnGJ7ss.d.mts} +37 -2
  96. package/dist/{user.interface-C0ZdsBZj.d.ts → user.interface-BqPjVyJI.d.ts} +37 -2
  97. package/messages/en.json +8 -0
  98. package/messages/it.json +8 -0
  99. package/package.json +1 -1
  100. package/src/components/containers/RoundPageContainer.tsx +7 -2
  101. package/src/components/index.ts +5 -0
  102. package/src/features/analytics/components/AnalyticsTracker.tsx +22 -9
  103. package/src/features/analytics/components/__tests__/AnalyticsTracker.spec.tsx +67 -0
  104. package/src/features/analytics/data/__tests__/models.spec.ts +13 -0
  105. package/src/features/analytics/data/analytics-event.ts +1 -0
  106. package/src/features/analytics/data/analytics.types.ts +2 -0
  107. package/src/features/billing/components/containers/BillingDashboardContainer.tsx +20 -3
  108. package/src/features/billing/components/widgets/CreditWarningBanner.tsx +93 -0
  109. package/src/features/billing/data/credit-warning.spec.ts +96 -0
  110. package/src/features/billing/data/credit-warning.ts +57 -0
  111. package/src/features/billing/data/index.ts +3 -0
  112. package/src/features/billing/i18n-keys.ts +6 -0
  113. package/src/features/company/data/company.interface.ts +11 -0
  114. package/src/features/company/data/company.service.ts +17 -0
  115. package/src/features/company/data/company.spec.ts +29 -0
  116. package/src/features/company/data/company.ts +31 -0
  117. package/dist/chunk-IPYXT7MH.js.map +0 -1
  118. package/dist/chunk-T3YQ47EF.js.map +0 -1
  119. package/dist/chunk-U7DR5VDT.mjs.map +0 -1
  120. package/dist/chunk-USYZNWKS.mjs.map +0 -1
  121. package/dist/feature.interface-BO25VLlx.d.ts +0 -19
  122. package/dist/feature.interface-CXb1-vNq.d.mts +0 -19
  123. /package/dist/{BlockNoteEditor-FZNU4YQT.mjs.map → BlockNoteEditor-M6UQRVRK.mjs.map} +0 -0
  124. /package/dist/{BlockNoteViewer-6XDDVTN6.mjs.map → BlockNoteViewer-XZJ5Y7WN.mjs.map} +0 -0
  125. /package/dist/{chunk-GFGKMUPZ.mjs.map → chunk-O2TEUH5S.mjs.map} +0 -0
  126. /package/dist/{chunk-JVJS5RHB.mjs.map → chunk-RUJCWOJE.mjs.map} +0 -0
  127. /package/dist/{chunk-RYZYKG6W.mjs.map → chunk-ZDUT5JPT.mjs.map} +0 -0
@@ -0,0 +1,132 @@
1
+ import { A as ApiDataInterface } from './ApiDataInterface-BcZeXy5X.js';
2
+
3
+ interface ModuleInterface extends ApiDataInterface {
4
+ get name(): string;
5
+ get permissions(): {
6
+ create: boolean | string;
7
+ read: boolean | string;
8
+ update: boolean | string;
9
+ delete: boolean | string;
10
+ };
11
+ }
12
+
13
+ interface FeatureInterface extends ApiDataInterface {
14
+ get name(): string;
15
+ get isCore(): boolean;
16
+ get modules(): ModuleInterface[];
17
+ }
18
+
19
+ declare enum SubscriptionStatus {
20
+ ACTIVE = "active",
21
+ PAST_DUE = "past_due",
22
+ UNPAID = "unpaid",
23
+ CANCELED = "canceled",
24
+ INCOMPLETE = "incomplete",
25
+ INCOMPLETE_EXPIRED = "incomplete_expired",
26
+ TRIALING = "trialing",
27
+ PAUSED = "paused"
28
+ }
29
+ /**
30
+ * The discount Stripe applies to the subscription (read-only, synced from Stripe).
31
+ * Exactly one of percentOff / amountOff is set.
32
+ */
33
+ type StripeSubscriptionDiscount = {
34
+ percentOff?: number;
35
+ amountOff?: number;
36
+ currency?: string;
37
+ duration: "forever" | "once" | "repeating";
38
+ durationInMonths?: number;
39
+ end?: Date;
40
+ };
41
+ interface StripeSubscriptionInterface extends ApiDataInterface {
42
+ get stripeSubscriptionId(): string;
43
+ get status(): SubscriptionStatus;
44
+ get currentPeriodStart(): Date;
45
+ get currentPeriodEnd(): Date;
46
+ get cancelAtPeriodEnd(): boolean;
47
+ get canceledAt(): Date | undefined;
48
+ get trialStart(): Date | undefined;
49
+ get trialEnd(): Date | undefined;
50
+ get price(): StripePriceInterface | undefined;
51
+ get discount(): StripeSubscriptionDiscount | undefined;
52
+ }
53
+ type StripeSubscriptionInput = {
54
+ id: string;
55
+ priceId?: string;
56
+ newPriceId?: string;
57
+ cancelImmediately?: boolean;
58
+ quantity?: number;
59
+ trialPeriodDays?: number;
60
+ paymentMethodId?: string;
61
+ metadata?: Record<string, any>;
62
+ promotionCode?: string;
63
+ };
64
+ interface StripeSubscriptionCreateMeta {
65
+ clientSecret: string | null;
66
+ paymentIntentId: string | null;
67
+ requiresAction: boolean;
68
+ }
69
+ interface StripeSubscriptionCreateResponse {
70
+ subscription: StripeSubscriptionInterface;
71
+ meta: StripeSubscriptionCreateMeta;
72
+ }
73
+
74
+ interface StripeProductInterface extends ApiDataInterface {
75
+ get stripeProductId(): string;
76
+ get name(): string;
77
+ get description(): string | undefined;
78
+ get active(): boolean;
79
+ get metadata(): Record<string, any> | undefined;
80
+ get stripePrices(): StripePriceInterface[];
81
+ }
82
+ type StripeProductInput = {
83
+ id: string;
84
+ name?: string;
85
+ description?: string;
86
+ active?: boolean;
87
+ metadata?: Record<string, any>;
88
+ };
89
+
90
+ interface StripePriceInterface extends ApiDataInterface {
91
+ get stripePriceId(): string;
92
+ get productId(): string;
93
+ get product(): StripeProductInterface | undefined;
94
+ get active(): boolean;
95
+ get currency(): string;
96
+ get unitAmount(): number | undefined;
97
+ get recurring(): PriceRecurring | undefined;
98
+ get priceType(): "one_time" | "recurring";
99
+ get nickname(): string | undefined;
100
+ get lookupKey(): string | undefined;
101
+ get metadata(): Record<string, any> | undefined;
102
+ get description(): string | undefined;
103
+ get features(): string[] | undefined;
104
+ get token(): number | undefined;
105
+ get isTrial(): boolean | undefined;
106
+ get priceFeatures(): FeatureInterface[];
107
+ }
108
+ interface PriceRecurring {
109
+ interval: "day" | "week" | "month" | "year";
110
+ intervalCount: number;
111
+ usageType?: "metered" | "licensed";
112
+ }
113
+ type StripePriceInput = {
114
+ id: string;
115
+ productId?: string;
116
+ currency?: string;
117
+ unitAmount?: number;
118
+ nickname?: string;
119
+ recurring?: {
120
+ interval: "day" | "week" | "month" | "year";
121
+ intervalCount?: number;
122
+ usageType?: "metered" | "licensed";
123
+ };
124
+ metadata?: Record<string, any>;
125
+ description?: string;
126
+ features?: string[];
127
+ token?: number;
128
+ isTrial?: boolean;
129
+ featureIds?: string[];
130
+ };
131
+
132
+ export { type FeatureInterface as F, type ModuleInterface as M, type PriceRecurring as P, type StripeSubscriptionInterface as S, type StripePriceInterface as a, type StripePriceInput as b, type StripeProductInterface as c, type StripeProductInput as d, SubscriptionStatus as e, type StripeSubscriptionDiscount as f, type StripeSubscriptionInput as g, type StripeSubscriptionCreateMeta as h, type StripeSubscriptionCreateResponse as i };
@@ -1,4 +1,4 @@
1
- import { N as NotificationInterface } from './notification.interface-CmAGrijJ.js';
1
+ import { N as NotificationInterface } from './notification.interface-DQReXAD7.js';
2
2
 
3
3
  interface UseSocketOptions {
4
4
  token: string;
@@ -1,4 +1,4 @@
1
- import { N as NotificationInterface } from './notification.interface-BB6GPpZY.mjs';
1
+ import { N as NotificationInterface } from './notification.interface-CF5h90U2.mjs';
2
2
 
3
3
  interface UseSocketOptions {
4
4
  token: string;
@@ -1,5 +1,31 @@
1
1
  import { A as ApiDataInterface } from './ApiDataInterface-BcZeXy5X.mjs';
2
- import { F as FeatureInterface, M as ModuleInterface } from './feature.interface-CXb1-vNq.mjs';
2
+ import { S as StripeSubscriptionInterface, F as FeatureInterface, M as ModuleInterface } from './stripe-price.interface-D6mKjAdr.mjs';
3
+
4
+ /**
5
+ * The offer the server chose when it sent this period's warning.
6
+ * Mirrors the backend `CreditWarningOffer`.
7
+ */
8
+ type CreditWarningOffer = "subscribe" | "upgrade-or-pack" | "pack";
9
+ /**
10
+ * Child segment of the dismiss route: `POST companies/:companyId/credit-warning/dismiss`.
11
+ * Must match the backend `CREDIT_WARNING_DISMISS_SEGMENT`.
12
+ */
13
+ declare const CREDIT_WARNING_DISMISS_SEGMENT = "credit-warning/dismiss";
14
+ /**
15
+ * Whether the in-app credit warning should show for this company.
16
+ *
17
+ * True only when the server sent the warning this period and nobody dismissed it,
18
+ * the company has AI and an active subscription, and the credits left are at or
19
+ * below `100 - thresholdPercent` percent of this period's grant. At 0 credits
20
+ * (or below) it is false: `OutOfCreditsBanner` owns that state.
21
+ */
22
+ declare function isCreditWarningActive(company: CompanyInterface, thresholdPercent: number): boolean;
23
+ /**
24
+ * The subscription the `?action=change-plan` deep link opens the wizard on: the
25
+ * first active or trialing recurring subscription, in the dashboard's own order.
26
+ * `undefined` means there is none, and the subscribe wizard opens instead.
27
+ */
28
+ declare function resolveChangePlanTarget(subscriptions: StripeSubscriptionInterface[]): StripeSubscriptionInterface | undefined;
3
29
 
4
30
  type CompanyInput = {
5
31
  id: string;
@@ -37,6 +63,15 @@ interface CompanyInterface extends ApiDataInterface {
37
63
  * defaults an absent attribute to `true`, so the getter always has a value.
38
64
  */
39
65
  get aiEnabled(): boolean;
66
+ /**
67
+ * Credit-warning state, written by the server only (never sent by
68
+ * `createJsonApi()`). All four are absent until the feature is on and the
69
+ * company has received a monthly grant.
70
+ */
71
+ get creditsGrantedThisPeriod(): number | undefined;
72
+ get creditWarningSentAt(): Date | undefined;
73
+ get creditWarningDismissedAt(): Date | undefined;
74
+ get creditWarningOffer(): CreditWarningOffer | undefined;
40
75
  get features(): FeatureInterface[];
41
76
  get modules(): ModuleInterface[];
42
77
  get legal_address(): string | undefined;
@@ -99,4 +134,4 @@ interface UserInterface extends ApiDataInterface {
99
134
  get modules(): ModuleInterface[];
100
135
  }
101
136
 
102
- export type { CompanyInput as C, RoleInput as R, UserInterface as U, CompanyInterface as a, RoleInterface as b, UserInput as c };
137
+ export { type CreditWarningOffer as C, type RoleInput as R, type UserInterface as U, CREDIT_WARNING_DISMISS_SEGMENT as a, type CompanyInput as b, type CompanyInterface as c, type RoleInterface as d, type UserInput as e, isCreditWarningActive as i, resolveChangePlanTarget as r };
@@ -1,5 +1,31 @@
1
1
  import { A as ApiDataInterface } from './ApiDataInterface-BcZeXy5X.js';
2
- import { F as FeatureInterface, M as ModuleInterface } from './feature.interface-BO25VLlx.js';
2
+ import { S as StripeSubscriptionInterface, F as FeatureInterface, M as ModuleInterface } from './stripe-price.interface-DGqcklFJ.js';
3
+
4
+ /**
5
+ * The offer the server chose when it sent this period's warning.
6
+ * Mirrors the backend `CreditWarningOffer`.
7
+ */
8
+ type CreditWarningOffer = "subscribe" | "upgrade-or-pack" | "pack";
9
+ /**
10
+ * Child segment of the dismiss route: `POST companies/:companyId/credit-warning/dismiss`.
11
+ * Must match the backend `CREDIT_WARNING_DISMISS_SEGMENT`.
12
+ */
13
+ declare const CREDIT_WARNING_DISMISS_SEGMENT = "credit-warning/dismiss";
14
+ /**
15
+ * Whether the in-app credit warning should show for this company.
16
+ *
17
+ * True only when the server sent the warning this period and nobody dismissed it,
18
+ * the company has AI and an active subscription, and the credits left are at or
19
+ * below `100 - thresholdPercent` percent of this period's grant. At 0 credits
20
+ * (or below) it is false: `OutOfCreditsBanner` owns that state.
21
+ */
22
+ declare function isCreditWarningActive(company: CompanyInterface, thresholdPercent: number): boolean;
23
+ /**
24
+ * The subscription the `?action=change-plan` deep link opens the wizard on: the
25
+ * first active or trialing recurring subscription, in the dashboard's own order.
26
+ * `undefined` means there is none, and the subscribe wizard opens instead.
27
+ */
28
+ declare function resolveChangePlanTarget(subscriptions: StripeSubscriptionInterface[]): StripeSubscriptionInterface | undefined;
3
29
 
4
30
  type CompanyInput = {
5
31
  id: string;
@@ -37,6 +63,15 @@ interface CompanyInterface extends ApiDataInterface {
37
63
  * defaults an absent attribute to `true`, so the getter always has a value.
38
64
  */
39
65
  get aiEnabled(): boolean;
66
+ /**
67
+ * Credit-warning state, written by the server only (never sent by
68
+ * `createJsonApi()`). All four are absent until the feature is on and the
69
+ * company has received a monthly grant.
70
+ */
71
+ get creditsGrantedThisPeriod(): number | undefined;
72
+ get creditWarningSentAt(): Date | undefined;
73
+ get creditWarningDismissedAt(): Date | undefined;
74
+ get creditWarningOffer(): CreditWarningOffer | undefined;
40
75
  get features(): FeatureInterface[];
41
76
  get modules(): ModuleInterface[];
42
77
  get legal_address(): string | undefined;
@@ -99,4 +134,4 @@ interface UserInterface extends ApiDataInterface {
99
134
  get modules(): ModuleInterface[];
100
135
  }
101
136
 
102
- export type { CompanyInput as C, RoleInput as R, UserInterface as U, CompanyInterface as a, RoleInterface as b, UserInput as c };
137
+ export { type CreditWarningOffer as C, type RoleInput as R, type UserInterface as U, CREDIT_WARNING_DISMISS_SEGMENT as a, type CompanyInput as b, type CompanyInterface as c, type RoleInterface as d, type UserInput as e, isCreditWarningActive as i, resolveChangePlanTarget as r };
package/messages/en.json CHANGED
@@ -628,6 +628,14 @@
628
628
  "subscribe": "Subscribe",
629
629
  "title": "Confirm Your Subscription"
630
630
  },
631
+ "credit_warning": {
632
+ "title": "You've used {percent}% of your AI credits.",
633
+ "body": "Everything except AI keeps working when they run out.",
634
+ "subscribe": "Subscribe",
635
+ "upgrade": "Upgrade your plan",
636
+ "add_pack": "Add a credit pack",
637
+ "dismiss": "Dismiss"
638
+ },
631
639
  "dashboard": {
632
640
  "errors": {
633
641
  "load_customer": "Failed to load billing account",
package/messages/it.json CHANGED
@@ -628,6 +628,14 @@
628
628
  "subscribe": "Abbonati",
629
629
  "title": "Conferma il tuo abbonamento"
630
630
  },
631
+ "credit_warning": {
632
+ "title": "Hai usato l'{percent}% dei tuoi crediti IA.",
633
+ "body": "Quando finiscono, tutto continua a funzionare tranne l'IA.",
634
+ "subscribe": "Abbonati",
635
+ "upgrade": "Passa a un piano superiore",
636
+ "add_pack": "Aggiungi un pacchetto di crediti",
637
+ "dismiss": "Chiudi"
638
+ },
631
639
  "dashboard": {
632
640
  "errors": {
633
641
  "load_customer": "Impossibile caricare l'account di fatturazione",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlonicora/nextjs-jsonapi",
3
- "version": "6.1.0",
3
+ "version": "6.3.0",
4
4
  "description": "Next.js JSON:API client with server/client support and caching",
5
5
  "author": "Carlo Nicora",
6
6
  "license": "GPL-3.0-or-later",
@@ -239,9 +239,14 @@ export function RoundPageContainer({
239
239
  // between the shell and the content is dropped so the document is the only
240
240
  // scroller; leaving a single `overflow-hidden` in that chain silently
241
241
  // truncates the page instead of letting it grow.
242
+ //
243
+ // `--app-banner-h` is the height of an app banner drawn above the header (a
244
+ // demo bar, a credit warning). Unset, it is 0 and the height is unchanged; an
245
+ // app that renders such a banner publishes its live height on <html>, or the
246
+ // banner pushes the page past the viewport and the document scrolls it away.
242
247
  const shellHeight = isFixed
243
- ? `h-[calc(100svh-var(--app-header-h,3rem))]`
244
- : `min-h-[calc(100svh-var(--app-header-h,3rem))]`;
248
+ ? `h-[calc(100svh-var(--app-header-h,3rem)-var(--app-banner-h,0px))]`
249
+ : `min-h-[calc(100svh-var(--app-header-h,3rem)-var(--app-banner-h,0px))]`;
245
250
  const clip = isFixed ? `overflow-hidden` : ``;
246
251
  const scrollY = isFixed ? `overflow-y-auto` : ``;
247
252
  // The bar is an in-flow sibling of the content (see MobileNavigationBar's own
@@ -24,6 +24,11 @@ export * from "../features/auth/components";
24
24
  export * from "../features/administration";
25
25
  export * from "../features/ai-connection";
26
26
  // Billing components moved to separate entry point: @carlonicora/nextjs-jsonapi/billing
27
+ // Exception: the credit warning banner mounts on every page, so it ships from this
28
+ // Stripe-free entry. Its import chain must never reach `@stripe/*`.
29
+ export { CreditWarningBanner } from "../features/billing/components/widgets/CreditWarningBanner";
30
+ export type { CreditWarningBannerProps } from "../features/billing/components/widgets/CreditWarningBanner";
31
+ export { isCreditWarningActive, CREDIT_WARNING_DISMISS_SEGMENT } from "../features/billing/data/credit-warning";
27
32
  export * from "../features/company/components";
28
33
  export * from "../features/content/components";
29
34
  export * from "../features/how-to/components";
@@ -20,7 +20,13 @@ type Attribution = Pick<
20
20
  >;
21
21
 
22
22
  type AnalyticsTrackerProps = {
23
- hasConsent: boolean;
23
+ /**
24
+ * `undefined` while the app has not read its consent store yet: the tracker sends
25
+ * nothing and leaves the visitor cookie alone. `false` when there is no consent
26
+ * (refused or not chosen yet): the cookie is cleared and page views go out without
27
+ * an id. Never pass `false` as a placeholder for "not read yet".
28
+ */
29
+ hasConsent?: boolean;
24
30
  resolveSection: (pathname: string) => "public" | "app";
25
31
  sensitivePrefixes?: string[];
26
32
  };
@@ -63,11 +69,16 @@ function maskSensitivePath(path: string, sensitivePrefixes: string[] | undefined
63
69
  export function AnalyticsTracker({ hasConsent, resolveSection, sensitivePrefixes }: AnalyticsTrackerProps): null {
64
70
  const pathname = usePathname();
65
71
  const lastPath = useRef<string | null>(null);
66
- const lastConsent = useRef<boolean>(false);
72
+ // The last consent value the app actually read. `undefined` until then, so a page
73
+ // load with consent already stored is never mistaken for the Accept click.
74
+ const lastKnownConsent = useRef<boolean | undefined>(undefined);
67
75
  const attribution = useRef<Attribution | null>(null);
68
76
 
69
77
  useEffect(() => {
70
- if (!hasConsent) clearVisitorCookie();
78
+ // Consent not read yet: send nothing and keep the cookie, or a visitor who
79
+ // already accepted would lose their id on every tracker mount.
80
+ if (hasConsent === undefined) return;
81
+ if (hasConsent === false) clearVisitorCookie();
71
82
 
72
83
  if (!pathname) return;
73
84
  if (typeof navigator !== "undefined" && navigator.webdriver) return;
@@ -75,12 +86,13 @@ export function AnalyticsTracker({ hasConsent, resolveSection, sensitivePrefixes
75
86
  const path = pathname.split(/[?#]/)[0] || "/";
76
87
  if (path.startsWith(EXCLUDED_PREFIX)) return;
77
88
 
78
- // One event per distinct path. The same path is sent again only when consent
79
- // was just granted, so the processor starts a new visitor on the cookie id.
89
+ // One event per distinct path. The same path is sent again only when the
90
+ // visitor clicks Accept (a known `false` turning `true`), flagged so the API
91
+ // moves the open cookieless session onto the cookie id.
80
92
  const isNewPath = lastPath.current !== path;
81
- const consentJustGranted = hasConsent && !lastConsent.current;
82
- lastConsent.current = hasConsent;
83
- if (!isNewPath && !consentJustGranted) return;
93
+ const consentGranted = hasConsent === true && lastKnownConsent.current === false;
94
+ lastKnownConsent.current = hasConsent;
95
+ if (!isNewPath && !consentGranted) return;
84
96
  lastPath.current = path;
85
97
 
86
98
  const sentPath = maskSensitivePath(path, sensitivePrefixes);
@@ -93,6 +105,7 @@ export function AnalyticsTracker({ hasConsent, resolveSection, sensitivePrefixes
93
105
  };
94
106
 
95
107
  if (hasConsent) input.visitorId = ensureVisitorCookie();
108
+ if (consentGranted) input.consentGranted = true;
96
109
 
97
110
  // The first event of the page load carries the attribution. The event sent
98
111
  // because consent was just granted carries it again, so the session opened
@@ -102,7 +115,7 @@ export function AnalyticsTracker({ hasConsent, resolveSection, sensitivePrefixes
102
115
  const referrer = readExternalReferrer();
103
116
  attribution.current = { ...(referrer ? { referrer } : {}), ...readUtm(window.location.search) };
104
117
  }
105
- if (isFirstEvent || consentJustGranted) Object.assign(input, attribution.current);
118
+ if (isFirstEvent || consentGranted) Object.assign(input, attribution.current);
106
119
 
107
120
  // AnalyticsService.track never rejects: it logs and swallows its own failures.
108
121
  void AnalyticsService.track(input);
@@ -215,6 +215,73 @@ describe("AnalyticsTracker", () => {
215
215
  expect(eventAt(1)).toEqual(expect.objectContaining({ path: "/pricing", visitorId: expect.stringMatching(UUID) }));
216
216
  });
217
217
 
218
+ it("sends nothing and keeps the cookie while consent is unknown", () => {
219
+ document.cookie = "analytics_visitor=keep-me; path=/";
220
+ mocks.pathname = "/";
221
+ const { rerender } = render(<AnalyticsTracker hasConsent={undefined} resolveSection={publicSection} />);
222
+
223
+ mocks.pathname = "/pricing";
224
+ rerender(<AnalyticsTracker hasConsent={undefined} resolveSection={publicSection} />);
225
+
226
+ expect(mocks.track).not.toHaveBeenCalled();
227
+ expect(document.cookie).toContain("analytics_visitor=keep-me");
228
+
229
+ rerender(<AnalyticsTracker hasConsent={true} resolveSection={publicSection} />);
230
+
231
+ expect(mocks.track).toHaveBeenCalledTimes(1);
232
+ expect(eventAt(0).path).toBe("/pricing");
233
+ });
234
+
235
+ it("keeps the existing cookie id when unknown consent resolves to accepted", () => {
236
+ document.cookie = "analytics_visitor=keep-me; path=/";
237
+ mocks.pathname = "/pricing";
238
+ const { rerender } = render(<AnalyticsTracker hasConsent={undefined} resolveSection={publicSection} />);
239
+ rerender(<AnalyticsTracker hasConsent={true} resolveSection={publicSection} />);
240
+
241
+ expect(mocks.track).toHaveBeenCalledTimes(1);
242
+ expect(eventAt(0)).toEqual(expect.objectContaining({ visitorId: "keep-me" }));
243
+ expect(eventAt(0)).not.toHaveProperty("consentGranted");
244
+ });
245
+
246
+ it("clears the cookie once unknown consent resolves to refused", () => {
247
+ document.cookie = "analytics_visitor=keep-me; path=/";
248
+ mocks.pathname = "/pricing";
249
+ const { rerender } = render(<AnalyticsTracker hasConsent={undefined} resolveSection={publicSection} />);
250
+
251
+ expect(mocks.track).not.toHaveBeenCalled();
252
+ expect(document.cookie).toContain("analytics_visitor=keep-me");
253
+
254
+ rerender(<AnalyticsTracker hasConsent={false} resolveSection={publicSection} />);
255
+
256
+ expect(mocks.track).toHaveBeenCalledTimes(1);
257
+ expect(eventAt(0).visitorId).toBeUndefined();
258
+ expect(document.cookie).not.toContain("analytics_visitor");
259
+ });
260
+
261
+ it("carries attribution on the first known event", () => {
262
+ setReferrer("https://elsewhere.example/");
263
+ mocks.pathname = "/pricing";
264
+ const { rerender } = render(<AnalyticsTracker hasConsent={undefined} resolveSection={publicSection} />);
265
+ rerender(<AnalyticsTracker hasConsent={true} resolveSection={publicSection} />);
266
+
267
+ expect(mocks.track).toHaveBeenCalledTimes(1);
268
+ expect(eventAt(0)).toEqual(
269
+ expect.objectContaining({ referrer: "https://elsewhere.example/", visitorId: expect.stringMatching(UUID) }),
270
+ );
271
+ });
272
+
273
+ it("flags the accept click", () => {
274
+ mocks.pathname = "/pricing";
275
+ const { rerender } = render(<AnalyticsTracker hasConsent={false} resolveSection={publicSection} />);
276
+ rerender(<AnalyticsTracker hasConsent={true} resolveSection={publicSection} />);
277
+
278
+ expect(mocks.track).toHaveBeenCalledTimes(2);
279
+ expect(eventAt(0)).not.toHaveProperty("consentGranted");
280
+ expect(eventAt(1)).toEqual(
281
+ expect.objectContaining({ consentGranted: true, visitorId: expect.stringMatching(UUID) }),
282
+ );
283
+ });
284
+
218
285
  it("skips automated browsers and the analytics admin pages", () => {
219
286
  setWebdriver(true);
220
287
  mocks.pathname = "/pricing";
@@ -212,6 +212,19 @@ describe("analytics models — createJsonApi", () => {
212
212
  expect(Object.keys(doc.data.relationships ?? {})).toHaveLength(0);
213
213
  });
214
214
 
215
+ it("serialises consentGranted only when given", () => {
216
+ const flagged = new AnalyticsEvent().createJsonApi({
217
+ id: "e2",
218
+ path: "/",
219
+ section: "public",
220
+ consentGranted: true,
221
+ });
222
+ const plain = new AnalyticsEvent().createJsonApi({ id: "e3", path: "/", section: "public" });
223
+
224
+ expect(flagged.data.attributes.consentGranted).toBe(true);
225
+ expect(plain.data.attributes).not.toHaveProperty("consentGranted");
226
+ });
227
+
215
228
  it("serialises a summary back to a JSON:API resource object", () => {
216
229
  const doc = new AnalyticsSummary().createJsonApi({
217
230
  id: "all|current",
@@ -57,6 +57,7 @@ export class AnalyticsEvent extends AbstractApiData implements AnalyticsEventInt
57
57
  if (data.utmContent !== undefined) response.data.attributes.utmContent = data.utmContent;
58
58
  if (data.visitorId !== undefined) response.data.attributes.visitorId = data.visitorId;
59
59
  if (data.screenWidth !== undefined) response.data.attributes.screenWidth = data.screenWidth;
60
+ if (data.consentGranted !== undefined) response.data.attributes.consentGranted = data.consentGranted;
60
61
 
61
62
  return response;
62
63
  }
@@ -31,6 +31,8 @@ export type AnalyticsEventInput = {
31
31
  utmContent?: string;
32
32
  visitorId?: string;
33
33
  screenWidth?: number;
34
+ /** True only on the event sent by the Accept click: the API adopts the open cookieless session. */
35
+ consentGranted?: boolean;
34
36
  };
35
37
 
36
38
  // Input types for the read-only models' createJsonApi(). `id` is always
@@ -14,6 +14,7 @@ import { SubscriptionsContainer } from "../../stripe-subscription/components";
14
14
  import { SubscriptionWizard } from "../../stripe-subscription/components/wizards";
15
15
  import { MeterInterface, MeterSummaryInterface, StripeUsageService } from "../../stripe-usage";
16
16
  import { UsageContainer } from "../../stripe-usage/components";
17
+ import { resolveChangePlanTarget } from "../../data/credit-warning";
17
18
  import {
18
19
  BillingUsageSummaryCard,
19
20
  CustomerInfoCard,
@@ -89,6 +90,10 @@ export function BillingDashboardContainer() {
89
90
  const [showWizard, setShowWizard] = useState(false);
90
91
  const [editingSubscription, setEditingSubscription] = useState<StripeSubscriptionInterface | null>(null);
91
92
 
93
+ // `?action=change-plan` needs the subscriptions to pick the wizard's target, so the
94
+ // URL effect only records the intent and a second effect acts on it once they load.
95
+ const [pendingChangePlan, setPendingChangePlan] = useState(false);
96
+
92
97
  // Check if company has metered subscriptions
93
98
  const hasMeteredSubscriptions = useCallback((): boolean => {
94
99
  return data.subscriptions.some((sub) => sub.price?.recurring?.usageType === "metered");
@@ -286,11 +291,13 @@ export function BillingDashboardContainer() {
286
291
  // Handle URL action parameter for deep linking. `?action=subscribe` records the
287
292
  // intent to subscribe; the wizard itself only renders once a customer exists, so a
288
293
  // company without one sees the setup card first and the wizard opens right after
289
- // "Set Up Billing Account" succeeds.
294
+ // "Set Up Billing Account" succeeds. `?action=change-plan` records a pending intent
295
+ // that the effect below resolves once the subscriptions have loaded.
290
296
  useEffect(() => {
291
297
  const action = searchParams.get("action");
292
- if (action === "subscribe") {
293
- setShowWizard(true);
298
+ if (action === "subscribe" || action === "change-plan") {
299
+ if (action === "subscribe") setShowWizard(true);
300
+ else setPendingChangePlan(true);
294
301
  // Drop only `action`, so a refresh does not re-trigger it. Every other parameter
295
302
  // must survive: a host page (the settings hub) selects this dashboard through
296
303
  // `?section=`, and losing it switches the page away and unmounts this component.
@@ -307,6 +314,16 @@ export function BillingDashboardContainer() {
307
314
  }
308
315
  }, [searchParams]);
309
316
 
317
+ // Act on a pending `?action=change-plan` once the subscriptions fetch has finished:
318
+ // open the wizard on the active or trialing recurring subscription, or the subscribe
319
+ // wizard when there is none. The intent is cleared in the same step, so the reload
320
+ // after the wizard closes does not open it again.
321
+ useEffect(() => {
322
+ if (!pendingChangePlan || loading.subscriptions) return;
323
+ setPendingChangePlan(false);
324
+ handleOpenWizard(resolveChangePlanTarget(data.subscriptions));
325
+ }, [pendingChangePlan, loading.subscriptions, data.subscriptions, handleOpenWizard]);
326
+
310
327
  // Detect critical subscriptions for alert banners
311
328
  const criticalSubscriptions = data.subscriptions.filter(
312
329
  (sub) =>
@@ -0,0 +1,93 @@
1
+ "use client";
2
+
3
+ // Exported from the Stripe-free `components` entry, because the banner mounts on
4
+ // every page. Keep every import here (and everything they import) free of `@stripe/*`.
5
+
6
+ import { XIcon } from "lucide-react";
7
+ import { useTranslations } from "next-intl";
8
+ import { useState } from "react";
9
+ import { Alert, AlertAction, AlertDescription, AlertTitle } from "../../../../shadcnui/ui/alert";
10
+ import { Button } from "../../../../shadcnui/ui/button";
11
+ import { Link } from "../../../../shadcnui/custom/link";
12
+ import { CompanyService } from "../../../company/data/company.service";
13
+ import { useCurrentUserContext } from "../../../user/contexts/CurrentUserContext";
14
+ import { isCreditWarningActive } from "../../data/credit-warning";
15
+
16
+ export type CreditWarningBannerProps = {
17
+ /** Only company administrators see the banner and can dismiss it. */
18
+ canManageBilling: boolean;
19
+ /** Must match the API's `billing.creditWarning.thresholdPercent`. Defaults to 80. */
20
+ thresholdPercent?: number;
21
+ changePlanHref?: string;
22
+ buyCreditsHref?: string;
23
+ };
24
+
25
+ /**
26
+ * Shown once per billing period when the credits left fall to `100 - thresholdPercent`
27
+ * percent of this period's grant. Hidden at 0 credits, where the app's out-of-credits
28
+ * banner takes over. The offer the server picked decides which links show.
29
+ */
30
+ export function CreditWarningBanner({
31
+ canManageBilling,
32
+ thresholdPercent = 80,
33
+ changePlanHref = "/settings/billing?action=change-plan",
34
+ buyCreditsHref = "/settings/billing?action=subscribe",
35
+ }: CreditWarningBannerProps) {
36
+ const t = useTranslations();
37
+ // `company` is the live one, with the `company:credits_updated` balances applied.
38
+ const { company, refreshUser } = useCurrentUserContext();
39
+ const [dismissing, setDismissing] = useState(false);
40
+
41
+ // Checked after every hook, so the hook count never changes between renders.
42
+ if (!company || !canManageBilling || !isCreditWarningActive(company, thresholdPercent)) return null;
43
+
44
+ const offer = company.creditWarningOffer;
45
+
46
+ const handleDismiss = async () => {
47
+ setDismissing(true);
48
+ try {
49
+ await CompanyService.dismissCreditWarning({ companyId: company.id });
50
+ await refreshUser({ skipCookieUpdate: true });
51
+ } finally {
52
+ setDismissing(false);
53
+ }
54
+ };
55
+
56
+ return (
57
+ <Alert className="mb-4">
58
+ <AlertTitle>{t("billing.ui.credit_warning.title", { percent: thresholdPercent })}</AlertTitle>
59
+ <AlertDescription>
60
+ <p>{t("billing.ui.credit_warning.body")}</p>
61
+ {/* Never underlined: colour and weight carry the affordance. */}
62
+ <div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1">
63
+ {offer === "subscribe" && (
64
+ <Link href={changePlanHref} className="text-primary font-medium">
65
+ {t("billing.ui.credit_warning.subscribe")}
66
+ </Link>
67
+ )}
68
+ {offer === "upgrade-or-pack" && (
69
+ <Link href={changePlanHref} className="text-primary font-medium">
70
+ {t("billing.ui.credit_warning.upgrade")}
71
+ </Link>
72
+ )}
73
+ {(offer === "upgrade-or-pack" || offer === "pack") && (
74
+ <Link href={buyCreditsHref} className="text-primary font-medium">
75
+ {t("billing.ui.credit_warning.add_pack")}
76
+ </Link>
77
+ )}
78
+ </div>
79
+ </AlertDescription>
80
+ <AlertAction>
81
+ <Button
82
+ variant="ghost"
83
+ size="icon-xs"
84
+ aria-label={t("billing.ui.credit_warning.dismiss")}
85
+ disabled={dismissing}
86
+ onClick={handleDismiss}
87
+ >
88
+ <XIcon />
89
+ </Button>
90
+ </AlertAction>
91
+ </Alert>
92
+ );
93
+ }