feat: unavailable addons support
This commit is contained in:
@@ -19,18 +19,21 @@ export function AddonList({ addons, devices, selected, onToggle }: AddonListProp
|
|||||||
<div className="addon-list">
|
<div className="addon-list">
|
||||||
{addons.map((a) => {
|
{addons.map((a) => {
|
||||||
const on = selected.includes(a.id);
|
const on = selected.includes(a.id);
|
||||||
|
const enabled = a.is_enabled;
|
||||||
const price = addonPriceForDevices(a, devices);
|
const price = addonPriceForDevices(a, devices);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={a.id}
|
key={a.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={`addon-row ${on ? 'is-selected' : ''}`.trim()}
|
className={`addon-row ${on ? 'is-selected' : ''} ${enabled ? '' : 'is-disabled'}`.trim()}
|
||||||
aria-pressed={on}
|
aria-pressed={on}
|
||||||
|
disabled={!enabled}
|
||||||
onClick={() => onToggle(a.id)}
|
onClick={() => onToggle(a.id)}
|
||||||
>
|
>
|
||||||
<span className="addon-toggle" aria-hidden="true" />
|
<span className="addon-toggle" aria-hidden="true" />
|
||||||
<span className="addon-info">
|
<span className="addon-info">
|
||||||
<span className="addon-name">{a.name}</span>
|
<span className="addon-name">{a.name}</span>
|
||||||
|
{!enabled && <span className="addon-status">Недоступно</span>}
|
||||||
</span>
|
</span>
|
||||||
<span className="addon-price">
|
<span className="addon-price">
|
||||||
{price === 0 ? 'Бесплатно' : <><span className="plus">+</span>{formatMoney(price)}</>}
|
{price === 0 ? 'Бесплатно' : <><span className="plus">+</span>{formatMoney(price)}</>}
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import type { AddonData, PricingPlans } from '../types';
|
|||||||
const pricing: PricingPlans = {
|
const pricing: PricingPlans = {
|
||||||
device_price: 100,
|
device_price: 100,
|
||||||
addons: [
|
addons: [
|
||||||
{ id: 'a', name: 'A', price: 50, free_threshold: -1 },
|
{ id: 'a', name: 'A', price: 50, free_threshold: -1, is_enabled: true },
|
||||||
{ id: 'b', name: 'B', price: 30, free_threshold: -1 },
|
{ id: 'b', name: 'B', price: 30, free_threshold: -1, is_enabled: true },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,8 +38,8 @@ describe('sumAddonPrices', () => {
|
|||||||
|
|
||||||
it('makes addons free at their device threshold but keeps -1 paid', () => {
|
it('makes addons free at their device threshold but keeps -1 paid', () => {
|
||||||
const thresholdAddons: AddonData[] = [
|
const thresholdAddons: AddonData[] = [
|
||||||
{ id: 'threshold', name: 'Threshold', price: 50, free_threshold: 6 },
|
{ id: 'threshold', name: 'Threshold', price: 50, free_threshold: 6, is_enabled: true },
|
||||||
{ id: 'paid', name: 'Paid', price: 30, free_threshold: -1 },
|
{ id: 'paid', name: 'Paid', price: 30, free_threshold: -1, is_enabled: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(sumAddonPrices(thresholdAddons, ['threshold', 'paid'], 5)).toBe(80);
|
expect(sumAddonPrices(thresholdAddons, ['threshold', 'paid'], 5)).toBe(80);
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function sumAddonPrices(addons: AddonData[], selected: string[], devices:
|
|||||||
const addonById = new Map(addons.map((addon) => [addon.id, addon] as const));
|
const addonById = new Map(addons.map((addon) => [addon.id, addon] as const));
|
||||||
return selected.reduce<number>((sum, id) => {
|
return selected.reduce<number>((sum, id) => {
|
||||||
const addon = addonById.get(id);
|
const addon = addonById.get(id);
|
||||||
return sum + (addon ? addonPriceForDevices(addon, devices) : 0);
|
return sum + (addon?.is_enabled ? addonPriceForDevices(addon, devices) : 0);
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ function renderPlan(initialEntries: string[] = ['/plan']) {
|
|||||||
const PLANS = {
|
const PLANS = {
|
||||||
device_price: 100,
|
device_price: 100,
|
||||||
addons: [
|
addons: [
|
||||||
{ id: 'dedicated-ip', name: 'Dedicated IP', price: 50, free_threshold: -1 },
|
{ id: 'dedicated-ip', name: 'Dedicated IP', price: 50, free_threshold: -1, is_enabled: true },
|
||||||
{ id: 'multi-hop', name: 'Multi-Hop', price: 30, free_threshold: -1 },
|
{ id: 'multi-hop', name: 'Multi-Hop', price: 30, free_threshold: -1, is_enabled: true },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,4 +130,21 @@ describe('PlanPage', () => {
|
|||||||
await waitFor(() => expect(api.checkout).toHaveBeenCalledOnce());
|
await waitFor(() => expect(api.checkout).toHaveBeenCalledOnce());
|
||||||
expect(redirectTo).toHaveBeenCalledWith('https://pay.example/abc');
|
expect(redirectTo).toHaveBeenCalledWith('https://pay.example/abc');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not allow selecting disabled addons', async () => {
|
||||||
|
vi.mocked(api.getPlans).mockResolvedValue({
|
||||||
|
...PLANS,
|
||||||
|
addons: [
|
||||||
|
...PLANS.addons,
|
||||||
|
{ id: 'unavailable', name: 'Unavailable', price: 30, free_threshold: -1, is_enabled: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPlan();
|
||||||
|
|
||||||
|
const addon = await screen.findByRole('button', { name: /Unavailable/i });
|
||||||
|
expect(addon).toBeDisabled();
|
||||||
|
await user.click(addon);
|
||||||
|
expect(addon).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function PlanPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!plans) return;
|
if (!plans) return;
|
||||||
const available = new Set(plans.addons.map((addon) => addon.id));
|
const available = new Set(plans.addons.filter((addon) => addon.is_enabled).map((addon) => addon.id));
|
||||||
setSelectedAddons((current) => current.filter((addon) => available.has(addon)));
|
setSelectedAddons((current) => current.filter((addon) => available.has(addon)));
|
||||||
}, [plans]);
|
}, [plans]);
|
||||||
|
|
||||||
@@ -63,6 +63,7 @@ export function PlanPage() {
|
|||||||
}, [plans, devices, selectedAddons, duration]);
|
}, [plans, devices, selectedAddons, duration]);
|
||||||
|
|
||||||
const toggleAddon = (id: string) => {
|
const toggleAddon = (id: string) => {
|
||||||
|
if (!plans?.addons.some((addon) => addon.id === id && addon.is_enabled)) return;
|
||||||
setSelectedAddons((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
setSelectedAddons((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1209,6 +1209,14 @@
|
|||||||
.addon-row:hover {
|
.addon-row:hover {
|
||||||
background: color-mix(in srgb, var(--surface) 82%, var(--fg) 4%);
|
background: color-mix(in srgb, var(--surface) 82%, var(--fg) 4%);
|
||||||
}
|
}
|
||||||
|
.addon-row.is-disabled {
|
||||||
|
color: var(--fg-muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.58;
|
||||||
|
}
|
||||||
|
.addon-row.is-disabled:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
.addon-row.is-selected {
|
.addon-row.is-selected {
|
||||||
background: color-mix(in srgb, var(--surface) 78%, var(--accent) 10%);
|
background: color-mix(in srgb, var(--surface) 78%, var(--accent) 10%);
|
||||||
}
|
}
|
||||||
@@ -1250,6 +1258,12 @@
|
|||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
|
.addon-status {
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
.addon-price {
|
.addon-price {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface AddonData {
|
|||||||
name: string;
|
name: string;
|
||||||
price: number;
|
price: number;
|
||||||
free_threshold: number;
|
free_threshold: number;
|
||||||
|
is_enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PricingPlans {
|
export interface PricingPlans {
|
||||||
|
|||||||
Reference in New Issue
Block a user