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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user