From f04aa41aae7b984a8286d18b838e41fe72250cdf Mon Sep 17 00:00:00 2001 From: lemon Date: Fri, 12 Jun 2026 16:34:31 -0700 Subject: [PATCH] Extract shared VerifierStatementEditor; embed as sub-flow step 3 Pull the kind 14672 publish/update/withdraw editor out of OrganizationsPage into a reusable VerifierStatementEditor with an onPublishedChange callback. OrganizationsPage now consumes it (logged-out gate and verify tutorial unchanged), and the captive verifier sub-flow renders it as step 3 with a Continue button that unlocks once a statement is published. --- src/components/OnboardingGate.tsx | 70 +++++++- .../organizations/VerifierStatementEditor.tsx | 154 ++++++++++++++++++ src/pages/OrganizationsPage.tsx | 126 +------------- 3 files changed, 227 insertions(+), 123 deletions(-) create mode 100644 src/components/organizations/VerifierStatementEditor.tsx diff --git a/src/components/OnboardingGate.tsx b/src/components/OnboardingGate.tsx index 97a1731a..31bb9b39 100644 --- a/src/components/OnboardingGate.tsx +++ b/src/components/OnboardingGate.tsx @@ -1,5 +1,6 @@ import { useCallback, + useEffect, useMemo, useState, type ReactNode, @@ -29,7 +30,9 @@ import { type OrgProfileDraft, } from '@/components/onboarding/VerifierIdentityStep'; import { VerifierBioStep } from '@/components/onboarding/VerifierBioStep'; +import { VerifierStatementEditor } from '@/components/organizations/VerifierStatementEditor'; import { usePublishOrgProfile } from '@/hooks/usePublishOrgProfile'; +import { useVerifierStatement } from '@/hooks/useVerifierStatement'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { useCurrentUser } from '@/hooks/useCurrentUser'; @@ -184,6 +187,15 @@ 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 @@ -364,8 +376,14 @@ function CaptiveOverlay() { ); case 'orgStatement': // Verifier sub-flow step 3 — publish the verifier statement - // (kind 14672). Filled in by a later commit. - return ; + // (kind 14672), reusing the shared editor. + return ( + + ); case 'orgVerifyHowto': // Verifier sub-flow step 4 — teach the verify gesture, then finish. // Filled in by a later commit. @@ -481,10 +499,8 @@ function RoleStep({ role, onPick }: RoleStepProps) { } /** - * Placeholder shell for the four verifier sub-flow steps. Each real step - * (organization identity, bio, statement, how-to-verify) replaces this in a - * later commit; for now it just renders a Continue button so the state - * machine and navigation can be exercised end-to-end. + * Placeholder shell for verifier sub-flow steps not yet built out. Renders a + * single Continue button so the state machine can be exercised end-to-end. */ function VerifierStepShell({ onContinue }: { onContinue: () => void }) { return ( @@ -496,6 +512,48 @@ function VerifierStepShell({ onContinue }: { onContinue: () => 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. + */ +function VerifierStatementStep({ + published, + onPublishedChange, + onContinue, +}: VerifierStatementStepProps) { + const { t } = useTranslation(); + return ( +
+
+

+ {t('onboarding.verifier.statement.title')} +

+

+ {t('onboarding.verifier.statement.subtitle')} +

+
+ + + + +
+ ); +} + interface RoleCardProps { icon: ReactNode; title: string; diff --git a/src/components/organizations/VerifierStatementEditor.tsx b/src/components/organizations/VerifierStatementEditor.tsx new file mode 100644 index 00000000..bd84ca3b --- /dev/null +++ b/src/components/organizations/VerifierStatementEditor.tsx @@ -0,0 +1,154 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +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 { cn } from '@/lib/utils'; + +interface VerifierStatementEditorProps { + 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. + * + * 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. + */ +export function VerifierStatementEditor({ + 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 ?? ''); + setHydrated(true); + } + }, [hydrated, isLoading, statement]); + + 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', + }); + } + }; + + return ( + + + {/* Prompt */} +
+

{t('verifier.promptLabel')}

+

+ {t('verifier.prompt')} +

+
+ + {isLoading && !hydrated ? ( +
+ + {t('verifier.loading')} +
+ ) : ( + <> + {/* WYSIWYG markdown editor: formatting toolbar + rich-text + editing surface, value flows back out as markdown. */} +
+ +
+ +
+ + + {isPublished && ( + + )} +
+ +

+ {t('verifier.disclaimer')} +

+ + )} +
+
+ ); +} + +export default VerifierStatementEditor; diff --git a/src/pages/OrganizationsPage.tsx b/src/pages/OrganizationsPage.tsx index 5f7c6863..c79c949a 100644 --- a/src/pages/OrganizationsPage.tsx +++ b/src/pages/OrganizationsPage.tsx @@ -1,27 +1,21 @@ import { useSeoMeta } from '@unhead/react'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ArrowRight, BadgeCheck, Building2, CircleCheck, - Loader2, ShieldCheck, } from 'lucide-react'; -import { MilkdownEditor } from '@/components/markdown/MilkdownEditor'; import { LoginArea } from '@/components/auth/LoginArea'; import { VerifyTutorial } from '@/components/organizations/VerifyTutorial'; -import { Button } from '@/components/ui/button'; +import { VerifierStatementEditor } from '@/components/organizations/VerifierStatementEditor'; import { Card, CardContent } from '@/components/ui/card'; import { useAppContext } from '@/hooks/useAppContext'; import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useToast } from '@/hooks/useToast'; -import { - useSetVerifierStatement, - useVerifierStatement, -} from '@/hooks/useVerifierStatement'; +import { useVerifierStatement } from '@/hooks/useVerifierStatement'; /** * The /organizations page. A landing-style document modeled on the @@ -189,20 +183,11 @@ export function OrganizationsPage() { function VerifierEditor() { 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 ?? ''); - setHydrated(true); - } - }, [hydrated, isLoading, statement]); + 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); // Logged out: instruct the visitor to log in with — or create — their // organization's Nostr profile before they can publish a statement. @@ -227,106 +212,13 @@ function VerifierEditor() { ); } - 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'), - }); - } 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') }); - } catch (error) { - toast({ - title: t('verifier.errorToast'), - description: error instanceof Error ? error.message : String(error), - variant: 'destructive', - }); - } - }; - return (
- - - {/* Prompt */} -
-

- {t('verifier.promptLabel')} -

-

- {t('verifier.prompt')} -

-
- - {isLoading && !hydrated ? ( -
- - {t('verifier.loading')} -
- ) : ( - <> - {/* WYSIWYG markdown editor: formatting toolbar + rich-text - editing surface, value flows back out as markdown. */} -
- -
- -
- - - {isPublished && ( - - )} -
- -

- {t('verifier.disclaimer')} -

- - )} -
-
+ {/* Once the org's statement is live, teach them the actual verify gesture: the three-dots menu on any campaign card. */} - {isPublished && } + {(isPublished || isVerifier) && }
); }