Subscription module: add gateway capability flow and UX fixes
This commit is contained in:
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { DynamicComponentLoader } from '@/components/DynamicComponentLoader';
|
||||
import { GatewayCapabilityMatrix as SubscriptionGatewayCapabilitiesSection } from './Modules/Subscription/GatewayCapabilityMatrix';
|
||||
|
||||
interface Module {
|
||||
id: string;
|
||||
@@ -143,6 +144,8 @@ export default function ModuleSettings() {
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{moduleId === 'subscription' && <SubscriptionGatewayCapabilitiesSection />}
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
import { SettingsCard } from '../../components/SettingsCard';
|
||||
import { ToggleField } from '../../components/ToggleField';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
interface GatewayRow {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
default: boolean;
|
||||
override: boolean | null;
|
||||
auto_renew: boolean;
|
||||
forced_manual: boolean;
|
||||
}
|
||||
|
||||
interface CapabilityResponse {
|
||||
gateways: GatewayRow[];
|
||||
kill_switch: boolean;
|
||||
}
|
||||
|
||||
type OverrideMap = Record<string, boolean | null>;
|
||||
|
||||
/**
|
||||
* Gateway Capability Matrix
|
||||
*
|
||||
* Renders one row per WC payment gateway with a per-gateway auto-renew
|
||||
* toggle. Overrides are persisted via POST /subscriptions/gateway-capabilities.
|
||||
* The kill switch is a separate field already on the standard settings form
|
||||
* (force_manual_renewal); when on, every row is rendered as forced-manual
|
||||
* and the per-gateway toggles are disabled.
|
||||
*/
|
||||
export const GatewayCapabilityMatrix: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [overrides, setOverrides] = useState<OverrideMap>({});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['subscription', 'gateway-capabilities'],
|
||||
queryFn: async () => {
|
||||
const r = await api.get('/subscriptions/gateway-capabilities');
|
||||
return r as CapabilityResponse;
|
||||
},
|
||||
});
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (payload: OverrideMap) => {
|
||||
return api.post('/subscriptions/gateway-capabilities', { overrides: payload });
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(__('Gateway capabilities saved'));
|
||||
setOverrides({});
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription', 'gateway-capabilities'] });
|
||||
},
|
||||
onError: (e: any) => {
|
||||
toast.error(e?.response?.data?.message ?? __('Failed to save'));
|
||||
},
|
||||
});
|
||||
|
||||
const rows = useMemo(() => data?.gateways ?? [], [data]);
|
||||
|
||||
const effectiveFor = (row: GatewayRow): boolean => {
|
||||
if (row.forced_manual) return false;
|
||||
if (row.id in overrides) return overrides[row.id] === true;
|
||||
return row.auto_renew;
|
||||
};
|
||||
|
||||
const dirty = Object.keys(overrides).length > 0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SettingsCard
|
||||
title={__('Gateway Auto-Renew Capabilities')}
|
||||
description={__('Per-gateway declaration of which payment methods can auto-debit subscription renewals.')}
|
||||
>
|
||||
<div className="text-sm text-muted-foreground">{__('Loading gateways…')}</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
title={__('Gateway Auto-Renew Capabilities')}
|
||||
description={__(
|
||||
'Declare which payment gateways can auto-debit subscription renewals. Indonesian VA/QRIS/e-wallet gateways default to manual. The kill switch (above) forces every gateway to manual regardless of these settings.'
|
||||
)}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">{__('No payment gateways available.')}</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{rows.map((row) => {
|
||||
const eff = effectiveFor(row);
|
||||
const overrideState = row.id in overrides ? overrides[row.id] : row.override;
|
||||
return (
|
||||
<div key={row.id} className="flex items-start justify-between gap-4 py-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-foreground">{row.title}</span>
|
||||
{!row.enabled && (
|
||||
<span className="text-xs px-2 py-0.5 bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded">
|
||||
{__('Site disabled')}
|
||||
</span>
|
||||
)}
|
||||
{row.forced_manual && (
|
||||
<span className="text-xs px-2 py-0.5 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded">
|
||||
{__('Forced manual (kill switch)')}
|
||||
</span>
|
||||
)}
|
||||
{overrideState === null && (
|
||||
<span className="text-xs px-2 py-0.5 bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 rounded">
|
||||
{__('Default')}: {row.default ? __('Auto-renew') : __('Manual')}
|
||||
</span>
|
||||
)}
|
||||
{overrideState !== null && (
|
||||
<span className="text-xs px-2 py-0.5 bg-purple-50 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300 rounded">
|
||||
{__('Override')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{row.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 break-words">{row.description}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1 font-mono">{row.id}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 shrink-0">
|
||||
<ToggleField
|
||||
id={`gateway-autorenew-${row.id}`}
|
||||
checked={eff}
|
||||
disabled={row.forced_manual}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
const currentEffective = row.auto_renew;
|
||||
if (checked === currentEffective) {
|
||||
setOverrides((p) => {
|
||||
const n = { ...p };
|
||||
delete n[row.id];
|
||||
return n;
|
||||
});
|
||||
} else {
|
||||
setOverrides((p) => ({ ...p, [row.id]: checked }));
|
||||
}
|
||||
}}
|
||||
label={eff ? __('Auto-renew') : __('Manual')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
<Button
|
||||
onClick={() => save.mutate(overrides)}
|
||||
disabled={!dirty || save.isPending}
|
||||
>
|
||||
{save.isPending ? __('Saving…') : __('Save Capability Overrides')}
|
||||
</Button>
|
||||
{dirty && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setOverrides({})}
|
||||
>
|
||||
{__('Discard changes')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
};
|
||||
@@ -118,8 +118,18 @@ async function fetchSubscription(id: string) {
|
||||
return res;
|
||||
}
|
||||
|
||||
async function subscriptionAction(id: number, action: string, reason?: string) {
|
||||
const res = await api.post(`/subscriptions/${id}/${action}`, { reason });
|
||||
async function subscriptionAction(id: number, action: string, reason?: string, extra?: Record<string, unknown>) {
|
||||
let path = `/subscriptions/${id}/${action}`;
|
||||
if (extra) {
|
||||
const usp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(extra)) {
|
||||
if (v == null) continue;
|
||||
usp.set(k, String(v));
|
||||
}
|
||||
const qs = usp.toString();
|
||||
if (qs) path += `?${qs}`;
|
||||
}
|
||||
const res = await api.post(path, { reason });
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -158,11 +168,70 @@ export default function SubscriptionDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
// H1 — Renew is special: the response carries { order_id, status } so the admin
|
||||
// can see whether a payment URL needs to be sent to the customer.
|
||||
const renewMutation = useMutation({
|
||||
mutationFn: () => subscriptionAction(parseInt(id!), 'renew'),
|
||||
onSuccess: (res: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
|
||||
const orderId = res?.order_id;
|
||||
const status = res?.status;
|
||||
if (status === 'complete') {
|
||||
toast.success(__(`Renewed successfully (order #${orderId}). Payment captured automatically.`));
|
||||
} else if (status === 'manual') {
|
||||
toast.success(
|
||||
__(`Renewal order #${orderId} created. The customer must complete payment manually — open the order to send a payment link.`),
|
||||
{ duration: 8000 }
|
||||
);
|
||||
} else if (status === 'existing') {
|
||||
toast.info(__(`Order #${orderId} is already pending payment — using the existing order.`));
|
||||
} else {
|
||||
toast.success(__(`Renewed (order #${orderId}).`));
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
// M2 — "Charge Now" is the same renew endpoint with `?charge_now=true`. It bypasses
|
||||
// the per-gateway capability gate so the auto-debit path is attempted even on
|
||||
// normally-manual gateways. On failure the order is marked failed (no manual
|
||||
// fallback) so the admin sees the charge could not be processed.
|
||||
const chargeNowMutation = useMutation({
|
||||
mutationFn: () => subscriptionAction(parseInt(id!), 'renew', undefined, { charge_now: 'true' }),
|
||||
onSuccess: (res: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
|
||||
const orderId = res?.order_id;
|
||||
const status = res?.status;
|
||||
if (status === 'complete') {
|
||||
toast.success(__(`Charged successfully (order #${orderId}). The subscription has been renewed.`));
|
||||
} else if (status === 'existing') {
|
||||
toast.info(__(`Order #${orderId} is already pending payment — using the existing order.`));
|
||||
} else {
|
||||
toast.warning(__(`Charge attempt completed with status "${status || 'unknown'}" (order #${orderId}).`));
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAction = (action: string) => {
|
||||
if (action === 'cancel') {
|
||||
setShowCancelDialog(true);
|
||||
return;
|
||||
}
|
||||
if (action === 'renew') {
|
||||
renewMutation.mutate();
|
||||
return;
|
||||
}
|
||||
if (action === 'charge_now') {
|
||||
chargeNowMutation.mutate();
|
||||
return;
|
||||
}
|
||||
actionMutation.mutate({ action });
|
||||
};
|
||||
|
||||
@@ -229,10 +298,21 @@ export default function SubscriptionDetail() {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('renew')}
|
||||
disabled={actionMutation.isPending}
|
||||
disabled={renewMutation.isPending || chargeNowMutation.isPending || actionMutation.isPending}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{__('Renew Now')}
|
||||
{renewMutation.isPending ? __('Renewing…') : __('Renew Now')}
|
||||
</Button>
|
||||
)}
|
||||
{subscription.status === 'active' && (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => handleAction('charge_now')}
|
||||
disabled={renewMutation.isPending || chargeNowMutation.isPending || actionMutation.isPending}
|
||||
title={__('Bypass the per-gateway capability gate and attempt an immediate charge. On failure the order is marked failed — no manual fallback.')}
|
||||
>
|
||||
<CreditCard className="w-4 h-4 mr-2" />
|
||||
{chargeNowMutation.isPending ? __('Charging…') : __('Charge Now')}
|
||||
</Button>
|
||||
)}
|
||||
{subscription.can_cancel && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { Repeat, MoreHorizontal, Play, Pause, XCircle, RefreshCw, Eye, Filter, Package } from 'lucide-react';
|
||||
@@ -93,23 +93,49 @@ export default function SubscriptionsIndex() {
|
||||
const initial = getQuery();
|
||||
const [page, setPage] = useState(Number(initial.page ?? 1) || 1);
|
||||
const [status, setStatus] = useState<string | undefined>(initial.status || undefined);
|
||||
// M4 — Search input is held in a local "raw" state for snappy typing, then
|
||||
// committed to `committedSearch` after a 300ms debounce. We key the React
|
||||
// Query cache on the committed value so the debounce actually coalesces
|
||||
// requests, not just defers re-renders.
|
||||
const [rawSearch, setRawSearch] = useState<string>((initial as any).search || '');
|
||||
const [committedSearch, setCommittedSearch] = useState<string>((initial as any).search || '');
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
||||
const [cancelTargetId, setCancelTargetId] = useState<number | null>(null);
|
||||
const [showBulkCancelDialog, setShowBulkCancelDialog] = useState(false);
|
||||
const perPage = 20;
|
||||
|
||||
// Debounce rawSearch → committedSearch. 300ms is the sweet spot for "feels
|
||||
// instant" vs "don't fire on every keystroke".
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setCommittedSearch(rawSearch.trim());
|
||||
setPage(1);
|
||||
}, 300);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [rawSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader(__('Subscriptions'));
|
||||
return () => clearPageHeader();
|
||||
}, [setPageHeader, clearPageHeader]);
|
||||
|
||||
useEffect(() => {
|
||||
setQuery({ page, status });
|
||||
}, [page, status]);
|
||||
setQuery({ page, status, search: committedSearch || undefined });
|
||||
}, [page, status, committedSearch]);
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ['subscriptions', { status, page }],
|
||||
queryFn: () => api.get('/subscriptions', { status, page, per_page: perPage }),
|
||||
queryKey: ['subscriptions', { status, page, search: committedSearch }],
|
||||
queryFn: () => api.get('/subscriptions', {
|
||||
status,
|
||||
page,
|
||||
per_page: perPage,
|
||||
search: committedSearch || undefined,
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
@@ -155,6 +181,71 @@ export default function SubscriptionsIndex() {
|
||||
}
|
||||
};
|
||||
|
||||
// M3 — Bulk actions. The checkboxes below drive `selectedIds`; this mutation
|
||||
// posts to /subscriptions/bulk and the toolbar above the table exposes the
|
||||
// available actions. CSV export uses a hidden form submit so the browser can
|
||||
// handle the download directly.
|
||||
const bulkActionMutation = useMutation({
|
||||
mutationFn: ({ action, ids }: { action: 'cancel' | 'export_csv'; ids: number[] }) =>
|
||||
api.post('/subscriptions/bulk', { action, ids }),
|
||||
onSuccess: (res: any, vars) => {
|
||||
if (vars.action === 'cancel') {
|
||||
const ok = res?.ok ?? 0;
|
||||
const failed = Array.isArray(res?.failed) ? res.failed.length : 0;
|
||||
if (failed === 0) {
|
||||
toast.success(__(`Cancelled ${ok} subscription${ok === 1 ? '' : 's'}.`));
|
||||
} else {
|
||||
toast.warning(__(`Cancelled ${ok}, failed ${failed}.`));
|
||||
}
|
||||
setSelectedIds([]);
|
||||
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleBulkCancel = () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
setShowBulkCancelDialog(true);
|
||||
};
|
||||
|
||||
const confirmBulkCancel = () => {
|
||||
bulkActionMutation.mutate({ action: 'cancel', ids: selectedIds });
|
||||
setShowBulkCancelDialog(false);
|
||||
};
|
||||
|
||||
const handleBulkExport = () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
// We can't easily stream a CSV through fetch+sonner, so open a POST form
|
||||
// with a hidden _wpnonce and let the browser download directly. The
|
||||
// server returns Content-Disposition: attachment.
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = (window.WNW_API?.root || '') + '/woonoow/v1/subscriptions/bulk';
|
||||
const nonce = document.createElement('input');
|
||||
nonce.type = 'hidden';
|
||||
nonce.name = '_wpnonce';
|
||||
nonce.value = window.WNW_API?.nonce || '';
|
||||
form.appendChild(nonce);
|
||||
const actionInput = document.createElement('input');
|
||||
actionInput.type = 'hidden';
|
||||
actionInput.name = 'action';
|
||||
actionInput.value = 'export_csv';
|
||||
form.appendChild(actionInput);
|
||||
selectedIds.forEach((id) => {
|
||||
const i = document.createElement('input');
|
||||
i.type = 'hidden';
|
||||
i.name = 'ids[]';
|
||||
i.value = String(id);
|
||||
form.appendChild(i);
|
||||
});
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
document.body.removeChild(form);
|
||||
};
|
||||
|
||||
// Checkbox logic
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const allIds = subscriptions.map(s => s.id);
|
||||
@@ -191,7 +282,22 @@ export default function SubscriptionsIndex() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
{/* M4 — Search input. The input is uncontrolled-looking
|
||||
(we just track rawSearch in state) so typing feels
|
||||
instant; the debounce above commits the value to
|
||||
the React Query cache 300ms after the user stops. */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="search"
|
||||
value={rawSearch}
|
||||
onChange={(e) => setRawSearch(e.target.value)}
|
||||
placeholder={__('Search by id, email, or name…')}
|
||||
aria-label={__('Search subscriptions')}
|
||||
className="border rounded-md pl-3 pr-3 py-2 text-sm w-64 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Filter className="min-w-4 w-4 h-4 opacity-60" />
|
||||
<Select
|
||||
value={status ?? 'all'}
|
||||
@@ -216,10 +322,10 @@ export default function SubscriptionsIndex() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{status && (
|
||||
{(status || committedSearch) && (
|
||||
<button
|
||||
className="text-sm text-muted-foreground hover:text-foreground underline text-nowrap"
|
||||
onClick={() => { setStatus(undefined); setPage(1); }}
|
||||
onClick={() => { setStatus(undefined); setRawSearch(''); setCommittedSearch(''); setPage(1); }}
|
||||
>
|
||||
{__('Clear filters')}
|
||||
</button>
|
||||
@@ -235,6 +341,14 @@ export default function SubscriptionsIndex() {
|
||||
{/* Mobile: Status filter bar */}
|
||||
<div className="md:hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="search"
|
||||
value={rawSearch}
|
||||
onChange={(e) => setRawSearch(e.target.value)}
|
||||
placeholder={__('Search…')}
|
||||
aria-label={__('Search subscriptions')}
|
||||
className="border rounded-md px-3 py-2 text-sm flex-1 min-w-0 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<Select
|
||||
value={status ?? 'all'}
|
||||
onValueChange={(v) => {
|
||||
@@ -273,6 +387,43 @@ export default function SubscriptionsIndex() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* M3 — Bulk actions toolbar. Visible only when at least one row is selected. */}
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="rounded-lg border border-primary/30 bg-primary/5 p-3 flex flex-wrap items-center gap-3">
|
||||
<div className="text-sm font-medium">
|
||||
{selectedIds.length === 1
|
||||
? __('1 subscription selected')
|
||||
: __('%s subscriptions selected').replace('%s', String(selectedIds.length))}
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBulkExport}
|
||||
disabled={bulkActionMutation.isPending}
|
||||
>
|
||||
{__('Export CSV')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleBulkCancel}
|
||||
disabled={bulkActionMutation.isPending}
|
||||
>
|
||||
<XCircle className="w-4 h-4 mr-2" />
|
||||
{bulkActionMutation.isPending ? __('Cancelling…') : __('Cancel selected')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedIds([])}
|
||||
disabled={bulkActionMutation.isPending}
|
||||
>
|
||||
{__('Clear')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{q.isLoading && (
|
||||
<div className="space-y-3">
|
||||
@@ -500,6 +651,37 @@ export default function SubscriptionsIndex() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* M3 — Bulk cancel confirmation dialog */}
|
||||
<AlertDialog open={showBulkCancelDialog} onOpenChange={setShowBulkCancelDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{__('Cancel %s subscriptions?').replace('%s', String(selectedIds.length))}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{__('Are you sure you want to cancel the selected subscriptions? This affects multiple customers at once.')}
|
||||
<br />
|
||||
<span className="text-red-600 font-medium">{__('This action cannot be undone.')}</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
onClick={() => setShowBulkCancelDialog(false)}
|
||||
disabled={bulkActionMutation.isPending}
|
||||
>
|
||||
{__('Keep Subscriptions')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmBulkCancel}
|
||||
disabled={bulkActionMutation.isPending}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{bulkActionMutation.isPending ? __('Cancelling…') : __('Cancel All Selected')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user