@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.
- package/dist/{AssistantMessageInterface-S7oyERTT.d.mts → AssistantMessageInterface-0ZQ1EJKO.d.mts} +1 -1
- package/dist/{AssistantMessageInterface-DHJJO_Z_.d.ts → AssistantMessageInterface-CA-hZ5Wj.d.ts} +1 -1
- package/dist/{BlockNoteEditor-SAU3G6H7.js → BlockNoteEditor-CT4XUXPC.js} +19 -19
- package/dist/{BlockNoteEditor-SAU3G6H7.js.map → BlockNoteEditor-CT4XUXPC.js.map} +1 -1
- package/dist/{BlockNoteEditor-FZNU4YQT.mjs → BlockNoteEditor-M6UQRVRK.mjs} +4 -4
- package/dist/{BlockNoteViewer-7BTV7QQA.js → BlockNoteViewer-MFLWF3Y2.js} +3 -3
- package/dist/{BlockNoteViewer-7BTV7QQA.js.map → BlockNoteViewer-MFLWF3Y2.js.map} +1 -1
- package/dist/{BlockNoteViewer-6XDDVTN6.mjs → BlockNoteViewer-XZJ5Y7WN.mjs} +2 -2
- package/dist/{BlogPostInterface-BB_4wcDR.d.mts → BlogPostInterface-BN2mXc46.d.mts} +1 -1
- package/dist/{BlogPostInterface-bWAT1rfR.d.ts → BlogPostInterface-CTGfsR1I.d.ts} +1 -1
- package/dist/{auth.interface-CSzJ1oP1.d.ts → auth.interface-D5606g19.d.ts} +1 -1
- package/dist/{auth.interface-DD4uQv2K.d.mts → auth.interface-tsOuc0Fi.d.mts} +1 -1
- package/dist/billing/index.d.mts +3 -3
- package/dist/billing/index.d.ts +3 -3
- package/dist/billing/index.js +501 -493
- package/dist/billing/index.js.map +1 -1
- package/dist/billing/index.mjs +13 -5
- package/dist/billing/index.mjs.map +1 -1
- package/dist/{chunk-USYZNWKS.mjs → chunk-3FD6Y734.mjs} +74 -15
- package/dist/chunk-3FD6Y734.mjs.map +1 -0
- package/dist/{chunk-F4JB7YAP.js → chunk-DSGP6GC3.js} +8 -8
- package/dist/{chunk-F4JB7YAP.js.map → chunk-DSGP6GC3.js.map} +1 -1
- package/dist/{chunk-GFGKMUPZ.mjs → chunk-O2TEUH5S.mjs} +2 -2
- package/dist/{chunk-MTI2UXDA.js → chunk-QJIDORHM.js} +4 -4
- package/dist/{chunk-MTI2UXDA.js.map → chunk-QJIDORHM.js.map} +1 -1
- package/dist/{chunk-JVJS5RHB.mjs → chunk-RUJCWOJE.mjs} +2 -2
- package/dist/{chunk-U7DR5VDT.mjs → chunk-TIT4CJYQ.mjs} +1233 -1181
- package/dist/chunk-TIT4CJYQ.mjs.map +1 -0
- package/dist/{chunk-TPZS46IM.js → chunk-V4I3XKOM.js} +3 -3
- package/dist/{chunk-TPZS46IM.js.map → chunk-V4I3XKOM.js.map} +1 -1
- package/dist/{chunk-T3YQ47EF.js → chunk-XZCCIKX3.js} +152 -93
- package/dist/chunk-XZCCIKX3.js.map +1 -0
- package/dist/{chunk-IPYXT7MH.js → chunk-Y7JIQNM6.js} +922 -870
- package/dist/chunk-Y7JIQNM6.js.map +1 -0
- package/dist/{chunk-RYZYKG6W.mjs → chunk-ZDUT5JPT.mjs} +2 -2
- package/dist/client/index.d.mts +4 -4
- package/dist/client/index.d.ts +4 -4
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +22 -6
- package/dist/components/index.d.ts +22 -6
- package/dist/components/index.js +10 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +10 -4
- package/dist/contexts/index.d.mts +5 -5
- package/dist/contexts/index.d.ts +5 -5
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +23 -13
- package/dist/core/index.d.ts +23 -13
- package/dist/core/index.js +8 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +7 -1
- package/dist/{currency-DrEVd_Xs.d.mts → currency-BhuEoBOn.d.mts} +2 -115
- package/dist/{currency-8ErEC39n.d.ts → currency-CBgZkS0u.d.ts} +2 -115
- package/dist/email-automations/index.js +92 -92
- package/dist/email-automations/index.mjs +3 -3
- package/dist/features/analytics/index.d.mts +11 -3
- package/dist/features/analytics/index.d.ts +11 -3
- package/dist/features/analytics/index.js +129 -127
- package/dist/features/analytics/index.js.map +1 -1
- package/dist/features/analytics/index.mjs +12 -10
- package/dist/features/analytics/index.mjs.map +1 -1
- package/dist/features/analytics/server.js +4 -4
- package/dist/features/analytics/server.mjs +2 -2
- package/dist/features/blog/index.d.mts +4 -4
- package/dist/features/blog/index.d.ts +4 -4
- package/dist/features/blog/index.js +150 -150
- package/dist/features/blog/index.mjs +3 -3
- package/dist/features/blog/server.d.mts +4 -4
- package/dist/features/blog/server.d.ts +4 -4
- package/dist/features/blog/server.js +16 -16
- package/dist/features/blog/server.mjs +1 -1
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +127 -127
- package/dist/features/tokenusage/index.mjs +4 -4
- package/dist/index.d.mts +8 -8
- package/dist/index.d.ts +8 -8
- package/dist/index.js +9 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +8 -2
- package/dist/{notification.interface-BB6GPpZY.d.mts → notification.interface-CF5h90U2.d.mts} +1 -1
- package/dist/{notification.interface-CmAGrijJ.d.ts → notification.interface-DQReXAD7.d.ts} +1 -1
- package/dist/{s3.service-Bz_K-tjz.d.ts → s3.service-BTCpmp5g.d.ts} +11 -4
- package/dist/{s3.service-D5zNCEW3.d.mts → s3.service-CzFBDEuQ.d.mts} +11 -4
- package/dist/server/index.d.mts +5 -5
- package/dist/server/index.d.ts +5 -5
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +2 -2
- package/dist/stripe-price.interface-D6mKjAdr.d.mts +132 -0
- package/dist/stripe-price.interface-DGqcklFJ.d.ts +132 -0
- package/dist/{useSocket-B0H9pmmH.d.ts → useSocket-BM3q9A0C.d.ts} +1 -1
- package/dist/{useSocket-B5RQb6v2.d.mts → useSocket-CwSoq6VS.d.mts} +1 -1
- package/dist/{user.interface-CIWGlbqM.d.mts → user.interface-BYnGJ7ss.d.mts} +37 -2
- package/dist/{user.interface-C0ZdsBZj.d.ts → user.interface-BqPjVyJI.d.ts} +37 -2
- package/messages/en.json +8 -0
- package/messages/it.json +8 -0
- package/package.json +1 -1
- package/src/components/containers/RoundPageContainer.tsx +7 -2
- package/src/components/index.ts +5 -0
- package/src/features/analytics/components/AnalyticsTracker.tsx +22 -9
- package/src/features/analytics/components/__tests__/AnalyticsTracker.spec.tsx +67 -0
- package/src/features/analytics/data/__tests__/models.spec.ts +13 -0
- package/src/features/analytics/data/analytics-event.ts +1 -0
- package/src/features/analytics/data/analytics.types.ts +2 -0
- package/src/features/billing/components/containers/BillingDashboardContainer.tsx +20 -3
- package/src/features/billing/components/widgets/CreditWarningBanner.tsx +93 -0
- package/src/features/billing/data/credit-warning.spec.ts +96 -0
- package/src/features/billing/data/credit-warning.ts +57 -0
- package/src/features/billing/data/index.ts +3 -0
- package/src/features/billing/i18n-keys.ts +6 -0
- package/src/features/company/data/company.interface.ts +11 -0
- package/src/features/company/data/company.service.ts +17 -0
- package/src/features/company/data/company.spec.ts +29 -0
- package/src/features/company/data/company.ts +31 -0
- package/dist/chunk-IPYXT7MH.js.map +0 -1
- package/dist/chunk-T3YQ47EF.js.map +0 -1
- package/dist/chunk-U7DR5VDT.mjs.map +0 -1
- package/dist/chunk-USYZNWKS.mjs.map +0 -1
- package/dist/feature.interface-BO25VLlx.d.ts +0 -19
- package/dist/feature.interface-CXb1-vNq.d.mts +0 -19
- /package/dist/{BlockNoteEditor-FZNU4YQT.mjs.map → BlockNoteEditor-M6UQRVRK.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-6XDDVTN6.mjs.map → BlockNoteViewer-XZJ5Y7WN.mjs.map} +0 -0
- /package/dist/{chunk-GFGKMUPZ.mjs.map → chunk-O2TEUH5S.mjs.map} +0 -0
- /package/dist/{chunk-JVJS5RHB.mjs.map → chunk-RUJCWOJE.mjs.map} +0 -0
- /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,5 +1,31 @@
|
|
|
1
1
|
import { A as ApiDataInterface } from './ApiDataInterface-BcZeXy5X.mjs';
|
|
2
|
-
import { F as FeatureInterface, M as ModuleInterface } from './
|
|
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
|
|
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 './
|
|
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
|
|
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
|
@@ -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
|
package/src/components/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
79
|
-
//
|
|
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
|
|
82
|
-
|
|
83
|
-
if (!isNewPath && !
|
|
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 ||
|
|
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
|
+
}
|