|
| 1 | +"use client"; |
| 2 | + |
| 3 | +import { useEffect, useState } from "react"; |
| 4 | +import { useRouter } from "next/navigation"; |
| 5 | +import { AppShell } from "@/components/app-shell"; |
| 6 | +import { Toast } from "@/components/toast"; |
| 7 | +import { EmptyState } from "@/components/empty-state"; |
| 8 | +import { apiFetch } from "@/lib/api-client"; |
| 9 | + |
| 10 | +const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:4000"; |
| 11 | + |
| 12 | +interface RecurringSupport { |
| 13 | + id: string; |
| 14 | + profileId: string; |
| 15 | + profileUsername: string; |
| 16 | + profileDisplayName: string; |
| 17 | + profileAvatarUrl: string | null; |
| 18 | + amount: string; |
| 19 | + assetCode: string; |
| 20 | + frequency: string; |
| 21 | + nextRunAt: string; |
| 22 | + status: string; |
| 23 | + createdAt: string; |
| 24 | +} |
| 25 | + |
| 26 | +export default function RecurringPage() { |
| 27 | + const router = useRouter(); |
| 28 | + const [subscriptions, setSubscriptions] = useState<RecurringSupport[]>([]); |
| 29 | + const [loading, setLoading] = useState(true); |
| 30 | + const [error, setError] = useState<string | null>(null); |
| 31 | + const [cancelTarget, setCancelTarget] = useState<string | null>(null); |
| 32 | + const [cancelling, setCancelling] = useState<string | null>(null); |
| 33 | + const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); |
| 34 | + |
| 35 | + useEffect(() => { |
| 36 | + const username = localStorage.getItem("username"); |
| 37 | + if (!username) { |
| 38 | + router.push("/"); |
| 39 | + return; |
| 40 | + } |
| 41 | + |
| 42 | + apiFetch(`${API_BASE_URL}/v1/recurring-support`) |
| 43 | + .then(async (res) => { |
| 44 | + if (!res.ok) throw new Error("Failed to load recurring support subscriptions"); |
| 45 | + const data = await res.json(); |
| 46 | + setSubscriptions(data); |
| 47 | + }) |
| 48 | + .catch((err: unknown) => { |
| 49 | + setError(err instanceof Error ? err.message : "Something went wrong"); |
| 50 | + }) |
| 51 | + .finally(() => setLoading(false)); |
| 52 | + }, [router]); |
| 53 | + |
| 54 | + async function handleCancel(id: string) { |
| 55 | + setCancelling(id); |
| 56 | + try { |
| 57 | + const res = await apiFetch(`${API_BASE_URL}/v1/recurring-support/${id}`, { |
| 58 | + method: "DELETE", |
| 59 | + }); |
| 60 | + if (!res.ok) throw new Error("Failed to cancel subscription"); |
| 61 | + setSubscriptions((prev) => prev.filter((s) => s.id !== id)); |
| 62 | + setToast({ message: "Subscription cancelled", type: "success" }); |
| 63 | + } catch (err: unknown) { |
| 64 | + setToast({ |
| 65 | + message: err instanceof Error ? err.message : "Failed to cancel subscription", |
| 66 | + type: "error", |
| 67 | + }); |
| 68 | + } finally { |
| 69 | + setCancelling(null); |
| 70 | + setCancelTarget(null); |
| 71 | + } |
| 72 | + } |
| 73 | + |
| 74 | + return ( |
| 75 | + <AppShell> |
| 76 | + <div className="mx-auto max-w-3xl px-4 py-10"> |
| 77 | + <h1 className="text-2xl font-semibold text-white mb-1">Recurring support</h1> |
| 78 | + <p className="text-sm text-white/50 mb-8"> |
| 79 | + Manage the creators you support on a recurring basis. |
| 80 | + </p> |
| 81 | + |
| 82 | + {loading && <p className="text-sm text-white/40">Loading…</p>} |
| 83 | + {error && <p className="text-sm text-red-400">{error}</p>} |
| 84 | + |
| 85 | + {!loading && !error && subscriptions.length === 0 && ( |
| 86 | + <EmptyState |
| 87 | + title="No recurring support yet" |
| 88 | + description="When you set up a recurring drip to a creator, it will show up here." |
| 89 | + /> |
| 90 | + )} |
| 91 | + |
| 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 | + // eslint-disable-next-line @next/next/no-img-element -- arbitrary external avatar URLs, matches profile-card.tsx convention |
| 102 | + <img |
| 103 | + src={sub.profileAvatarUrl} |
| 104 | + alt={sub.profileDisplayName} |
| 105 | + className="h-10 w-10 rounded-full object-cover flex-shrink-0" |
| 106 | + /> |
| 107 | + ) : ( |
| 108 | + <div className="h-10 w-10 rounded-full bg-white/10 flex items-center justify-center text-sm text-white/60 flex-shrink-0"> |
| 109 | + {sub.profileDisplayName?.[0]?.toUpperCase() ?? "?"} |
| 110 | + </div> |
| 111 | + )} |
| 112 | + <div className="min-w-0"> |
| 113 | + <p className="text-sm font-medium text-white truncate"> |
| 114 | + {sub.profileDisplayName} |
| 115 | + </p> |
| 116 | + <p className="text-xs text-white/40"> |
| 117 | + {sub.amount} {sub.assetCode} · {sub.frequency} · next{" "} |
| 118 | + {new Date(sub.nextRunAt).toLocaleDateString()} |
| 119 | + </p> |
| 120 | + </div> |
| 121 | + </div> |
| 122 | + |
| 123 | + {cancelTarget === sub.id ? ( |
| 124 | + <div className="flex items-center gap-2 flex-shrink-0"> |
| 125 | + <span className="text-xs text-white/50">Cancel?</span> |
| 126 | + <button |
| 127 | + onClick={() => handleCancel(sub.id)} |
| 128 | + disabled={cancelling === sub.id} |
| 129 | + className="rounded-lg bg-red-500/20 px-3 py-1.5 text-xs font-medium text-red-300 hover:bg-red-500/30" |
| 130 | + > |
| 131 | + {cancelling === sub.id ? "Cancelling…" : "Yes, cancel"} |
| 132 | + </button> |
| 133 | + <button |
| 134 | + onClick={() => setCancelTarget(null)} |
| 135 | + className="rounded-lg px-3 py-1.5 text-xs text-white/50 hover:text-white" |
| 136 | + > |
| 137 | + Keep it |
| 138 | + </button> |
| 139 | + </div> |
| 140 | + ) : ( |
| 141 | + <button |
| 142 | + onClick={() => setCancelTarget(sub.id)} |
| 143 | + 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" |
| 144 | + > |
| 145 | + Cancel |
| 146 | + </button> |
| 147 | + )} |
| 148 | + </li> |
| 149 | + ))} |
| 150 | + </ul> |
| 151 | + )} |
| 152 | + </div> |
| 153 | + |
| 154 | + {toast && <Toast message={toast.message} type={toast.type} onDismiss={() => setToast(null)} />} |
| 155 | + </AppShell> |
| 156 | + ); |
| 157 | +} |
0 commit comments