@burdenoff/microfe-subscriberbot 2026.1030.5 → 2026.1030.6
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/components/savings/SavingsCards.d.ts +2 -2
- package/dist/subscriberbot/components/savings/SavingsCards.d.ts.map +1 -1
- package/dist/subscriberbot/components/savings/SavingsCards.js +128 -104
- package/dist/subscriberbot/components/savings/SavingsCards.js.map +1 -1
- package/dist/subscriberbot/components/savings/savingsModel.d.ts +14 -9
- package/dist/subscriberbot/components/savings/savingsModel.d.ts.map +1 -1
- package/dist/subscriberbot/components/savings/savingsModel.js +9 -7
- package/dist/subscriberbot/components/savings/savingsModel.js.map +1 -1
- package/dist/subscriberbot/hooks/useSubscriberBotApi.d.ts.map +1 -1
- package/dist/subscriberbot/hooks/useSubscriberBotApi.js +1 -1
- package/dist/subscriberbot/hooks/useSubscriberBotApi.js.map +1 -1
- package/dist/subscriberbot/pages/SavingsPage.js +1 -1
- package/dist/subscriberbot/pages/SavingsPage.js.map +1 -1
- package/dist/subscriberbot/types/index.d.ts +27 -15
- package/dist/subscriberbot/types/index.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { FC } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { SubOverlap, SubConsolidationResult, SubDecision } from '../../types';
|
|
3
3
|
|
|
4
4
|
export declare const SavingsCard: FC<{
|
|
5
5
|
decision: SubDecision;
|
|
6
6
|
}>;
|
|
7
7
|
export declare const BundleCard: FC<{
|
|
8
|
-
bundle:
|
|
8
|
+
bundle: SubOverlap;
|
|
9
9
|
onConsolidated?: (result: SubConsolidationResult) => void;
|
|
10
10
|
}>;
|
|
11
11
|
//# sourceMappingURL=SavingsCards.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SavingsCards.d.ts","sourceRoot":"","sources":["../../../../src/subscriberbot/components/savings/SavingsCards.tsx"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"SavingsCards.d.ts","sourceRoot":"","sources":["../../../../src/subscriberbot/components/savings/SavingsCards.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAY,KAAK,EAAE,EAAkB,MAAM,OAAO,CAAC;AA6B1D,OAAO,KAAK,EAEV,UAAU,EAEV,sBAAsB,EACtB,WAAW,EACZ,MAAM,aAAa,CAAC;AA8BrB,eAAO,MAAM,WAAW,EAAE,EAAE,CAAC;IAAE,QAAQ,EAAE,WAAW,CAAA;CAAE,CA0HrD,CAAC;AAySF,eAAO,MAAM,UAAU,EAAE,EAAE,CAAC;IAC1B,MAAM,EAAE,UAAU,CAAC;IACnB,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,sBAAsB,KAAK,IAAI,CAAC;CAC3D,CAoIA,CAAC"}
|
|
@@ -134,67 +134,88 @@ var L = ({ decision: r }) => {
|
|
|
134
134
|
})
|
|
135
135
|
]
|
|
136
136
|
});
|
|
137
|
-
}, R =
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
137
|
+
}, R = {
|
|
138
|
+
keep: "bg-status-success-bg-subtle text-status-success-text",
|
|
139
|
+
cancel: "bg-status-error-bg-subtle text-status-error-text",
|
|
140
|
+
untouched: "bg-bg-sunken text-text-secondary"
|
|
141
|
+
}, z = ({ role: e }) => {
|
|
142
|
+
let n = a(), r = {
|
|
143
|
+
keep: n("savings.bundle.keep", "Keep"),
|
|
144
|
+
cancel: n("savings.bundle.cancel", "Cancel"),
|
|
145
|
+
untouched: n("savings.bundle.leftAlone", "Left alone")
|
|
146
|
+
}[e];
|
|
147
|
+
return /* @__PURE__ */ A("span", {
|
|
148
|
+
className: t("shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium uppercase", R[e]),
|
|
149
|
+
children: r
|
|
150
|
+
});
|
|
151
|
+
}, B = ({ member: e, role: t }) => /* @__PURE__ */ j("li", {
|
|
152
|
+
className: "flex items-center gap-2.5 rounded-lg border border-border-subtle px-3 py-2 text-sm",
|
|
153
|
+
"data-role": t,
|
|
154
|
+
children: [
|
|
155
|
+
/* @__PURE__ */ A(z, { role: t }),
|
|
156
|
+
/* @__PURE__ */ A("span", {
|
|
157
|
+
className: "min-w-0 flex-1 truncate text-text-primary",
|
|
158
|
+
children: e.label
|
|
159
|
+
}),
|
|
160
|
+
/* @__PURE__ */ A("span", {
|
|
161
|
+
className: "shrink-0 tabular-nums text-text-secondary",
|
|
162
|
+
children: I(e.amount, e.currency, e.interval)
|
|
163
|
+
})
|
|
164
|
+
]
|
|
165
|
+
}), V = ({ bundle: e, onClose: t, onDone: s }) => {
|
|
166
|
+
let c = a(), l = o(), [u, d] = y(""), [f, p] = y("MONTHLY"), [m, v] = y(!1), [b, x] = y(null), { keep: S, cancel: C, untouched: w } = h(e), T = g(u), E = Number.isNaN(T), D = T !== null && !E ? {
|
|
167
|
+
amount: T,
|
|
168
|
+
interval: f
|
|
169
|
+
} : null, O = _(e, D), k = C.map((e) => `"${e.label}"`).join(", "), M = m && !E && !l.isPending && S !== null;
|
|
142
170
|
return /* @__PURE__ */ A(i, {
|
|
143
|
-
title:
|
|
144
|
-
description:
|
|
145
|
-
onClose:
|
|
171
|
+
title: c("savings.bundle.consolidateTitle", "Consolidate subscriptions"),
|
|
172
|
+
description: c("savings.bundle.consolidateDescription", "Keep one subscription and cancel the others. Each cancel is the same change you could make by hand."),
|
|
173
|
+
onClose: t,
|
|
146
174
|
children: /* @__PURE__ */ j("form", {
|
|
147
|
-
onSubmit: (
|
|
148
|
-
|
|
149
|
-
keepRelationshipId:
|
|
150
|
-
cancelRelationshipIds:
|
|
151
|
-
...
|
|
152
|
-
amount:
|
|
153
|
-
interval:
|
|
175
|
+
onSubmit: (n) => {
|
|
176
|
+
n.preventDefault(), M && (x(null), l.mutate({
|
|
177
|
+
keepRelationshipId: e.keepRelationshipId,
|
|
178
|
+
cancelRelationshipIds: C.map((e) => e.relationshipId),
|
|
179
|
+
...D ? {
|
|
180
|
+
amount: D.amount,
|
|
181
|
+
interval: D.interval
|
|
154
182
|
} : {}
|
|
155
183
|
}, {
|
|
156
184
|
onSuccess: (e) => {
|
|
157
|
-
|
|
185
|
+
s(e), t();
|
|
158
186
|
},
|
|
159
|
-
onError: (e) =>
|
|
187
|
+
onError: (e) => x(F(e, c("savings.bundle.consolidateError", "Could not consolidate.")))
|
|
160
188
|
}));
|
|
161
189
|
},
|
|
162
190
|
className: "space-y-4",
|
|
163
191
|
"data-testid": "consolidate-dialog",
|
|
164
192
|
children: [
|
|
165
|
-
/* @__PURE__ */ j("
|
|
166
|
-
className: "mb-1.5 text-xs font-medium text-text-secondary",
|
|
167
|
-
children: l("savings.bundle.keepLabel", "Keep")
|
|
168
|
-
}), /* @__PURE__ */ A("div", {
|
|
193
|
+
/* @__PURE__ */ j("ul", {
|
|
169
194
|
className: "space-y-1.5",
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
})
|
|
191
|
-
]
|
|
192
|
-
}, n.id))
|
|
193
|
-
})] }),
|
|
195
|
+
"data-testid": "consolidate-members",
|
|
196
|
+
children: [
|
|
197
|
+
S && /* @__PURE__ */ A(B, {
|
|
198
|
+
member: S,
|
|
199
|
+
role: "keep"
|
|
200
|
+
}),
|
|
201
|
+
C.map((e) => /* @__PURE__ */ A(B, {
|
|
202
|
+
member: e,
|
|
203
|
+
role: "cancel"
|
|
204
|
+
}, e.relationshipId)),
|
|
205
|
+
w.map((e) => /* @__PURE__ */ A(B, {
|
|
206
|
+
member: e,
|
|
207
|
+
role: "untouched"
|
|
208
|
+
}, e.relationshipId))
|
|
209
|
+
]
|
|
210
|
+
}),
|
|
211
|
+
w.length > 0 && /* @__PURE__ */ A("p", {
|
|
212
|
+
className: "text-xs text-text-secondary",
|
|
213
|
+
children: c("savings.bundle.untouched", "Subscriptions marked \"Left alone\" cover something the kept one does not, so they are not cancelled.")
|
|
214
|
+
}),
|
|
194
215
|
/* @__PURE__ */ j("div", { children: [
|
|
195
216
|
/* @__PURE__ */ A("p", {
|
|
196
217
|
className: "mb-1.5 text-xs font-medium text-text-secondary",
|
|
197
|
-
children:
|
|
218
|
+
children: c("savings.bundle.planLabel", "Bundled plan price for the kept subscription (optional)")
|
|
198
219
|
}),
|
|
199
220
|
/* @__PURE__ */ j("div", {
|
|
200
221
|
className: "flex gap-2",
|
|
@@ -203,17 +224,17 @@ var L = ({ decision: r }) => {
|
|
|
203
224
|
inputMode: "decimal",
|
|
204
225
|
min: "0",
|
|
205
226
|
step: "0.01",
|
|
206
|
-
value:
|
|
207
|
-
onChange: (e) =>
|
|
208
|
-
placeholder:
|
|
209
|
-
"aria-label":
|
|
210
|
-
"aria-invalid":
|
|
227
|
+
value: u,
|
|
228
|
+
onChange: (e) => d(e.target.value),
|
|
229
|
+
placeholder: S ? String(S.amount) : c("savings.bundle.planPlaceholder", "Same price"),
|
|
230
|
+
"aria-label": c("savings.bundle.planAmount", "Bundled plan amount"),
|
|
231
|
+
"aria-invalid": E,
|
|
211
232
|
"data-testid": "consolidate-amount",
|
|
212
233
|
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
234
|
}), /* @__PURE__ */ A("select", {
|
|
214
|
-
value:
|
|
215
|
-
onChange: (e) =>
|
|
216
|
-
"aria-label":
|
|
235
|
+
value: f,
|
|
236
|
+
onChange: (e) => p(e.target.value),
|
|
237
|
+
"aria-label": c("savings.bundle.planInterval", "Billing interval"),
|
|
217
238
|
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
239
|
children: P.map((e) => /* @__PURE__ */ A("option", {
|
|
219
240
|
value: e,
|
|
@@ -221,52 +242,52 @@ var L = ({ decision: r }) => {
|
|
|
221
242
|
}, e))
|
|
222
243
|
})]
|
|
223
244
|
}),
|
|
224
|
-
|
|
245
|
+
E && /* @__PURE__ */ A("p", {
|
|
225
246
|
className: "mt-1 text-xs text-status-error-text",
|
|
226
|
-
children:
|
|
247
|
+
children: c("savings.bundle.planInvalid", "Enter zero or a positive amount.")
|
|
227
248
|
})
|
|
228
249
|
] }),
|
|
229
250
|
/* @__PURE__ */ A("p", {
|
|
230
251
|
className: "text-sm text-text-primary",
|
|
231
252
|
"data-testid": "consolidate-projected",
|
|
232
|
-
children:
|
|
253
|
+
children: c("savings.bundle.projected", "Estimated saving: {{amount}} a year", { amount: n(Math.max(O, 0), e.currency) })
|
|
233
254
|
}),
|
|
234
255
|
/* @__PURE__ */ j("label", {
|
|
235
256
|
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
257
|
children: [/* @__PURE__ */ A("input", {
|
|
237
258
|
type: "checkbox",
|
|
238
|
-
checked:
|
|
239
|
-
onChange: (e) =>
|
|
259
|
+
checked: m,
|
|
260
|
+
onChange: (e) => v(e.target.checked),
|
|
240
261
|
className: "mt-0.5 size-4 shrink-0 rounded border-border-subtle",
|
|
241
262
|
"data-testid": "consolidate-acknowledge"
|
|
242
|
-
}), /* @__PURE__ */ A("span", { children:
|
|
243
|
-
names:
|
|
244
|
-
keep:
|
|
263
|
+
}), /* @__PURE__ */ A("span", { children: c("savings.bundle.acknowledge", "I want to cancel {{names}} and keep \"{{keep}}\". I understand the cancelled subscriptions will stop.", {
|
|
264
|
+
names: k,
|
|
265
|
+
keep: S?.label ?? ""
|
|
245
266
|
}) })]
|
|
246
267
|
}),
|
|
247
|
-
|
|
268
|
+
b && /* @__PURE__ */ A("div", {
|
|
248
269
|
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:
|
|
270
|
+
children: b
|
|
250
271
|
}),
|
|
251
272
|
/* @__PURE__ */ j("div", {
|
|
252
273
|
className: "flex justify-end gap-3",
|
|
253
274
|
children: [/* @__PURE__ */ A("button", {
|
|
254
275
|
type: "button",
|
|
255
|
-
onClick:
|
|
276
|
+
onClick: t,
|
|
256
277
|
className: "rounded-lg border border-border-subtle px-4 py-2 text-sm font-medium text-text-primary hover:bg-bg-sunken",
|
|
257
|
-
children:
|
|
278
|
+
children: c("decisions.apply.back", "Not now")
|
|
258
279
|
}), /* @__PURE__ */ A("button", {
|
|
259
280
|
type: "submit",
|
|
260
|
-
disabled: !
|
|
281
|
+
disabled: !M,
|
|
261
282
|
"data-testid": "confirm-consolidate",
|
|
262
283
|
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:
|
|
284
|
+
children: l.isPending ? c("savings.bundle.consolidating", "Consolidating…") : c("savings.bundle.consolidateSubmit", "Consolidate")
|
|
264
285
|
})]
|
|
265
286
|
})
|
|
266
287
|
]
|
|
267
288
|
})
|
|
268
289
|
});
|
|
269
|
-
},
|
|
290
|
+
}, H = ({ bundle: e, onClose: t }) => {
|
|
270
291
|
let n = a(), r = s(), [o, c] = y(""), [l, u] = y(null);
|
|
271
292
|
return /* @__PURE__ */ A(i, {
|
|
272
293
|
title: n("savings.bundle.dismissTitle", "Dismiss bundle suggestion"),
|
|
@@ -320,14 +341,18 @@ var L = ({ decision: r }) => {
|
|
|
320
341
|
]
|
|
321
342
|
})
|
|
322
343
|
});
|
|
323
|
-
},
|
|
324
|
-
let
|
|
344
|
+
}, U = ({ bundle: t, onConsolidated: r }) => {
|
|
345
|
+
let i = a(), { navigateTo: o } = e(), [s, c] = y(null), { keep: u, cancel: d } = h(t), f = new Set(t.dropRelationshipIds), p = {
|
|
346
|
+
EXACT_DUPLICATE: i("savings.bundle.exactDuplicate", "Merge duplicate subscriptions"),
|
|
347
|
+
CAPABILITY: i("savings.bundle.capability", "Several services for {{capability}}", { capability: t.title }),
|
|
348
|
+
ENTITLEMENT: i("savings.bundle.entitlement", "Covered by a subscription you keep")
|
|
349
|
+
}[t.type], m = t.annualSpend - t.annualSavings;
|
|
325
350
|
return /* @__PURE__ */ j(M, {
|
|
326
351
|
treatment: "metric",
|
|
327
352
|
className: "flex flex-col gap-3 border border-status-warning-text/30 p-4",
|
|
328
353
|
"data-testid": "bundle-card",
|
|
329
|
-
"data-bundle-id":
|
|
330
|
-
"data-
|
|
354
|
+
"data-bundle-id": t.id,
|
|
355
|
+
"data-type": t.type,
|
|
331
356
|
children: [
|
|
332
357
|
/* @__PURE__ */ j("div", {
|
|
333
358
|
className: "flex items-start gap-3",
|
|
@@ -341,41 +366,39 @@ var L = ({ decision: r }) => {
|
|
|
341
366
|
className: "min-w-0 flex-1",
|
|
342
367
|
children: [/* @__PURE__ */ j("p", {
|
|
343
368
|
className: "text-[11px] font-medium uppercase tracking-wide text-text-secondary",
|
|
344
|
-
children: [
|
|
369
|
+
children: [i("savings.bundle.label", "Bundle suggestion"), t.type === "EXACT_DUPLICATE" && t.title ? ` · ${t.title}` : ""]
|
|
345
370
|
}), /* @__PURE__ */ A("p", {
|
|
346
371
|
className: "text-sm font-semibold text-text-primary",
|
|
347
|
-
children:
|
|
372
|
+
children: p
|
|
348
373
|
})]
|
|
349
374
|
}),
|
|
350
375
|
/* @__PURE__ */ j("p", {
|
|
351
376
|
className: "shrink-0 text-right text-sm font-semibold tabular-nums text-status-success-text",
|
|
352
377
|
"data-testid": "bundle-card-amount",
|
|
353
|
-
children: [
|
|
378
|
+
children: [i("savings.card.save", "Save {{amount}}", { amount: n(t.annualSavings, t.currency) }), /* @__PURE__ */ A("span", {
|
|
354
379
|
className: "block text-[11px] font-normal text-text-secondary",
|
|
355
|
-
children:
|
|
380
|
+
children: i("savings.card.perYear", "per year")
|
|
356
381
|
})]
|
|
357
382
|
})
|
|
358
383
|
]
|
|
359
384
|
}),
|
|
360
385
|
/* @__PURE__ */ A("p", {
|
|
361
386
|
className: "text-sm text-text-secondary",
|
|
362
|
-
children:
|
|
387
|
+
children: t.rationale
|
|
363
388
|
}),
|
|
364
389
|
/* @__PURE__ */ A("ul", {
|
|
365
390
|
className: "space-y-1",
|
|
366
391
|
"data-testid": "bundle-relationships",
|
|
367
|
-
children:
|
|
368
|
-
let n = e.
|
|
392
|
+
children: t.members.map((e) => {
|
|
393
|
+
let n = e.relationshipId === t.keepRelationshipId ? "keep" : f.has(e.relationshipId) ? "cancel" : "untouched";
|
|
369
394
|
return /* @__PURE__ */ A("li", { children: /* @__PURE__ */ j("button", {
|
|
370
395
|
type: "button",
|
|
371
|
-
onClick: () =>
|
|
372
|
-
"data-relationship-id": e.
|
|
396
|
+
onClick: () => o(`/relationships/${e.relationshipId}`),
|
|
397
|
+
"data-relationship-id": e.relationshipId,
|
|
398
|
+
"data-role": n,
|
|
373
399
|
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
400
|
children: [
|
|
375
|
-
/* @__PURE__ */ A(
|
|
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
|
-
}),
|
|
401
|
+
/* @__PURE__ */ A(z, { role: n }),
|
|
379
402
|
/* @__PURE__ */ A("span", {
|
|
380
403
|
className: "min-w-0 flex-1 truncate font-medium text-text-primary",
|
|
381
404
|
children: e.label
|
|
@@ -389,60 +412,61 @@ var L = ({ decision: r }) => {
|
|
|
389
412
|
"aria-hidden": !0
|
|
390
413
|
})
|
|
391
414
|
]
|
|
392
|
-
}) }, e.
|
|
415
|
+
}) }, e.relationshipId);
|
|
393
416
|
})
|
|
394
417
|
}),
|
|
395
418
|
/* @__PURE__ */ j("div", {
|
|
396
419
|
className: "flex flex-wrap gap-2 text-xs",
|
|
397
420
|
children: [/* @__PURE__ */ A("span", {
|
|
398
421
|
className: "inline-flex items-center rounded-md bg-bg-sunken px-2 py-0.5 tabular-nums text-text-secondary",
|
|
399
|
-
children:
|
|
400
|
-
before: n(
|
|
401
|
-
after: n(
|
|
422
|
+
children: i("savings.bundle.costChange", "{{before}} → {{after}} a year", {
|
|
423
|
+
before: n(t.annualSpend, t.currency),
|
|
424
|
+
after: n(m, t.currency)
|
|
402
425
|
})
|
|
403
426
|
}), /* @__PURE__ */ j("span", {
|
|
404
427
|
className: "inline-flex items-center gap-1.5 rounded-md bg-bg-sunken px-2 py-0.5 text-text-secondary",
|
|
405
428
|
children: [/* @__PURE__ */ A(T, {
|
|
406
429
|
className: "h-3.5 w-3.5",
|
|
407
430
|
"aria-hidden": !0
|
|
408
|
-
}),
|
|
431
|
+
}), i("decisions.card.confidence", "Confidence {{pct}}%", { pct: l(t.confidence) })]
|
|
409
432
|
})]
|
|
410
433
|
}),
|
|
411
434
|
/* @__PURE__ */ j("div", {
|
|
412
435
|
className: "flex flex-wrap justify-end gap-2 border-t border-border-subtle pt-3",
|
|
413
436
|
children: [/* @__PURE__ */ j("button", {
|
|
414
437
|
type: "button",
|
|
415
|
-
onClick: () =>
|
|
438
|
+
onClick: () => c("dismiss"),
|
|
416
439
|
"data-testid": "bundle-dismiss",
|
|
417
440
|
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
441
|
children: [/* @__PURE__ */ A(k, {
|
|
419
442
|
className: "h-3.5 w-3.5",
|
|
420
443
|
"aria-hidden": !0
|
|
421
|
-
}),
|
|
444
|
+
}), i("decisions.card.dismiss", "Dismiss")]
|
|
422
445
|
}), /* @__PURE__ */ j("button", {
|
|
423
446
|
type: "button",
|
|
424
|
-
onClick: () =>
|
|
447
|
+
onClick: () => c("consolidate"),
|
|
448
|
+
disabled: !u || d.length === 0,
|
|
425
449
|
"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",
|
|
450
|
+
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 disabled:cursor-not-allowed disabled:opacity-50",
|
|
427
451
|
children: [/* @__PURE__ */ A(D, {
|
|
428
452
|
className: "h-3.5 w-3.5",
|
|
429
453
|
"aria-hidden": !0
|
|
430
|
-
}),
|
|
454
|
+
}), i("savings.bundle.consolidate", "Review and consolidate")]
|
|
431
455
|
})]
|
|
432
456
|
}),
|
|
433
|
-
|
|
434
|
-
bundle:
|
|
435
|
-
onClose: () =>
|
|
436
|
-
onDone: (e) =>
|
|
457
|
+
s === "consolidate" && /* @__PURE__ */ A(V, {
|
|
458
|
+
bundle: t,
|
|
459
|
+
onClose: () => c(null),
|
|
460
|
+
onDone: (e) => r?.(e)
|
|
437
461
|
}),
|
|
438
|
-
|
|
439
|
-
bundle:
|
|
440
|
-
onClose: () =>
|
|
462
|
+
s === "dismiss" && /* @__PURE__ */ A(H, {
|
|
463
|
+
bundle: t,
|
|
464
|
+
onClose: () => c(null)
|
|
441
465
|
})
|
|
442
466
|
]
|
|
443
467
|
});
|
|
444
468
|
};
|
|
445
469
|
//#endregion
|
|
446
|
-
export {
|
|
470
|
+
export { U as BundleCard, L as SavingsCard };
|
|
447
471
|
|
|
448
472
|
//# sourceMappingURL=SavingsCards.js.map
|
|
@@ -1 +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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"}
|
|
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, one duplicate/overlap\n * group from BOFF-3703: the overlapping subscriptions with links to each, the\n * cost today and after, and a Consolidate flow that keeps the recommended\n * subscription (optionally on a bundled plan) and cancels the ones it fully\n * covers 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 SubOverlap,\n SubOverlapMember,\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 ROLE_STYLE: Record<'keep' | 'cancel' | 'untouched', string> = {\n keep: 'bg-status-success-bg-subtle text-status-success-text',\n cancel: 'bg-status-error-bg-subtle text-status-error-text',\n untouched: 'bg-bg-sunken text-text-secondary',\n};\n\nconst RoleBadge: FC<{ role: 'keep' | 'cancel' | 'untouched' }> = ({ role }) => {\n const tr = useTr();\n const label = {\n keep: tr('savings.bundle.keep', 'Keep'),\n cancel: tr('savings.bundle.cancel', 'Cancel'),\n untouched: tr('savings.bundle.leftAlone', 'Left alone'),\n }[role];\n return (\n <span\n className={cn(\n 'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium uppercase',\n ROLE_STYLE[role]\n )}\n >\n {label}\n </span>\n );\n};\n\nconst ConsolidateMemberRow: FC<{\n member: SubOverlapMember;\n role: 'keep' | 'cancel' | 'untouched';\n}> = ({ member, role }) => (\n <li\n className=\"flex items-center gap-2.5 rounded-lg border border-border-subtle px-3 py-2 text-sm\"\n data-role={role}\n >\n <RoleBadge role={role} />\n <span className=\"min-w-0 flex-1 truncate text-text-primary\">{member.label}</span>\n <span className=\"shrink-0 tabular-nums text-text-secondary\">\n {priceText(member.amount, member.currency, member.interval)}\n </span>\n </li>\n);\n\nconst ConsolidateDialog: FC<{\n bundle: SubOverlap;\n onClose: () => void;\n onDone: (result: SubConsolidationResult) => void;\n}> = ({ bundle, onClose, onDone }) => {\n const tr = useTr();\n const consolidate = useConsolidateRelationships();\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, untouched } = bundleMembers(bundle);\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, bundledPlan);\n const cancelNames = cancel.map((m) => `\"${m.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: bundle.keepRelationshipId,\n cancelRelationshipIds: cancel.map((m) => m.relationshipId),\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 <ul className=\"space-y-1.5\" data-testid=\"consolidate-members\">\n {keep && <ConsolidateMemberRow member={keep} role=\"keep\" />}\n {cancel.map((m) => (\n <ConsolidateMemberRow key={m.relationshipId} member={m} role=\"cancel\" />\n ))}\n {untouched.map((m) => (\n <ConsolidateMemberRow key={m.relationshipId} member={m} role=\"untouched\" />\n ))}\n </ul>\n {untouched.length > 0 && (\n <p className=\"text-xs text-text-secondary\">\n {tr(\n 'savings.bundle.untouched',\n 'Subscriptions marked \"Left alone\" cover something the kept one does not, so they are not cancelled.'\n )}\n </p>\n )}\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: SubOverlap; 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: SubOverlap;\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 { keep, cancel } = bundleMembers(bundle);\n const drop = new Set(bundle.dropRelationshipIds);\n const heading = {\n EXACT_DUPLICATE: tr('savings.bundle.exactDuplicate', 'Merge duplicate subscriptions'),\n CAPABILITY: tr('savings.bundle.capability', 'Several services for {{capability}}', {\n capability: bundle.title,\n }),\n ENTITLEMENT: tr('savings.bundle.entitlement', 'Covered by a subscription you keep'),\n }[bundle.type];\n const afterCost = bundle.annualSpend - bundle.annualSavings;\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-type={bundle.type}\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.type === 'EXACT_DUPLICATE' && bundle.title ? ` · ${bundle.title}` : ''}\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.annualSavings, 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.members.map((m) => {\n const role =\n m.relationshipId === bundle.keepRelationshipId\n ? 'keep'\n : drop.has(m.relationshipId)\n ? 'cancel'\n : 'untouched';\n return (\n <li key={m.relationshipId}>\n <button\n type=\"button\"\n onClick={() => navigateTo(`/relationships/${m.relationshipId}`)}\n data-relationship-id={m.relationshipId}\n data-role={role}\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 <RoleBadge role={role} />\n <span className=\"min-w-0 flex-1 truncate font-medium text-text-primary\">\n {m.label}\n </span>\n <span className=\"shrink-0 tabular-nums text-text-secondary\">\n {priceText(m.amount, m.currency, m.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.annualSpend, bundle.currency),\n after: formatCurrency(afterCost, 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 disabled={!keep || cancel.length === 0}\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 disabled:cursor-not-allowed disabled:opacity-50\"\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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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { SubOverlap, SubOverlapMember, SubDecision, SubDecisionRecommendation, SubSavingsTotal } from '../../types';
|
|
2
2
|
|
|
3
3
|
/** The savings sections, in the order the dashboard shows them. */
|
|
4
4
|
export type SavingsSectionId = 'cancel' | 'downgrade' | 'annual' | 'other';
|
|
@@ -32,19 +32,24 @@ export declare function headlineAmounts(totals: readonly SubSavingsTotal[]): Arr
|
|
|
32
32
|
}>;
|
|
33
33
|
/** Monthly equivalent of a yearly amount, for the "per month" sub-line. */
|
|
34
34
|
export declare function perMonth(annual: number): number;
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
/**
|
|
36
|
+
* A bundle's members split the way consolidating it acts on them: the one it
|
|
37
|
+
* keeps, the ones it cancels, and any it leaves alone because they also cover
|
|
38
|
+
* something the kept one does not.
|
|
39
|
+
*/
|
|
40
|
+
export declare function bundleMembers(bundle: SubOverlap): {
|
|
41
|
+
keep: SubOverlapMember | null;
|
|
42
|
+
cancel: SubOverlapMember[];
|
|
43
|
+
untouched: SubOverlapMember[];
|
|
39
44
|
};
|
|
40
45
|
/** Yearly cost of an amount billed every `interval`; 0 for non-recurring intervals. */
|
|
41
46
|
export declare function annualCost(amount: number, interval: string): number;
|
|
42
47
|
/**
|
|
43
|
-
* Yearly saving of a consolidation the user is about to confirm: the
|
|
44
|
-
*
|
|
45
|
-
* bundled price when one is entered).
|
|
48
|
+
* Yearly saving of a consolidation the user is about to confirm: the kept and
|
|
49
|
+
* cancelled subscriptions' yearly cost today minus the kept one afterwards
|
|
50
|
+
* (at the bundled price when one is entered).
|
|
46
51
|
*/
|
|
47
|
-
export declare function projectedConsolidationSavings(bundle:
|
|
52
|
+
export declare function projectedConsolidationSavings(bundle: SubOverlap, bundledPlan?: {
|
|
48
53
|
amount: number;
|
|
49
54
|
interval: string;
|
|
50
55
|
} | null): number;
|