Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
369 changes: 210 additions & 159 deletions src/app/requests/RequestsPageClient.tsx
Original file line number Diff line number Diff line change
@@ -1,183 +1,234 @@
"use client";

import { useState } from "react";
import { Nav } from "@/components/Nav";
import { ConnectWalletButton } from "@/components/ConnectWalletButton";
import { useWalletStore } from "@/store/wallet";
import { useTranslation } from "@/lib/i18n/I18nProvider";
import type { MessageKey } from "@/lib/i18n";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n/I18nProvider";
import { useWallet } from "@/lib/wallet/WalletProvider";
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
import {
getSupportRequests,
JUSTIFICATION_MAX_LENGTH,
NAME_MAX_LENGTH,
submitSupportRequest,
upvoteSupportRequest,
type SubmitError,
isAlreadySupported,
NAME_MIN_LENGTH,
NAME_MAX_LENGTH,
JUSTIFICATION_MIN_LENGTH,
JUSTIFICATION_MAX_LENGTH,
type SupportRequest,
type SupportRequestKind,
type SubmitError,
} from "@/lib/supportRequestStore";

const SUBMIT_ERROR_KEY: Record<SubmitError, MessageKey> = {
"name-length": "requests.error.nameLength",
"justification-length": "requests.error.justificationLength",
"already-supported": "requests.error.alreadySupported",
duplicate: "requests.error.duplicate",
};
type SubmitState =
| { status: "idle" }
| { status: "submitting" }
| { status: "error"; error: SubmitError; existingId?: string }
| { status: "success" };

export default function RequestsPageClient() {
const { t } = useTranslation();
const { isConnected, address } = useWalletStore();
const [requests, setRequests] = useState(getSupportRequests);
const { t } = useI18n();
const { address } = useWallet();

const [requests, setRequests] = useState<SupportRequest[]>([]);
const [kind, setKind] = useState<SupportRequestKind>("chain");
const [name, setName] = useState("");
const [justification, setJustification] = useState("");
const [error, setError] = useState<string | null>(null);

const refresh = () => setRequests(getSupportRequests());

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!address) return;
const result = submitSupportRequest(address, { kind, name, justification });
if (!result.ok) {
setError(t(SUBMIT_ERROR_KEY[result.error], { name: name.trim() }));
return;
}
setError(null);
setName("");
setJustification("");
refresh();
};
const [submitState, setSubmitState] = useState<SubmitState>({ status: "idle" });
const [upvoteError, setUpvoteError] = useState<string | null>(null);

const debouncedName = useDebouncedValue(name, 300);

const handleUpvote = (id: string) => {
if (!address) return;
upvoteSupportRequest(id, address);
const refresh = useCallback(() => {
setRequests(getSupportRequests());
}, []);

useEffect(() => {
refresh();
};
}, [refresh]);

const alreadySupported = useMemo(
() => (name.trim() ? isAlreadySupported(kind, name) : false),
[kind, name],
);

const liveAlreadySupported = useMemo(
() => (debouncedName.trim() ? isAlreadySupported(kind, debouncedName) : false),
[kind, debouncedName],
);

const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!address) return;
const result = submitSupportRequest(address, { kind, name, justification });
if (result.ok) {
setSubmitState({ status: "success" });
setName("");
setJustification("");
refresh();
return;
}
setSubmitState({ status: "error", error: result.error, existingId: result.existingId });
},
[address, kind, name, justification, refresh],
);

const handleUpvote = useCallback(
(id: string) => {
if (!address) return;
setUpvoteError(null);
const result = upvoteSupportRequest(id, address);
if (!result.ok) {
setUpvoteError(t(`requests.upvoteError.${result.error}`));
return;
}
refresh();
},
[address, refresh, t],
);

const handleDuplicateUpvote = useCallback(() => {
if (submitState.status !== "error" || !submitState.existingId) return;
handleUpvote(submitState.existingId);
setSubmitState({ status: "idle" });
}, [submitState, handleUpvote]);

return (
<div className="min-h-screen">
<Nav variant="breadcrumb" label={t("requests.nav")} />

<main id="main-content" className="max-w-3xl mx-auto px-3 sm:px-5 py-8 sm:py-12 space-y-8">
<header>
<div className="eyebrow mb-2 text-xs">{t("requests.eyebrow")}</div>
<h1 className="text-2xl sm:text-3xl font-bold text-vx-text mb-2">{t("requests.title")}</h1>
<p className="text-vx-muted text-xs sm:text-sm leading-relaxed">{t("requests.description")}</p>
<p className="text-[11px] text-vx-dim mt-2">{t("requests.mockNotice")}</p>
</header>

<section aria-labelledby="request-form-heading" className="card p-5 sm:p-6">
<h2 id="request-form-heading" className="text-sm font-semibold text-vx-text mb-4">
{t("requests.form.heading")}
</h2>
{!isConnected || !address ? (
<div className="text-center space-y-3">
<p className="text-xs sm:text-sm text-vx-muted">{t("requests.connectToSubmit")}</p>
<div className="flex justify-center">
<ConnectWalletButton />
</div>
</div>
) : (
<form onSubmit={handleSubmit} noValidate className="space-y-3">
<fieldset className="flex gap-4 text-xs text-vx-text">
<legend className="sr-only">{t("requests.form.kind")}</legend>
{(["chain", "token"] as const).map((k) => (
<label key={k} className="flex items-center gap-1.5">
<input
type="radio"
name="request-kind"
value={k}
checked={kind === k}
onChange={() => setKind(k)}
className="accent-vx-sage"
/>
{t(k === "chain" ? "requests.kind.chain" : "requests.kind.token")}
</label>
))}
</fieldset>
<div>
<label htmlFor="request-name" className="block text-xs text-vx-muted mb-1">
{t("requests.form.name")}
</label>
<input
id="request-name"
value={name}
maxLength={NAME_MAX_LENGTH}
onChange={(e) => setName(e.target.value)}
className="w-full bg-vx-surface border border-vx-border rounded-md px-3 py-2 text-sm text-vx-text focus:outline-none focus:border-vx-sage/50"
/>
</div>
<div>
<label htmlFor="request-justification" className="block text-xs text-vx-muted mb-1">
{t("requests.form.justification")}
</label>
<textarea
id="request-justification"
value={justification}
maxLength={JUSTIFICATION_MAX_LENGTH}
rows={3}
onChange={(e) => setJustification(e.target.value)}
aria-describedby="request-justification-count"
className="w-full bg-vx-surface border border-vx-border rounded-md px-3 py-2 text-sm text-vx-text focus:outline-none focus:border-vx-sage/50"
/>
<p id="request-justification-count" className="text-[10px] text-vx-dim text-right">
{t("requests.form.count", { current: justification.length, max: JUSTIFICATION_MAX_LENGTH })}
</p>
</div>
{error && (
<p role="alert" className="text-xs text-red-400">
{error}
</p>
)}
<div className="mx-auto flex w-full max-w-3xl flex-col gap-8 px-4 py-10">
<header className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold text-slate-900 dark:text-slate-100">
{t("requests.title")}
</h1>
<p className="text-sm text-slate-600 dark:text-slate-400">{t("requests.subtitle")}</p>
</header>

<form
onSubmit={handleSubmit}
className="flex flex-col gap-4 rounded-xl border border-slate-200 bg-white p-5 dark:border-slate-700 dark:bg-slate-900"
>
<div className="flex flex-col gap-1">
<label htmlFor="request-kind" className="text-sm font-medium text-slate-700 dark:text-slate-300">
{t("requests.form.kind")}
</label>
<select
id="request-kind"
value={kind}
onChange={(e) => setKind(e.target.value as SupportRequestKind)}
className="rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100"
>
<option value="chain">{t("requests.form.kindChain")}</option>
<option value="token">{t("requests.form.kindToken")}</option>
</select>
</div>

<div className="flex flex-col gap-1">
<label htmlFor="request-name" className="text-sm font-medium text-slate-700 dark:text-slate-300">
{t("requests.form.name")}
</label>
<input
id="request-name"
value={name}
onChange={(e) => setName(e.target.value)}
minLength={NAME_MIN_LENGTH}
maxLength={NAME_MAX_LENGTH}
required
aria-describedby={liveAlreadySupported ? "request-name-hint" : undefined}
className="rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100"
/>
{liveAlreadySupported && (
<p
id="request-name-hint"
role="status"
className="text-sm text-amber-700 dark:text-amber-400"
>
{t("requests.form.alreadySupportedHint")}
</p>
)}
</div>

<div className="flex flex-col gap-1">
<label htmlFor="request-justification" className="text-sm font-medium text-slate-700 dark:text-slate-300">
{t("requests.form.justification")}
</label>
<textarea
id="request-justification"
value={justification}
onChange={(e) => setJustification(e.target.value)}
minLength={JUSTIFICATION_MIN_LENGTH}
maxLength={JUSTIFICATION_MAX_LENGTH}
rows={4}
required
className="rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100"
/>
</div>

{alreadySupported && (
<p role="status" className="text-sm text-amber-700 dark:text-amber-400">
{t("requests.error.already-supported")}
</p>
)}

{submitState.status === "error" && (
<div role="alert" className="flex flex-col gap-2 text-sm text-red-700 dark:text-red-400">
<p>{t(`requests.error.${submitState.error}`)}</p>
{submitState.error === "duplicate" && submitState.existingId && (
<button
type="submit"
className="px-4 py-2 rounded-lg border border-vx-sage/40 bg-vx-sage-bg text-xs font-semibold text-vx-sage hover:bg-vx-sage/15 focus:outline-none focus-visible:ring-2 focus-visible:ring-vx-sage"
type="button"
onClick={handleDuplicateUpvote}
className="self-start rounded-md border border-indigo-500 px-3 py-1.5 text-sm font-medium text-indigo-700 hover:bg-indigo-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-indigo-400 dark:text-indigo-300 dark:hover:bg-indigo-950"
>
{t("requests.form.submit")}
{t("requests.error.duplicateUpvote")}
</button>
</form>
)}
</section>

<section aria-labelledby="request-list-heading">
<h2 id="request-list-heading" className="text-sm font-semibold text-vx-text mb-3">
{t("requests.list.heading")}
</h2>
{requests.length === 0 ? (
<p className="text-xs text-vx-muted">{t("requests.list.empty")}</p>
) : (
<ol className="space-y-2">
{requests.map((request) => {
const upvoted = Boolean(address && request.upvoters.includes(address));
return (
<li key={request.id} className="card p-4 flex items-start gap-4">
<button
type="button"
onClick={() => handleUpvote(request.id)}
disabled={!isConnected || !address || upvoted}
aria-pressed={upvoted}
aria-label={t("requests.list.upvote", { name: request.name, count: request.upvoters.length })}
className="flex flex-col items-center min-w-12 px-2 py-1.5 rounded-lg border border-vx-border text-vx-text disabled:opacity-60 enabled:hover:border-vx-sage/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-vx-sage aria-pressed:border-vx-sage/50 aria-pressed:text-vx-sage"
>
<span aria-hidden="true">▲</span>
<span className="text-sm font-semibold num">{request.upvoters.length}</span>
</button>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-vx-text">{request.name}</span>
<span className="chip bg-vx-surface text-vx-muted text-[10px]">
{t(request.kind === "chain" ? "requests.kind.chain" : "requests.kind.token")}
</span>
</div>
<p className="text-xs text-vx-muted mt-1 break-words">{request.justification}</p>
</div>
</li>
);
})}
</ol>
)}
</section>
</main>
)}
</div>
)}

{submitState.status === "success" && (
<p role="status" className="text-sm text-emerald-700 dark:text-emerald-400">
{t("requests.form.success")}
</p>
)}

<button
type="submit"
disabled={!address || alreadySupported}
className="self-start rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{t("requests.form.submit")}
</button>
</form>

{upvoteError && (
<p role="alert" className="text-sm text-red-700 dark:text-red-400">
{upvoteError}
</p>
)}

<ul className="flex flex-col gap-3">
{requests.map((request) => (
<li
key={request.id}
className="flex items-start justify-between gap-4 rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-900"
>
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{request.name}
</span>
<span className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">
{t(`requests.form.kind${request.kind === "chain" ? "Chain" : "Token"}`)}
</span>
<p className="text-sm text-slate-600 dark:text-slate-400">{request.justification}</p>
</div>
<button
type="button"
onClick={() => handleUpvote(request.id)}
disabled={!address}
className="shrink-0 rounded-md border border-slate-300 px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-800"
>
{t("requests.upvote")} ({request.upvotes.length})
</button>
</li>
))}
</ul>
</div>
);
}
1 change: 1 addition & 0 deletions src/lib/i18n/messages/en.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
PLACEHOLDER
1 change: 1 addition & 0 deletions src/lib/i18n/messages/es.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
PLACEHOLDER
Loading
Loading