Create monorepo from known-good production state
This commit is contained in:
commit
c034824338
651 changed files with 120469 additions and 0 deletions
86
app/components/account/AvatarUploader.tsx
Normal file
86
app/components/account/AvatarUploader.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// components/account/AvatarUploader.tsx
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export default function AvatarUploader({
|
||||
avatarId,
|
||||
onUpdated,
|
||||
}: {
|
||||
avatarId?: string | null;
|
||||
onUpdated?: () => void;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
const API_BASE = useMemo(
|
||||
() => (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""),
|
||||
[]
|
||||
);
|
||||
const currentUrl = avatarId ? `${API_BASE}/assets/${avatarId}` : null;
|
||||
|
||||
const onUpload = async () => {
|
||||
setMsg(null);
|
||||
if (!file) {
|
||||
setMsg("Choose a file first.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.set("file", file, file.name);
|
||||
|
||||
const r = await fetch("/api/account/avatar", { method: "POST", body: fd });
|
||||
if (r.status === 401) {
|
||||
location.assign(`/auth/sign-in?reauth=1&next=${encodeURIComponent("/portal/account")}`);
|
||||
return;
|
||||
}
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
setMsg(j?.error || "Upload failed");
|
||||
return;
|
||||
}
|
||||
setMsg("Avatar updated.");
|
||||
setFile(null);
|
||||
onUpdated?.();
|
||||
} catch (e: any) {
|
||||
setMsg(e?.message || "Upload failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-md border p-4 space-y-3">
|
||||
<h3 className="font-semibold">Avatar</h3>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-16 w-16 rounded-full overflow-hidden border bg-muted flex items-center justify-center">
|
||||
{currentUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={currentUrl} alt="Avatar" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<span className="text-xs opacity-60">No Avatar</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
className="text-sm"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setFile(e.currentTarget.files?.[0] ?? null)}
|
||||
/>
|
||||
<button
|
||||
onClick={onUpload}
|
||||
disabled={busy || !file}
|
||||
className="rounded-md bg-black text-white px-3 py-2 text-sm disabled:opacity-60"
|
||||
>
|
||||
{busy ? "Uploading…" : "Upload"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{msg && <div className="text-sm opacity-80">{msg}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
app/components/account/ConfirmIdentity.tsx
Normal file
63
app/components/account/ConfirmIdentity.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// components/account/ConfirmIdentity.tsx
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function ConfirmIdentity({
|
||||
defaultIdentifier,
|
||||
onSuccess,
|
||||
}: {
|
||||
defaultIdentifier: string; // prefill with username or email you show on the page
|
||||
onSuccess: () => void; // called after re-auth succeeds
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [identifier, setIdentifier] = useState(defaultIdentifier);
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const res = await fetch("/api/auth/reconfirm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ identifier, password }),
|
||||
});
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j?.error || "Failed");
|
||||
setOpen(false);
|
||||
setPassword("");
|
||||
onSuccess(); // now do the sensitive call
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Re-auth failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* wherever you need the step-up, render a button that opens this */}
|
||||
<button className="btn" onClick={() => setOpen(true)}>Confirm it’s you</button>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 bg-black/50 grid place-items-center">
|
||||
<div className="bg-card border rounded-lg p-4 w-full max-w-sm">
|
||||
<h3 className="font-semibold mb-2">Confirm it’s you</h3>
|
||||
<label className="block text-sm mb-1">Email or Username</label>
|
||||
<input className="input w-full mb-2" value={identifier} onChange={e=>setIdentifier(e.target.value)} />
|
||||
<label className="block text-sm mb-1">Password</label>
|
||||
<input className="input w-full mb-3" type="password" value={password} onChange={e=>setPassword(e.target.value)} />
|
||||
{err && <div className="text-red-600 text-sm mb-2">{err}</div>}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button className="btn" onClick={()=>setOpen(false)} disabled={busy}>Cancel</button>
|
||||
<button className="btn-primary" onClick={submit} disabled={busy}>{busy ? "Checking…" : "Continue"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
137
app/components/account/ConnectKofi.tsx
Normal file
137
app/components/account/ConnectKofi.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// components/account/ConnectKofi.tsx
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
export default function ConnectKofi({
|
||||
email,
|
||||
userId,
|
||||
}: {
|
||||
email?: string | null;
|
||||
userId?: string | null;
|
||||
}) {
|
||||
const [value, setValue] = useState(email || "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const canSubmit = useMemo(
|
||||
() => !!value && /\S+@\S+\.\S+/.test(value),
|
||||
[value]
|
||||
);
|
||||
|
||||
const startClaim = useCallback(async () => {
|
||||
setErr(null);
|
||||
setMsg(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/support/kofi/claim/start", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// if your auth middleware expects anything, set it here; otherwise cookies suffice
|
||||
"x-user-id": userId ?? "",
|
||||
"x-user-email": email ?? "",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email: value }),
|
||||
});
|
||||
const j = await res.json().catch(() => ({} as any));
|
||||
if (!res.ok) {
|
||||
// Show specific errors where helpful
|
||||
const detail = j?.detail || j?.error || res.statusText;
|
||||
throw new Error(
|
||||
j?.error === "not_found"
|
||||
? "We don’t have any Ko-fi records for that email yet. If you’re sure it’s correct, try again after your next Ko-fi payment or after we run the backfill."
|
||||
: String(detail || "Failed to start verification")
|
||||
);
|
||||
}
|
||||
if (j?.alreadyLinked) {
|
||||
setMsg("This Ko-fi email is already linked to your account.");
|
||||
} else {
|
||||
setMsg(
|
||||
"Verification email sent! Check your inbox and click the link to finish linking Ko-fi."
|
||||
);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Something went wrong.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [value, userId, email]);
|
||||
|
||||
const unlink = useCallback(async () => {
|
||||
setErr(null);
|
||||
setMsg(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/support/kofi/unlink", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-user-id": userId ?? "",
|
||||
},
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const t = await res.text().catch(() => "");
|
||||
throw new Error(t || "Unlink failed");
|
||||
}
|
||||
setMsg("Ko-fi has been unlinked from your account.");
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Unlink failed.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border p-4">
|
||||
<h3 className="mb-2 text-base font-semibold">Link Ko-fi</h3>
|
||||
<p className="mb-3 text-sm opacity-80">
|
||||
Enter the email you use on Ko-fi. We’ll send a one-time verification link to confirm it’s you.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="email"
|
||||
className="w-full rounded border px-3 py-2 text-sm"
|
||||
placeholder="you@kofi-email.com"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startClaim}
|
||||
disabled={!canSubmit || busy}
|
||||
className="inline-flex items-center justify-center rounded bg-black px-4 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-black"
|
||||
>
|
||||
{busy ? "Sending…" : "Send Verify Link"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={unlink}
|
||||
disabled={busy}
|
||||
className="inline-flex items-center justify-center rounded border px-4 py-2 text-sm"
|
||||
title="Remove the Ko-fi link from your account"
|
||||
>
|
||||
Unlink
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className="mt-3 rounded-md border border-emerald-300/50 bg-emerald-50 p-2 text-sm text-emerald-900">
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
{err && (
|
||||
<div className="mt-3 rounded-md border border-red-300/50 bg-red-50 p-2 text-sm text-red-900">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-3 text-xs opacity-70">
|
||||
Tip: after you verify, badges update automatically. If you don’t see a badge yet, it’ll appear the next time a Ko-fi payment webhook arrives (or after backfill).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
app/components/account/LinkStatus.tsx
Normal file
28
app/components/account/LinkStatus.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// /components/account/LinkStatus.tsx
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
export default function LinkStatus() {
|
||||
const sp = useSearchParams();
|
||||
const linked = sp.get("linked");
|
||||
if (linked !== "kofi") return null;
|
||||
|
||||
const isOk = sp.get("ok") === "1";
|
||||
const isErr = sp.get("error") === "1";
|
||||
|
||||
if (!isOk && !isErr) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"mb-3 rounded-md border p-3 text-sm",
|
||||
isOk
|
||||
? "border-emerald-300/50 bg-emerald-50 text-emerald-900"
|
||||
: "border-red-300/50 bg-red-50 text-red-900",
|
||||
].join(" ")}
|
||||
>
|
||||
{isOk ? "Ko-fi successfully linked to your account." : "Couldn’t verify that Ko-fi link. Please try again."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
app/components/account/PasswordChange.tsx
Normal file
148
app/components/account/PasswordChange.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// components/account/PasswordChange.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Me = {
|
||||
id: string;
|
||||
username?: string | null;
|
||||
email?: string | null;
|
||||
};
|
||||
|
||||
export default function PasswordChange() {
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [next2, setNext2] = useState("");
|
||||
const [identifier, setIdentifier] = useState(""); // email OR username (sent to API)
|
||||
const [needIdentifier, setNeedIdentifier] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
// Try to auto-fill identifier from /api/account
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch("/api/account", { credentials: "include", cache: "no-store" });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
const me: Me | undefined = j?.user ?? j?.data ?? undefined;
|
||||
const id = (me?.email || me?.username || "").trim();
|
||||
if (id) {
|
||||
setIdentifier(id);
|
||||
setNeedIdentifier(false);
|
||||
} else {
|
||||
setNeedIdentifier(true);
|
||||
}
|
||||
} catch {
|
||||
// If it fails, we'll let the user type it
|
||||
setNeedIdentifier(true);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const onSave = async () => {
|
||||
setMsg(null);
|
||||
if (next !== next2) {
|
||||
setMsg("New passwords do not match.");
|
||||
return;
|
||||
}
|
||||
if (next.length < 8) {
|
||||
setMsg("Password must be at least 8 characters.");
|
||||
return;
|
||||
}
|
||||
if (!identifier.trim()) {
|
||||
setNeedIdentifier(true);
|
||||
setMsg("Please enter your email or username.");
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await fetch("/api/account/password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ current, next, identifier: identifier.trim() }),
|
||||
});
|
||||
|
||||
const j = await r.json().catch(() => ({} as any));
|
||||
if (!r.ok) {
|
||||
setMsg(j?.error ? (j?.debug ? `${j.error} (${j.debug})` : j.error) : "Password change failed");
|
||||
return;
|
||||
}
|
||||
|
||||
setMsg("Password updated.");
|
||||
setCurrent("");
|
||||
setNext("");
|
||||
setNext2("");
|
||||
} catch (e: any) {
|
||||
setMsg(e?.message || "Password change failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="security" className="rounded-md border p-4 space-y-3">
|
||||
<h3 className="font-semibold">Change Password</h3>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-3 text-sm">
|
||||
{needIdentifier && (
|
||||
<label className="grid gap-1 sm:col-span-2">
|
||||
<span className="opacity-60">Email or Username</span>
|
||||
<input
|
||||
className="rounded-md border px-2 py-1"
|
||||
type="text"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
placeholder="you@example.com or your-handle"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="grid gap-1 sm:col-span-2">
|
||||
<span className="opacity-60">Current Password</span>
|
||||
<input
|
||||
className="rounded-md border px-2 py-1"
|
||||
type="password"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1">
|
||||
<span className="opacity-60">New Password</span>
|
||||
<input
|
||||
className="rounded-md border px-2 py-1"
|
||||
type="password"
|
||||
value={next}
|
||||
onChange={(e) => setNext(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1">
|
||||
<span className="opacity-60">Confirm New Password</span>
|
||||
<input
|
||||
className="rounded-md border px-2 py-1"
|
||||
type="password"
|
||||
value={next2}
|
||||
onChange={(e) => setNext2(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={busy}
|
||||
className="rounded-md bg-black text-white px-3 py-2 text-sm disabled:opacity-60"
|
||||
>
|
||||
{busy ? "Saving…" : "Update Password"}
|
||||
</button>
|
||||
{msg && <div className="text-sm opacity-80">{msg}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
211
app/components/account/ProfileEditor.tsx
Normal file
211
app/components/account/ProfileEditor.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
// components/account/ProfileEditor.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Me = {
|
||||
id: string;
|
||||
username: string;
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
email?: string | null;
|
||||
location?: string | null;
|
||||
avatar?: { id: string; filename_download?: string } | null;
|
||||
};
|
||||
|
||||
export default function ProfileEditor({
|
||||
me: meProp,
|
||||
onUpdated,
|
||||
}: {
|
||||
me?: Me | null;
|
||||
onUpdated?: () => void;
|
||||
}) {
|
||||
const [me, setMe] = useState<Me | null>(meProp ?? null);
|
||||
const [first_name, setFirst] = useState("");
|
||||
const [last_name, setLast] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [profileLocation, setProfileLocation] = useState("");
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [loading, setLoading] = useState(!meProp);
|
||||
|
||||
const nextAccount = "/portal/account";
|
||||
|
||||
// Load profile if not provided via props
|
||||
useEffect(() => {
|
||||
if (meProp) {
|
||||
setMe(meProp);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch("/api/account", { credentials: "include", cache: "no-store" });
|
||||
if (!r.ok) throw new Error(String(r.status));
|
||||
const j = await r.json();
|
||||
const user: Me | undefined = j?.user ?? j?.data ?? undefined;
|
||||
if (!user) throw new Error("Bad response");
|
||||
if (alive) setMe(user);
|
||||
} catch (e: any) {
|
||||
if (alive) setMsg(`Failed to load: ${e?.message || e}`);
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [meProp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!me) return;
|
||||
setFirst(me.first_name || "");
|
||||
setLast(me.last_name || "");
|
||||
setEmail(me.email || "");
|
||||
setProfileLocation(me.location || "");
|
||||
}, [me]);
|
||||
|
||||
// Auto-retry a pending sensitive update after coming back from reauth
|
||||
useEffect(() => {
|
||||
const raw = typeof window !== "undefined" ? sessionStorage.getItem("pendingProfileUpdate") : null;
|
||||
if (!raw) return;
|
||||
let pending: Record<string, any> | null = null;
|
||||
try {
|
||||
pending = JSON.parse(raw);
|
||||
} catch {
|
||||
pending = null;
|
||||
}
|
||||
if (!pending) {
|
||||
sessionStorage.removeItem("pendingProfileUpdate");
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch("/api/account/profile", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(pending),
|
||||
});
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
setMsg(j?.error || "Update after re-auth failed");
|
||||
} else {
|
||||
setMsg("Saved after re-authentication.");
|
||||
onUpdated?.();
|
||||
}
|
||||
} finally {
|
||||
sessionStorage.removeItem("pendingProfileUpdate");
|
||||
}
|
||||
})();
|
||||
}, [onUpdated]);
|
||||
|
||||
const onSave = async () => {
|
||||
setMsg(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload: Record<string, any> = {
|
||||
first_name: first_name.trim(),
|
||||
last_name: last_name.trim(),
|
||||
email: email.trim() || null, // allow clearing email
|
||||
location: profileLocation.trim(),
|
||||
};
|
||||
const r = await fetch("/api/account/profile", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (r.status === 428) {
|
||||
// Need reauth for sensitive change (email)
|
||||
if (typeof window !== "undefined") {
|
||||
// Stash the pending payload so we can retry after reauth
|
||||
sessionStorage.setItem("pendingProfileUpdate", JSON.stringify(payload));
|
||||
window.location.assign(
|
||||
`/auth/sign-in?reauth=1&next=${encodeURIComponent(nextAccount + "#security")}`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (r.status === 401) {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.assign(`/auth/sign-in?reauth=1&next=${encodeURIComponent(nextAccount)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
setMsg(j?.error || "Update failed");
|
||||
return;
|
||||
}
|
||||
setMsg("Saved.");
|
||||
onUpdated?.();
|
||||
} catch (e: any) {
|
||||
setMsg(e?.message || "Update failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="rounded-md border p-4 text-sm opacity-70">Loading editor…</div>;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border p-4 space-y-3">
|
||||
<h3 className="font-semibold">Edit Profile</h3>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-3 text-sm">
|
||||
<label className="grid gap-1">
|
||||
<span className="opacity-60">First Name</span>
|
||||
<input
|
||||
className="rounded-md border px-2 py-1"
|
||||
value={first_name}
|
||||
onChange={(e) => setFirst(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1">
|
||||
<span className="opacity-60">Last Name</span>
|
||||
<input
|
||||
className="rounded-md border px-2 py-1"
|
||||
value={last_name}
|
||||
onChange={(e) => setLast(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1 sm:col-span-2">
|
||||
<span className="opacity-60">Email (reauth required)</span>
|
||||
<input
|
||||
className="rounded-md border px-2 py-1"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1 sm:col-span-2">
|
||||
<span className="opacity-60">Location</span>
|
||||
<input
|
||||
className="rounded-md border px-2 py-1"
|
||||
value={profileLocation}
|
||||
onChange={(e) => setProfileLocation(e.target.value)}
|
||||
placeholder="City, Country"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={busy}
|
||||
className="rounded-md bg-black text-white px-3 py-2 text-sm disabled:opacity-60"
|
||||
>
|
||||
{busy ? "Saving…" : "Save Changes"}
|
||||
</button>
|
||||
{msg && <div className="text-sm opacity-80">{msg}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
app/components/account/SupporterBadges.tsx
Normal file
111
app/components/account/SupporterBadges.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// components/account/SupporterBadges.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type SupportBadge = {
|
||||
provider: "kofi" | "patreon" | "mighty" | string;
|
||||
active: boolean; // currently entitled (status==="active" && renews_at >= now)
|
||||
kind: "member" | "one_time" | "inactive";
|
||||
label: string; // e.g., "Ko-fi • Bronze" or "Ko-fi Supporter"
|
||||
tier?: string | null;
|
||||
renews_at?: string | null;
|
||||
started_at?: string | null;
|
||||
};
|
||||
|
||||
function providerIcon(p: string) {
|
||||
// Swap these for real icons later if you want
|
||||
if (p === "kofi") return "☕";
|
||||
if (p === "patreon") return "🅿️";
|
||||
if (p === "mighty") return "💬";
|
||||
return "🎖️";
|
||||
}
|
||||
|
||||
export default function SupporterBadges({
|
||||
email,
|
||||
userId,
|
||||
}: {
|
||||
email?: string | null;
|
||||
userId?: string | null;
|
||||
}) {
|
||||
const [badges, setBadges] = useState<SupportBadge[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let url = "/api/support/badges";
|
||||
const params = new URLSearchParams();
|
||||
if (email) params.set("email", String(email));
|
||||
if (userId) params.set("userId", String(userId));
|
||||
const qs = params.toString();
|
||||
if (qs) url += `?${qs}`;
|
||||
|
||||
fetch(url, { cache: "no-store" })
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(r)))
|
||||
.then((json) => setBadges(Array.isArray(json?.badges) ? json.badges : []))
|
||||
.catch(async (e) => {
|
||||
try {
|
||||
const t = await e.text();
|
||||
setError(t || String(e));
|
||||
} catch {
|
||||
setError(String(e));
|
||||
}
|
||||
});
|
||||
}, [email, userId]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mt-4 rounded-lg border border-red-300/50 bg-red-50 p-3 text-sm text-red-800">
|
||||
Couldn’t load supporter badges.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!badges) {
|
||||
return (
|
||||
<div className="mt-4 animate-pulse rounded-lg border p-3 text-sm opacity-60">
|
||||
Loading supporter badges…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (badges.length === 0) {
|
||||
return (
|
||||
<div className="mt-4 rounded-lg border p-3 text-sm opacity-70">
|
||||
No supporter badges yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="text-sm font-medium opacity-80">Supporter Badges</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{badges.map((b, i) => (
|
||||
<span
|
||||
key={`${b.provider}-${i}`}
|
||||
className={[
|
||||
"inline-flex items-center gap-1 rounded-full border px-3 py-1 text-sm",
|
||||
b.active
|
||||
? "border-green-300 bg-green-50 text-green-900"
|
||||
: b.kind === "one_time"
|
||||
? "border-amber-300 bg-amber-50 text-amber-900"
|
||||
: "border-slate-300 bg-slate-50 text-slate-700",
|
||||
].join(" ")}
|
||||
title={
|
||||
b.active
|
||||
? b.renews_at
|
||||
? `Active • renews by ${new Date(b.renews_at).toLocaleDateString()}`
|
||||
: "Active"
|
||||
: b.kind === "one_time"
|
||||
? "One-time support"
|
||||
: "Inactive"
|
||||
}
|
||||
>
|
||||
<span>{providerIcon(b.provider)}</span>
|
||||
<span>{b.label}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue