@burdenoff/microfe-subscriberbot 2026.1030.3 → 2026.1030.5
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/subscriberbot/SubscriberBotRoutes.d.ts.map +1 -1
- package/dist/subscriberbot/SubscriberBotRoutes.js +12 -8
- package/dist/subscriberbot/SubscriberBotRoutes.js.map +1 -1
- package/dist/subscriberbot/components/decisions/DecisionCard.d.ts.map +1 -1
- package/dist/subscriberbot/components/decisions/DecisionCard.js +1 -0
- package/dist/subscriberbot/components/decisions/DecisionCard.js.map +1 -1
- package/dist/subscriberbot/components/decisions/decisionModel.d.ts +3 -2
- package/dist/subscriberbot/components/decisions/decisionModel.d.ts.map +1 -1
- package/dist/subscriberbot/components/decisions/decisionModel.js +18 -5
- package/dist/subscriberbot/components/decisions/decisionModel.js.map +1 -1
- package/dist/subscriberbot/components/savings/SavingsCards.d.ts +11 -0
- package/dist/subscriberbot/components/savings/SavingsCards.d.ts.map +1 -0
- package/dist/subscriberbot/components/savings/SavingsCards.js +448 -0
- package/dist/subscriberbot/components/savings/SavingsCards.js.map +1 -0
- package/dist/subscriberbot/components/savings/index.d.ts +8 -0
- package/dist/subscriberbot/components/savings/index.d.ts.map +1 -0
- package/dist/subscriberbot/components/savings/index.js +2 -0
- package/dist/subscriberbot/components/savings/savingsModel.d.ts +53 -0
- package/dist/subscriberbot/components/savings/savingsModel.d.ts.map +1 -0
- package/dist/subscriberbot/components/savings/savingsModel.js +105 -0
- package/dist/subscriberbot/components/savings/savingsModel.js.map +1 -0
- package/dist/subscriberbot/hooks/index.d.ts +1 -1
- package/dist/subscriberbot/hooks/index.d.ts.map +1 -1
- package/dist/subscriberbot/hooks/useSubscriberBotApi.d.ts +8 -1
- package/dist/subscriberbot/hooks/useSubscriberBotApi.d.ts.map +1 -1
- package/dist/subscriberbot/hooks/useSubscriberBotApi.js +45 -15
- package/dist/subscriberbot/hooks/useSubscriberBotApi.js.map +1 -1
- package/dist/subscriberbot/pages/InsightsPage.d.ts.map +1 -1
- package/dist/subscriberbot/pages/InsightsPage.js +121 -110
- package/dist/subscriberbot/pages/InsightsPage.js.map +1 -1
- package/dist/subscriberbot/pages/SavingsPage.d.ts +4 -0
- package/dist/subscriberbot/pages/SavingsPage.d.ts.map +1 -0
- package/dist/subscriberbot/pages/SavingsPage.js +205 -0
- package/dist/subscriberbot/pages/SavingsPage.js.map +1 -0
- package/dist/subscriberbot/pages/relationships/RelationshipDetailPage.d.ts.map +1 -1
- package/dist/subscriberbot/pages/relationships/RelationshipDetailPage.js +292 -301
- package/dist/subscriberbot/pages/relationships/RelationshipDetailPage.js.map +1 -1
- package/dist/subscriberbot/types/index.d.ts +62 -2
- package/dist/subscriberbot/types/index.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { useSubscriberBot as e } from "../../context/SubscriberBotContext.js";
|
|
2
|
+
import { cn as t } from "../../utils/cn.js";
|
|
3
|
+
import { formatCurrency as n, formatEnumLabel as r } from "../../utils/formatters.js";
|
|
4
|
+
import { Modal as i } from "../Modal.js";
|
|
5
|
+
import { useTr as a } from "../../hooks/useTr.js";
|
|
6
|
+
import { useConsolidateRelationships as o, useDismissBundle as s } from "../../hooks/useSubscriberBotApi.js";
|
|
7
|
+
import { RECOMMENDATION_LABEL as c, confidencePercent as l, isDestructive as u, relationshipSnapshot as d } from "../decisions/decisionModel.js";
|
|
8
|
+
import { ApplyDecisionDialog as f, DecisionSources as p, DismissDecisionDialog as m } from "../decisions/DecisionCard.js";
|
|
9
|
+
import "../decisions/index.js";
|
|
10
|
+
import { bundleMembers as h, parseBundledAmount as g, projectedConsolidationSavings as _, sectionOf as v } from "./savingsModel.js";
|
|
11
|
+
import { useState as y } from "react";
|
|
12
|
+
import { ArrowDownRight as b, CalendarRange as x, CheckCircle2 as S, Combine as C, ExternalLink as w, Gauge as T, Layers as E, ShieldAlert as D, Trash2 as O, XCircle as k } from "lucide-react";
|
|
13
|
+
import { jsx as A, jsxs as j } from "react/jsx-runtime";
|
|
14
|
+
import { GlassCard as M } from "@burdenoff/fe-libs/ui";
|
|
15
|
+
//#region src/subscriberbot/components/savings/SavingsCards.tsx
|
|
16
|
+
var N = {
|
|
17
|
+
cancel: O,
|
|
18
|
+
downgrade: b,
|
|
19
|
+
annual: x,
|
|
20
|
+
other: E
|
|
21
|
+
}, P = [
|
|
22
|
+
"MONTHLY",
|
|
23
|
+
"QUARTERLY",
|
|
24
|
+
"YEARLY"
|
|
25
|
+
];
|
|
26
|
+
function F(e, t) {
|
|
27
|
+
return e instanceof Error && e.message ? e.message : t;
|
|
28
|
+
}
|
|
29
|
+
function I(e, t, i) {
|
|
30
|
+
return `${n(e, t)} / ${r(i).toLowerCase()}`;
|
|
31
|
+
}
|
|
32
|
+
var L = ({ decision: r }) => {
|
|
33
|
+
let i = a(), { navigateTo: o } = e(), [s, h] = y(null), g = c[r.recommendation], _ = u(r.recommendation), b = d(r), x = N[v(r.recommendation)], C = b.label ?? r.relationshipId;
|
|
34
|
+
return /* @__PURE__ */ j(M, {
|
|
35
|
+
treatment: "metric",
|
|
36
|
+
className: "flex flex-col gap-3 p-4",
|
|
37
|
+
"data-testid": "savings-card",
|
|
38
|
+
"data-decision-id": r.id,
|
|
39
|
+
"data-recommendation": r.recommendation,
|
|
40
|
+
children: [
|
|
41
|
+
/* @__PURE__ */ j("div", {
|
|
42
|
+
className: "flex items-start gap-3",
|
|
43
|
+
children: [
|
|
44
|
+
/* @__PURE__ */ A("span", {
|
|
45
|
+
className: t("flex size-9 shrink-0 items-center justify-center rounded-lg", _ ? "bg-status-error-bg-subtle text-status-error-text" : "bg-bg-sunken text-text-secondary"),
|
|
46
|
+
"aria-hidden": !0,
|
|
47
|
+
children: /* @__PURE__ */ A(x, { className: "h-4 w-4" })
|
|
48
|
+
}),
|
|
49
|
+
/* @__PURE__ */ j("div", {
|
|
50
|
+
className: "min-w-0 flex-1",
|
|
51
|
+
children: [
|
|
52
|
+
/* @__PURE__ */ A("p", {
|
|
53
|
+
className: "text-sm font-semibold text-text-primary",
|
|
54
|
+
children: i(g.key, g.fallback)
|
|
55
|
+
}),
|
|
56
|
+
/* @__PURE__ */ A("button", {
|
|
57
|
+
type: "button",
|
|
58
|
+
onClick: () => o(`/relationships/${r.relationshipId}`),
|
|
59
|
+
className: "block max-w-full truncate text-left text-sm text-text-primary hover:underline",
|
|
60
|
+
"data-testid": "savings-card-relationship",
|
|
61
|
+
children: C
|
|
62
|
+
}),
|
|
63
|
+
b.amount !== null && /* @__PURE__ */ A("p", {
|
|
64
|
+
className: "text-xs tabular-nums text-text-secondary",
|
|
65
|
+
children: i("savings.card.current", "Now {{price}}", { price: I(b.amount, b.currency ?? r.currency, b.interval ?? "MONTHLY") })
|
|
66
|
+
})
|
|
67
|
+
]
|
|
68
|
+
}),
|
|
69
|
+
r.estimatedSavings > 0 && /* @__PURE__ */ j("p", {
|
|
70
|
+
className: "shrink-0 text-right text-sm font-semibold tabular-nums text-status-success-text",
|
|
71
|
+
"data-testid": "savings-card-amount",
|
|
72
|
+
children: [i("savings.card.save", "Save {{amount}}", { amount: n(r.estimatedSavings, r.currency) }), /* @__PURE__ */ A("span", {
|
|
73
|
+
className: "block text-[11px] font-normal text-text-secondary",
|
|
74
|
+
children: i("savings.card.perYear", "per year")
|
|
75
|
+
})]
|
|
76
|
+
})
|
|
77
|
+
]
|
|
78
|
+
}),
|
|
79
|
+
/* @__PURE__ */ A("p", {
|
|
80
|
+
className: "text-sm text-text-secondary",
|
|
81
|
+
children: r.rationale
|
|
82
|
+
}),
|
|
83
|
+
/* @__PURE__ */ j("div", {
|
|
84
|
+
className: "flex flex-wrap items-center gap-2 text-xs",
|
|
85
|
+
children: [/* @__PURE__ */ j("span", {
|
|
86
|
+
className: "inline-flex items-center gap-1.5 rounded-md bg-bg-sunken px-2 py-0.5 text-text-secondary",
|
|
87
|
+
children: [/* @__PURE__ */ A(T, {
|
|
88
|
+
className: "h-3.5 w-3.5",
|
|
89
|
+
"aria-hidden": !0
|
|
90
|
+
}), i("decisions.card.confidence", "Confidence {{pct}}%", { pct: l(r.confidence) })]
|
|
91
|
+
}), /* @__PURE__ */ j("button", {
|
|
92
|
+
type: "button",
|
|
93
|
+
onClick: () => o(`/decisions/${r.id}`),
|
|
94
|
+
className: "inline-flex items-center gap-1 rounded-md px-1 text-text-secondary hover:text-text-primary",
|
|
95
|
+
children: [i("savings.card.details", "Details"), /* @__PURE__ */ A(w, {
|
|
96
|
+
className: "h-3 w-3",
|
|
97
|
+
"aria-hidden": !0
|
|
98
|
+
})]
|
|
99
|
+
})]
|
|
100
|
+
}),
|
|
101
|
+
/* @__PURE__ */ A(p, {
|
|
102
|
+
decision: r,
|
|
103
|
+
limit: 3
|
|
104
|
+
}),
|
|
105
|
+
/* @__PURE__ */ j("div", {
|
|
106
|
+
className: "flex flex-wrap justify-end gap-2 border-t border-border-subtle pt-3",
|
|
107
|
+
children: [/* @__PURE__ */ j("button", {
|
|
108
|
+
type: "button",
|
|
109
|
+
onClick: () => h("dismiss"),
|
|
110
|
+
"data-testid": "savings-dismiss",
|
|
111
|
+
className: "inline-flex items-center gap-1.5 rounded-lg border border-border-subtle px-3 py-1.5 text-xs font-medium text-text-primary hover:bg-bg-sunken",
|
|
112
|
+
children: [/* @__PURE__ */ A(k, {
|
|
113
|
+
className: "h-3.5 w-3.5",
|
|
114
|
+
"aria-hidden": !0
|
|
115
|
+
}), i("decisions.card.dismiss", "Dismiss")]
|
|
116
|
+
}), /* @__PURE__ */ j("button", {
|
|
117
|
+
type: "button",
|
|
118
|
+
onClick: () => h("apply"),
|
|
119
|
+
"data-testid": "savings-apply",
|
|
120
|
+
className: t("inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium hover:opacity-90", _ ? "border border-status-error-text/40 bg-status-error-bg-subtle text-status-error-text" : "bg-action-primary-bg text-action-primary-text"),
|
|
121
|
+
children: [/* @__PURE__ */ A(S, {
|
|
122
|
+
className: "h-3.5 w-3.5",
|
|
123
|
+
"aria-hidden": !0
|
|
124
|
+
}), i("savings.card.apply", "Apply")]
|
|
125
|
+
})]
|
|
126
|
+
}),
|
|
127
|
+
s === "apply" && /* @__PURE__ */ A(f, {
|
|
128
|
+
decision: r,
|
|
129
|
+
onClose: () => h(null)
|
|
130
|
+
}),
|
|
131
|
+
s === "dismiss" && /* @__PURE__ */ A(m, {
|
|
132
|
+
decision: r,
|
|
133
|
+
onClose: () => h(null)
|
|
134
|
+
})
|
|
135
|
+
]
|
|
136
|
+
});
|
|
137
|
+
}, R = ({ bundle: e, onClose: s, onDone: c }) => {
|
|
138
|
+
let l = a(), u = o(), [d, f] = y(e.keepRelationshipId), [p, m] = y(""), [v, b] = y("MONTHLY"), [x, S] = y(!1), [C, w] = y(null), { keep: T, cancel: E } = h(e, d), D = g(p), O = Number.isNaN(D), k = D !== null && !O ? {
|
|
139
|
+
amount: D,
|
|
140
|
+
interval: v
|
|
141
|
+
} : null, M = _(e, d, k), N = E.map((e) => `"${e.label}"`).join(", "), L = x && !O && !u.isPending && T !== null;
|
|
142
|
+
return /* @__PURE__ */ A(i, {
|
|
143
|
+
title: l("savings.bundle.consolidateTitle", "Consolidate subscriptions"),
|
|
144
|
+
description: l("savings.bundle.consolidateDescription", "Keep one subscription and cancel the others. Each cancel is the same change you could make by hand."),
|
|
145
|
+
onClose: s,
|
|
146
|
+
children: /* @__PURE__ */ j("form", {
|
|
147
|
+
onSubmit: (e) => {
|
|
148
|
+
e.preventDefault(), L && (w(null), u.mutate({
|
|
149
|
+
keepRelationshipId: d,
|
|
150
|
+
cancelRelationshipIds: E.map((e) => e.id),
|
|
151
|
+
...k ? {
|
|
152
|
+
amount: k.amount,
|
|
153
|
+
interval: k.interval
|
|
154
|
+
} : {}
|
|
155
|
+
}, {
|
|
156
|
+
onSuccess: (e) => {
|
|
157
|
+
c(e), s();
|
|
158
|
+
},
|
|
159
|
+
onError: (e) => w(F(e, l("savings.bundle.consolidateError", "Could not consolidate.")))
|
|
160
|
+
}));
|
|
161
|
+
},
|
|
162
|
+
className: "space-y-4",
|
|
163
|
+
"data-testid": "consolidate-dialog",
|
|
164
|
+
children: [
|
|
165
|
+
/* @__PURE__ */ j("fieldset", { children: [/* @__PURE__ */ A("legend", {
|
|
166
|
+
className: "mb-1.5 text-xs font-medium text-text-secondary",
|
|
167
|
+
children: l("savings.bundle.keepLabel", "Keep")
|
|
168
|
+
}), /* @__PURE__ */ A("div", {
|
|
169
|
+
className: "space-y-1.5",
|
|
170
|
+
children: e.relationships.map((n) => /* @__PURE__ */ j("label", {
|
|
171
|
+
className: t("flex items-center gap-2.5 rounded-lg border px-3 py-2 text-sm", n.id === d ? "border-action-primary-bg bg-bg-sunken" : "border-border-subtle"),
|
|
172
|
+
children: [
|
|
173
|
+
/* @__PURE__ */ A("input", {
|
|
174
|
+
type: "radio",
|
|
175
|
+
name: `keep-${e.id}`,
|
|
176
|
+
value: n.id,
|
|
177
|
+
checked: n.id === d,
|
|
178
|
+
onChange: () => {
|
|
179
|
+
f(n.id), S(!1);
|
|
180
|
+
},
|
|
181
|
+
className: "size-4 shrink-0"
|
|
182
|
+
}),
|
|
183
|
+
/* @__PURE__ */ A("span", {
|
|
184
|
+
className: "min-w-0 flex-1 truncate text-text-primary",
|
|
185
|
+
children: n.label
|
|
186
|
+
}),
|
|
187
|
+
/* @__PURE__ */ A("span", {
|
|
188
|
+
className: "shrink-0 tabular-nums text-text-secondary",
|
|
189
|
+
children: I(n.amount, n.currency, n.interval)
|
|
190
|
+
})
|
|
191
|
+
]
|
|
192
|
+
}, n.id))
|
|
193
|
+
})] }),
|
|
194
|
+
/* @__PURE__ */ j("div", { children: [
|
|
195
|
+
/* @__PURE__ */ A("p", {
|
|
196
|
+
className: "mb-1.5 text-xs font-medium text-text-secondary",
|
|
197
|
+
children: l("savings.bundle.planLabel", "Bundled plan price for the kept subscription (optional)")
|
|
198
|
+
}),
|
|
199
|
+
/* @__PURE__ */ j("div", {
|
|
200
|
+
className: "flex gap-2",
|
|
201
|
+
children: [/* @__PURE__ */ A("input", {
|
|
202
|
+
type: "number",
|
|
203
|
+
inputMode: "decimal",
|
|
204
|
+
min: "0",
|
|
205
|
+
step: "0.01",
|
|
206
|
+
value: p,
|
|
207
|
+
onChange: (e) => m(e.target.value),
|
|
208
|
+
placeholder: T ? String(T.amount) : l("savings.bundle.planPlaceholder", "Same price"),
|
|
209
|
+
"aria-label": l("savings.bundle.planAmount", "Bundled plan amount"),
|
|
210
|
+
"aria-invalid": O,
|
|
211
|
+
"data-testid": "consolidate-amount",
|
|
212
|
+
className: "w-full rounded-lg border border-border-subtle bg-bg-surface px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)]"
|
|
213
|
+
}), /* @__PURE__ */ A("select", {
|
|
214
|
+
value: v,
|
|
215
|
+
onChange: (e) => b(e.target.value),
|
|
216
|
+
"aria-label": l("savings.bundle.planInterval", "Billing interval"),
|
|
217
|
+
className: "rounded-lg border border-border-subtle bg-bg-surface px-2 py-2 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)]",
|
|
218
|
+
children: P.map((e) => /* @__PURE__ */ A("option", {
|
|
219
|
+
value: e,
|
|
220
|
+
children: r(e)
|
|
221
|
+
}, e))
|
|
222
|
+
})]
|
|
223
|
+
}),
|
|
224
|
+
O && /* @__PURE__ */ A("p", {
|
|
225
|
+
className: "mt-1 text-xs text-status-error-text",
|
|
226
|
+
children: l("savings.bundle.planInvalid", "Enter zero or a positive amount.")
|
|
227
|
+
})
|
|
228
|
+
] }),
|
|
229
|
+
/* @__PURE__ */ A("p", {
|
|
230
|
+
className: "text-sm text-text-primary",
|
|
231
|
+
"data-testid": "consolidate-projected",
|
|
232
|
+
children: l("savings.bundle.projected", "Estimated saving: {{amount}} a year", { amount: n(Math.max(M, 0), e.currency) })
|
|
233
|
+
}),
|
|
234
|
+
/* @__PURE__ */ j("label", {
|
|
235
|
+
className: "flex items-start gap-2.5 rounded-lg border border-status-error-text/30 bg-status-error-bg-subtle px-4 py-3 text-sm text-status-error-text",
|
|
236
|
+
children: [/* @__PURE__ */ A("input", {
|
|
237
|
+
type: "checkbox",
|
|
238
|
+
checked: x,
|
|
239
|
+
onChange: (e) => S(e.target.checked),
|
|
240
|
+
className: "mt-0.5 size-4 shrink-0 rounded border-border-subtle",
|
|
241
|
+
"data-testid": "consolidate-acknowledge"
|
|
242
|
+
}), /* @__PURE__ */ A("span", { children: l("savings.bundle.acknowledge", "I want to cancel {{names}} and keep \"{{keep}}\". I understand the cancelled subscriptions will stop.", {
|
|
243
|
+
names: N,
|
|
244
|
+
keep: T?.label ?? ""
|
|
245
|
+
}) })]
|
|
246
|
+
}),
|
|
247
|
+
C && /* @__PURE__ */ A("div", {
|
|
248
|
+
className: "rounded-lg border border-status-error-text/30 bg-status-error-bg-subtle px-4 py-2 text-sm text-status-error-text",
|
|
249
|
+
children: C
|
|
250
|
+
}),
|
|
251
|
+
/* @__PURE__ */ j("div", {
|
|
252
|
+
className: "flex justify-end gap-3",
|
|
253
|
+
children: [/* @__PURE__ */ A("button", {
|
|
254
|
+
type: "button",
|
|
255
|
+
onClick: s,
|
|
256
|
+
className: "rounded-lg border border-border-subtle px-4 py-2 text-sm font-medium text-text-primary hover:bg-bg-sunken",
|
|
257
|
+
children: l("decisions.apply.back", "Not now")
|
|
258
|
+
}), /* @__PURE__ */ A("button", {
|
|
259
|
+
type: "submit",
|
|
260
|
+
disabled: !L,
|
|
261
|
+
"data-testid": "confirm-consolidate",
|
|
262
|
+
className: "rounded-lg bg-action-danger-bg px-4 py-2 text-sm font-medium text-action-danger-text hover:bg-action-danger-bg-hover disabled:cursor-not-allowed disabled:opacity-50",
|
|
263
|
+
children: u.isPending ? l("savings.bundle.consolidating", "Consolidating…") : l("savings.bundle.consolidateSubmit", "Consolidate")
|
|
264
|
+
})]
|
|
265
|
+
})
|
|
266
|
+
]
|
|
267
|
+
})
|
|
268
|
+
});
|
|
269
|
+
}, z = ({ bundle: e, onClose: t }) => {
|
|
270
|
+
let n = a(), r = s(), [o, c] = y(""), [l, u] = y(null);
|
|
271
|
+
return /* @__PURE__ */ A(i, {
|
|
272
|
+
title: n("savings.bundle.dismissTitle", "Dismiss bundle suggestion"),
|
|
273
|
+
description: n("savings.bundle.dismissDescription", "It stays hidden until one of these subscriptions changes."),
|
|
274
|
+
onClose: t,
|
|
275
|
+
children: /* @__PURE__ */ j("form", {
|
|
276
|
+
onSubmit: (i) => {
|
|
277
|
+
i.preventDefault(), u(null), r.mutate({
|
|
278
|
+
id: e.id,
|
|
279
|
+
reason: o
|
|
280
|
+
}, {
|
|
281
|
+
onSuccess: () => t(),
|
|
282
|
+
onError: (e) => u(F(e, n("savings.bundle.dismissError", "Could not dismiss.")))
|
|
283
|
+
});
|
|
284
|
+
},
|
|
285
|
+
className: "space-y-4",
|
|
286
|
+
"data-testid": "dismiss-bundle-dialog",
|
|
287
|
+
children: [
|
|
288
|
+
/* @__PURE__ */ j("div", { children: [/* @__PURE__ */ A("label", {
|
|
289
|
+
htmlFor: `dismiss-bundle-${e.id}`,
|
|
290
|
+
className: "mb-1 block text-xs font-medium text-text-secondary",
|
|
291
|
+
children: n("decisions.dismiss.reason", "Reason (optional)")
|
|
292
|
+
}), /* @__PURE__ */ A("textarea", {
|
|
293
|
+
id: `dismiss-bundle-${e.id}`,
|
|
294
|
+
value: o,
|
|
295
|
+
onChange: (e) => c(e.target.value),
|
|
296
|
+
rows: 3,
|
|
297
|
+
maxLength: 500,
|
|
298
|
+
placeholder: n("savings.bundle.dismissPlaceholder", "e.g. They cover different needs"),
|
|
299
|
+
className: "w-full rounded-lg border border-border-subtle bg-bg-surface px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)]"
|
|
300
|
+
})] }),
|
|
301
|
+
l && /* @__PURE__ */ A("div", {
|
|
302
|
+
className: "rounded-lg border border-status-error-text/30 bg-status-error-bg-subtle px-4 py-2 text-sm text-status-error-text",
|
|
303
|
+
children: l
|
|
304
|
+
}),
|
|
305
|
+
/* @__PURE__ */ j("div", {
|
|
306
|
+
className: "flex justify-end gap-3",
|
|
307
|
+
children: [/* @__PURE__ */ A("button", {
|
|
308
|
+
type: "button",
|
|
309
|
+
onClick: t,
|
|
310
|
+
className: "rounded-lg border border-border-subtle px-4 py-2 text-sm font-medium text-text-primary hover:bg-bg-sunken",
|
|
311
|
+
children: n("decisions.dismiss.back", "Back")
|
|
312
|
+
}), /* @__PURE__ */ A("button", {
|
|
313
|
+
type: "submit",
|
|
314
|
+
disabled: r.isPending,
|
|
315
|
+
"data-testid": "confirm-dismiss-bundle",
|
|
316
|
+
className: "rounded-lg bg-action-primary-bg px-4 py-2 text-sm font-medium text-action-primary-text hover:opacity-90 disabled:opacity-50",
|
|
317
|
+
children: r.isPending ? n("decisions.dismiss.dismissing", "Dismissing…") : n("decisions.dismiss.submit", "Dismiss")
|
|
318
|
+
})]
|
|
319
|
+
})
|
|
320
|
+
]
|
|
321
|
+
})
|
|
322
|
+
});
|
|
323
|
+
}, B = ({ bundle: i, onConsolidated: o }) => {
|
|
324
|
+
let s = a(), { navigateTo: c } = e(), [u, d] = y(null), f = i.basis === "SAME_PROVIDER" ? s("savings.bundle.sameProvider", "Merge duplicate subscriptions") : s("savings.bundle.sameCategory", "Overlapping {{category}} services", { category: r(i.category ?? "OTHER").toLowerCase() });
|
|
325
|
+
return /* @__PURE__ */ j(M, {
|
|
326
|
+
treatment: "metric",
|
|
327
|
+
className: "flex flex-col gap-3 border border-status-warning-text/30 p-4",
|
|
328
|
+
"data-testid": "bundle-card",
|
|
329
|
+
"data-bundle-id": i.id,
|
|
330
|
+
"data-basis": i.basis,
|
|
331
|
+
children: [
|
|
332
|
+
/* @__PURE__ */ j("div", {
|
|
333
|
+
className: "flex items-start gap-3",
|
|
334
|
+
children: [
|
|
335
|
+
/* @__PURE__ */ A("span", {
|
|
336
|
+
className: "flex size-9 shrink-0 items-center justify-center rounded-lg bg-status-warning-bg-subtle text-status-warning-text",
|
|
337
|
+
"aria-hidden": !0,
|
|
338
|
+
children: /* @__PURE__ */ A(C, { className: "h-4 w-4" })
|
|
339
|
+
}),
|
|
340
|
+
/* @__PURE__ */ j("div", {
|
|
341
|
+
className: "min-w-0 flex-1",
|
|
342
|
+
children: [/* @__PURE__ */ j("p", {
|
|
343
|
+
className: "text-[11px] font-medium uppercase tracking-wide text-text-secondary",
|
|
344
|
+
children: [s("savings.bundle.label", "Bundle suggestion"), i.providerName ? ` · ${i.providerName}` : ""]
|
|
345
|
+
}), /* @__PURE__ */ A("p", {
|
|
346
|
+
className: "text-sm font-semibold text-text-primary",
|
|
347
|
+
children: f
|
|
348
|
+
})]
|
|
349
|
+
}),
|
|
350
|
+
/* @__PURE__ */ j("p", {
|
|
351
|
+
className: "shrink-0 text-right text-sm font-semibold tabular-nums text-status-success-text",
|
|
352
|
+
"data-testid": "bundle-card-amount",
|
|
353
|
+
children: [s("savings.card.save", "Save {{amount}}", { amount: n(i.estimatedSavings, i.currency) }), /* @__PURE__ */ A("span", {
|
|
354
|
+
className: "block text-[11px] font-normal text-text-secondary",
|
|
355
|
+
children: s("savings.card.perYear", "per year")
|
|
356
|
+
})]
|
|
357
|
+
})
|
|
358
|
+
]
|
|
359
|
+
}),
|
|
360
|
+
/* @__PURE__ */ A("p", {
|
|
361
|
+
className: "text-sm text-text-secondary",
|
|
362
|
+
children: i.rationale
|
|
363
|
+
}),
|
|
364
|
+
/* @__PURE__ */ A("ul", {
|
|
365
|
+
className: "space-y-1",
|
|
366
|
+
"data-testid": "bundle-relationships",
|
|
367
|
+
children: i.relationships.map((e) => {
|
|
368
|
+
let n = e.id === i.keepRelationshipId;
|
|
369
|
+
return /* @__PURE__ */ A("li", { children: /* @__PURE__ */ j("button", {
|
|
370
|
+
type: "button",
|
|
371
|
+
onClick: () => c(`/relationships/${e.id}`),
|
|
372
|
+
"data-relationship-id": e.id,
|
|
373
|
+
className: "flex w-full items-center gap-2 rounded-md border border-border-subtle bg-bg-sunken px-2.5 py-1.5 text-left text-xs hover:bg-bg-surface",
|
|
374
|
+
children: [
|
|
375
|
+
/* @__PURE__ */ A("span", {
|
|
376
|
+
className: t("shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium uppercase", n ? "bg-status-success-bg-subtle text-status-success-text" : "bg-status-error-bg-subtle text-status-error-text"),
|
|
377
|
+
children: n ? s("savings.bundle.keep", "Keep") : s("savings.bundle.cancel", "Cancel")
|
|
378
|
+
}),
|
|
379
|
+
/* @__PURE__ */ A("span", {
|
|
380
|
+
className: "min-w-0 flex-1 truncate font-medium text-text-primary",
|
|
381
|
+
children: e.label
|
|
382
|
+
}),
|
|
383
|
+
/* @__PURE__ */ A("span", {
|
|
384
|
+
className: "shrink-0 tabular-nums text-text-secondary",
|
|
385
|
+
children: I(e.amount, e.currency, e.interval)
|
|
386
|
+
}),
|
|
387
|
+
/* @__PURE__ */ A(w, {
|
|
388
|
+
className: "h-3 w-3 shrink-0 text-text-secondary",
|
|
389
|
+
"aria-hidden": !0
|
|
390
|
+
})
|
|
391
|
+
]
|
|
392
|
+
}) }, e.id);
|
|
393
|
+
})
|
|
394
|
+
}),
|
|
395
|
+
/* @__PURE__ */ j("div", {
|
|
396
|
+
className: "flex flex-wrap gap-2 text-xs",
|
|
397
|
+
children: [/* @__PURE__ */ A("span", {
|
|
398
|
+
className: "inline-flex items-center rounded-md bg-bg-sunken px-2 py-0.5 tabular-nums text-text-secondary",
|
|
399
|
+
children: s("savings.bundle.costChange", "{{before}} → {{after}} a year", {
|
|
400
|
+
before: n(i.currentAnnualCost, i.currency),
|
|
401
|
+
after: n(i.projectedAnnualCost, i.currency)
|
|
402
|
+
})
|
|
403
|
+
}), /* @__PURE__ */ j("span", {
|
|
404
|
+
className: "inline-flex items-center gap-1.5 rounded-md bg-bg-sunken px-2 py-0.5 text-text-secondary",
|
|
405
|
+
children: [/* @__PURE__ */ A(T, {
|
|
406
|
+
className: "h-3.5 w-3.5",
|
|
407
|
+
"aria-hidden": !0
|
|
408
|
+
}), s("decisions.card.confidence", "Confidence {{pct}}%", { pct: l(i.confidence) })]
|
|
409
|
+
})]
|
|
410
|
+
}),
|
|
411
|
+
/* @__PURE__ */ j("div", {
|
|
412
|
+
className: "flex flex-wrap justify-end gap-2 border-t border-border-subtle pt-3",
|
|
413
|
+
children: [/* @__PURE__ */ j("button", {
|
|
414
|
+
type: "button",
|
|
415
|
+
onClick: () => d("dismiss"),
|
|
416
|
+
"data-testid": "bundle-dismiss",
|
|
417
|
+
className: "inline-flex items-center gap-1.5 rounded-lg border border-border-subtle px-3 py-1.5 text-xs font-medium text-text-primary hover:bg-bg-sunken",
|
|
418
|
+
children: [/* @__PURE__ */ A(k, {
|
|
419
|
+
className: "h-3.5 w-3.5",
|
|
420
|
+
"aria-hidden": !0
|
|
421
|
+
}), s("decisions.card.dismiss", "Dismiss")]
|
|
422
|
+
}), /* @__PURE__ */ j("button", {
|
|
423
|
+
type: "button",
|
|
424
|
+
onClick: () => d("consolidate"),
|
|
425
|
+
"data-testid": "bundle-consolidate",
|
|
426
|
+
className: "inline-flex items-center gap-1.5 rounded-lg border border-status-error-text/40 bg-status-error-bg-subtle px-3 py-1.5 text-xs font-medium text-status-error-text hover:opacity-90",
|
|
427
|
+
children: [/* @__PURE__ */ A(D, {
|
|
428
|
+
className: "h-3.5 w-3.5",
|
|
429
|
+
"aria-hidden": !0
|
|
430
|
+
}), s("savings.bundle.consolidate", "Review and consolidate")]
|
|
431
|
+
})]
|
|
432
|
+
}),
|
|
433
|
+
u === "consolidate" && /* @__PURE__ */ A(R, {
|
|
434
|
+
bundle: i,
|
|
435
|
+
onClose: () => d(null),
|
|
436
|
+
onDone: (e) => o?.(e)
|
|
437
|
+
}),
|
|
438
|
+
u === "dismiss" && /* @__PURE__ */ A(z, {
|
|
439
|
+
bundle: i,
|
|
440
|
+
onClose: () => d(null)
|
|
441
|
+
})
|
|
442
|
+
]
|
|
443
|
+
});
|
|
444
|
+
};
|
|
445
|
+
//#endregion
|
|
446
|
+
export { B as BundleCard, L as SavingsCard };
|
|
447
|
+
|
|
448
|
+
//# sourceMappingURL=SavingsCards.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SavingsCards.js","names":[],"sources":["../../../../src/subscriberbot/components/savings/SavingsCards.tsx"],"sourcesContent":["/**\n * Savings cards (BOFF-4001)\n * @module subscriberbot/components/savings\n *\n * `SavingsCard` is the compact action card on `/subscriberbot/savings`: one\n * open decision with the subscription, its current price, the recommended\n * action, the yearly saving, rationale, confidence and the rows it used.\n * Apply and Dismiss reuse the decision dialogs, so a cancel still needs the\n * named acknowledgement before it can run.\n *\n * `BundleCard` is a bundle (consolidation) suggestion: the overlapping\n * subscriptions with links to each, the cost today and after, and a\n * Consolidate flow that keeps one subscription (optionally on a bundled plan)\n * and cancels the rest through `subConsolidateRelationships`.\n */\n\nimport { useState, type FC, type FormEvent } from 'react';\nimport {\n ArrowDownRight,\n CalendarRange,\n CheckCircle2,\n Combine,\n ExternalLink,\n Gauge,\n Layers,\n ShieldAlert,\n Trash2,\n XCircle,\n} from 'lucide-react';\nimport { GlassCard } from '@burdenoff/fe-libs/ui';\nimport { Modal } from '../Modal';\nimport {\n ApplyDecisionDialog,\n DecisionSources,\n DismissDecisionDialog,\n RECOMMENDATION_LABEL,\n confidencePercent,\n isDestructive,\n relationshipSnapshot,\n} from '../decisions';\nimport { useTr } from '../../hooks/useTr';\nimport { useSubscriberBot } from '../../context/SubscriberBotContext';\nimport { useConsolidateRelationships, useDismissBundle } from '../../hooks/useSubscriberBotApi';\nimport { formatCurrency, formatEnumLabel } from '../../utils/formatters';\nimport { cn } from '../../utils/cn';\nimport type {\n SubBillingInterval,\n SubBundleRecommendation,\n SubConsolidationResult,\n SubDecision,\n} from '../../types';\nimport {\n bundleMembers,\n parseBundledAmount,\n projectedConsolidationSavings,\n sectionOf,\n type SavingsSectionId,\n} from './savingsModel';\n\nconst SECTION_ICON: Record<SavingsSectionId, typeof Trash2> = {\n cancel: Trash2,\n downgrade: ArrowDownRight,\n annual: CalendarRange,\n other: Layers,\n};\n\nconst BUNDLED_INTERVALS: SubBillingInterval[] = ['MONTHLY', 'QUARTERLY', 'YEARLY'];\n\nfunction errorText(err: unknown, fallback: string): string {\n return err instanceof Error && err.message ? err.message : fallback;\n}\n\nfunction priceText(amount: number, currency: string, interval: string): string {\n return `${formatCurrency(amount, currency)} / ${formatEnumLabel(interval).toLowerCase()}`;\n}\n\n// ---------------------------------------------------------------------------\n// Decision savings card\n// ---------------------------------------------------------------------------\n\nexport const SavingsCard: FC<{ decision: SubDecision }> = ({ decision }) => {\n const tr = useTr();\n const { navigateTo } = useSubscriberBot();\n const [dialog, setDialog] = useState<'apply' | 'dismiss' | null>(null);\n const rec = RECOMMENDATION_LABEL[decision.recommendation];\n const destructive = isDestructive(decision.recommendation);\n const snap = relationshipSnapshot(decision);\n const Icon = SECTION_ICON[sectionOf(decision.recommendation)];\n const label = snap.label ?? decision.relationshipId;\n\n return (\n <GlassCard\n treatment=\"metric\"\n className=\"flex flex-col gap-3 p-4\"\n data-testid=\"savings-card\"\n data-decision-id={decision.id}\n data-recommendation={decision.recommendation}\n >\n <div className=\"flex items-start gap-3\">\n <span\n className={cn(\n 'flex size-9 shrink-0 items-center justify-center rounded-lg',\n destructive\n ? 'bg-status-error-bg-subtle text-status-error-text'\n : 'bg-bg-sunken text-text-secondary'\n )}\n aria-hidden\n >\n <Icon className=\"h-4 w-4\" />\n </span>\n <div className=\"min-w-0 flex-1\">\n <p className=\"text-sm font-semibold text-text-primary\">{tr(rec.key, rec.fallback)}</p>\n <button\n type=\"button\"\n onClick={() => navigateTo(`/relationships/${decision.relationshipId}`)}\n className=\"block max-w-full truncate text-left text-sm text-text-primary hover:underline\"\n data-testid=\"savings-card-relationship\"\n >\n {label}\n </button>\n {snap.amount !== null && (\n <p className=\"text-xs tabular-nums text-text-secondary\">\n {tr('savings.card.current', 'Now {{price}}', {\n price: priceText(\n snap.amount,\n snap.currency ?? decision.currency,\n snap.interval ?? 'MONTHLY'\n ),\n })}\n </p>\n )}\n </div>\n {decision.estimatedSavings > 0 && (\n <p\n className=\"shrink-0 text-right text-sm font-semibold tabular-nums text-status-success-text\"\n data-testid=\"savings-card-amount\"\n >\n {tr('savings.card.save', 'Save {{amount}}', {\n amount: formatCurrency(decision.estimatedSavings, decision.currency),\n })}\n <span className=\"block text-[11px] font-normal text-text-secondary\">\n {tr('savings.card.perYear', 'per year')}\n </span>\n </p>\n )}\n </div>\n\n <p className=\"text-sm text-text-secondary\">{decision.rationale}</p>\n\n <div className=\"flex flex-wrap items-center gap-2 text-xs\">\n <span className=\"inline-flex items-center gap-1.5 rounded-md bg-bg-sunken px-2 py-0.5 text-text-secondary\">\n <Gauge className=\"h-3.5 w-3.5\" aria-hidden />\n {tr('decisions.card.confidence', 'Confidence {{pct}}%', {\n pct: confidencePercent(decision.confidence),\n })}\n </span>\n <button\n type=\"button\"\n onClick={() => navigateTo(`/decisions/${decision.id}`)}\n className=\"inline-flex items-center gap-1 rounded-md px-1 text-text-secondary hover:text-text-primary\"\n >\n {tr('savings.card.details', 'Details')}\n <ExternalLink className=\"h-3 w-3\" aria-hidden />\n </button>\n </div>\n\n <DecisionSources decision={decision} limit={3} />\n\n <div className=\"flex flex-wrap justify-end gap-2 border-t border-border-subtle pt-3\">\n <button\n type=\"button\"\n onClick={() => setDialog('dismiss')}\n data-testid=\"savings-dismiss\"\n className=\"inline-flex items-center gap-1.5 rounded-lg border border-border-subtle px-3 py-1.5 text-xs font-medium text-text-primary hover:bg-bg-sunken\"\n >\n <XCircle className=\"h-3.5 w-3.5\" aria-hidden />\n {tr('decisions.card.dismiss', 'Dismiss')}\n </button>\n <button\n type=\"button\"\n onClick={() => setDialog('apply')}\n data-testid=\"savings-apply\"\n className={cn(\n 'inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium hover:opacity-90',\n destructive\n ? 'border border-status-error-text/40 bg-status-error-bg-subtle text-status-error-text'\n : 'bg-action-primary-bg text-action-primary-text'\n )}\n >\n <CheckCircle2 className=\"h-3.5 w-3.5\" aria-hidden />\n {tr('savings.card.apply', 'Apply')}\n </button>\n </div>\n\n {dialog === 'apply' && (\n <ApplyDecisionDialog decision={decision} onClose={() => setDialog(null)} />\n )}\n {dialog === 'dismiss' && (\n <DismissDecisionDialog decision={decision} onClose={() => setDialog(null)} />\n )}\n </GlassCard>\n );\n};\n\n// ---------------------------------------------------------------------------\n// Bundle consolidation dialog\n// ---------------------------------------------------------------------------\n\nconst ConsolidateDialog: FC<{\n bundle: SubBundleRecommendation;\n onClose: () => void;\n onDone: (result: SubConsolidationResult) => void;\n}> = ({ bundle, onClose, onDone }) => {\n const tr = useTr();\n const consolidate = useConsolidateRelationships();\n const [keepId, setKeepId] = useState(bundle.keepRelationshipId);\n const [priceRaw, setPriceRaw] = useState('');\n const [bundledInterval, setBundledInterval] = useState<SubBillingInterval>('MONTHLY');\n const [acknowledged, setAcknowledged] = useState(false);\n const [error, setError] = useState<string | null>(null);\n\n const { keep, cancel } = bundleMembers(bundle, keepId);\n const bundledAmount = parseBundledAmount(priceRaw);\n const priceInvalid = Number.isNaN(bundledAmount);\n const bundledPlan =\n bundledAmount !== null && !priceInvalid\n ? { amount: bundledAmount, interval: bundledInterval }\n : null;\n const projected = projectedConsolidationSavings(bundle, keepId, bundledPlan);\n const cancelNames = cancel.map((r) => `\"${r.label}\"`).join(', ');\n const canSubmit = acknowledged && !priceInvalid && !consolidate.isPending && keep !== null;\n\n const submit = (e: FormEvent) => {\n e.preventDefault();\n if (!canSubmit) return;\n setError(null);\n consolidate.mutate(\n {\n keepRelationshipId: keepId,\n cancelRelationshipIds: cancel.map((r) => r.id),\n ...(bundledPlan ? { amount: bundledPlan.amount, interval: bundledPlan.interval } : {}),\n },\n {\n onSuccess: (result) => {\n onDone(result);\n onClose();\n },\n onError: (err) =>\n setError(errorText(err, tr('savings.bundle.consolidateError', 'Could not consolidate.'))),\n }\n );\n };\n\n return (\n <Modal\n title={tr('savings.bundle.consolidateTitle', 'Consolidate subscriptions')}\n description={tr(\n 'savings.bundle.consolidateDescription',\n 'Keep one subscription and cancel the others. Each cancel is the same change you could make by hand.'\n )}\n onClose={onClose}\n >\n <form onSubmit={submit} className=\"space-y-4\" data-testid=\"consolidate-dialog\">\n <fieldset>\n <legend className=\"mb-1.5 text-xs font-medium text-text-secondary\">\n {tr('savings.bundle.keepLabel', 'Keep')}\n </legend>\n <div className=\"space-y-1.5\">\n {bundle.relationships.map((r) => (\n <label\n key={r.id}\n className={cn(\n 'flex items-center gap-2.5 rounded-lg border px-3 py-2 text-sm',\n r.id === keepId ? 'border-action-primary-bg bg-bg-sunken' : 'border-border-subtle'\n )}\n >\n <input\n type=\"radio\"\n name={`keep-${bundle.id}`}\n value={r.id}\n checked={r.id === keepId}\n onChange={() => {\n setKeepId(r.id);\n setAcknowledged(false);\n }}\n className=\"size-4 shrink-0\"\n />\n <span className=\"min-w-0 flex-1 truncate text-text-primary\">{r.label}</span>\n <span className=\"shrink-0 tabular-nums text-text-secondary\">\n {priceText(r.amount, r.currency, r.interval)}\n </span>\n </label>\n ))}\n </div>\n </fieldset>\n\n <div>\n <p className=\"mb-1.5 text-xs font-medium text-text-secondary\">\n {tr(\n 'savings.bundle.planLabel',\n 'Bundled plan price for the kept subscription (optional)'\n )}\n </p>\n <div className=\"flex gap-2\">\n <input\n type=\"number\"\n inputMode=\"decimal\"\n min=\"0\"\n step=\"0.01\"\n value={priceRaw}\n onChange={(e) => setPriceRaw(e.target.value)}\n placeholder={\n keep ? String(keep.amount) : tr('savings.bundle.planPlaceholder', 'Same price')\n }\n aria-label={tr('savings.bundle.planAmount', 'Bundled plan amount')}\n aria-invalid={priceInvalid}\n data-testid=\"consolidate-amount\"\n className=\"w-full rounded-lg border border-border-subtle bg-bg-surface px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)]\"\n />\n <select\n value={bundledInterval}\n onChange={(e) => setBundledInterval(e.target.value as SubBillingInterval)}\n aria-label={tr('savings.bundle.planInterval', 'Billing interval')}\n className=\"rounded-lg border border-border-subtle bg-bg-surface px-2 py-2 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)]\"\n >\n {BUNDLED_INTERVALS.map((i) => (\n <option key={i} value={i}>\n {formatEnumLabel(i)}\n </option>\n ))}\n </select>\n </div>\n {priceInvalid && (\n <p className=\"mt-1 text-xs text-status-error-text\">\n {tr('savings.bundle.planInvalid', 'Enter zero or a positive amount.')}\n </p>\n )}\n </div>\n\n <p className=\"text-sm text-text-primary\" data-testid=\"consolidate-projected\">\n {tr('savings.bundle.projected', 'Estimated saving: {{amount}} a year', {\n amount: formatCurrency(Math.max(projected, 0), bundle.currency),\n })}\n </p>\n\n <label className=\"flex items-start gap-2.5 rounded-lg border border-status-error-text/30 bg-status-error-bg-subtle px-4 py-3 text-sm text-status-error-text\">\n <input\n type=\"checkbox\"\n checked={acknowledged}\n onChange={(e) => setAcknowledged(e.target.checked)}\n className=\"mt-0.5 size-4 shrink-0 rounded border-border-subtle\"\n data-testid=\"consolidate-acknowledge\"\n />\n <span>\n {tr(\n 'savings.bundle.acknowledge',\n 'I want to cancel {{names}} and keep \"{{keep}}\". I understand the cancelled subscriptions will stop.',\n { names: cancelNames, keep: keep?.label ?? '' }\n )}\n </span>\n </label>\n\n {error && (\n <div className=\"rounded-lg border border-status-error-text/30 bg-status-error-bg-subtle px-4 py-2 text-sm text-status-error-text\">\n {error}\n </div>\n )}\n\n <div className=\"flex justify-end gap-3\">\n <button\n type=\"button\"\n onClick={onClose}\n className=\"rounded-lg border border-border-subtle px-4 py-2 text-sm font-medium text-text-primary hover:bg-bg-sunken\"\n >\n {tr('decisions.apply.back', 'Not now')}\n </button>\n <button\n type=\"submit\"\n disabled={!canSubmit}\n data-testid=\"confirm-consolidate\"\n className=\"rounded-lg bg-action-danger-bg px-4 py-2 text-sm font-medium text-action-danger-text hover:bg-action-danger-bg-hover disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {consolidate.isPending\n ? tr('savings.bundle.consolidating', 'Consolidating…')\n : tr('savings.bundle.consolidateSubmit', 'Consolidate')}\n </button>\n </div>\n </form>\n </Modal>\n );\n};\n\nconst DismissBundleDialog: FC<{ bundle: SubBundleRecommendation; onClose: () => void }> = ({\n bundle,\n onClose,\n}) => {\n const tr = useTr();\n const dismiss = useDismissBundle();\n const [reason, setReason] = useState('');\n const [error, setError] = useState<string | null>(null);\n\n const submit = (e: FormEvent) => {\n e.preventDefault();\n setError(null);\n dismiss.mutate(\n { id: bundle.id, reason },\n {\n onSuccess: () => onClose(),\n onError: (err) =>\n setError(errorText(err, tr('savings.bundle.dismissError', 'Could not dismiss.'))),\n }\n );\n };\n\n return (\n <Modal\n title={tr('savings.bundle.dismissTitle', 'Dismiss bundle suggestion')}\n description={tr(\n 'savings.bundle.dismissDescription',\n 'It stays hidden until one of these subscriptions changes.'\n )}\n onClose={onClose}\n >\n <form onSubmit={submit} className=\"space-y-4\" data-testid=\"dismiss-bundle-dialog\">\n <div>\n <label\n htmlFor={`dismiss-bundle-${bundle.id}`}\n className=\"mb-1 block text-xs font-medium text-text-secondary\"\n >\n {tr('decisions.dismiss.reason', 'Reason (optional)')}\n </label>\n <textarea\n id={`dismiss-bundle-${bundle.id}`}\n value={reason}\n onChange={(e) => setReason(e.target.value)}\n rows={3}\n maxLength={500}\n placeholder={tr('savings.bundle.dismissPlaceholder', 'e.g. They cover different needs')}\n className=\"w-full rounded-lg border border-border-subtle bg-bg-surface px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)]\"\n />\n </div>\n {error && (\n <div className=\"rounded-lg border border-status-error-text/30 bg-status-error-bg-subtle px-4 py-2 text-sm text-status-error-text\">\n {error}\n </div>\n )}\n <div className=\"flex justify-end gap-3\">\n <button\n type=\"button\"\n onClick={onClose}\n className=\"rounded-lg border border-border-subtle px-4 py-2 text-sm font-medium text-text-primary hover:bg-bg-sunken\"\n >\n {tr('decisions.dismiss.back', 'Back')}\n </button>\n <button\n type=\"submit\"\n disabled={dismiss.isPending}\n data-testid=\"confirm-dismiss-bundle\"\n className=\"rounded-lg bg-action-primary-bg px-4 py-2 text-sm font-medium text-action-primary-text hover:opacity-90 disabled:opacity-50\"\n >\n {dismiss.isPending\n ? tr('decisions.dismiss.dismissing', 'Dismissing…')\n : tr('decisions.dismiss.submit', 'Dismiss')}\n </button>\n </div>\n </form>\n </Modal>\n );\n};\n\n// ---------------------------------------------------------------------------\n// Bundle card\n// ---------------------------------------------------------------------------\n\nexport const BundleCard: FC<{\n bundle: SubBundleRecommendation;\n onConsolidated?: (result: SubConsolidationResult) => void;\n}> = ({ bundle, onConsolidated }) => {\n const tr = useTr();\n const { navigateTo } = useSubscriberBot();\n const [dialog, setDialog] = useState<'consolidate' | 'dismiss' | null>(null);\n const heading =\n bundle.basis === 'SAME_PROVIDER'\n ? tr('savings.bundle.sameProvider', 'Merge duplicate subscriptions')\n : tr('savings.bundle.sameCategory', 'Overlapping {{category}} services', {\n category: formatEnumLabel(bundle.category ?? 'OTHER').toLowerCase(),\n });\n\n return (\n <GlassCard\n treatment=\"metric\"\n className=\"flex flex-col gap-3 border border-status-warning-text/30 p-4\"\n data-testid=\"bundle-card\"\n data-bundle-id={bundle.id}\n data-basis={bundle.basis}\n >\n <div className=\"flex items-start gap-3\">\n <span\n className=\"flex size-9 shrink-0 items-center justify-center rounded-lg bg-status-warning-bg-subtle text-status-warning-text\"\n aria-hidden\n >\n <Combine className=\"h-4 w-4\" />\n </span>\n <div className=\"min-w-0 flex-1\">\n <p className=\"text-[11px] font-medium uppercase tracking-wide text-text-secondary\">\n {tr('savings.bundle.label', 'Bundle suggestion')}\n {bundle.providerName ? ` · ${bundle.providerName}` : ''}\n </p>\n <p className=\"text-sm font-semibold text-text-primary\">{heading}</p>\n </div>\n <p\n className=\"shrink-0 text-right text-sm font-semibold tabular-nums text-status-success-text\"\n data-testid=\"bundle-card-amount\"\n >\n {tr('savings.card.save', 'Save {{amount}}', {\n amount: formatCurrency(bundle.estimatedSavings, bundle.currency),\n })}\n <span className=\"block text-[11px] font-normal text-text-secondary\">\n {tr('savings.card.perYear', 'per year')}\n </span>\n </p>\n </div>\n\n <p className=\"text-sm text-text-secondary\">{bundle.rationale}</p>\n\n <ul className=\"space-y-1\" data-testid=\"bundle-relationships\">\n {bundle.relationships.map((r) => {\n const kept = r.id === bundle.keepRelationshipId;\n return (\n <li key={r.id}>\n <button\n type=\"button\"\n onClick={() => navigateTo(`/relationships/${r.id}`)}\n data-relationship-id={r.id}\n className=\"flex w-full items-center gap-2 rounded-md border border-border-subtle bg-bg-sunken px-2.5 py-1.5 text-left text-xs hover:bg-bg-surface\"\n >\n <span\n className={cn(\n 'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium uppercase',\n kept\n ? 'bg-status-success-bg-subtle text-status-success-text'\n : 'bg-status-error-bg-subtle text-status-error-text'\n )}\n >\n {kept ? tr('savings.bundle.keep', 'Keep') : tr('savings.bundle.cancel', 'Cancel')}\n </span>\n <span className=\"min-w-0 flex-1 truncate font-medium text-text-primary\">\n {r.label}\n </span>\n <span className=\"shrink-0 tabular-nums text-text-secondary\">\n {priceText(r.amount, r.currency, r.interval)}\n </span>\n <ExternalLink className=\"h-3 w-3 shrink-0 text-text-secondary\" aria-hidden />\n </button>\n </li>\n );\n })}\n </ul>\n\n <div className=\"flex flex-wrap gap-2 text-xs\">\n <span className=\"inline-flex items-center rounded-md bg-bg-sunken px-2 py-0.5 tabular-nums text-text-secondary\">\n {tr('savings.bundle.costChange', '{{before}} → {{after}} a year', {\n before: formatCurrency(bundle.currentAnnualCost, bundle.currency),\n after: formatCurrency(bundle.projectedAnnualCost, bundle.currency),\n })}\n </span>\n <span className=\"inline-flex items-center gap-1.5 rounded-md bg-bg-sunken px-2 py-0.5 text-text-secondary\">\n <Gauge className=\"h-3.5 w-3.5\" aria-hidden />\n {tr('decisions.card.confidence', 'Confidence {{pct}}%', {\n pct: confidencePercent(bundle.confidence),\n })}\n </span>\n </div>\n\n <div className=\"flex flex-wrap justify-end gap-2 border-t border-border-subtle pt-3\">\n <button\n type=\"button\"\n onClick={() => setDialog('dismiss')}\n data-testid=\"bundle-dismiss\"\n className=\"inline-flex items-center gap-1.5 rounded-lg border border-border-subtle px-3 py-1.5 text-xs font-medium text-text-primary hover:bg-bg-sunken\"\n >\n <XCircle className=\"h-3.5 w-3.5\" aria-hidden />\n {tr('decisions.card.dismiss', 'Dismiss')}\n </button>\n <button\n type=\"button\"\n onClick={() => setDialog('consolidate')}\n data-testid=\"bundle-consolidate\"\n className=\"inline-flex items-center gap-1.5 rounded-lg border border-status-error-text/40 bg-status-error-bg-subtle px-3 py-1.5 text-xs font-medium text-status-error-text hover:opacity-90\"\n >\n <ShieldAlert className=\"h-3.5 w-3.5\" aria-hidden />\n {tr('savings.bundle.consolidate', 'Review and consolidate')}\n </button>\n </div>\n\n {dialog === 'consolidate' && (\n <ConsolidateDialog\n bundle={bundle}\n onClose={() => setDialog(null)}\n onDone={(result) => onConsolidated?.(result)}\n />\n )}\n {dialog === 'dismiss' && (\n <DismissBundleDialog bundle={bundle} onClose={() => setDialog(null)} />\n )}\n </GlassCard>\n );\n};\n"],"mappings":"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"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Savings dashboard components (BOFF-4001)
|
|
3
|
+
* @module subscriberbot/components/savings
|
|
4
|
+
*/
|
|
5
|
+
export { SavingsCard, BundleCard } from './SavingsCards';
|
|
6
|
+
export { SAVINGS_SECTIONS, groupDecisions, headlineAmounts, joinAmounts, perMonth, savingsByCurrency, } from './savingsModel';
|
|
7
|
+
export type { SavingsSectionId } from './savingsModel';
|
|
8
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/subscriberbot/components/savings/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACzD,OAAO,EACL,gBAAgB,EAChB,cAAc,EACd,eAAe,EACf,WAAW,EACX,QAAQ,EACR,iBAAiB,GAClB,MAAM,gBAAgB,CAAC;AACxB,YAAY,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { SubBundleRecommendation, SubDecision, SubDecisionRecommendation, SubSavingsTotal } from '../../types';
|
|
2
|
+
|
|
3
|
+
/** The savings sections, in the order the dashboard shows them. */
|
|
4
|
+
export type SavingsSectionId = 'cancel' | 'downgrade' | 'annual' | 'other';
|
|
5
|
+
export declare const SAVINGS_SECTIONS: Array<{
|
|
6
|
+
id: SavingsSectionId;
|
|
7
|
+
key: string;
|
|
8
|
+
fallback: string;
|
|
9
|
+
descriptionKey: string;
|
|
10
|
+
descriptionFallback: string;
|
|
11
|
+
}>;
|
|
12
|
+
export declare function sectionOf(recommendation: SubDecisionRecommendation): SavingsSectionId;
|
|
13
|
+
/**
|
|
14
|
+
* Open decisions grouped into sections, biggest saving first within each.
|
|
15
|
+
* Decisions that are not PROPOSED are left out: only open advice saves money.
|
|
16
|
+
*/
|
|
17
|
+
export declare function groupDecisions(decisions: readonly SubDecision[]): Record<SavingsSectionId, SubDecision[]>;
|
|
18
|
+
/** Sum of estimated savings per currency, for a section header. */
|
|
19
|
+
export declare function savingsByCurrency(decisions: readonly Pick<SubDecision, 'estimatedSavings' | 'currency'>[]): Array<{
|
|
20
|
+
currency: string;
|
|
21
|
+
amount: number;
|
|
22
|
+
}>;
|
|
23
|
+
/** Join per-currency amounts with a formatter: "$480.00 + €50.00". Empty list → null. */
|
|
24
|
+
export declare function joinAmounts(amounts: ReadonlyArray<{
|
|
25
|
+
currency: string;
|
|
26
|
+
amount: number;
|
|
27
|
+
}>, format: (amount: number, currency: string) => string): string | null;
|
|
28
|
+
/** Headline totals as amounts (yearly). */
|
|
29
|
+
export declare function headlineAmounts(totals: readonly SubSavingsTotal[]): Array<{
|
|
30
|
+
currency: string;
|
|
31
|
+
amount: number;
|
|
32
|
+
}>;
|
|
33
|
+
/** Monthly equivalent of a yearly amount, for the "per month" sub-line. */
|
|
34
|
+
export declare function perMonth(annual: number): number;
|
|
35
|
+
/** The kept subscription and the ones a consolidation would cancel, for a bundle. */
|
|
36
|
+
export declare function bundleMembers(bundle: SubBundleRecommendation, keepId?: string): {
|
|
37
|
+
keep: SubBundleRecommendation['relationships'][number] | null;
|
|
38
|
+
cancel: SubBundleRecommendation['relationships'];
|
|
39
|
+
};
|
|
40
|
+
/** Yearly cost of an amount billed every `interval`; 0 for non-recurring intervals. */
|
|
41
|
+
export declare function annualCost(amount: number, interval: string): number;
|
|
42
|
+
/**
|
|
43
|
+
* Yearly saving of a consolidation the user is about to confirm: the yearly
|
|
44
|
+
* cost of every member today minus the kept subscription afterwards (at the
|
|
45
|
+
* bundled price when one is entered).
|
|
46
|
+
*/
|
|
47
|
+
export declare function projectedConsolidationSavings(bundle: SubBundleRecommendation, keepId: string, bundledPlan?: {
|
|
48
|
+
amount: number;
|
|
49
|
+
interval: string;
|
|
50
|
+
} | null): number;
|
|
51
|
+
/** Parse the optional bundled-plan price field: blank → null, invalid → NaN. */
|
|
52
|
+
export declare function parseBundledAmount(raw: string): number | null;
|
|
53
|
+
//# sourceMappingURL=savingsModel.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"savingsModel.d.ts","sourceRoot":"","sources":["../../../../src/subscriberbot/components/savings/savingsModel.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EACV,uBAAuB,EACvB,WAAW,EACX,yBAAyB,EACzB,eAAe,EAChB,MAAM,aAAa,CAAC;AAErB,mEAAmE;AACnE,MAAM,MAAM,gBAAgB,GAAG,QAAQ,GAAG,WAAW,GAAG,QAAQ,GAAG,OAAO,CAAC;AAE3E,eAAO,MAAM,gBAAgB,EAAE,KAAK,CAAC;IACnC,EAAE,EAAE,gBAAgB,CAAC;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,mBAAmB,EAAE,MAAM,CAAC;CAC7B,CA8BA,CAAC;AAWF,wBAAgB,SAAS,CAAC,cAAc,EAAE,yBAAyB,GAAG,gBAAgB,CAErF;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,SAAS,EAAE,SAAS,WAAW,EAAE,GAChC,MAAM,CAAC,gBAAgB,EAAE,WAAW,EAAE,CAAC,CAiBzC;AAED,mEAAmE;AACnE,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,SAAS,IAAI,CAAC,WAAW,EAAE,kBAAkB,GAAG,UAAU,CAAC,EAAE,GACvE,KAAK,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAS7C;AAED,yFAAyF;AACzF,wBAAgB,WAAW,CACzB,OAAO,EAAE,aAAa,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,EAC5D,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,MAAM,GACnD,MAAM,GAAG,IAAI,CAGf;AAED,2CAA2C;AAC3C,wBAAgB,eAAe,CAC7B,MAAM,EAAE,SAAS,eAAe,EAAE,GACjC,KAAK,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAE7C;AAED,2EAA2E;AAC3E,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAE/C;AAED,qFAAqF;AACrF,wBAAgB,aAAa,CAC3B,MAAM,EAAE,uBAAuB,EAC/B,MAAM,GAAE,MAAkC,GACzC;IACD,IAAI,EAAE,uBAAuB,CAAC,eAAe,CAAC,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC;IAC9D,MAAM,EAAE,uBAAuB,CAAC,eAAe,CAAC,CAAC;CAClD,CAIA;AAUD,uFAAuF;AACvF,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEnE;AAED;;;;GAIG;AACH,wBAAgB,6BAA6B,CAC3C,MAAM,EAAE,uBAAuB,EAC/B,MAAM,EAAE,MAAM,EACd,WAAW,CAAC,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,GACxD,MAAM,CAQR;AAED,gFAAgF;AAChF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAK7D"}
|