Subscription module: add gateway capability flow and UX fixes

This commit is contained in:
Dwindi Ramadhana
2026-06-02 00:38:42 +07:00
parent fec786daa6
commit df969b442d
15 changed files with 2375 additions and 138 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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 && (

View File

@@ -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>
);
}