Skip to content

Commit 6bdebb5

Browse files
authored
Merge pull request #901 from MissHarah/fix/issue-874-recurring-status-filter
fix(#874): filter recurring subscriptions by status, add pause action…
2 parents 18b773e + 01c16aa commit 6bdebb5

1 file changed

Lines changed: 195 additions & 67 deletions

File tree

frontend/src/app/recurring/page.tsx

Lines changed: 195 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,41 @@ interface RecurringSupport {
1919
assetCode: string;
2020
frequency: string;
2121
nextRunAt: string;
22-
status: string;
22+
status: "active" | "paused" | "cancelled";
2323
createdAt: string;
2424
}
2525

26+
type ActionTarget = { id: string; action: "cancel" | "pause" | "resume" };
27+
28+
function StatusBadge({ status }: { status: RecurringSupport["status"] }) {
29+
if (status === "active") {
30+
return (
31+
<span className="inline-flex items-center rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-400">
32+
Active
33+
</span>
34+
);
35+
}
36+
if (status === "paused") {
37+
return (
38+
<span className="inline-flex items-center rounded-full bg-yellow-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-yellow-400">
39+
Paused
40+
</span>
41+
);
42+
}
43+
return (
44+
<span className="inline-flex items-center rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white/40">
45+
{status}
46+
</span>
47+
);
48+
}
49+
2650
export default function RecurringPage() {
2751
const router = useRouter();
2852
const [subscriptions, setSubscriptions] = useState<RecurringSupport[]>([]);
2953
const [loading, setLoading] = useState(true);
3054
const [error, setError] = useState<string | null>(null);
31-
const [cancelTarget, setCancelTarget] = useState<string | null>(null);
32-
const [cancelling, setCancelling] = useState<string | null>(null);
55+
const [actionTarget, setActionTarget] = useState<ActionTarget | null>(null);
56+
const [processing, setProcessing] = useState<string | null>(null);
3357
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
3458

3559
useEffect(() => {
@@ -52,12 +76,13 @@ export default function RecurringPage() {
5276
}, [router]);
5377

5478
async function handleCancel(id: string) {
55-
setCancelling(id);
79+
setProcessing(id);
5680
try {
5781
const res = await apiFetch(`${API_BASE_URL}/v1/recurring-support/${id}`, {
5882
method: "DELETE",
5983
});
6084
if (!res.ok) throw new Error("Failed to cancel subscription");
85+
// Remove cancelled subscription from list
6186
setSubscriptions((prev) => prev.filter((s) => s.id !== id));
6287
setToast({ message: "Subscription cancelled", type: "success" });
6388
} catch (err: unknown) {
@@ -66,11 +91,150 @@ export default function RecurringPage() {
6691
type: "error",
6792
});
6893
} finally {
69-
setCancelling(null);
70-
setCancelTarget(null);
94+
setProcessing(null);
95+
setActionTarget(null);
7196
}
7297
}
7398

99+
async function handlePatch(id: string, status: "paused" | "active") {
100+
setProcessing(id);
101+
try {
102+
const res = await apiFetch(`${API_BASE_URL}/v1/recurring-support/${id}`, {
103+
method: "PATCH",
104+
headers: { "Content-Type": "application/json" },
105+
body: JSON.stringify({ status }),
106+
});
107+
if (!res.ok) throw new Error(`Failed to ${status === "paused" ? "pause" : "resume"} subscription`);
108+
setSubscriptions((prev) =>
109+
prev.map((s) => (s.id === id ? { ...s, status } : s)),
110+
);
111+
setToast({
112+
message: status === "paused" ? "Subscription paused" : "Subscription resumed",
113+
type: "success",
114+
});
115+
} catch (err: unknown) {
116+
setToast({
117+
message: err instanceof Error ? err.message : "Action failed",
118+
type: "error",
119+
});
120+
} finally {
121+
setProcessing(null);
122+
setActionTarget(null);
123+
}
124+
}
125+
126+
// Split into active and paused groups for clear visual separation
127+
const activeSubscriptions = subscriptions.filter((s) => s.status === "active");
128+
const pausedSubscriptions = subscriptions.filter((s) => s.status === "paused");
129+
130+
function renderConfirmBar(sub: RecurringSupport) {
131+
if (!actionTarget || actionTarget.id !== sub.id) return null;
132+
133+
const { action } = actionTarget;
134+
135+
const labelMap = {
136+
cancel: { question: "Cancel this drip?", confirm: "Yes, cancel", confirming: "Cancelling…" },
137+
pause: { question: "Pause this drip?", confirm: "Yes, pause", confirming: "Pausing…" },
138+
resume: { question: "Resume this drip?", confirm: "Yes, resume", confirming: "Resuming…" },
139+
};
140+
const labels = labelMap[action];
141+
const isProcessing = processing === sub.id;
142+
143+
return (
144+
<div className="flex items-center gap-2 flex-shrink-0">
145+
<span className="text-xs text-white/50">{labels.question}</span>
146+
<button
147+
onClick={() => {
148+
if (action === "cancel") handleCancel(sub.id);
149+
else if (action === "pause") handlePatch(sub.id, "paused");
150+
else handlePatch(sub.id, "active");
151+
}}
152+
disabled={isProcessing}
153+
className="rounded-lg bg-red-500/20 px-3 py-1.5 text-xs font-medium text-red-300 hover:bg-red-500/30 disabled:opacity-50"
154+
>
155+
{isProcessing ? labels.confirming : labels.confirm}
156+
</button>
157+
<button
158+
onClick={() => setActionTarget(null)}
159+
className="rounded-lg px-3 py-1.5 text-xs text-white/50 hover:text-white"
160+
>
161+
Keep it
162+
</button>
163+
</div>
164+
);
165+
}
166+
167+
function renderActions(sub: RecurringSupport) {
168+
if (actionTarget?.id === sub.id) return renderConfirmBar(sub);
169+
170+
return (
171+
<div className="flex items-center gap-2 flex-shrink-0">
172+
{sub.status === "active" ? (
173+
<button
174+
onClick={() => setActionTarget({ id: sub.id, action: "pause" })}
175+
className="rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/70 hover:border-yellow-400/40 hover:text-yellow-300"
176+
>
177+
Pause
178+
</button>
179+
) : (
180+
<button
181+
onClick={() => setActionTarget({ id: sub.id, action: "resume" })}
182+
className="rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/70 hover:border-emerald-400/40 hover:text-emerald-300"
183+
>
184+
Resume
185+
</button>
186+
)}
187+
<button
188+
onClick={() => setActionTarget({ id: sub.id, action: "cancel" })}
189+
className="rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/70 hover:border-red-400/40 hover:text-red-300"
190+
>
191+
Cancel
192+
</button>
193+
</div>
194+
);
195+
}
196+
197+
function renderSubscriptionCard(sub: RecurringSupport) {
198+
return (
199+
<li
200+
key={sub.id}
201+
className="flex items-center justify-between gap-4 rounded-2xl border border-white/10 bg-white/5 p-4"
202+
>
203+
<div className="flex items-center gap-3 min-w-0">
204+
{sub.profileAvatarUrl ? (
205+
<Image
206+
src={sub.profileAvatarUrl}
207+
alt={sub.profileDisplayName}
208+
width={40}
209+
height={40}
210+
className="h-10 w-10 rounded-full object-cover flex-shrink-0"
211+
/>
212+
) : (
213+
<div className="h-10 w-10 rounded-full bg-white/10 flex items-center justify-center text-sm text-white/60 flex-shrink-0">
214+
{sub.profileDisplayName?.[0]?.toUpperCase() ?? "?"}
215+
</div>
216+
)}
217+
<div className="min-w-0">
218+
<div className="flex items-center gap-2">
219+
<p className="text-sm font-medium text-white truncate">
220+
{sub.profileDisplayName}
221+
</p>
222+
<StatusBadge status={sub.status} />
223+
</div>
224+
<p className="text-xs text-white/40">
225+
{sub.amount} {sub.assetCode} · {sub.frequency} · next{" "}
226+
{new Date(sub.nextRunAt).toLocaleDateString()}
227+
</p>
228+
</div>
229+
</div>
230+
231+
{renderActions(sub)}
232+
</li>
233+
);
234+
}
235+
236+
const hasAny = subscriptions.length > 0;
237+
74238
return (
75239
<AppShell>
76240
<div className="mx-auto max-w-3xl px-4 py-10">
@@ -82,73 +246,37 @@ export default function RecurringPage() {
82246
{loading && <p className="text-sm text-white/40">Loading…</p>}
83247
{error && <p className="text-sm text-red-400">{error}</p>}
84248

85-
{!loading && !error && subscriptions.length === 0 && (
249+
{!loading && !error && !hasAny && (
86250
<EmptyState
87251
title="No recurring support yet"
88252
description="When you set up a recurring drip to a creator, it will show up here."
89253
/>
90254
)}
91255

92-
{!loading && subscriptions.length > 0 && (
93-
<ul className="space-y-3">
94-
{subscriptions.map((sub) => (
95-
<li
96-
key={sub.id}
97-
className="flex items-center justify-between gap-4 rounded-2xl border border-white/10 bg-white/5 p-4"
98-
>
99-
<div className="flex items-center gap-3 min-w-0">
100-
{sub.profileAvatarUrl ? (
101-
<Image
102-
src={sub.profileAvatarUrl}
103-
alt={sub.profileDisplayName}
104-
width={40}
105-
height={40}
106-
className="h-10 w-10 rounded-full object-cover flex-shrink-0"
107-
/>
108-
) : (
109-
<div className="h-10 w-10 rounded-full bg-white/10 flex items-center justify-center text-sm text-white/60 flex-shrink-0">
110-
{sub.profileDisplayName?.[0]?.toUpperCase() ?? "?"}
111-
</div>
112-
)}
113-
<div className="min-w-0">
114-
<p className="text-sm font-medium text-white truncate">
115-
{sub.profileDisplayName}
116-
</p>
117-
<p className="text-xs text-white/40">
118-
{sub.amount} {sub.assetCode} · {sub.frequency} · next{" "}
119-
{new Date(sub.nextRunAt).toLocaleDateString()}
120-
</p>
121-
</div>
122-
</div>
123-
124-
{cancelTarget === sub.id ? (
125-
<div className="flex items-center gap-2 flex-shrink-0">
126-
<span className="text-xs text-white/50">Cancel?</span>
127-
<button
128-
onClick={() => handleCancel(sub.id)}
129-
disabled={cancelling === sub.id}
130-
className="rounded-lg bg-red-500/20 px-3 py-1.5 text-xs font-medium text-red-300 hover:bg-red-500/30"
131-
>
132-
{cancelling === sub.id ? "Cancelling…" : "Yes, cancel"}
133-
</button>
134-
<button
135-
onClick={() => setCancelTarget(null)}
136-
className="rounded-lg px-3 py-1.5 text-xs text-white/50 hover:text-white"
137-
>
138-
Keep it
139-
</button>
140-
</div>
141-
) : (
142-
<button
143-
onClick={() => setCancelTarget(sub.id)}
144-
className="flex-shrink-0 rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/70 hover:border-red-400/40 hover:text-red-300"
145-
>
146-
Cancel
147-
</button>
148-
)}
149-
</li>
150-
))}
151-
</ul>
256+
{!loading && hasAny && (
257+
<div className="space-y-8">
258+
{activeSubscriptions.length > 0 && (
259+
<section>
260+
<h2 className="text-xs font-semibold uppercase tracking-widest text-white/40 mb-3">
261+
Active ({activeSubscriptions.length})
262+
</h2>
263+
<ul className="space-y-3">
264+
{activeSubscriptions.map(renderSubscriptionCard)}
265+
</ul>
266+
</section>
267+
)}
268+
269+
{pausedSubscriptions.length > 0 && (
270+
<section>
271+
<h2 className="text-xs font-semibold uppercase tracking-widest text-white/40 mb-3">
272+
Paused ({pausedSubscriptions.length})
273+
</h2>
274+
<ul className="space-y-3">
275+
{pausedSubscriptions.map(renderSubscriptionCard)}
276+
</ul>
277+
</section>
278+
)}
279+
</div>
152280
)}
153281
</div>
154282

0 commit comments

Comments
 (0)