Move verifier withdraw to the profile "How We Verify" card

Remove the inline Withdraw button (and its props) from
VerifierStatementEditor, and drop the success toast from the onboarding
"Publish your verifier statement" step. Withdrawing now lives in the
top-right of the "How We Verify" card on the user's own profile — gated
on isOwnProfile, mirroring the Edit Profile affordance — with an
AlertDialog confirmation before retracting the kind 14672 statement.
This commit is contained in:
lemon
2026-06-12 23:26:02 -07:00
parent f3deb14c8b
commit e9bc52030f
6 changed files with 97 additions and 54 deletions
+1 -22
View File
@@ -553,7 +553,7 @@ interface VerifierStatementStepProps {
* One header and one combined subtext sit above a borderless
* {@link VerifierStatementEditor}. There's no separate publish button: the
* primary button publishes the statement (when there's content) and then
* advances. A returning verifier can withdraw inline.
* advances. Withdrawing happens later from the profile's "How We Verify" card.
*/
function VerifierStatementStep({
onContinue,
@@ -563,14 +563,12 @@ function VerifierStatementStep({
const { mutateAsync: setStatement, isPending } = useSetVerifierStatement();
const [value, setValue] = useState('');
const [alreadyPublished, setAlreadyPublished] = useState(false);
const trimmed = value.trim();
const handleContinue = useCallback(async () => {
try {
await setStatement(trimmed);
toast({ title: t('verifier.publishedToast') });
onContinue();
} catch (error) {
toast({
@@ -581,21 +579,6 @@ function VerifierStatementStep({
}
}, [setStatement, trimmed, toast, t, onContinue]);
const handleWithdraw = useCallback(async () => {
try {
await setStatement('');
setValue('');
setAlreadyPublished(false);
toast({ title: t('verifier.withdrawnToast') });
} catch (error) {
toast({
title: t('verifier.errorToast'),
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
});
}
}, [setStatement, toast, t]);
return (
<div className="space-y-6">
<div className="space-y-2 text-center">
@@ -610,10 +593,6 @@ function VerifierStatementStep({
<VerifierStatementEditor
value={value}
onChange={setValue}
onHydrated={(s) => setAlreadyPublished(!!s)}
showWithdraw={alreadyPublished}
onWithdraw={handleWithdraw}
isWithdrawing={isPending}
/>
<Button
@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next';
import { Loader2 } from 'lucide-react';
import { MilkdownEditor } from '@/components/markdown/MilkdownEditor';
import { Button } from '@/components/ui/button';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useVerifierStatement } from '@/hooks/useVerifierStatement';
import { cn } from '@/lib/utils';
@@ -14,10 +13,6 @@ interface VerifierStatementEditorProps {
onChange: (value: string) => void;
/** Hydration callback — fired once with the user's existing statement. */
onHydrated?: (statement: string) => void;
/** Show a Withdraw control (only when a statement is already published). */
showWithdraw?: boolean;
onWithdraw?: () => void;
isWithdrawing?: boolean;
className?: string;
}
@@ -27,15 +22,13 @@ interface VerifierStatementEditorProps {
* A controlled, borderless WYSIWYG editor: the host owns the value and the
* publish action (publishing is wired to the onboarding step's primary
* button). The editor only renders the editing surface, hydrating once from
* the user's existing statement, plus an optional inline Withdraw control.
* the user's existing statement. Withdrawing happens from the profile's
* "How We Verify" card, not here.
*/
export function VerifierStatementEditor({
value,
onChange,
onHydrated,
showWithdraw = false,
onWithdraw,
isWithdrawing = false,
className,
}: VerifierStatementEditorProps) {
const { t } = useTranslation();
@@ -79,19 +72,6 @@ export function VerifierStatementEditor({
placeholder={t('verifier.placeholder')}
/>
</div>
{showWithdraw && onWithdraw && (
<Button
type="button"
variant="ghost"
onClick={onWithdraw}
disabled={isWithdrawing}
className="text-destructive hover:text-destructive px-0"
>
{isWithdrawing && <Loader2 className="size-4 animate-spin mr-2" />}
{t('verifier.withdraw')}
</Button>
)}
</div>
);
}
@@ -9,6 +9,7 @@ import { useVerifiedCampaigns } from '@/hooks/useVerifiedCampaigns';
interface ProfileVerifiedTabProps {
pubkey: string;
displayName: string;
isOwnProfile?: boolean;
}
/**
@@ -20,14 +21,14 @@ interface ProfileVerifiedTabProps {
* immediately see how the organization vets campaigns and what it
* stands behind.
*/
export function ProfileVerifiedTab({ pubkey, displayName }: ProfileVerifiedTabProps) {
export function ProfileVerifiedTab({ pubkey, displayName, isOwnProfile = false }: ProfileVerifiedTabProps) {
const { t } = useTranslation();
const { campaigns, isLoading } = useVerifiedCampaigns(pubkey);
if (isLoading && campaigns.length === 0) {
return (
<div className="px-4 sm:px-6 py-6 space-y-6">
<ProfileVerifierSection pubkey={pubkey} />
<ProfileVerifierSection pubkey={pubkey} isOwnProfile={isOwnProfile} />
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
{Array.from({ length: 3 }).map((_, i) => (
<CampaignCardSkeleton key={i} />
@@ -40,7 +41,7 @@ export function ProfileVerifiedTab({ pubkey, displayName }: ProfileVerifiedTabPr
if (campaigns.length === 0) {
return (
<div className="px-4 sm:px-6 py-6 space-y-6" data-pubkey={pubkey}>
<ProfileVerifierSection pubkey={pubkey} />
<ProfileVerifierSection pubkey={pubkey} isOwnProfile={isOwnProfile} />
<Card className="border-dashed">
<div className="py-12 px-8 text-center">
<BadgeCheck className="size-10 mx-auto mb-3 text-muted-foreground" />
@@ -55,7 +56,7 @@ export function ProfileVerifiedTab({ pubkey, displayName }: ProfileVerifiedTabPr
return (
<div className="px-4 sm:px-6 py-6 space-y-4">
<ProfileVerifierSection pubkey={pubkey} className="mb-2" />
<ProfileVerifierSection pubkey={pubkey} isOwnProfile={isOwnProfile} className="mb-2" />
<p className="text-sm text-muted-foreground">
{t('profile.verified.count', { count: campaigns.length })}
</p>
@@ -1,12 +1,32 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, Trash2 } from 'lucide-react';
import { PolicyMarkdown } from '@/components/PolicyMarkdown';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useVerifierStatement } from '@/hooks/useVerifierStatement';
import { useToast } from '@/hooks/useToast';
import { useSetVerifierStatement, useVerifierStatement } from '@/hooks/useVerifierStatement';
import { cn } from '@/lib/utils';
interface ProfileVerifierSectionProps {
pubkey: string;
/**
* Whether the viewer owns this profile. When true, a Withdraw control is
* surfaced in the card's top-right corner (mirroring the "Edit Profile"
* affordance), letting the verifier retract their statement.
*/
isOwnProfile?: boolean;
className?: string;
}
@@ -22,9 +42,12 @@ interface ProfileVerifierSectionProps {
*
* Renders nothing when the profile has no statement (or has withdrawn it).
*/
export function ProfileVerifierSection({ pubkey, className }: ProfileVerifierSectionProps) {
export function ProfileVerifierSection({ pubkey, isOwnProfile = false, className }: ProfileVerifierSectionProps) {
const { t } = useTranslation();
const { toast } = useToast();
const { statement, isLoading } = useVerifierStatement(pubkey);
const { mutateAsync: setStatement, isPending } = useSetVerifierStatement();
const [confirmOpen, setConfirmOpen] = useState(false);
if (isLoading) {
return (
@@ -37,14 +60,72 @@ export function ProfileVerifierSection({ pubkey, className }: ProfileVerifierSec
if (!statement) return null;
const handleWithdraw = async () => {
try {
await setStatement('');
setConfirmOpen(false);
toast({ title: t('verifier.withdrawnToast') });
} catch (error) {
toast({
title: t('verifier.errorToast'),
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
});
}
};
return (
<section className={className}>
<div className="rounded-xl border border-primary/20 bg-primary/5 px-4 py-3 space-y-2">
<h2 className="text-sm font-semibold uppercase tracking-wide text-primary">
{t('verifier.howWeVerifyTitle')}
</h2>
<div className="flex items-start justify-between gap-3">
<h2 className="text-sm font-semibold uppercase tracking-wide text-primary">
{t('verifier.howWeVerifyTitle')}
</h2>
{isOwnProfile && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setConfirmOpen(true)}
disabled={isPending}
className="-mt-1 -mr-2 h-7 shrink-0 px-2 text-xs text-destructive hover:text-destructive"
>
{isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Trash2 className="size-3.5" />
)}
<span className="ml-1.5">{t('verifier.withdraw')}</span>
</Button>
)}
</div>
<PolicyMarkdown source={statement} />
</div>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('verifier.withdrawConfirmTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('verifier.withdrawConfirmBody')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleWithdraw();
}}
disabled={isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isPending && <Loader2 className="size-4 animate-spin mr-2" />}
{t('verifier.withdraw')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
+2
View File
@@ -1514,6 +1514,8 @@
"publish": "Become a verifier",
"update": "Update statement",
"withdraw": "Withdraw",
"withdrawConfirmTitle": "Withdraw your verifier statement?",
"withdrawConfirmBody": "Your \"How We Verify\" statement will be removed from your profile. You can publish a new one anytime.",
"loading": "Loading your statement…",
"publishedToast": "Your verifier statement is live.",
"withdrawnToast": "Your verifier statement has been withdrawn.",
+1 -1
View File
@@ -914,7 +914,7 @@ function ProfileTabContent({
}
if (activeTab === 'verified') {
return <ProfileVerifiedTab pubkey={pubkey} displayName={displayName} />;
return <ProfileVerifiedTab pubkey={pubkey} displayName={displayName} isOwnProfile={isOwnProfile} />;
}
if (activeTab === 'campaigns') {