Simplify the statement step; remove the editor from /organizations

Drop the 'Become a verifier' publish button: the statement step's primary
Continue button now publishes the kind 14672 statement and advances, with
an inline Withdraw for returning verifiers. Collapse the duplicated prompt /
disclaimer copy into a single header + subtext and make the editor
borderless. Replace the /organizations functional editor with a CTA that
launches the verifier onboarding flow.
This commit is contained in:
lemon
2026-06-12 18:21:11 -07:00
parent 8f4bea1210
commit 6b01a24248
4 changed files with 136 additions and 165 deletions
+57 -23
View File
@@ -1,6 +1,5 @@
import {
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
@@ -33,7 +32,7 @@ import { VerifierBioStep } from '@/components/onboarding/VerifierBioStep';
import { VerifierStatementEditor } from '@/components/organizations/VerifierStatementEditor';
import { VerifyTutorial } from '@/components/organizations/VerifyTutorial';
import { usePublishOrgProfile } from '@/hooks/usePublishOrgProfile';
import { useVerifierStatement } from '@/hooks/useVerifierStatement';
import { useSetVerifierStatement } from '@/hooks/useVerifierStatement';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@@ -188,15 +187,6 @@ function CaptiveOverlay() {
const { mutateAsync: publishOrgProfile, isPending: isPublishingOrg } =
usePublishOrgProfile();
// Tracks whether the verifier statement (kind 14672) has been published,
// gating the statement step's Next button. Seeded from any existing
// statement so a returning verifier isn't blocked.
const { isVerifier } = useVerifierStatement(user?.pubkey);
const [statementPublished, setStatementPublished] = useState(false);
useEffect(() => {
if (isVerifier) setStatementPublished(true);
}, [isVerifier]);
// Linear progress bar position. Once the user has chosen the verifier
// role, the bar tracks the extended verifier step list so the four
// sub-flow screens are reflected; otherwise the base three-step list is
@@ -380,8 +370,6 @@ function CaptiveOverlay() {
// (kind 14672), reusing the shared editor.
return (
<VerifierStatementStep
published={statementPublished}
onPublishedChange={setStatementPublished}
onContinue={goNextVerifierStep}
/>
);
@@ -535,22 +523,58 @@ function VerifierHowtoStep({ onFinish }: { onFinish: () => void }) {
}
interface VerifierStatementStepProps {
published: boolean;
onPublishedChange: (published: boolean) => void;
onContinue: () => void;
}
/**
* Verifier sub-flow step 3 — publish the verifier statement (kind 14672).
* Wraps the shared {@link VerifierStatementEditor} with the captive-flow
* heading and a Continue button that unlocks once a statement is live.
*
* 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.
*/
function VerifierStatementStep({
published,
onPublishedChange,
onContinue,
}: VerifierStatementStepProps) {
const { t } = useTranslation();
const { toast } = useToast();
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({
title: t('verifier.errorToast'),
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
});
}
}, [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">
@@ -562,15 +586,25 @@ function VerifierStatementStep({
</p>
</div>
<VerifierStatementEditor onPublishedChange={onPublishedChange} />
<VerifierStatementEditor
value={value}
onChange={setValue}
onHydrated={(s) => setAlreadyPublished(!!s)}
showWithdraw={alreadyPublished}
onWithdraw={handleWithdraw}
isWithdrawing={isPending}
/>
<Button
onClick={onContinue}
disabled={!published}
onClick={handleContinue}
disabled={!trimmed || isPending}
className="w-full h-12 text-base rounded-full"
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{t('common.continue')}
<ArrowRight className="ml-2 h-4 w-4 rtl:rotate-180" />
{!isPending && <ArrowRight className="ml-2 h-4 w-4 rtl:rotate-180" />}
</Button>
</div>
);
@@ -4,150 +4,87 @@ import { Loader2 } from 'lucide-react';
import { MilkdownEditor } from '@/components/markdown/MilkdownEditor';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import {
useSetVerifierStatement,
useVerifierStatement,
} from '@/hooks/useVerifierStatement';
import { useVerifierStatement } from '@/hooks/useVerifierStatement';
import { cn } from '@/lib/utils';
interface VerifierStatementEditorProps {
/** Current markdown value (controlled). */
value: string;
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;
/**
* Called after a non-empty statement has been published or updated, and
* with `false` after a withdrawal. Lets hosts (e.g. the captive onboarding
* flow) react to publish state — for instance, enabling a "Next" button.
*/
onPublishedChange?: (isPublished: boolean) => void;
}
/**
* The functional verifier-statement editor (kind 14672): a WYSIWYG Markdown
* surface with publish / update / withdraw controls and a live hydrate from
* the user's existing statement.
* The verifier-statement (kind 14672) markdown editing surface.
*
* Extracted from OrganizationsPage so both the public /organizations tool
* and the captive verifier onboarding flow render the exact same editor and
* stay in sync. Assumes a logged-in user; callers gate on auth.
* 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.
*/
export function VerifierStatementEditor({
value,
onChange,
onHydrated,
showWithdraw = false,
onWithdraw,
isWithdrawing = false,
className,
onPublishedChange,
}: VerifierStatementEditorProps) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { toast } = useToast();
const { statement, isLoading } = useVerifierStatement(user?.pubkey);
const { mutateAsync: setStatement, isPending } = useSetVerifierStatement();
const [value, setValue] = useState('');
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
if (!hydrated && !isLoading) {
setValue(statement ?? '');
onChange(statement ?? '');
onHydrated?.(statement ?? '');
setHydrated(true);
}
}, [hydrated, isLoading, statement]);
}, [hydrated, isLoading, statement, onChange, onHydrated]);
const trimmed = value.trim();
const isPublished = !!statement;
const unchanged = trimmed === (statement ?? '');
const handlePublish = async () => {
try {
await setStatement(trimmed);
toast({
title: trimmed
? t('verifier.publishedToast')
: t('verifier.withdrawnToast'),
});
onPublishedChange?.(!!trimmed);
} catch (error) {
toast({
title: t('verifier.errorToast'),
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
});
}
};
const handleWithdraw = async () => {
try {
await setStatement('');
setValue('');
toast({ title: t('verifier.withdrawnToast') });
onPublishedChange?.(false);
} catch (error) {
toast({
title: t('verifier.errorToast'),
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
});
}
};
if (isLoading && !hydrated) {
return (
<div className={cn('flex items-center gap-2 text-sm text-muted-foreground', className)}>
<Loader2 className="size-4 animate-spin" />
{t('verifier.loading')}
</div>
);
}
return (
<Card className={cn('border-border/60 shadow-sm', className)}>
<CardContent className="p-6 sm:p-8 space-y-6">
{/* Prompt */}
<div className="space-y-2">
<p className="text-sm font-semibold">{t('verifier.promptLabel')}</p>
<p className="text-sm text-muted-foreground leading-relaxed">
{t('verifier.prompt')}
</p>
</div>
<div className={cn('space-y-4', className)}>
{/* Borderless WYSIWYG markdown editor — no surrounding card/box so it
blends into the step. */}
<div className="rounded-lg overflow-hidden focus-within:ring-1 focus-within:ring-ring">
<MilkdownEditor
value={value}
onChange={onChange}
placeholder={t('verifier.placeholder')}
/>
</div>
{isLoading && !hydrated ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{t('verifier.loading')}
</div>
) : (
<>
{/* WYSIWYG markdown editor: formatting toolbar + rich-text
editing surface, value flows back out as markdown. */}
<div className="rounded-lg border border-input bg-background overflow-hidden focus-within:ring-1 focus-within:ring-ring">
<MilkdownEditor
value={value}
onChange={setValue}
placeholder={t('verifier.placeholder')}
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
onClick={handlePublish}
disabled={isPending || !trimmed || unchanged}
>
{isPending && <Loader2 className="size-4 animate-spin mr-2" />}
{isPublished ? t('verifier.update') : t('verifier.publish')}
</Button>
{isPublished && (
<Button
type="button"
variant="ghost"
onClick={handleWithdraw}
disabled={isPending}
className="text-destructive hover:text-destructive"
>
{t('verifier.withdraw')}
</Button>
)}
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
{t('verifier.disclaimer')}
</p>
</>
)}
</CardContent>
</Card>
{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>
);
}
+8 -3
View File
@@ -171,7 +171,7 @@
"publishFailedDescription": "Your account is ready, but the organization profile couldn't be published. You can finish it later from settings.",
"statement": {
"title": "Publish your verifier statement",
"subtitle": "Explain how your organization checks out campaigns before vouching for them. This is public and helps donors trust your badge."
"subtitle": "Explain how your organization checks out campaigns before vouching for them — the checks you run, the evidence you require, how you confirm an organizer's identity. This is published publicly and shown on your profile so donors can trust your badge."
},
"howto": {
"title": "How to verify a campaign",
@@ -1590,8 +1590,13 @@
},
"getStarted": {
"eyebrow": "Get started",
"title": "Publish your statement",
"lede": "Sign in with your organization's profile to publish, update, or withdraw your verification statement."
"title": "Become a verifier",
"lede": "Set up your organization and publish your verification statement in a few quick steps."
},
"getStartedCard": {
"title": "Set up your organization",
"body": "We'll walk you through creating your organization's profile, publishing your verification statement, and verifying your first campaign.",
"cta": "Start verifying"
},
"loginGateTitle": "Sign in with your organization's profile",
"loginGateBody": "Log in with your organization's Nostr profile, or create one, to get started. Once you're signed in, you can publish your verification statement here.",
+16 -21
View File
@@ -1,5 +1,4 @@
import { useSeoMeta } from '@unhead/react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ArrowRight,
@@ -9,12 +8,12 @@ import {
ShieldCheck,
} from 'lucide-react';
import { LoginArea } from '@/components/auth/LoginArea';
import { VerifyTutorial } from '@/components/organizations/VerifyTutorial';
import { VerifierStatementEditor } from '@/components/organizations/VerifierStatementEditor';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useOnboarding } from '@/contexts/onboardingContextDef';
import { useVerifierStatement } from '@/hooks/useVerifierStatement';
/**
@@ -183,16 +182,11 @@ export function OrganizationsPage() {
function VerifierEditor() {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { isVerifier } = useVerifierStatement(user?.pubkey);
// Track publish state locally so the tutorial appears/disappears
// immediately on publish / withdraw, seeded from the queried state.
const [isPublished, setIsPublished] = useState(isVerifier);
const { startSignup } = useOnboarding();
// Logged out: instruct the visitor to log in with — or create — their
// organization's Nostr profile before they can publish a statement.
if (!user) {
return (
return (
<div className="space-y-8">
<Card className="border-border/60 shadow-sm">
<CardContent className="py-12 px-8 flex flex-col items-center gap-6 text-center">
<div className="p-4 rounded-full bg-primary/10">
@@ -200,25 +194,26 @@ function VerifierEditor() {
</div>
<div className="space-y-2 max-w-sm">
<h3 className="text-xl font-bold tracking-tight">
{t('organizations.loginGateTitle')}
{t('organizations.getStartedCard.title')}
</h3>
<p className="text-muted-foreground text-sm leading-relaxed">
{t('organizations.loginGateBody')}
{t('organizations.getStartedCard.body')}
</p>
</div>
<LoginArea className="max-w-60" />
<Button
size="lg"
className="gap-2"
onClick={() => startSignup({ role: 'verifier' })}
>
<BadgeCheck className="size-5" />
{t('organizations.getStartedCard.cta')}
</Button>
</CardContent>
</Card>
);
}
return (
<div className="space-y-8">
<VerifierStatementEditor onPublishedChange={setIsPublished} />
{/* Once the org's statement is live, teach them the actual
verify gesture: the three-dots menu on any campaign card. */}
{(isPublished || isVerifier) && <VerifyTutorial />}
{isVerifier && <VerifyTutorial />}
</div>
);
}