Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f4e69344c | |||
| 3500afb3f9 | |||
| 8503cea367 | |||
| 31ccaa2f99 | |||
| b0a81b5d94 | |||
| 035119091b | |||
| 741ec3ed09 | |||
| 30d3d85743 | |||
| 1a835a5fe6 | |||
| 4adbed2c1b | |||
| e9bc52030f | |||
| f3deb14c8b | |||
| 85601bdca8 | |||
| 1d35f1fe63 | |||
| 24842d5a05 | |||
| 48e18c16b6 | |||
| 5db139b930 | |||
| 4fd320d5c0 | |||
| 656ea70492 | |||
| ae8d3cea56 | |||
| 316f6dd8ec | |||
| c662db2ce0 | |||
| e562f7d0a2 | |||
| c2a80df9ed | |||
| 239c83f1a8 | |||
| 07927c1911 | |||
| e806b373d3 | |||
| fea8166472 | |||
| 6e017a88be | |||
| 0645a60f3a | |||
| ccf64f5906 | |||
| ab03489a3f | |||
| 29a5ede59a | |||
| 425923a4dd | |||
| 55a2321330 | |||
| 59196af579 | |||
| 6b01a24248 | |||
| 8f4bea1210 | |||
| 90e06e328f | |||
| fbed6aa0ff | |||
| f49ca00c09 | |||
| 23773d352c | |||
| 5d4b9cf2f5 | |||
| 72c7520a12 | |||
| 8a908cd11c | |||
| a60a757f0f | |||
| f2fa16c3bb |
+6
-5
@@ -99,7 +99,7 @@ function SiteFooter() {
|
||||
</button>
|
||||
<nav className="flex items-center gap-5">
|
||||
<Link to="/about" className="hover:text-foreground motion-safe:transition-colors">{t('nav.about')}</Link>
|
||||
<Link to="/organizations" className="hover:text-foreground motion-safe:transition-colors">{t('nav.organizations')}</Link>
|
||||
<Link to="/verify" className="hover:text-foreground motion-safe:transition-colors">{t('nav.verify')}</Link>
|
||||
<Link to="/privacy" className="hover:text-foreground motion-safe:transition-colors">{t('nav.privacy')}</Link>
|
||||
<Link to="/safety" className="hover:text-foreground motion-safe:transition-colors">{t('nav.safety')}</Link>
|
||||
<Link to="/changelog" className="hover:text-foreground motion-safe:transition-colors">{t('nav.changelog')}</Link>
|
||||
@@ -190,9 +190,9 @@ export function AppRouter() {
|
||||
<Route path="/safety" element={<CSAEPolicyPage />} />
|
||||
<Route path="/changelog" element={<ChangelogPage />} />
|
||||
<Route path="/organizers" element={<OrganizersPage />} />
|
||||
{/* `/settings/verifier` moved to the public `/organizations` onboarding
|
||||
{/* `/settings/verifier` moved to the public `/verify` onboarding
|
||||
page. Keep the old path as a redirect so existing links resolve. */}
|
||||
<Route path="/settings/verifier" element={<Navigate to="/organizations" replace />} />
|
||||
<Route path="/settings/verifier" element={<Navigate to="/verify" replace />} />
|
||||
{/* Callback target for remote signers (e.g. Amber, Primal) after NIP-46 approval */}
|
||||
<Route path="/remoteloginsuccess" element={<RemoteLoginSuccessPage />} />
|
||||
</Route>
|
||||
@@ -221,9 +221,10 @@ export function AppRouter() {
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/about/donors" element={<DonorGuidePage />} />
|
||||
<Route path="/about/recipients" element={<RecipientGuidePage />} />
|
||||
{/* Organizations onboarding / marketing page. Wide layout so the
|
||||
{/* Verification onboarding / marketing page. Wide layout so the
|
||||
hero and section backgrounds can span the viewport like /about. */}
|
||||
<Route path="/organizations" element={<OrganizationsPage />} />
|
||||
<Route path="/verify" element={<OrganizationsPage />} />
|
||||
<Route path="/organizations" element={<Navigate to="/verify" replace />} />
|
||||
{/* Legacy URL: the recipient guide lived at `/about/activists`
|
||||
before the "activist" → "recipient" copy change. Redirect so
|
||||
external links and bookmarks still resolve. */}
|
||||
|
||||
@@ -27,10 +27,12 @@ interface ImageCropDialogProps {
|
||||
* encoded smaller — see `encodeImage` in `@/lib/resizeImage`). The
|
||||
* mime/extension on the file reflects the winning format.
|
||||
*/
|
||||
onCrop: (croppedFile: File) => void;
|
||||
onCrop: (croppedFile: File) => void | Promise<void>;
|
||||
/** Called when source decoding/cropping fails before `onCrop` receives a file. */
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export function ImageCropDialog({ open, imageSrc, aspect, title = 'Crop Image', maxOutputSize, onCancel, onCrop }: ImageCropDialogProps) {
|
||||
export function ImageCropDialog({ open, imageSrc, aspect, title = 'Crop Image', maxOutputSize, onCancel, onCrop, onError }: ImageCropDialogProps) {
|
||||
const [crop, setCrop] = useState<Point>({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||
@@ -60,7 +62,9 @@ export function ImageCropDialog({ open, imageSrc, aspect, title = 'Crop Image',
|
||||
maxOutputSize,
|
||||
filename: 'cropped',
|
||||
});
|
||||
onCrop(file);
|
||||
await onCrop(file);
|
||||
} catch (error) {
|
||||
onError?.(error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
BadgeCheck,
|
||||
Bitcoin,
|
||||
Download,
|
||||
Eye,
|
||||
@@ -23,6 +24,15 @@ import {
|
||||
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
|
||||
|
||||
import { AgoraBoltIcon } from '@/components/icons/AgoraBoltIcon';
|
||||
import {
|
||||
VerifierIdentityStep,
|
||||
type OrgProfileDraft,
|
||||
} from '@/components/onboarding/VerifierIdentityStep';
|
||||
import { VerifierBioStep } from '@/components/onboarding/VerifierBioStep';
|
||||
import { VerifierStatementEditor } from '@/components/organizations/VerifierStatementEditor';
|
||||
import { VerifyTutorial } from '@/components/organizations/VerifyTutorial';
|
||||
import { usePublishOrgProfile } from '@/hooks/usePublishOrgProfile';
|
||||
import { useSetVerifierStatement } from '@/hooks/useVerifierStatement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
@@ -35,26 +45,64 @@ import { cn } from '@/lib/utils';
|
||||
/**
|
||||
* Step state machine for the captive signup flow.
|
||||
*
|
||||
* Order:
|
||||
* Base order (creator / donor):
|
||||
* keygen → secure → role
|
||||
*
|
||||
* Three screens total. The old flow had a separate "wallet-coupling explainer"
|
||||
* step and a separate "outro" celebration screen; both were folded in. The
|
||||
* coupling explainer was redundant with `secure` (both screens are about the
|
||||
* key), so the secure step now carries the "this key is your account AND
|
||||
* your wallet" framing inline. The outro was a glorified tap-to-continue —
|
||||
* the role step's primary button already navigates somewhere meaningful, so
|
||||
* the role pick *is* the outro.
|
||||
* Picking the *verifier* role doesn't navigate away — it branches into a
|
||||
* captive sub-flow that continues from the role step:
|
||||
* role → orgIdentity → orgBio → orgStatement → orgVerifyHowto
|
||||
*
|
||||
* 1. orgIdentity — banner, avatar, org name, website (kind-0 identity)
|
||||
* 2. orgBio — the organization's bio (kind-0 about)
|
||||
* 3. orgStatement — publish the verifier statement (kind 14672)
|
||||
* 4. orgVerifyHowto— teach the verify gesture, then "View Campaigns"
|
||||
*
|
||||
* The old flow had a separate "wallet-coupling explainer" step and a
|
||||
* separate "outro" celebration screen; both were folded in. The coupling
|
||||
* explainer was redundant with `secure` (both screens are about the key), so
|
||||
* the secure step now carries the "this key is your account AND your wallet"
|
||||
* framing inline. For creator/donor the role pick *is* the outro.
|
||||
*
|
||||
* Login is handled by the existing `AuthDialog` modal — the captive flow is
|
||||
* only ever opened by an explicit `startSignup()` call (e.g. from
|
||||
* AuthDialog's "Create a new Nostr account" button), so the user has
|
||||
* already picked "signup" by the time we mount.
|
||||
*/
|
||||
type Step = 'keygen' | 'secure' | 'role';
|
||||
type Step =
|
||||
| 'keygen'
|
||||
| 'secure'
|
||||
| 'role'
|
||||
| 'orgIdentity'
|
||||
| 'orgBio'
|
||||
| 'orgStatement'
|
||||
| 'orgVerifyHowto';
|
||||
|
||||
/** Base steps that count toward the progress bar for creator/donor. */
|
||||
const SIGNUP_STEPS: Step[] = ['keygen', 'secure', 'role'];
|
||||
|
||||
/**
|
||||
* Steps that count toward the progress bar once the user has chosen the
|
||||
* verifier role. The role step is shared with the base flow, then the four
|
||||
* verifier sub-flow steps extend it.
|
||||
*/
|
||||
const VERIFIER_STEPS: Step[] = [
|
||||
'keygen',
|
||||
'secure',
|
||||
'role',
|
||||
'orgIdentity',
|
||||
'orgBio',
|
||||
'orgStatement',
|
||||
'orgVerifyHowto',
|
||||
];
|
||||
|
||||
/** Ordered verifier sub-flow steps, used for sequential next/back nav. */
|
||||
const VERIFIER_SUBFLOW: Step[] = [
|
||||
'orgIdentity',
|
||||
'orgBio',
|
||||
'orgStatement',
|
||||
'orgVerifyHowto',
|
||||
];
|
||||
|
||||
/**
|
||||
* The captive onboarding gate. Render this as a sibling of `<AppRouter />`;
|
||||
* it renders nothing when inactive and a fullscreen `fixed inset-0 z-50`
|
||||
@@ -105,12 +153,51 @@ function CaptiveOverlay() {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
|
||||
// Linear progress bar position. Every step in the machine counts toward
|
||||
// the bar.
|
||||
const currentProgressIndex = SIGNUP_STEPS.indexOf(step);
|
||||
// Verifier sub-flow: the organization's kind-0 profile draft, accumulated
|
||||
// across the identity + bio steps and published once at the end. Held here
|
||||
// so back-navigation between sub-flow steps preserves what's entered.
|
||||
const [orgDraft, setOrgDraft] = useState<OrgProfileDraft>({
|
||||
name: '',
|
||||
website: '',
|
||||
picture: '',
|
||||
banner: '',
|
||||
about: '',
|
||||
});
|
||||
const patchOrgDraft = useCallback(
|
||||
(patch: Partial<OrgProfileDraft>) =>
|
||||
setOrgDraft((prev) => ({ ...prev, ...patch })),
|
||||
[],
|
||||
);
|
||||
|
||||
// Pubkey of the key generated in this captive flow, if any. Used as the
|
||||
// `expectedPubkey` guard when publishing the org profile so a failed
|
||||
// auto-login can't overwrite a different account's kind-0. Empty when the
|
||||
// user was already authenticated on entry (no guard needed then).
|
||||
const signupPubkey = useMemo(() => {
|
||||
if (!nsec) return undefined;
|
||||
try {
|
||||
const decoded = nip19.decode(nsec);
|
||||
if (decoded.type !== 'nsec') return undefined;
|
||||
return getPublicKey(decoded.data);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}, [nsec]);
|
||||
|
||||
const { mutateAsync: publishOrgProfile, isPending: isPublishingOrg } =
|
||||
usePublishOrgProfile();
|
||||
|
||||
// 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
|
||||
// used (creator/donor progress math is unaffected).
|
||||
const isVerifierFlow =
|
||||
contextRole === 'verifier' || VERIFIER_SUBFLOW.includes(step);
|
||||
const progressSteps = isVerifierFlow ? VERIFIER_STEPS : SIGNUP_STEPS;
|
||||
const currentProgressIndex = progressSteps.indexOf(step);
|
||||
const progress = currentProgressIndex < 0
|
||||
? 0
|
||||
: ((currentProgressIndex + 1) / SIGNUP_STEPS.length) * 100;
|
||||
: ((currentProgressIndex + 1) / progressSteps.length) * 100;
|
||||
|
||||
// Navigation helpers ------------------------------------------------------
|
||||
const goTo = useCallback((target: Step) => {
|
||||
@@ -123,21 +210,41 @@ function CaptiveOverlay() {
|
||||
cancel();
|
||||
} else if (step === 'secure') {
|
||||
goTo('keygen');
|
||||
} else if (VERIFIER_SUBFLOW.includes(step)) {
|
||||
// Within the verifier sub-flow: step back one screen, or back to the
|
||||
// role picker from the first sub-flow step.
|
||||
const idx = VERIFIER_SUBFLOW.indexOf(step);
|
||||
goTo(idx <= 0 ? 'role' : VERIFIER_SUBFLOW[idx - 1]);
|
||||
} else {
|
||||
// role step
|
||||
if (user) cancel();
|
||||
else goTo('secure');
|
||||
}
|
||||
}, [step, user, cancel, goTo]);
|
||||
|
||||
// Role pick is the final step. Picking a role both records the choice
|
||||
// (used by the role-pick CTA labels) and navigates to the matching
|
||||
// surface: creator → campaign-creation form, donor → full campaign grid
|
||||
// (`/campaigns`, not `/`, so they land on the browse-everything view
|
||||
// rather than the curated home with its own marketing hero). No separate
|
||||
// outro / celebration screen.
|
||||
// Advance one screen within the verifier sub-flow. The first call (from
|
||||
// the role pick) enters at `orgIdentity`; subsequent calls walk the list.
|
||||
const goNextVerifierStep = useCallback(() => {
|
||||
const idx = VERIFIER_SUBFLOW.indexOf(step);
|
||||
if (idx < 0) {
|
||||
goTo(VERIFIER_SUBFLOW[0]);
|
||||
} else if (idx < VERIFIER_SUBFLOW.length - 1) {
|
||||
goTo(VERIFIER_SUBFLOW[idx + 1]);
|
||||
}
|
||||
}, [step, goTo]);
|
||||
|
||||
// Role pick. For creator/donor this is the final step: it records the
|
||||
// choice and navigates to the matching surface (creator → /campaigns/new,
|
||||
// donor → /campaigns). The verifier role does NOT navigate away — it
|
||||
// records the role and enters the captive verifier sub-flow, which
|
||||
// finishes on its own terms ("View Campaigns").
|
||||
const handleRolePick = useCallback(
|
||||
(next: 'creator' | 'donor') => {
|
||||
(next: 'creator' | 'donor' | 'verifier') => {
|
||||
setContextRole(next);
|
||||
if (next === 'verifier') {
|
||||
goTo('orgIdentity');
|
||||
return;
|
||||
}
|
||||
cancel();
|
||||
if (next === 'creator') {
|
||||
navigate('/campaigns/new');
|
||||
@@ -145,9 +252,33 @@ function CaptiveOverlay() {
|
||||
navigate('/campaigns');
|
||||
}
|
||||
},
|
||||
[setContextRole, cancel, navigate],
|
||||
[setContextRole, cancel, navigate, goTo],
|
||||
);
|
||||
|
||||
// Terminal CTA for the verifier sub-flow — drop the new verifier on the
|
||||
// campaign grid so they can immediately start vouching.
|
||||
const handleVerifierFinish = useCallback(() => {
|
||||
cancel();
|
||||
navigate('/campaigns');
|
||||
}, [cancel, navigate]);
|
||||
|
||||
// Leaving the bio step: publish the assembled kind-0 org profile, then
|
||||
// advance to the statement step. Publishing is best-effort — a failure
|
||||
// surfaces a non-fatal toast and the user still proceeds (they can fix the
|
||||
// profile later from settings), mirroring the InitialSyncGate behavior.
|
||||
const handleBioContinue = useCallback(async () => {
|
||||
try {
|
||||
await publishOrgProfile({ draft: orgDraft, expectedPubkey: signupPubkey });
|
||||
} catch {
|
||||
toast({
|
||||
title: t('onboarding.verifier.publishFailedTitle'),
|
||||
description: t('onboarding.verifier.publishFailedDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
goNextVerifierStep();
|
||||
}, [publishOrgProfile, orgDraft, signupPubkey, toast, t, goNextVerifierStep]);
|
||||
|
||||
// Key generation ----------------------------------------------------------
|
||||
const handleGenerateKey = useCallback(() => {
|
||||
setIsGenerating(true);
|
||||
@@ -214,6 +345,38 @@ function CaptiveOverlay() {
|
||||
onPick={handleRolePick}
|
||||
/>
|
||||
);
|
||||
case 'orgIdentity':
|
||||
// Verifier sub-flow step 1 — organization identity (kind-0).
|
||||
return (
|
||||
<VerifierIdentityStep
|
||||
draft={orgDraft}
|
||||
onChange={patchOrgDraft}
|
||||
onContinue={goNextVerifierStep}
|
||||
/>
|
||||
);
|
||||
case 'orgBio':
|
||||
// Verifier sub-flow step 2 — organization bio (kind-0 about).
|
||||
return (
|
||||
<VerifierBioStep
|
||||
draft={orgDraft}
|
||||
onChange={patchOrgDraft}
|
||||
onContinue={handleBioContinue}
|
||||
isPublishing={isPublishingOrg}
|
||||
/>
|
||||
);
|
||||
case 'orgStatement':
|
||||
// Verifier sub-flow step 3 — publish the verifier statement
|
||||
// (kind 14672), reusing the shared editor.
|
||||
return (
|
||||
<VerifierStatementStep
|
||||
onContinue={goNextVerifierStep}
|
||||
/>
|
||||
);
|
||||
case 'orgVerifyHowto':
|
||||
// Verifier sub-flow step 4 — teach the verify gesture, then finish.
|
||||
return (
|
||||
<VerifierHowtoStep draft={orgDraft} onFinish={handleVerifierFinish} />
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -258,7 +421,16 @@ function CaptiveOverlay() {
|
||||
<div className="flex-1 flex items-start sm:items-center justify-center px-6 pt-16 pb-12">
|
||||
<div
|
||||
key={step}
|
||||
className="w-full max-w-md mx-auto animate-in fade-in slide-in-from-bottom-2 duration-300"
|
||||
className={cn(
|
||||
'w-full mx-auto animate-in fade-in slide-in-from-bottom-2 duration-300',
|
||||
// Bio, statement & how-to steps host a text surface / markdown
|
||||
// editor / tutorial and want a slightly roomier column than the
|
||||
// narrow base screens — but not the full-width 3xl that left the
|
||||
// text boxes and tutorial feeling oversized.
|
||||
step === 'orgBio' || step === 'orgStatement' || step === 'orgVerifyHowto'
|
||||
? 'max-w-xl'
|
||||
: 'max-w-md',
|
||||
)}
|
||||
>
|
||||
{stepBody}
|
||||
</div>
|
||||
@@ -273,7 +445,7 @@ function CaptiveOverlay() {
|
||||
|
||||
interface RoleStepProps {
|
||||
role: OnboardingRole;
|
||||
onPick: (role: 'creator' | 'donor') => void;
|
||||
onPick: (role: 'creator' | 'donor' | 'verifier') => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -310,12 +482,134 @@ function RoleStep({ role, onPick }: RoleStepProps) {
|
||||
selected={role === 'donor'}
|
||||
onClick={() => onPick('donor')}
|
||||
/>
|
||||
<RoleCard
|
||||
icon={<BadgeCheck className="h-5 w-5 md:h-6 md:w-6 text-primary" />}
|
||||
title={t('onboarding.role.verifier.title')}
|
||||
description={t('onboarding.role.verifier.description')}
|
||||
finderNote={t('onboarding.role.verifier.finderNote')}
|
||||
selected={role === 'verifier'}
|
||||
onClick={() => onPick('verifier')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifier sub-flow step 4 — teach the verify gesture with the shared
|
||||
* {@link VerifyTutorial}, then offer the terminal "View campaigns" CTA.
|
||||
*/
|
||||
function VerifierHowtoStep({
|
||||
draft,
|
||||
onFinish,
|
||||
}: {
|
||||
draft: OrgProfileDraft;
|
||||
onFinish: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [hasSeenLoop, setHasSeenLoop] = useState(false);
|
||||
const handleLoopComplete = useCallback(() => setHasSeenLoop(true), []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2 text-center">
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
{t('onboarding.verifier.howto.title')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('onboarding.verifier.howto.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<VerifyTutorial
|
||||
hideHeader
|
||||
bare
|
||||
stacked
|
||||
verifierName={draft.name}
|
||||
verifierPicture={draft.picture}
|
||||
onLoopComplete={handleLoopComplete}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={onFinish}
|
||||
disabled={!hasSeenLoop}
|
||||
className="w-full h-12 text-base rounded-full"
|
||||
>
|
||||
{t('onboarding.verifier.howto.finish')}
|
||||
<ArrowRight className="ml-2 h-4 w-4 rtl:rotate-180" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VerifierStatementStepProps {
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifier sub-flow step 3 — publish the verifier statement (kind 14672).
|
||||
*
|
||||
* 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. Withdrawing happens later from the profile's "How We Verify" card.
|
||||
*/
|
||||
function VerifierStatementStep({
|
||||
onContinue,
|
||||
}: VerifierStatementStepProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const { mutateAsync: setStatement, isPending } = useSetVerifierStatement();
|
||||
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
const handleContinue = useCallback(async () => {
|
||||
try {
|
||||
await setStatement(trimmed);
|
||||
onContinue();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('verifier.errorToast'),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [setStatement, trimmed, toast, t, onContinue]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2 text-center">
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
{t('onboarding.verifier.statement.title')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('onboarding.verifier.statement.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<VerifierStatementEditor
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
|
||||
<Button
|
||||
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')}
|
||||
{!isPending && <ArrowRight className="ml-2 h-4 w-4 rtl:rotate-180" />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RoleCardProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
|
||||
+157
-25
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { NostrMetadata } from '@nostrify/nostrify';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { CheckCircle2, Pencil, Plus, Trash2, ChevronDown, ImagePlus, X as XIcon } from 'lucide-react';
|
||||
import { CheckCircle2, Pencil, Plus, Trash2, ChevronDown, ImagePlus, X as XIcon, Link as LinkIcon } from 'lucide-react';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { BioContent } from '@/components/BioContent';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -33,11 +33,13 @@ function EditableInput({
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
maxLength,
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
placeholder: string;
|
||||
onChange: (v: string) => void;
|
||||
maxLength?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
@@ -45,6 +47,7 @@ function EditableInput({
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={cn(editableBase, 'w-full min-w-0 py-0.5', className)}
|
||||
/>
|
||||
@@ -86,30 +89,111 @@ interface ProfileField {
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared dropdown of image actions used by both the avatar and the banner.
|
||||
* Wraps the provided trigger element and surfaces "Upload file", an optional
|
||||
* "Paste URL", and an optional "Remove" (only shown when the image exists and
|
||||
* a remove handler is wired). Deduplicating this between avatar and banner
|
||||
* keeps the two menus identical and the actions in one place.
|
||||
*/
|
||||
function ImageEditMenu({
|
||||
trigger,
|
||||
hasImage,
|
||||
onUpload,
|
||||
onPasteUrl,
|
||||
onRemove,
|
||||
}: {
|
||||
trigger: React.ReactNode;
|
||||
hasImage: boolean;
|
||||
onUpload: () => void;
|
||||
onPasteUrl?: () => void;
|
||||
onRemove?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={6}>
|
||||
<DropdownMenuItem onClick={onUpload}>
|
||||
<ImagePlus className="size-4 mr-2" />
|
||||
Upload file
|
||||
</DropdownMenuItem>
|
||||
{onPasteUrl && (
|
||||
<DropdownMenuItem onClick={onPasteUrl}>
|
||||
<LinkIcon className="size-4 mr-2" />
|
||||
Paste URL
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasImage && onRemove && (
|
||||
<DropdownMenuItem onClick={onRemove} className="text-destructive focus:text-destructive">
|
||||
<XIcon className="size-4 mr-2" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProfileCardProps {
|
||||
className?: string;
|
||||
pubkey?: string;
|
||||
metadata: Partial<NostrMetadata>;
|
||||
onChange?: (patch: Partial<NostrMetadata>) => void;
|
||||
onPickImage?: (field: 'picture' | 'banner') => void;
|
||||
/**
|
||||
* Called when the user chooses "Paste URL" for an image field. The handler
|
||||
* is expected to read the clipboard, validate the URL, and apply it. When
|
||||
* provided, the banner gains a dropdown menu (matching the avatar) so the
|
||||
* paste action is reachable for both images.
|
||||
*/
|
||||
onPasteUrl?: (field: 'picture' | 'banner') => void;
|
||||
/** Called when user removes their avatar picture. */
|
||||
onRemoveAvatar?: () => void;
|
||||
/** Called when user removes their banner image. */
|
||||
onRemoveBanner?: () => void;
|
||||
/** Show the banner area (default true). When false, only the avatar shows. */
|
||||
showBanner?: boolean;
|
||||
/** Show the avatar area (default true). */
|
||||
showAvatar?: boolean;
|
||||
/** Placeholder for the editable name input. */
|
||||
namePlaceholder?: string;
|
||||
/** Maximum length for the editable name input. */
|
||||
nameMaxLength?: number;
|
||||
/** Show NIP-05 row (default true) */
|
||||
showNip05?: boolean;
|
||||
/** Show NIP-58 badge showcase row (default true). */
|
||||
showBadges?: boolean;
|
||||
/**
|
||||
* Which kind-0 field the editable text slot below the name edits.
|
||||
* - `'about'` (default): the bio textarea.
|
||||
* - `'website'`: a single-line website input, replacing the bio entirely.
|
||||
* - `'none'`: hide the slot entirely (just name).
|
||||
*/
|
||||
bioField?: 'about' | 'website' | 'none';
|
||||
/** Placeholder for the bio textarea when `bioField` is `'about'`. */
|
||||
aboutPlaceholder?: string;
|
||||
/** When provided, render an editable profile fields section below bio */
|
||||
extraFields?: ProfileField[];
|
||||
onExtraFieldsChange?: (fields: ProfileField[]) => void;
|
||||
}
|
||||
|
||||
export function ProfileCard({
|
||||
className,
|
||||
pubkey,
|
||||
metadata,
|
||||
onChange,
|
||||
onPickImage,
|
||||
onPasteUrl,
|
||||
onRemoveAvatar,
|
||||
onRemoveBanner,
|
||||
showBanner = true,
|
||||
showAvatar = true,
|
||||
namePlaceholder = 'Your name',
|
||||
nameMaxLength,
|
||||
showNip05 = true,
|
||||
showBadges = true,
|
||||
bioField = 'about',
|
||||
aboutPlaceholder = 'Write a short bio…',
|
||||
extraFields,
|
||||
onExtraFieldsChange,
|
||||
}: ProfileCardProps) {
|
||||
@@ -138,9 +222,47 @@ export function ProfileCard({
|
||||
onExtraFieldsChange?.((extraFields ?? []).map((f, idx) => idx === i ? { ...f, [key]: val } : f));
|
||||
|
||||
return (
|
||||
<div className="bg-card border rounded-xl overflow-hidden">
|
||||
<div className={cn('bg-card border rounded-xl overflow-hidden', className)}>
|
||||
|
||||
{/* Banner */}
|
||||
{showBanner && (editable && (onPasteUrl || onRemoveBanner) ? (
|
||||
// When a paste or remove action exists, the banner opens the shared
|
||||
// image menu instead of going straight to the file picker.
|
||||
<ImageEditMenu
|
||||
hasImage={!!metadata.banner}
|
||||
onUpload={() => onPickImage?.('banner')}
|
||||
onPasteUrl={onPasteUrl ? () => onPasteUrl('banner') : undefined}
|
||||
onRemove={onRemoveBanner}
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
className="relative block w-full h-36 bg-secondary cursor-pointer group outline-none"
|
||||
style={
|
||||
bannerUrl
|
||||
? { backgroundImage: `url("${bannerUrl}")`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{!metadata.banner && <div className="absolute inset-0 bg-gradient-to-br from-accent/10 via-transparent to-primary/5" />}
|
||||
{!metadata.banner && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Plus className="size-6 text-muted-foreground" strokeWidth={4} />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center">
|
||||
<span className="opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1.5 text-white text-xs font-medium bg-black/50 rounded-full px-3 py-1.5 backdrop-blur-sm">
|
||||
<Pencil className="size-3.5" /> {metadata.banner ? 'Change banner' : 'Add banner'}
|
||||
</span>
|
||||
</div>
|
||||
{metadata.banner && (
|
||||
<div className="absolute bottom-2 right-2 size-7 rounded-full bg-background border border-border shadow-sm flex items-center justify-center transition-opacity">
|
||||
<Pencil className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn('relative h-36 bg-secondary', editable && 'cursor-pointer group')}
|
||||
style={
|
||||
@@ -171,15 +293,20 @@ export function ProfileCard({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Profile info */}
|
||||
<div className="px-4 pb-4">
|
||||
<div className={cn('px-4 pb-4', !showAvatar && (showBanner ? 'pt-3' : 'pt-4'))}>
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="flex justify-between items-start -mt-12 mb-3">
|
||||
{showAvatar && <div className={cn('flex justify-between items-start mb-3', showBanner ? '-mt-12' : 'mt-3')}>
|
||||
{editable ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ImageEditMenu
|
||||
hasImage={!!metadata.picture}
|
||||
onUpload={() => onPickImage?.('picture')}
|
||||
onPasteUrl={onPasteUrl ? () => onPasteUrl('picture') : undefined}
|
||||
onRemove={onRemoveAvatar}
|
||||
trigger={
|
||||
<button type="button" className="relative shrink-0 cursor-pointer group outline-none">
|
||||
<Avatar className="shadow-sm size-24 border-4 border-background">
|
||||
<AvatarImage src={metadata.picture} alt={displayName} className="object-cover" />
|
||||
@@ -196,20 +323,8 @@ export function ProfileCard({
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={6}>
|
||||
<DropdownMenuItem onClick={() => onPickImage?.('picture')}>
|
||||
<ImagePlus className="size-4 mr-2" />
|
||||
Change avatar
|
||||
</DropdownMenuItem>
|
||||
{metadata.picture && (
|
||||
<DropdownMenuItem onClick={() => onRemoveAvatar?.()} className="text-destructive focus:text-destructive">
|
||||
<XIcon className="size-4 mr-2" />
|
||||
Remove avatar
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative shrink-0">
|
||||
<Avatar className="shadow-sm size-24 border-4 border-background">
|
||||
@@ -220,13 +335,14 @@ export function ProfileCard({
|
||||
</Avatar>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Name */}
|
||||
{editable ? (
|
||||
<EditableInput
|
||||
value={metadata.name ?? ''}
|
||||
placeholder="Your name"
|
||||
placeholder={namePlaceholder}
|
||||
maxLength={nameMaxLength}
|
||||
onChange={patch('name')}
|
||||
className="text-xl font-bold"
|
||||
/>
|
||||
@@ -268,12 +384,27 @@ export function ProfileCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bio */}
|
||||
{/* Bio — or, when `bioField` is `'website'`, a website input that
|
||||
takes the bio's place entirely; `'none'` hides the slot. */}
|
||||
{bioField !== 'none' && (
|
||||
<div className="mt-2">
|
||||
{editable ? (
|
||||
{bioField === 'website' ? (
|
||||
editable ? (
|
||||
<EditableInput
|
||||
value={(metadata.website as string) ?? ''}
|
||||
placeholder="https://your-website.com"
|
||||
onChange={patch('website')}
|
||||
className="text-sm"
|
||||
/>
|
||||
) : metadata.website ? (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed truncate">
|
||||
{metadata.website}
|
||||
</p>
|
||||
) : null
|
||||
) : editable ? (
|
||||
<EditableTextarea
|
||||
value={metadata.about ?? ''}
|
||||
placeholder="Write a short bio…"
|
||||
placeholder={aboutPlaceholder}
|
||||
onChange={patch('about')}
|
||||
/>
|
||||
) : metadata.about ? (
|
||||
@@ -282,6 +413,7 @@ export function ProfileCard({
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extra profile fields — collapsible, only when prop provided */}
|
||||
{extraFields !== undefined && (
|
||||
|
||||
@@ -63,6 +63,8 @@ export interface WizardProps {
|
||||
launchNowLabel?: string;
|
||||
onSubmit: (e: FormEvent) => void;
|
||||
onClose: () => void;
|
||||
/** Optional back action for step 1 when there is a meaningful previous flow. */
|
||||
onBackFromFirstStep?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,6 +105,7 @@ export function Wizard({
|
||||
launchNowLabel,
|
||||
onSubmit,
|
||||
onClose,
|
||||
onBackFromFirstStep,
|
||||
}: WizardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState(1);
|
||||
@@ -124,6 +127,7 @@ export function Wizard({
|
||||
const canSubmit = isTerminal
|
||||
? !submitting && !isAdvancing
|
||||
: launchVisible && canAdvance && !submitting && !isAdvancing;
|
||||
const backVisible = step > 1 || !!onBackFromFirstStep;
|
||||
|
||||
const handleAdvance = async () => {
|
||||
if (submitting || isAdvancing || !canAdvance) return;
|
||||
@@ -169,12 +173,18 @@ export function Wizard({
|
||||
</button>
|
||||
|
||||
{/* Top-left back. Mirrors the close button so the user can step
|
||||
back through the wizard without scrolling to the footer. Only
|
||||
rendered from step 2 onward — step 1's escape route is the X. */}
|
||||
{step > 1 && (
|
||||
back through the wizard without scrolling to the footer. Step 1
|
||||
only renders it when the host provides an external back target. */}
|
||||
{backVisible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep((s) => Math.max(s - 1, 1))}
|
||||
onClick={() => {
|
||||
if (step === 1) {
|
||||
onBackFromFirstStep?.();
|
||||
} else {
|
||||
setStep((s) => Math.max(s - 1, 1));
|
||||
}
|
||||
}}
|
||||
disabled={submitting || isAdvancing}
|
||||
aria-label={t('common.back')}
|
||||
className="absolute left-4 top-4 z-20 sm:left-6 sm:top-6 inline-flex h-9 w-9 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-50"
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ProfileCard } from '@/components/ProfileCard';
|
||||
import { ImageCropDialog } from '@/components/ImageCropDialog';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { fetchImageAsFile } from '@/lib/proxyImageUrl';
|
||||
|
||||
/**
|
||||
* The mutable kind-0 identity fields this editor manages. The host owns the
|
||||
* draft; the editor only emits patches.
|
||||
*/
|
||||
export interface ProfileIdentityDraft {
|
||||
/** kind-0 `name` (and `display_name`). */
|
||||
name: string;
|
||||
/** kind-0 `picture` (avatar) — a Blossom URL. */
|
||||
picture: string;
|
||||
/** kind-0 `banner` — a Blossom URL. */
|
||||
banner: string;
|
||||
/** kind-0 `website`. Used when `bioField` is `'website'`. */
|
||||
website: string;
|
||||
/** kind-0 `about` (bio). Used when `bioField` is `'about'`. */
|
||||
about: string;
|
||||
}
|
||||
|
||||
/** Which image field the crop dialog is currently editing. */
|
||||
type CropField = 'picture' | 'banner';
|
||||
|
||||
/** Aspect ratios: circular avatar crops square; banner crops 3:1. */
|
||||
const CROP_ASPECT: Record<CropField, number> = {
|
||||
picture: 1,
|
||||
banner: 3,
|
||||
};
|
||||
|
||||
interface ProfileIdentityEditorProps {
|
||||
draft: ProfileIdentityDraft;
|
||||
onChange: (patch: Partial<ProfileIdentityDraft>) => void;
|
||||
/**
|
||||
* Which kind-0 field the editable text slot below the name edits:
|
||||
* `'website'` for organizations, `'about'` (bio) for campaigners, or
|
||||
* `'none'` to show just the name.
|
||||
*/
|
||||
bioField: 'website' | 'about' | 'none';
|
||||
/** Placeholder for the bio textarea when `bioField` is `'about'`. */
|
||||
aboutPlaceholder?: string;
|
||||
/** Show the banner area (default true). */
|
||||
showBanner?: boolean;
|
||||
/** Show the avatar area (default true). */
|
||||
showAvatar?: boolean;
|
||||
/** Placeholder for the editable name input. */
|
||||
namePlaceholder?: string;
|
||||
/** Maximum length for the editable name input. */
|
||||
nameMaxLength?: number;
|
||||
/** Notifies the host with the Blossom/NIP-94 tags from a completed image upload. */
|
||||
onImageUploadComplete?: (field: 'picture' | 'banner', nip94Tags: string[][]) => void;
|
||||
/** Notifies the host of upload progress so it can gate its primary button. */
|
||||
onUploadingChange?: (uploading: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared editable identity card: banner, circular avatar, inline name, and a
|
||||
* configurable bio/website slot, with the full upload → crop → Blossom flow
|
||||
* (local file picker + paste-URL) and image removal. Used by the verifier
|
||||
* (organization) onboarding step and the campaign-creator wizard so both
|
||||
* surfaces present an identical identity-editing experience.
|
||||
*
|
||||
* Nothing is published here; patches flow back through `onChange` and the
|
||||
* host decides when to persist.
|
||||
*/
|
||||
export function ProfileIdentityEditor({
|
||||
draft,
|
||||
onChange,
|
||||
bioField,
|
||||
aboutPlaceholder,
|
||||
showBanner = true,
|
||||
showAvatar = true,
|
||||
namePlaceholder,
|
||||
nameMaxLength,
|
||||
onImageUploadComplete,
|
||||
onUploadingChange,
|
||||
className,
|
||||
}: ProfileIdentityEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const { mutateAsync: uploadFile } = useUploadFile();
|
||||
const { config } = useAppContext();
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const pendingFieldRef = useRef<CropField | null>(null);
|
||||
const [cropState, setCropState] = useState<{
|
||||
field: CropField;
|
||||
imageSrc: string;
|
||||
objectUrl: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// Open the OS file picker for the requested image field.
|
||||
const handlePickImage = useCallback((field: CropField) => {
|
||||
pendingFieldRef.current = field;
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
// Read an image URL from the clipboard, validate it, then fetch its bytes
|
||||
// (through the image proxy so the request is CORS-safe) into an object URL.
|
||||
// From there it joins the exact same crop → Blossom-upload flow as a local
|
||||
// file — the cropper only ever sees a same-origin `blob:` source, so the
|
||||
// canvas never taints and arbitrary remote hosts / SVGs work.
|
||||
const handlePasteUrl = useCallback(
|
||||
async (field: CropField) => {
|
||||
let text = '';
|
||||
try {
|
||||
text = (await navigator.clipboard.readText()).trim();
|
||||
} catch {
|
||||
toast({
|
||||
title: t('onboarding.verifier.identity.clipboardFailed'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const url = sanitizeUrl(text);
|
||||
if (!url) {
|
||||
toast({
|
||||
title: t('onboarding.verifier.identity.pasteUrlInvalid'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let file: File;
|
||||
try {
|
||||
file = await fetchImageAsFile(
|
||||
url,
|
||||
config.imageProxy,
|
||||
field === 'banner' ? 1500 : 1024,
|
||||
);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('onboarding.verifier.identity.pasteUrlFetchFailed'),
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setCropState({
|
||||
field,
|
||||
imageSrc: URL.createObjectURL(file),
|
||||
objectUrl: true,
|
||||
});
|
||||
},
|
||||
[config.imageProxy, t, toast],
|
||||
);
|
||||
|
||||
const handleFileChosen = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
const field = pendingFieldRef.current;
|
||||
pendingFieldRef.current = null;
|
||||
if (!file || !field) return;
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast({ title: t('onboarding.profile.imageOnly'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast({ title: t('onboarding.profile.imageTooLarge'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
const imageSrc = URL.createObjectURL(file);
|
||||
setCropState({ field, imageSrc, objectUrl: true });
|
||||
},
|
||||
[t, toast],
|
||||
);
|
||||
|
||||
const handleCropCancel = useCallback(() => {
|
||||
if (cropState?.objectUrl) URL.revokeObjectURL(cropState.imageSrc);
|
||||
setCropState(null);
|
||||
}, [cropState]);
|
||||
|
||||
const handleCropConfirm = useCallback(
|
||||
async (croppedFile: File) => {
|
||||
if (!cropState) return;
|
||||
const { field, imageSrc, objectUrl } = cropState;
|
||||
if (objectUrl) URL.revokeObjectURL(imageSrc);
|
||||
setCropState(null);
|
||||
onUploadingChange?.(true);
|
||||
try {
|
||||
const tags = await uploadFile(croppedFile);
|
||||
const url = tags[0]?.[1];
|
||||
if (url) {
|
||||
onChange({ [field]: url });
|
||||
onImageUploadComplete?.(field, tags);
|
||||
}
|
||||
} catch {
|
||||
toast({ title: t('onboarding.profile.uploadFailed'), variant: 'destructive' });
|
||||
} finally {
|
||||
onUploadingChange?.(false);
|
||||
}
|
||||
},
|
||||
[cropState, uploadFile, onChange, onImageUploadComplete, onUploadingChange, t, toast],
|
||||
);
|
||||
|
||||
const handleCropError = useCallback(
|
||||
(error: unknown) => {
|
||||
toast({
|
||||
title: t('onboarding.profile.uploadFailed'),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
[t, toast],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChosen}
|
||||
/>
|
||||
|
||||
{cropState && (
|
||||
<ImageCropDialog
|
||||
open
|
||||
imageSrc={cropState.imageSrc}
|
||||
aspect={CROP_ASPECT[cropState.field]}
|
||||
title={
|
||||
cropState.field === 'picture'
|
||||
? t('onboarding.verifier.identity.cropAvatar')
|
||||
: t('onboarding.verifier.identity.cropBanner')
|
||||
}
|
||||
maxOutputSize={cropState.field === 'banner' ? 1500 : 512}
|
||||
onCancel={handleCropCancel}
|
||||
onCrop={handleCropConfirm}
|
||||
onError={handleCropError}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ProfileCard
|
||||
className="rounded-none border-0 bg-transparent"
|
||||
metadata={{
|
||||
name: draft.name,
|
||||
website: draft.website,
|
||||
about: draft.about,
|
||||
picture: draft.picture,
|
||||
banner: draft.banner,
|
||||
}}
|
||||
onChange={(patch) => {
|
||||
if (patch.name !== undefined) onChange({ name: patch.name });
|
||||
if (patch.website !== undefined) onChange({ website: patch.website as string });
|
||||
if (patch.about !== undefined) onChange({ about: patch.about });
|
||||
}}
|
||||
onPickImage={handlePickImage}
|
||||
onPasteUrl={handlePasteUrl}
|
||||
onRemoveAvatar={() => onChange({ picture: '' })}
|
||||
onRemoveBanner={() => onChange({ banner: '' })}
|
||||
bioField={bioField}
|
||||
aboutPlaceholder={aboutPlaceholder}
|
||||
showBanner={showBanner}
|
||||
showAvatar={showAvatar}
|
||||
namePlaceholder={namePlaceholder}
|
||||
nameMaxLength={nameMaxLength}
|
||||
showNip05={false}
|
||||
showBadges={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProfileIdentityEditor;
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowRight, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { OrgProfileDraft } from '@/components/onboarding/VerifierIdentityStep';
|
||||
|
||||
interface VerifierBioStepProps {
|
||||
draft: OrgProfileDraft;
|
||||
onChange: (patch: Partial<OrgProfileDraft>) => void;
|
||||
onContinue: () => void;
|
||||
/** True while the kind-0 profile is being published on continue. */
|
||||
isPublishing?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifier sub-flow step 2 — the organization's bio (kind-0 `about`).
|
||||
*
|
||||
* A single required textarea. The bio is added to the shared draft;
|
||||
* publishing of the assembled kind-0 profile happens when this step's
|
||||
* continue handler runs (wired in the gate).
|
||||
*/
|
||||
export function VerifierBioStep({
|
||||
draft,
|
||||
onChange,
|
||||
onContinue,
|
||||
isPublishing = false,
|
||||
}: VerifierBioStepProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const bioProvided = draft.about.trim().length > 0;
|
||||
const canContinue = bioProvided && !isPublishing;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2 text-center">
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
{t('onboarding.verifier.bio.title')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('onboarding.verifier.bio.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Textarea
|
||||
id="verifier-org-bio"
|
||||
value={draft.about}
|
||||
onChange={(e) => {
|
||||
onChange({ about: e.target.value });
|
||||
// Auto-grow: reset then size to content so the box expands
|
||||
// downward as the user types instead of scrolling internally.
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
placeholder={t('onboarding.verifier.bio.placeholder')}
|
||||
className={cn(
|
||||
'min-h-[200px] w-full resize-none overflow-hidden p-3',
|
||||
'text-lg leading-7 md:text-lg',
|
||||
// Match the muted, borderless look of the "Your name" field on
|
||||
// the previous identity step (ProfileCard's editable inputs).
|
||||
'rounded-lg border-2 border-transparent bg-muted/40',
|
||||
'hover:bg-muted/60 hover:border-border',
|
||||
'focus-visible:bg-transparent focus-visible:border-primary focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
'placeholder:text-muted-foreground/40 transition-colors duration-150',
|
||||
)}
|
||||
aria-required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={onContinue}
|
||||
disabled={!canContinue}
|
||||
className={cn('w-full h-12 text-base rounded-full')}
|
||||
>
|
||||
{isPublishing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('onboarding.verifier.bio.publishing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t('common.continue')}
|
||||
<ArrowRight className="ml-2 h-4 w-4 rtl:rotate-180" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VerifierBioStep;
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowRight, Loader2 } from 'lucide-react';
|
||||
|
||||
import { ProfileIdentityEditor } from '@/components/onboarding/ProfileIdentityEditor';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* The mutable draft of the organization's kind-0 profile, shared across the
|
||||
* verifier sub-flow steps (identity here, bio next) and published once at
|
||||
* the end. Held by the captive overlay so back-navigation preserves entries.
|
||||
*/
|
||||
export interface OrgProfileDraft {
|
||||
/** Maps to kind-0 `name` (and `display_name`). */
|
||||
name: string;
|
||||
/** Maps to kind-0 `website`. */
|
||||
website: string;
|
||||
/** Maps to kind-0 `picture` (avatar) — a Blossom URL. */
|
||||
picture: string;
|
||||
/** Maps to kind-0 `banner` — a Blossom URL. */
|
||||
banner: string;
|
||||
/** Maps to kind-0 `about` (collected in the bio step). */
|
||||
about: string;
|
||||
}
|
||||
|
||||
interface VerifierIdentityStepProps {
|
||||
draft: OrgProfileDraft;
|
||||
onChange: (patch: Partial<OrgProfileDraft>) => void;
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifier sub-flow step 1 — the organization's identity.
|
||||
*
|
||||
* Wraps the shared {@link ProfileIdentityEditor} (circular avatar,
|
||||
* rectangular banner, inline name, and a website field that replaces the bio
|
||||
* slot). Avatar and name are required; banner and website are optional. When
|
||||
* a website is entered, it must be a well-formed `https:` URL.
|
||||
*
|
||||
* Nothing is published here; the draft is published as a single kind-0 event
|
||||
* at the end of the sub-flow, so stepping back and forth never republishes.
|
||||
*/
|
||||
export function VerifierIdentityStep({
|
||||
draft,
|
||||
onChange,
|
||||
onContinue,
|
||||
}: VerifierIdentityStepProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(patch: Partial<OrgProfileDraft>) => onChange(patch),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// ── Continue gating ──────────────────────────────────────────────────────
|
||||
// Avatar + name are required; banner is optional. Website is optional too,
|
||||
// but if entered it must be a valid https URL.
|
||||
const nameProvided = draft.name.trim().length > 0;
|
||||
const avatarProvided = draft.picture.trim().length > 0;
|
||||
const websiteTouched = draft.website.trim().length > 0;
|
||||
const websiteValid = !websiteTouched || !!sanitizeUrl(draft.website.trim());
|
||||
const canContinue = nameProvided && avatarProvided && websiteValid && !isUploading;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2 text-center">
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
{t('onboarding.verifier.identity.title')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('onboarding.verifier.identity.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ProfileIdentityEditor
|
||||
className={cn(isUploading && 'opacity-50 pointer-events-none')}
|
||||
draft={draft}
|
||||
onChange={handleChange}
|
||||
bioField="website"
|
||||
onUploadingChange={setIsUploading}
|
||||
/>
|
||||
|
||||
{/* Website is optional, but if entered it must be a valid https URL. */}
|
||||
{websiteTouched && !websiteValid && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t('onboarding.verifier.identity.websiteInvalid')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isUploading && (
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{t('onboarding.verifier.identity.uploading')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={onContinue}
|
||||
disabled={!canContinue}
|
||||
className="w-full h-12 text-base rounded-full"
|
||||
>
|
||||
{t('common.continue')}
|
||||
<ArrowRight className="ml-2 h-4 w-4 rtl:rotate-180" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VerifierIdentityStep;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { MilkdownEditor } from '@/components/markdown/MilkdownEditor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
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;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The verifier-statement (kind 14672) markdown editing surface.
|
||||
*
|
||||
* 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. Withdrawing happens from the profile's
|
||||
* "How We Verify" card, not here.
|
||||
*/
|
||||
export function VerifierStatementEditor({
|
||||
value,
|
||||
onChange,
|
||||
onHydrated,
|
||||
className,
|
||||
}: VerifierStatementEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { statement, isLoading } = useVerifierStatement(user?.pubkey);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated && !isLoading) {
|
||||
onChange(statement ?? '');
|
||||
onHydrated?.(statement ?? '');
|
||||
setHydrated(true);
|
||||
}
|
||||
}, [hydrated, isLoading, statement, onChange, onHydrated]);
|
||||
|
||||
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 (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Muted, borderless WYSIWYG markdown editor that matches the "Tell us
|
||||
about your organization" bio box on the previous step — same muted
|
||||
fill, no border until focus, and the same min height. */}
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border-2 border-transparent bg-muted/40 overflow-hidden transition-colors duration-150',
|
||||
'hover:bg-muted/60 hover:border-border',
|
||||
'focus-within:bg-transparent focus-within:border-primary',
|
||||
)}
|
||||
>
|
||||
<MilkdownEditor
|
||||
className="verifier-statement-editor"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={t('verifier.placeholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VerifierStatementEditor;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useReducer, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
BadgeCheck,
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
|
||||
/**
|
||||
* An animated, interactive tutorial shown on /organizations once an
|
||||
* An animated, interactive tutorial shown on /verify once an
|
||||
* organization has published its verifier statement. It demonstrates the
|
||||
* exact gesture a verifier uses to vouch for a campaign: tapping the
|
||||
* three-dots (kebab) button on a campaign card and choosing
|
||||
@@ -17,46 +19,25 @@ import { cn } from '@/lib/utils';
|
||||
*
|
||||
* The component renders a faithful mock campaign card and drives a small
|
||||
* three-step state machine that mimics a cursor opening the kebab menu and
|
||||
* clicking the verify item. It auto-advances on a timer, loops, and exposes
|
||||
* clickable step dots so users can scrub. Motion is fully gated behind
|
||||
* `motion-safe:` / a `prefers-reduced-motion` check — with reduced motion the
|
||||
* cursor and looping are disabled and the final state is shown statically.
|
||||
* clicking the verify item. It auto-advances on a timer and loops forever so
|
||||
* users learn the gesture purely by watching. The cursor is gated behind a
|
||||
* `prefers-reduced-motion` check; the UI state replay itself is a simple
|
||||
* visibility sequence so the instruction still works without cursor motion.
|
||||
*/
|
||||
|
||||
type Phase = 'idle' | 'menuOpen' | 'verified';
|
||||
|
||||
const PHASE_ORDER: Phase[] = ['idle', 'menuOpen', 'verified'];
|
||||
|
||||
// How long each phase is held before auto-advancing (ms).
|
||||
const PHASE_DURATION: Record<Phase, number> = {
|
||||
idle: 2200,
|
||||
menuOpen: 2600,
|
||||
verified: 3000,
|
||||
const NEXT_PHASE: Record<Phase, Phase> = {
|
||||
idle: 'menuOpen',
|
||||
menuOpen: 'verified',
|
||||
verified: 'idle',
|
||||
};
|
||||
|
||||
interface State {
|
||||
phase: Phase;
|
||||
/** Bumps on every manual interaction to pause autoplay briefly. */
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
type Action =
|
||||
| { type: 'advance' }
|
||||
| { type: 'goto'; phase: Phase };
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case 'advance': {
|
||||
const idx = PHASE_ORDER.indexOf(state.phase);
|
||||
const next = PHASE_ORDER[(idx + 1) % PHASE_ORDER.length];
|
||||
return { phase: next, paused: false };
|
||||
}
|
||||
case 'goto':
|
||||
return { phase: action.phase, paused: true };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
const PHASE_DELAY: Record<Phase, number> = {
|
||||
idle: 2000,
|
||||
menuOpen: 2000,
|
||||
verified: 1200,
|
||||
};
|
||||
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
const ref = useRef(false);
|
||||
@@ -66,64 +47,67 @@ function usePrefersReducedMotion(): boolean {
|
||||
return ref.current;
|
||||
}
|
||||
|
||||
export function VerifyTutorial({ className }: { className?: string }) {
|
||||
interface VerifyTutorialProps {
|
||||
className?: string;
|
||||
/** Hide the component's internal eyebrow/title/lede header (when the host
|
||||
* already provides one). */
|
||||
hideHeader?: boolean;
|
||||
/** Drop the bordered card chrome so it blends into the surrounding page. */
|
||||
bare?: boolean;
|
||||
/** Let the demo span the full available width in stacked onboarding flows. */
|
||||
stacked?: boolean;
|
||||
/**
|
||||
* When provided, the demo card's verified badge shows this organization's
|
||||
* avatar + name (the preview a verifier just configured) instead of the
|
||||
* generic "Verified by you" label — so the onboarding flow previews how the
|
||||
* org's own badge will surface on a campaign.
|
||||
*/
|
||||
verifierName?: string;
|
||||
verifierPicture?: string;
|
||||
/** Fired after the first full replay cycle completes and resets. */
|
||||
onLoopComplete?: () => void;
|
||||
}
|
||||
|
||||
export function VerifyTutorial({
|
||||
className,
|
||||
hideHeader = false,
|
||||
bare = false,
|
||||
stacked = false,
|
||||
verifierName,
|
||||
verifierPicture,
|
||||
onLoopComplete,
|
||||
}: VerifyTutorialProps) {
|
||||
const { t } = useTranslation();
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
|
||||
const [state, dispatch] = useReducer(reducer, {
|
||||
phase: (reducedMotion ? 'verified' : 'idle') as Phase,
|
||||
paused: false,
|
||||
});
|
||||
const [phase, setPhase] = useState<Phase>('idle');
|
||||
|
||||
// Autoplay timer. Disabled under reduced motion, or while paused after a
|
||||
// manual interaction (resumes on the next phase change).
|
||||
// Simple visibility loop: start with the card, reveal the menu after 2s,
|
||||
// reveal the badge after another 2s, then pause briefly and reset.
|
||||
useEffect(() => {
|
||||
if (reducedMotion || state.paused) return;
|
||||
const id = window.setTimeout(
|
||||
() => dispatch({ type: 'advance' }),
|
||||
PHASE_DURATION[state.phase],
|
||||
);
|
||||
const id = window.setTimeout(() => {
|
||||
if (phase === 'verified') {
|
||||
onLoopComplete?.();
|
||||
}
|
||||
setPhase((prev) => NEXT_PHASE[prev]);
|
||||
}, PHASE_DELAY[phase]);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [state.phase, state.paused, reducedMotion]);
|
||||
}, [phase, onLoopComplete]);
|
||||
|
||||
// When a user scrubs (paused), resume autoplay after a grace period.
|
||||
useEffect(() => {
|
||||
if (!state.paused || reducedMotion) return;
|
||||
const id = window.setTimeout(
|
||||
() => dispatch({ type: 'advance' }),
|
||||
PHASE_DURATION[state.phase] + 1500,
|
||||
);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [state.paused, state.phase, reducedMotion]);
|
||||
|
||||
const phaseIndex = PHASE_ORDER.indexOf(state.phase);
|
||||
const menuVisible = state.phase === 'menuOpen' || state.phase === 'verified';
|
||||
const verified = state.phase === 'verified';
|
||||
|
||||
const stepCopy = [
|
||||
{
|
||||
title: t('organizations.tutorial.steps.open.title'),
|
||||
body: t('organizations.tutorial.steps.open.body'),
|
||||
},
|
||||
{
|
||||
title: t('organizations.tutorial.steps.verify.title'),
|
||||
body: t('organizations.tutorial.steps.verify.body'),
|
||||
},
|
||||
{
|
||||
title: t('organizations.tutorial.steps.confirm.title'),
|
||||
body: t('organizations.tutorial.steps.confirm.body'),
|
||||
},
|
||||
];
|
||||
const menuVisible = phase === 'menuOpen';
|
||||
const verified = phase === 'verified';
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
!bare &&
|
||||
'rounded-2xl border border-primary/20 bg-gradient-to-br from-primary/[0.07] via-background to-background p-6 sm:p-8 shadow-sm',
|
||||
className,
|
||||
)}
|
||||
aria-labelledby="verify-tutorial-title"
|
||||
>
|
||||
{/* Header */}
|
||||
{!hideHeader && (
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 mb-8">
|
||||
<div className="max-w-md">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs font-semibold tracking-widest uppercase text-primary mb-2">
|
||||
@@ -141,113 +125,120 @@ export function VerifyTutorial({ className }: { className?: string }) {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-2 lg:items-center">
|
||||
{/* ── Left: animated mock campaign card ───────────────────────── */}
|
||||
<DemoStage
|
||||
phaseIndex={phaseIndex}
|
||||
phase={phase}
|
||||
menuVisible={menuVisible}
|
||||
verified={verified}
|
||||
reducedMotion={reducedMotion}
|
||||
fullWidth={stacked}
|
||||
verifierName={verifierName}
|
||||
verifierPicture={verifierPicture}
|
||||
/>
|
||||
|
||||
{/* ── Right: step list, synced to the animation ───────────────── */}
|
||||
<ol className="space-y-3">
|
||||
{stepCopy.map((step, i) => {
|
||||
const active = i === phaseIndex;
|
||||
const done = i < phaseIndex;
|
||||
return (
|
||||
<li key={step.title}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: 'goto', phase: PHASE_ORDER[i] })}
|
||||
aria-current={active ? 'step' : undefined}
|
||||
className={cn(
|
||||
'group flex w-full items-start gap-4 rounded-xl border p-4 text-left transition-all',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60',
|
||||
active
|
||||
? 'border-primary/40 bg-primary/5 shadow-sm'
|
||||
: 'border-border/60 bg-background hover:border-primary/30 hover:bg-muted/40',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-8 shrink-0 items-center justify-center rounded-full text-sm font-bold transition-colors',
|
||||
done
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: active
|
||||
? 'bg-primary/15 text-primary ring-2 ring-primary/40'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{done ? <BadgeCheck className="size-4" /> : i + 1}
|
||||
</span>
|
||||
<span className="space-y-1">
|
||||
<span
|
||||
className={cn(
|
||||
'block text-sm font-semibold leading-snug',
|
||||
active ? 'text-foreground' : 'text-foreground/90',
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</span>
|
||||
<span className="block text-sm text-muted-foreground leading-relaxed">
|
||||
{step.body}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── The animated mock card ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A real published campaign (kind 33863) used as the demo subject so the
|
||||
* tutorial mirrors an actual card rather than invented placeholder copy.
|
||||
* Static by design — the tutorial is purely illustrative, so we read the
|
||||
* fields directly instead of fetching the event.
|
||||
*/
|
||||
const DEMO_CAMPAIGN = {
|
||||
title: 'Agora App Development Fund',
|
||||
organizer: 'Team Soapbox',
|
||||
organizerPicture:
|
||||
'https://blossom.primal.net/e93f617f8331509acdddde3df0c1cd23cda1803d92c70815fc96e2d5f8d48ac8.png',
|
||||
story: 'Help fund the development of Agora!',
|
||||
banner:
|
||||
'https://blossom.primal.net/aade02e86584a7ab269550992d0266bae31059a34e6e08fddba1f6f5acb6e7d6.jpg',
|
||||
goalLabel: '$1,000',
|
||||
raisedLabel: '$670',
|
||||
pct: 67,
|
||||
} as const;
|
||||
|
||||
interface DemoStageProps {
|
||||
phaseIndex: number;
|
||||
phase: Phase;
|
||||
menuVisible: boolean;
|
||||
verified: boolean;
|
||||
reducedMotion: boolean;
|
||||
/** Span the full container width instead of the narrow `max-w-sm` card. */
|
||||
fullWidth?: boolean;
|
||||
/** Optional verifier identity to preview in the badge (see VerifyTutorial). */
|
||||
verifierName?: string;
|
||||
verifierPicture?: string;
|
||||
}
|
||||
|
||||
function DemoStage({
|
||||
phaseIndex,
|
||||
phase,
|
||||
menuVisible,
|
||||
verified,
|
||||
reducedMotion,
|
||||
fullWidth = false,
|
||||
verifierName,
|
||||
verifierPicture,
|
||||
}: DemoStageProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto w-full max-w-sm select-none" aria-hidden="true">
|
||||
{/* Mock campaign card */}
|
||||
<div className="overflow-hidden rounded-2xl border border-border/60 bg-card shadow-md">
|
||||
{/* Banner */}
|
||||
<div className="relative h-40 bg-gradient-to-br from-sky-500/80 via-cyan-500/70 to-emerald-500/80">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 opacity-30 mix-blend-overlay"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'radial-gradient(circle at 30% 30%, rgba(255,255,255,0.5), transparent 45%)',
|
||||
}}
|
||||
/>
|
||||
// The badge replicates the live overlay `CampaignVerificationBadge`
|
||||
// (dark translucent pill, single ring-bordered avatar, sky check) so the
|
||||
// preview matches exactly how a verification surfaces on a real card.
|
||||
const badgePicture = sanitizeUrl(verifierPicture);
|
||||
const verifierInitials =
|
||||
(verifierName?.trim() || '')
|
||||
.slice(0, 2)
|
||||
.toUpperCase() || '?';
|
||||
|
||||
{/* Verified badge (top-left) — appears in the final phase */}
|
||||
const bannerUrl = sanitizeUrl(DEMO_CAMPAIGN.banner);
|
||||
const organizerPicture = sanitizeUrl(DEMO_CAMPAIGN.organizerPicture);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-3 top-3 flex items-center gap-1.5 rounded-full bg-background/90 px-2.5 py-1 text-xs font-semibold text-foreground shadow-sm backdrop-blur transition-all duration-500',
|
||||
'relative w-full select-none',
|
||||
fullWidth ? 'mx-0' : 'mx-auto max-w-sm',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Mock campaign card — mirrors CampaignCard's structure. */}
|
||||
<div className="overflow-hidden rounded-2xl border border-border/60 bg-card shadow-md">
|
||||
{/* Banner */}
|
||||
<div
|
||||
className="relative h-40 bg-gradient-to-br from-sky-500/80 via-cyan-500/70 to-emerald-500/80 bg-cover bg-center"
|
||||
style={bannerUrl ? { backgroundImage: `url("${bannerUrl}")` } : undefined}
|
||||
>
|
||||
{/* Top scrim for badge legibility — as on the real card. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-x-0 top-0 h-16 bg-gradient-to-b from-black/30 to-transparent"
|
||||
/>
|
||||
|
||||
{/* Verified badge (top-left) — appears in the final phase. A faithful
|
||||
copy of the live overlay CampaignVerificationBadge for a single
|
||||
verifier: the org's avatar + sky check, no count text. */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-3 top-3 z-10 inline-flex items-center gap-1 rounded-full bg-black/40 px-1.5 py-1 text-white backdrop-blur-md transition-all duration-500',
|
||||
verified
|
||||
? 'opacity-100 translate-y-0'
|
||||
: 'opacity-0 -translate-y-1 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<BadgeCheck className="size-4 text-primary" />
|
||||
{t('organizations.tutorial.demo.verifiedBadge')}
|
||||
<span className="flex items-center -space-x-2">
|
||||
<Avatar className="size-6 ring-2 ring-background">
|
||||
{badgePicture && <AvatarImage src={badgePicture} alt="" className="object-cover" />}
|
||||
<AvatarFallback className="bg-secondary text-[9px] text-secondary-foreground">
|
||||
{verifierInitials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</span>
|
||||
<span className="ml-0.5 inline-flex items-center gap-1 pr-1 text-xs font-semibold">
|
||||
<BadgeCheck className="size-4 text-sky-300" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Three-dots button (top-right) */}
|
||||
@@ -255,7 +246,7 @@ function DemoStage({
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-8 items-center justify-center rounded-md bg-background/80 text-muted-foreground backdrop-blur transition-all duration-300',
|
||||
phaseIndex === 0 &&
|
||||
phase === 'idle' &&
|
||||
!reducedMotion &&
|
||||
'motion-safe:animate-pulse ring-2 ring-primary/60',
|
||||
menuVisible && 'bg-background text-foreground ring-2 ring-primary/50',
|
||||
@@ -276,7 +267,7 @@ function DemoStage({
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-sm px-2 py-2 text-sm font-medium transition-colors',
|
||||
phaseIndex >= 1
|
||||
menuVisible
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-foreground',
|
||||
)}
|
||||
@@ -291,23 +282,39 @@ function DemoStage({
|
||||
{/* Card body */}
|
||||
<div className="space-y-3 p-4">
|
||||
<div>
|
||||
<p className="font-semibold leading-snug">
|
||||
{t('organizations.tutorial.demo.campaignTitle')}
|
||||
<p className="font-semibold leading-snug truncate">
|
||||
{DEMO_CAMPAIGN.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('organizations.tutorial.demo.campaignOrganizer')}
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{DEMO_CAMPAIGN.story}
|
||||
</p>
|
||||
</div>
|
||||
{/* Fake progress bar */}
|
||||
{/* Progress — mirrors CampaignProgress (bar + raised / goal). */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full w-2/3 rounded-full bg-primary" />
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-foreground/15">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${DEMO_CAMPAIGN.pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>0.45 BTC</span>
|
||||
<span>67%</span>
|
||||
<div className="flex items-baseline justify-between gap-2 text-sm">
|
||||
<span className="font-semibold">{DEMO_CAMPAIGN.raisedLabel}</span>
|
||||
<span className="text-muted-foreground">of {DEMO_CAMPAIGN.goalLabel} goal</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Organizer footer — mirrors CampaignCard's AuthorByline row. */}
|
||||
<div className="flex items-center gap-2 border-t border-border/60 pt-3 text-xs text-muted-foreground">
|
||||
<Avatar className="size-5">
|
||||
{organizerPicture && <AvatarImage src={organizerPicture} alt="" className="object-cover" />}
|
||||
<AvatarFallback className="bg-secondary text-[9px] text-secondary-foreground">
|
||||
{DEMO_CAMPAIGN.organizer.slice(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate font-medium text-foreground/80">
|
||||
{DEMO_CAMPAIGN.organizer}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -317,12 +324,13 @@ function DemoStage({
|
||||
className={cn(
|
||||
'pointer-events-none absolute z-30 transition-all duration-700 ease-out',
|
||||
// idle → hover the kebab (top-right); menuOpen/verified → hover the verify item
|
||||
phaseIndex === 0
|
||||
phase === 'idle'
|
||||
? 'right-4 top-5'
|
||||
: 'right-8 top-[4.5rem]',
|
||||
)}
|
||||
>
|
||||
<MousePointer2
|
||||
key={phase}
|
||||
className={cn(
|
||||
'size-6 fill-foreground text-background drop-shadow-md transition-transform',
|
||||
'motion-safe:animate-tutorial-tap',
|
||||
|
||||
@@ -31,7 +31,6 @@ import { Nip05Badge } from '@/components/Nip05Badge';
|
||||
import { PledgeCard } from '@/components/PledgeCard';
|
||||
import { ProfileReactionButton } from '@/components/ProfileReactionButton';
|
||||
import { OrganizationsAllDialog } from '@/components/profile/OrganizationsAllDialog';
|
||||
import { ProfileVerifierSection } from '@/components/profile/ProfileVerifierSection';
|
||||
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
|
||||
import { useProfileOrganizations, type ProfileOrganization } from '@/hooks/useProfileOrganizations';
|
||||
import type { ProfileCampaignStats } from '@/hooks/useProfileCampaignStats';
|
||||
@@ -390,11 +389,6 @@ export function ProfileOverviewSections({
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-5', className)}>
|
||||
{/* Verifier statement (kind 14672) — surfaced first so donors can
|
||||
immediately see how this account verifies campaigns. Renders
|
||||
nothing when the profile has not published a statement. */}
|
||||
<ProfileVerifierSection pubkey={pubkey} />
|
||||
|
||||
{/* Profile fields (rendered upstream) — placed first so the
|
||||
profile's own freeform metadata (links, addresses, etc.) is
|
||||
the first thing visitors read, ahead of campaigns/orgs. */}
|
||||
|
||||
@@ -2,27 +2,33 @@ import { useTranslation } from 'react-i18next';
|
||||
import { BadgeCheck } from 'lucide-react';
|
||||
|
||||
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
|
||||
import { ProfileVerifierSection } from '@/components/profile/ProfileVerifierSection';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { useVerifiedCampaigns } from '@/hooks/useVerifiedCampaigns';
|
||||
|
||||
interface ProfileVerifiedTabProps {
|
||||
pubkey: string;
|
||||
displayName: string;
|
||||
isOwnProfile?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid of campaigns this profile has verified — resolved from the
|
||||
* account's own `agora.verified` (kind 1985) labels via
|
||||
* {@link useVerifiedCampaigns}. Surfaced as the default tab for verifier
|
||||
* profiles so visitors immediately see what the organization stands behind.
|
||||
* The profile's verification tab: the account's self-published
|
||||
* "How We Verify" statement (kind 14672) followed by the grid of
|
||||
* campaigns it has verified — resolved from the account's own
|
||||
* `agora.verified` (kind 1985) labels via {@link useVerifiedCampaigns}.
|
||||
* Surfaced as the default tab for verifier profiles so visitors
|
||||
* 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">
|
||||
<div className="px-4 sm:px-6 py-6 space-y-6">
|
||||
<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} />
|
||||
@@ -34,7 +40,8 @@ export function ProfileVerifiedTab({ pubkey, displayName }: ProfileVerifiedTabPr
|
||||
|
||||
if (campaigns.length === 0) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 py-12" data-pubkey={pubkey}>
|
||||
<div className="px-4 sm:px-6 py-6 space-y-6" data-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" />
|
||||
@@ -49,6 +56,7 @@ export function ProfileVerifiedTab({ pubkey, displayName }: ProfileVerifiedTabPr
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 py-6 space-y-4">
|
||||
<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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
|
||||
// animation. We re-seed on the next startSignup().
|
||||
}, []);
|
||||
|
||||
const setRole = useCallback((next: 'creator' | 'donor') => {
|
||||
const setRole = useCallback((next: 'creator' | 'donor' | 'verifier') => {
|
||||
setRoleState(next);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
/**
|
||||
* The two top-level roles a new user can pick during onboarding. Drives
|
||||
* downstream copy (creator vs. donor framing) and the role-pick CTA target
|
||||
* (creator → /campaigns/new, donor → /campaigns).
|
||||
* The top-level roles a new user can pick during onboarding. Drives
|
||||
* downstream copy (creator vs. donor vs. verifier framing) and the
|
||||
* role-pick behavior:
|
||||
* - `creator` → navigate to /campaigns/new
|
||||
* - `donor` → navigate to /campaigns
|
||||
* - `verifier`→ stay captive and branch into the verifier sub-flow
|
||||
* (org identity → org bio → publish statement → how-to-verify)
|
||||
*
|
||||
* `null` before the user has answered the role-picker step.
|
||||
*/
|
||||
export type OnboardingRole = 'creator' | 'donor' | null;
|
||||
export type OnboardingRole = 'creator' | 'donor' | 'verifier' | null;
|
||||
|
||||
/** Options to pre-seed when invoking the captive flow from a specific CTA. */
|
||||
export interface StartSignupOptions {
|
||||
@@ -15,7 +19,7 @@ export interface StartSignupOptions {
|
||||
* Pre-fill the role picker. CTAs that semantically already imply a role
|
||||
* (e.g. "Start a campaign") can skip the role step by passing this.
|
||||
*/
|
||||
role?: 'creator' | 'donor';
|
||||
role?: 'creator' | 'donor' | 'verifier';
|
||||
}
|
||||
|
||||
export interface OnboardingContextValue {
|
||||
@@ -29,7 +33,7 @@ export interface OnboardingContextValue {
|
||||
* finishes or explicitly bails out. */
|
||||
cancel: () => void;
|
||||
/** Update the selected role from inside the flow (role-picker step). */
|
||||
setRole: (role: 'creator' | 'donor') => void;
|
||||
setRole: (role: 'creator' | 'donor' | 'verifier') => void;
|
||||
}
|
||||
|
||||
export const OnboardingContext = createContext<OnboardingContextValue | undefined>(undefined);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
import type { OrgProfileDraft } from '@/components/onboarding/VerifierIdentityStep';
|
||||
|
||||
/** Safely parse a kind-0 `content` JSON string into a metadata object. */
|
||||
function parseMetadata(content: string | undefined): Record<string, unknown> {
|
||||
if (!content) return {};
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// Malformed existing profile shouldn't block writing a fresh one.
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the verifier's organization profile as a kind-0 event from the
|
||||
* collected {@link OrgProfileDraft}.
|
||||
*
|
||||
* - **Read-modify-write:** merges onto any existing kind-0 (via
|
||||
* `fetchFreshEvent` + `prev`) so other metadata fields and `published_at`
|
||||
* are preserved.
|
||||
* - **Signer guard:** when `expectedPubkey` is provided (the signup flow),
|
||||
* refuses to publish if the active signer doesn't match the freshly
|
||||
* created key — otherwise a failed auto-switch could overwrite a
|
||||
* different account's profile.
|
||||
*
|
||||
* Throws on failure so callers can surface a non-fatal toast and still let
|
||||
* the user continue.
|
||||
*/
|
||||
export function usePublishOrgProfile() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<void, Error, { draft: OrgProfileDraft; expectedPubkey?: string }>({
|
||||
mutationFn: async ({ draft, expectedPubkey }) => {
|
||||
if (!user) throw new Error('Not logged in');
|
||||
if (expectedPubkey && user.pubkey !== expectedPubkey) {
|
||||
throw new Error('Active account does not match the new key');
|
||||
}
|
||||
|
||||
const name = draft.name.trim();
|
||||
const website = draft.website.trim();
|
||||
const picture = draft.picture.trim();
|
||||
const banner = draft.banner.trim();
|
||||
const about = draft.about.trim();
|
||||
|
||||
const prev = await fetchFreshEvent(nostr, { kinds: [0], authors: [user.pubkey] });
|
||||
const metadata = parseMetadata(prev?.content);
|
||||
|
||||
if (name) {
|
||||
metadata.name = name;
|
||||
metadata.display_name = name;
|
||||
}
|
||||
if (website) metadata.website = website;
|
||||
if (picture) metadata.picture = picture;
|
||||
if (banner) metadata.banner = banner;
|
||||
if (about) metadata.about = about;
|
||||
|
||||
await publishEvent({
|
||||
kind: 0,
|
||||
content: JSON.stringify(metadata),
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (user) {
|
||||
void queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['logins'] });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -690,6 +690,15 @@
|
||||
@apply min-h-[350px];
|
||||
}
|
||||
|
||||
/* Verifier-statement instance: shorter, transparent fill so it matches the
|
||||
muted bio textarea on the previous onboarding step. The muted background
|
||||
and border live on the wrapper in VerifierStatementEditor, not here. */
|
||||
.milkdown-editor.verifier-statement-editor .editor,
|
||||
.milkdown-editor.verifier-statement-editor .ProseMirror,
|
||||
.milkdown-editor.verifier-statement-editor .milkdown-content .ProseMirror {
|
||||
@apply min-h-[200px] bg-transparent;
|
||||
}
|
||||
|
||||
/* Room navigation arrow nudge — subtle horizontal pulse */
|
||||
@keyframes room-arrow-nudge-left {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
|
||||
@@ -61,3 +61,83 @@ export function proxyImageUrl(
|
||||
return `${base}/?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a remote image and return its bytes as a `File`, routed through the
|
||||
* configured image proxy so the request is CORS-safe and can later be drawn
|
||||
* onto a `<canvas>` without tainting it.
|
||||
*
|
||||
* This is the canvas-safe counterpart to {@link proxyImageUrl}: where that
|
||||
* helper produces an `<img src>` (and tolerates `data:`/`.svg` pass-through),
|
||||
* this one needs the *bytes* and therefore forces everything — including SVGs
|
||||
* — through the proxy, which rasterizes to PNG. Without that, a cross-origin
|
||||
* `fetch()` of an arbitrary host (or an SVG the proxy would otherwise skip)
|
||||
* fails CORS and the crop/encode pipeline silently dies.
|
||||
*
|
||||
* `data:` URIs are fetched directly — they carry their own bytes and never
|
||||
* hit the network or a CORS check.
|
||||
*
|
||||
* @param src Original image URL.
|
||||
* @param proxyBaseUrl Base URL of a wsrv.nl-compatible proxy. Falls back to
|
||||
* `https://wsrv.nl` when empty, since a direct fetch of an
|
||||
* arbitrary origin would almost always fail CORS.
|
||||
* @param width Target raster width in px. Small or vector (SVG)
|
||||
* sources are *enlarged* to this so the cropper has a
|
||||
* usable canvas instead of a tiny speck. Defaults to 1024.
|
||||
* @param filename Base filename for the returned `File`.
|
||||
*/
|
||||
export async function fetchImageAsFile(
|
||||
src: string,
|
||||
proxyBaseUrl: string,
|
||||
width = 1024,
|
||||
filename = 'pasted-image',
|
||||
): Promise<File> {
|
||||
// data: URIs carry their own bytes — fetch directly, no proxy, no CORS.
|
||||
const fetchUrl = src.startsWith('data:')
|
||||
? src
|
||||
: proxyFetchUrl(src, proxyBaseUrl || 'https://wsrv.nl', width);
|
||||
|
||||
const res = await fetch(fetchUrl);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch image (${res.status})`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
if (!blob.type.startsWith('image/')) {
|
||||
throw new Error('Fetched resource is not an image');
|
||||
}
|
||||
|
||||
const ext = blob.type === 'image/png' ? '.png'
|
||||
: blob.type === 'image/webp' ? '.webp'
|
||||
: '.jpg';
|
||||
|
||||
return new File([blob], `${filename}${ext}`, { type: blob.type });
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a proxy URL for *fetching bytes* (as opposed to an `<img src>`).
|
||||
* Unlike {@link proxyImageUrl} this forces SVGs through the proxy so they
|
||||
* rasterize, and requests a target `width` so small or vector sources are
|
||||
* enlarged to a usable crop resolution rather than handed to the cropper at
|
||||
* a tiny intrinsic size.
|
||||
*/
|
||||
function proxyFetchUrl(src: string, proxyBaseUrl: string, width: number): string {
|
||||
let parsedProxy: URL;
|
||||
try {
|
||||
parsedProxy = new URL(proxyBaseUrl);
|
||||
} catch {
|
||||
return src;
|
||||
}
|
||||
if (parsedProxy.protocol !== 'https:') return src;
|
||||
|
||||
const base = (parsedProxy.origin + parsedProxy.pathname).replace(/\/+$/, '');
|
||||
// `w` resizes to the target width. wsrv.nl enlarges smaller sources by
|
||||
// default (no `we` flag), so a 32px SVG icon becomes a `width`-wide raster
|
||||
// the cropper can actually work with. `fit=inside` preserves aspect ratio.
|
||||
const params = new URLSearchParams({
|
||||
url: src,
|
||||
output: 'png',
|
||||
w: String(width),
|
||||
fit: 'inside',
|
||||
});
|
||||
return `${base}/?${params.toString()}`;
|
||||
}
|
||||
|
||||
|
||||
+2
-5
@@ -70,7 +70,7 @@
|
||||
"profile": "الملف الشخصي",
|
||||
"settings": "الإعدادات",
|
||||
"about": "حول",
|
||||
"organizations": "المنظّمات",
|
||||
"verify": "التوثيق",
|
||||
"privacy": "الخصوصية",
|
||||
"safety": "السلامة",
|
||||
"changelog": "سجل التغييرات",
|
||||
@@ -1107,10 +1107,7 @@
|
||||
"confirm": { "title": "أكِّد وانتهيت", "body": "أقرّ بأن الحملة أصلية. تنضم شارتك إلى البطاقة ليعلم المتبرعون أنك تقف خلفها." }
|
||||
},
|
||||
"demo": {
|
||||
"campaignTitle": "مياه نظيفة لموانزا",
|
||||
"campaignOrganizer": "بواسطة مؤسسة Mradi",
|
||||
"menuVerify": "وثّق هذه الحملة",
|
||||
"verifiedBadge": "موثّقة بواسطتك"
|
||||
"menuVerify": "وثّق هذه الحملة"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+55
-12
@@ -23,6 +23,7 @@
|
||||
"close": "Close",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"continue": "Continue",
|
||||
"refresh": "Refresh",
|
||||
"search": "Search",
|
||||
"filter": "Filter",
|
||||
@@ -74,7 +75,7 @@
|
||||
"profile": "Profile",
|
||||
"settings": "Settings",
|
||||
"about": "About",
|
||||
"organizations": "Organizations",
|
||||
"verify": "Verify",
|
||||
"privacy": "Privacy",
|
||||
"safety": "Safety",
|
||||
"changelog": "Changelog",
|
||||
@@ -103,7 +104,12 @@
|
||||
"donor": {
|
||||
"title": "Give to campaigns",
|
||||
"description": "Support causes with Bitcoin.",
|
||||
"finderNote": "Your donation goes straight to the organizer's wallet."
|
||||
"finderNote": "100% of your donation goes to the campaign."
|
||||
},
|
||||
"verifier": {
|
||||
"title": "Verify campaigns",
|
||||
"description": "Vouch for campaigns as an organization.",
|
||||
"finderNote": "Donors see badge on campaigns you trust."
|
||||
}
|
||||
},
|
||||
"keygen": {
|
||||
@@ -143,6 +149,37 @@
|
||||
"uploadFailed": "Upload failed.",
|
||||
"publishFailedTitle": "Profile setup failed",
|
||||
"publishFailedDescription": "Your account was created but the profile could not be saved. You can update it later."
|
||||
},
|
||||
"verifier": {
|
||||
"identity": {
|
||||
"title": "Set up your organization",
|
||||
"subtitle": "Name and logo are required. Website and banner are optional.",
|
||||
"websiteInvalid": "Enter a valid website starting with https://",
|
||||
"cropAvatar": "Crop logo",
|
||||
"cropBanner": "Crop banner",
|
||||
"uploading": "Uploading image…",
|
||||
"clipboardFailed": "Couldn't read from clipboard.",
|
||||
"pasteUrlInvalid": "Clipboard doesn't contain a valid https URL.",
|
||||
"pasteUrlFetchFailed": "Couldn't load that image. Check the URL and try again."
|
||||
},
|
||||
"bio": {
|
||||
"title": "Tell us about your organization",
|
||||
"subtitle": "A short description for your profile.",
|
||||
"label": "About your organization",
|
||||
"placeholder": "We're a nonprofit that…",
|
||||
"publishing": "Saving your profile…"
|
||||
},
|
||||
"publishFailedTitle": "Profile not saved",
|
||||
"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": "Describe how you vet campaigns before vouching. Published publicly so donors can trust your badge."
|
||||
},
|
||||
"howto": {
|
||||
"title": "How to verify a campaign",
|
||||
"subtitle": "You're all set. Here's how to vouch for a campaign once you've checked it out.",
|
||||
"finish": "View campaigns"
|
||||
}
|
||||
}
|
||||
},
|
||||
"feed": {
|
||||
@@ -1057,7 +1094,7 @@
|
||||
"errorPublishedInvalid": "Published event failed validation. Please refresh and try again.",
|
||||
"wizard": {
|
||||
"titleStepTitle": "Name your campaign",
|
||||
"titleStepSubtitle": "A short, clear name donors will recognize.",
|
||||
"titleStepSubtitle": "A short, clear name and a striking banner donors will recognize.",
|
||||
"walletStepTitle": "Where do donations go?",
|
||||
"walletStepSubtitle": "Pick your Agora wallet or paste your own address.",
|
||||
"bannerStepTitle": "Add a banner",
|
||||
@@ -1231,6 +1268,7 @@
|
||||
"heroTagline": "Connecting the\u00a0world to<1></1><0>unstoppable</0> funding.",
|
||||
"heroBody": "Raise Bitcoin directly from supporters around the world. Every donation settles straight to your wallet, with no middlemen, no chargebacks, and no platform holding your funds.",
|
||||
"startCampaign": "Start a campaign",
|
||||
"verifyCampaigns": "Verify campaigns",
|
||||
"howItWorks": "How it works",
|
||||
"exploreCampaigns": "Explore campaigns",
|
||||
"featuredTitle": "Featured Campaigns",
|
||||
@@ -1297,6 +1335,7 @@
|
||||
"sortNew": "New",
|
||||
"showHidden": "Show hidden",
|
||||
"startCampaign": "Start a campaign",
|
||||
"verifyCampaigns": "Verify campaigns",
|
||||
"noMatch": "No campaigns match “{{query}}”",
|
||||
"noMatchHint": "Try a different search term, or clear the search to see every campaign.",
|
||||
"allHidden": "No campaigns to show",
|
||||
@@ -1477,6 +1516,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.",
|
||||
@@ -1555,8 +1596,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.",
|
||||
@@ -1567,22 +1613,19 @@
|
||||
"steps": {
|
||||
"open": {
|
||||
"title": "Open the menu",
|
||||
"body": "On any campaign card, tap the three-dots button in the top-right corner of the banner."
|
||||
"body": "Tap the three-dots button on any campaign card."
|
||||
},
|
||||
"verify": {
|
||||
"title": "Choose \"Verify this campaign\"",
|
||||
"body": "The menu reveals a verify action — visible only to moderators and verifiers like you."
|
||||
"body": "Pick the verify action — shown only to verifiers."
|
||||
},
|
||||
"confirm": {
|
||||
"title": "Confirm & you're done",
|
||||
"body": "Attest the campaign is authentic. Your badge joins the card so donors know you stand behind it."
|
||||
"body": "Attest it's authentic. Your badge joins the card."
|
||||
}
|
||||
},
|
||||
"demo": {
|
||||
"campaignTitle": "Clean Water for Mwanza",
|
||||
"campaignOrganizer": "by Mradi Foundation",
|
||||
"menuVerify": "Verify this campaign",
|
||||
"verifiedBadge": "Verified by you"
|
||||
"menuVerify": "Verify this campaign"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+2
-5
@@ -74,7 +74,7 @@
|
||||
"profile": "Perfil",
|
||||
"settings": "Ajustes",
|
||||
"about": "Acerca de",
|
||||
"organizations": "Organizaciones",
|
||||
"verify": "Verificar",
|
||||
"privacy": "Privacidad",
|
||||
"safety": "Seguridad",
|
||||
"changelog": "Novedades",
|
||||
@@ -1126,10 +1126,7 @@
|
||||
}
|
||||
},
|
||||
"demo": {
|
||||
"campaignTitle": "Agua limpia para Mwanza",
|
||||
"campaignOrganizer": "de la Fundación Mradi",
|
||||
"menuVerify": "Verificar esta campaña",
|
||||
"verifiedBadge": "Verificada por ti"
|
||||
"menuVerify": "Verificar esta campaña"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+2
-5
@@ -74,7 +74,7 @@
|
||||
"profile": "نمایه",
|
||||
"settings": "تنظیمات",
|
||||
"about": "درباره",
|
||||
"organizations": "سازمانها",
|
||||
"verify": "تأیید",
|
||||
"privacy": "حریم خصوصی",
|
||||
"safety": "ایمنی",
|
||||
"changelog": "تغییرات",
|
||||
@@ -1117,10 +1117,7 @@
|
||||
"confirm": { "title": "تأیید کنید و کار تمام است", "body": "گواهی دهید که کمپین معتبر است. نشان شما به کارت افزوده میشود تا اهداکنندگان بدانند که شما پشتیبان آن هستید." }
|
||||
},
|
||||
"demo": {
|
||||
"campaignTitle": "آب پاکیزه برای Mwanza",
|
||||
"campaignOrganizer": "توسط بنیاد Mradi",
|
||||
"menuVerify": "این کمپین را تأیید کنید",
|
||||
"verifiedBadge": "تأییدشده توسط شما"
|
||||
"menuVerify": "این کمپین را تأیید کنید"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+2
-5
@@ -73,7 +73,7 @@
|
||||
"profile": "Profil",
|
||||
"settings": "Paramètres",
|
||||
"about": "À propos",
|
||||
"organizations": "Organisations",
|
||||
"verify": "Vérifier",
|
||||
"privacy": "Confidentialité",
|
||||
"safety": "Sécurité",
|
||||
"changelog": "Journal des modifications",
|
||||
@@ -1565,10 +1565,7 @@
|
||||
}
|
||||
},
|
||||
"demo": {
|
||||
"campaignTitle": "De l'eau potable pour Mwanza",
|
||||
"campaignOrganizer": "par la Fondation Mradi",
|
||||
"menuVerify": "Vérifier cette campagne",
|
||||
"verifiedBadge": "Vérifié par vous"
|
||||
"menuVerify": "Vérifier cette campagne"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+2
-5
@@ -74,7 +74,7 @@
|
||||
"profile": "प्रोफ़ाइल",
|
||||
"settings": "सेटिंग्स",
|
||||
"about": "बारे में",
|
||||
"organizations": "संगठन",
|
||||
"verify": "सत्यापित करें",
|
||||
"privacy": "प्राइवेसी",
|
||||
"safety": "सुरक्षा",
|
||||
"changelog": "बदलाव",
|
||||
@@ -1559,10 +1559,7 @@
|
||||
"confirm": { "title": "पुष्टि करें और हो गया", "body": "प्रमाणित करें कि अभियान प्रामाणिक है। आपका बैज कार्ड में जुड़ जाता है ताकि दानदाताओं को पता चले कि आप इसके पीछे खड़े हैं।" }
|
||||
},
|
||||
"demo": {
|
||||
"campaignTitle": "Mwanza के लिए स्वच्छ जल",
|
||||
"campaignOrganizer": "Mradi Foundation द्वारा",
|
||||
"menuVerify": "इस अभियान को सत्यापित करें",
|
||||
"verifiedBadge": "आपके द्वारा सत्यापित"
|
||||
"menuVerify": "इस अभियान को सत्यापित करें"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+2
-5
@@ -74,7 +74,7 @@
|
||||
"profile": "Profil",
|
||||
"settings": "Pengaturan",
|
||||
"about": "Tentang",
|
||||
"organizations": "Organisasi",
|
||||
"verify": "Verifikasi",
|
||||
"privacy": "Privasi",
|
||||
"safety": "Keamanan",
|
||||
"changelog": "Catatan Versi",
|
||||
@@ -1559,10 +1559,7 @@
|
||||
"confirm": { "title": "Konfirmasi & selesai", "body": "Tegaskan bahwa kampanye ini autentik. Lencana Anda akan muncul di kartu sehingga para donatur tahu Anda mendukungnya." }
|
||||
},
|
||||
"demo": {
|
||||
"campaignTitle": "Air Bersih untuk Mwanza",
|
||||
"campaignOrganizer": "oleh Mradi Foundation",
|
||||
"menuVerify": "Verifikasi kampanye ini",
|
||||
"verifiedBadge": "Diverifikasi oleh Anda"
|
||||
"menuVerify": "Verifikasi kampanye ini"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+2
-5
@@ -74,7 +74,7 @@
|
||||
"profile": "ប្រវត្តិរូប",
|
||||
"settings": "ការកំណត់",
|
||||
"about": "អំពី",
|
||||
"organizations": "អង្គការ",
|
||||
"verify": "ផ្ទៀងផ្ទាត់",
|
||||
"privacy": "ភាពឯកជន",
|
||||
"safety": "សុវត្ថិភាព",
|
||||
"changelog": "កំណត់ហេតុការផ្លាស់ប្តូរ",
|
||||
@@ -1117,10 +1117,7 @@
|
||||
"confirm": { "title": "បញ្ជាក់ ហើយអ្នកបានបញ្ចប់", "body": "បញ្ជាក់ថាយុទ្ធនាការនេះពិតប្រាកដ។ ផ្លាកសញ្ញារបស់អ្នកនឹងភ្ជាប់ទៅកាត ដើម្បីឱ្យអ្នកបរិច្ចាគដឹងថាអ្នកគាំទ្រវា។" }
|
||||
},
|
||||
"demo": {
|
||||
"campaignTitle": "ទឹកស្អាតសម្រាប់ Mwanza",
|
||||
"campaignOrganizer": "ដោយ Mradi Foundation",
|
||||
"menuVerify": "ផ្ទៀងផ្ទាត់យុទ្ធនាការនេះ",
|
||||
"verifiedBadge": "បានផ្ទៀងផ្ទាត់ដោយអ្នក"
|
||||
"menuVerify": "ផ្ទៀងផ្ទាត់យុទ្ធនាការនេះ"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+2
-5
@@ -74,7 +74,7 @@
|
||||
"profile": "پروفایل",
|
||||
"settings": "تنظیمات",
|
||||
"about": "په اړه",
|
||||
"organizations": "سازمانونه",
|
||||
"verify": "تصدیق",
|
||||
"privacy": "محرمیت",
|
||||
"safety": "خوندیتوب",
|
||||
"changelog": "د بدلونونو لاګ",
|
||||
@@ -1119,10 +1119,7 @@
|
||||
"confirm": { "title": "تایید کړئ او کار مو پای ته ورسېد", "body": "تصدیق کړئ چې کمپاین اصلي دی. ستاسو نښان د کارت سره یوځای کېږي ترڅو بسپنه ورکوونکي پوه شي چې تاسو یې ملاتړ کوئ." }
|
||||
},
|
||||
"demo": {
|
||||
"campaignTitle": "د موانزا لپاره پاکې اوبه",
|
||||
"campaignOrganizer": "د Mradi بنسټ لخوا",
|
||||
"menuVerify": "دا کمپاین تصدیق کړئ",
|
||||
"verifiedBadge": "ستاسو لخوا تصدیق شوی"
|
||||
"menuVerify": "دا کمپاین تصدیق کړئ"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+2
-5
@@ -74,7 +74,7 @@
|
||||
"profile": "Perfil",
|
||||
"settings": "Configurações",
|
||||
"about": "Sobre",
|
||||
"organizations": "Organizações",
|
||||
"verify": "Verificar",
|
||||
"privacy": "Privacidade",
|
||||
"safety": "Segurança",
|
||||
"changelog": "Notas de versão",
|
||||
@@ -1570,10 +1570,7 @@
|
||||
}
|
||||
},
|
||||
"demo": {
|
||||
"campaignTitle": "Água Limpa para Mwanza",
|
||||
"campaignOrganizer": "por Mradi Foundation",
|
||||
"menuVerify": "Verificar esta campanha",
|
||||
"verifiedBadge": "Verificado por você"
|
||||
"menuVerify": "Verificar esta campanha"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+2
-5
@@ -74,7 +74,7 @@
|
||||
"profile": "Профиль",
|
||||
"settings": "Настройки",
|
||||
"about": "О приложении",
|
||||
"organizations": "Организации",
|
||||
"verify": "Проверка",
|
||||
"privacy": "Конфиденциальность",
|
||||
"safety": "Безопасность",
|
||||
"changelog": "История изменений",
|
||||
@@ -1570,10 +1570,7 @@
|
||||
}
|
||||
},
|
||||
"demo": {
|
||||
"campaignTitle": "Чистая вода для Mwanza",
|
||||
"campaignOrganizer": "от Mradi Foundation",
|
||||
"menuVerify": "Проверить эту кампанию",
|
||||
"verifiedBadge": "Проверено вами"
|
||||
"menuVerify": "Проверить эту кампанию"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+2
-5
@@ -74,7 +74,7 @@
|
||||
"profile": "Profile",
|
||||
"settings": "Marongero",
|
||||
"about": "Nezve",
|
||||
"organizations": "Masangano",
|
||||
"verify": "Simbisa",
|
||||
"privacy": "Akavanzika",
|
||||
"safety": "Kuchengetedzeka",
|
||||
"changelog": "Rondedzero yeshanduko",
|
||||
@@ -1119,10 +1119,7 @@
|
||||
"confirm": { "title": "Simbisa uye wapedza", "body": "Pupura kuti mushandirapamwe ndewechokwadi. Bheji rako rinobatana nekadhi kuti vanopa vazive kuti unowutsigira." }
|
||||
},
|
||||
"demo": {
|
||||
"campaignTitle": "Mvura Yakachena yeMwanza",
|
||||
"campaignOrganizer": "naMradi Foundation",
|
||||
"menuVerify": "Simbisa mushandirapamwe uyu",
|
||||
"verifiedBadge": "Yasimbiswa newe"
|
||||
"menuVerify": "Simbisa mushandirapamwe uyu"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+2
-5
@@ -73,7 +73,7 @@
|
||||
"profile": "Wasifu",
|
||||
"settings": "Mipangilio",
|
||||
"about": "Kuhusu",
|
||||
"organizations": "Mashirika",
|
||||
"verify": "Thibitisha",
|
||||
"privacy": "Faragha",
|
||||
"safety": "Usalama",
|
||||
"changelog": "Kumbukumbu ya mabadiliko",
|
||||
@@ -1558,10 +1558,7 @@
|
||||
"confirm": { "title": "Thibitisha na umemaliza", "body": "Shuhudia kuwa kampeni ni halisi. Beji yako huungana na kadi ili wafadhili wajue unaiunga mkono." }
|
||||
},
|
||||
"demo": {
|
||||
"campaignTitle": "Maji Safi kwa Mwanza",
|
||||
"campaignOrganizer": "na Mradi Foundation",
|
||||
"menuVerify": "Thibitisha kampeni hii",
|
||||
"verifiedBadge": "Imethibitishwa na wewe"
|
||||
"menuVerify": "Thibitisha kampeni hii"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+2
-5
@@ -73,7 +73,7 @@
|
||||
"profile": "Profil",
|
||||
"settings": "Ayarlar",
|
||||
"about": "Hakkında",
|
||||
"organizations": "Organizasyonlar",
|
||||
"verify": "Doğrula",
|
||||
"privacy": "Gizlilik",
|
||||
"safety": "Güvenlik",
|
||||
"changelog": "Sürüm notları",
|
||||
@@ -1560,10 +1560,7 @@
|
||||
"confirm": { "title": "Onaylayın ve işlem tamam", "body": "Kampanyanın gerçek olduğunu teyit edin. Rozetiniz kartta yerini alır, böylece bağışçılar arkasında durduğunuzu bilir." }
|
||||
},
|
||||
"demo": {
|
||||
"campaignTitle": "Mwanza için Temiz Su",
|
||||
"campaignOrganizer": "Mradi Vakfı tarafından",
|
||||
"menuVerify": "Bu kampanyayı doğrula",
|
||||
"verifiedBadge": "Sizin tarafınızdan doğrulandı"
|
||||
"menuVerify": "Bu kampanyayı doğrula"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
"profile": "個人資料",
|
||||
"settings": "設定",
|
||||
"about": "關於",
|
||||
"organizations": "組織",
|
||||
"verify": "驗證",
|
||||
"privacy": "隱私",
|
||||
"safety": "安全",
|
||||
"changelog": "更新日誌",
|
||||
@@ -1119,10 +1119,7 @@
|
||||
"confirm": { "title": "確認後即完成", "body": "證明這個專案是真實的。你的徽章會加入卡片,讓捐款者知道你為它背書。" }
|
||||
},
|
||||
"demo": {
|
||||
"campaignTitle": "為姆萬扎提供潔淨用水",
|
||||
"campaignOrganizer": "由 Mradi 基金會發起",
|
||||
"menuVerify": "驗證此活動",
|
||||
"verifiedBadge": "已由你驗證"
|
||||
"menuVerify": "驗證此活動"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+2
-5
@@ -74,7 +74,7 @@
|
||||
"profile": "个人资料",
|
||||
"settings": "设置",
|
||||
"about": "关于",
|
||||
"organizations": "组织",
|
||||
"verify": "验证",
|
||||
"privacy": "隐私",
|
||||
"safety": "安全",
|
||||
"changelog": "更新日志",
|
||||
@@ -1119,10 +1119,7 @@
|
||||
"confirm": { "title": "确认即可完成", "body": "证明该活动真实可信。你的徽章会出现在卡片上,让捐赠者知道你为它背书。" }
|
||||
},
|
||||
"demo": {
|
||||
"campaignTitle": "为 Mwanza 提供清洁饮水",
|
||||
"campaignOrganizer": "由 Mradi 基金会发起",
|
||||
"menuVerify": "验证此活动",
|
||||
"verifiedBadge": "已由你验证"
|
||||
"menuVerify": "验证此活动"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { EyeOff, HandHeart, PlusCircle } from 'lucide-react';
|
||||
import { BadgeCheck, EyeOff, HandHeart, PlusCircle } from 'lucide-react';
|
||||
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
|
||||
@@ -254,6 +255,22 @@ function AllCampaignsHero({ campaignCount }: AllCampaignsHeroProps) {
|
||||
{t('campaigns.all.startCampaign')}
|
||||
</StartCampaignLink>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'rounded-full text-white font-semibold text-base h-12 px-7 [&_svg]:size-[18px]',
|
||||
'bg-white/5 hover:bg-white/10 backdrop-blur-xl backdrop-saturate-150',
|
||||
'border border-white/25 hover:border-white/35',
|
||||
'motion-safe:transition-colors motion-safe:duration-200',
|
||||
)}
|
||||
>
|
||||
<Link to="/verify">
|
||||
<BadgeCheck className="mr-2" />
|
||||
{t('campaigns.all.verifyCampaigns')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -187,6 +187,12 @@ export function CampaignsPage() {
|
||||
<ArrowRight className="ml-2 size-4 rtl:rotate-180" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="lg" variant="outline" className="rounded-full">
|
||||
<Link to="/verify">
|
||||
<BadgeCheck className="mr-2 size-4" />
|
||||
{t('campaigns.home.verifyCampaigns')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="lg" className="rounded-full">
|
||||
<StartCampaignLink>
|
||||
<PlusCircle className="mr-2 size-4" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent } from 'react';
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -12,14 +12,12 @@ import {
|
||||
ArrowRight,
|
||||
Bitcoin,
|
||||
Check,
|
||||
ChevronDown,
|
||||
EyeOff,
|
||||
Globe,
|
||||
HandHeart,
|
||||
HelpCircle,
|
||||
Loader2,
|
||||
ShieldCheck,
|
||||
Upload,
|
||||
Wallet,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -29,6 +27,7 @@ import { CategoryPicker } from '@/components/CategoryPicker';
|
||||
import { Wizard } from '@/components/Wizard';
|
||||
import { FormSection } from '@/components/FormSection';
|
||||
import { OrganizationContextChip } from '@/components/OrganizationContextChip';
|
||||
import { ProfileIdentityEditor } from '@/components/onboarding/ProfileIdentityEditor';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
@@ -46,8 +45,8 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useHdWallet } from '@/hooks/useHdWallet';
|
||||
import { useManageableOrganizations } from '@/hooks/useManageableOrganizations';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useOnboarding } from '@/contexts/onboardingContextDef';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { formatBTC, satsToUSD } from '@/lib/bitcoin';
|
||||
import {
|
||||
CAMPAIGN_KIND,
|
||||
@@ -138,7 +137,7 @@ export function CreateCampaignPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { nostr } = useNostr();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const { mutateAsync: uploadProfileFile, isPending: isUploadingProfileAvatar } = useUploadFile();
|
||||
const { role: onboardingRole, startSignup } = useOnboarding();
|
||||
const { toast } = useToast();
|
||||
const hdWallet = useHdWallet();
|
||||
const hdWalletAvailable = hdWallet.availability.status === 'available';
|
||||
@@ -210,11 +209,10 @@ export function CreateCampaignPage() {
|
||||
const [organizationATag, setOrganizationATag] = useState('');
|
||||
const [formError, setFormError] = useState('');
|
||||
const [prepopulatedEventId, setPrepopulatedEventId] = useState<string | null>(null);
|
||||
const [profileData, setProfileData] = useState({ name: '', about: '', picture: '' });
|
||||
const [profileData, setProfileData] = useState({ name: '', about: '', picture: '', banner: '', website: '' });
|
||||
const [profilePrefilledPubkey, setProfilePrefilledPubkey] = useState<string | null>(null);
|
||||
const [includeCampaignProfileStep, setIncludeCampaignProfileStep] = useState(false);
|
||||
const [showProfileMore, setShowProfileMore] = useState(false);
|
||||
const profileAvatarInputRef = useRef<HTMLInputElement>(null);
|
||||
const [profileImageUploading, setProfileImageUploading] = useState(false);
|
||||
|
||||
const editTarget = useMemo(() => getEditTarget(editNaddr), [editNaddr]);
|
||||
|
||||
@@ -252,6 +250,8 @@ export function CreateCampaignPage() {
|
||||
name: userMetadata?.name ?? userMetadata?.display_name ?? '',
|
||||
about: userMetadata?.about ?? '',
|
||||
picture: userMetadata?.picture ?? '',
|
||||
banner: userMetadata?.banner ?? '',
|
||||
website: (userMetadata?.website as string) ?? '',
|
||||
});
|
||||
setProfilePrefilledPubkey(user.pubkey);
|
||||
}, [isEditMode, profilePrefilledPubkey, user, userAuthor.isLoading, userMetadata]);
|
||||
@@ -379,29 +379,6 @@ export function CreateCampaignPage() {
|
||||
setPrepopulatedEventId(editCampaign.event.id);
|
||||
}, [editCampaign, prepopulatedEventId]);
|
||||
|
||||
const handleProfileAvatarChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = '';
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast({ title: t('onboarding.profile.imageOnly'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast({ title: t('onboarding.profile.imageTooLarge'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tags = await uploadProfileFile(file);
|
||||
const url = tags[0]?.[1];
|
||||
if (url) setProfileData((prev) => ({ ...prev, picture: url }));
|
||||
} catch {
|
||||
toast({ title: t('onboarding.profile.uploadFailed'), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const profileMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!user) throw new Error(t('campaignsCreate.errorLoginRequired'));
|
||||
@@ -409,6 +386,7 @@ export function CreateCampaignPage() {
|
||||
const name = profileData.name.trim();
|
||||
const about = profileData.about.trim();
|
||||
const picture = profileData.picture.trim();
|
||||
const banner = profileData.banner.trim();
|
||||
|
||||
if (!name || !picture) {
|
||||
throw new Error(t('onboarding.profile.publishFailedDescription'));
|
||||
@@ -419,6 +397,7 @@ export function CreateCampaignPage() {
|
||||
metadata.name = name;
|
||||
if (about) metadata.about = about;
|
||||
metadata.picture = picture;
|
||||
if (banner) metadata.banner = banner;
|
||||
|
||||
await publishEvent({ kind: 0, content: JSON.stringify(metadata), prev: prev ?? undefined });
|
||||
},
|
||||
@@ -778,96 +757,41 @@ export function CreateCampaignPage() {
|
||||
const profileNameProvided = profileData.name.trim().length > 0;
|
||||
const profileAvatarProvided = profileData.picture.trim().length > 0;
|
||||
const profileSection = (
|
||||
<div className={cn('space-y-4', profileMutation.isPending && 'opacity-50 pointer-events-none')}>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="campaign-profile-name" className="text-sm font-medium">
|
||||
{t('onboarding.profile.nameLabel')}
|
||||
</label>
|
||||
<Input
|
||||
id="campaign-profile-name"
|
||||
value={profileData.name}
|
||||
onChange={(e) => setProfileData((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder={t('onboarding.profile.namePlaceholder')}
|
||||
required
|
||||
aria-required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="campaign-profile-picture" className="text-sm font-medium">
|
||||
{t('onboarding.profile.avatarLabel')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="campaign-profile-picture"
|
||||
value={profileData.picture}
|
||||
onChange={(e) => setProfileData((prev) => ({ ...prev, picture: e.target.value }))}
|
||||
placeholder="https://…"
|
||||
className="flex-1"
|
||||
required
|
||||
aria-required
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
ref={profileAvatarInputRef}
|
||||
onChange={handleProfileAvatarChange}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => profileAvatarInputRef.current?.click()}
|
||||
disabled={isUploadingProfileAvatar}
|
||||
title={t('onboarding.profile.uploadAvatar')}
|
||||
>
|
||||
{isUploadingProfileAvatar ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-4 h-4" />
|
||||
<ProfileIdentityEditor
|
||||
className={cn(
|
||||
(profileMutation.isPending || profileImageUploading) && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowProfileMore((v) => !v)}
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn('h-4 w-4 transition-transform duration-200', showProfileMore && 'rotate-180')}
|
||||
draft={profileData}
|
||||
onChange={(patch) => setProfileData((prev) => ({ ...prev, ...patch }))}
|
||||
bioField="none"
|
||||
showBanner={false}
|
||||
onUploadingChange={setProfileImageUploading}
|
||||
/>
|
||||
{t('onboarding.profile.advanced')}
|
||||
</button>
|
||||
|
||||
{showProfileMore && (
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="campaign-profile-about" className="text-sm font-medium">
|
||||
{t('onboarding.profile.aboutLabel')}
|
||||
</label>
|
||||
<Textarea
|
||||
id="campaign-profile-about"
|
||||
value={profileData.about}
|
||||
onChange={(e) => setProfileData((prev) => ({ ...prev, about: e.target.value }))}
|
||||
placeholder={t('onboarding.profile.aboutPlaceholder')}
|
||||
className="resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const titleSection = (
|
||||
<FormSection title={t('forms.title')} requirement="Required">
|
||||
<Input
|
||||
{/* Styled to match the "Your name" field from the profile step
|
||||
(ProfileCard's EditableInput) — muted idle bg, border on
|
||||
hover/focus — rather than the boxed shadcn Input. */}
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t('campaignsCreate.titlePlaceholder')}
|
||||
maxLength={200}
|
||||
required
|
||||
className={cn(
|
||||
'rounded-lg px-2',
|
||||
'border-2 border-transparent',
|
||||
'bg-muted/40',
|
||||
'hover:bg-muted/60 hover:border-border',
|
||||
'focus:bg-transparent focus:border-primary',
|
||||
'transition-colors duration-150',
|
||||
'placeholder:text-muted-foreground/40',
|
||||
'outline-none',
|
||||
'w-full min-w-0 py-1.5 text-xl font-bold',
|
||||
)}
|
||||
/>
|
||||
</FormSection>
|
||||
);
|
||||
@@ -959,17 +883,61 @@ export function CreateCampaignPage() {
|
||||
</FormSection>
|
||||
);
|
||||
|
||||
const campaignIdentitySection = (
|
||||
<ProfileIdentityEditor
|
||||
className={cn(coverUploading && 'opacity-50 pointer-events-none')}
|
||||
draft={{
|
||||
name: title,
|
||||
website: '',
|
||||
about: '',
|
||||
picture: '',
|
||||
banner: bannerUrl,
|
||||
}}
|
||||
onChange={(patch) => {
|
||||
if (patch.name !== undefined) setTitle(patch.name);
|
||||
if (patch.banner !== undefined) {
|
||||
setBannerUrl(patch.banner);
|
||||
setBannerNip94Tags(null);
|
||||
}
|
||||
}}
|
||||
bioField="none"
|
||||
showBanner
|
||||
showAvatar={false}
|
||||
namePlaceholder="Campaign title"
|
||||
nameMaxLength={200}
|
||||
onUploadingChange={setCoverUploading}
|
||||
onImageUploadComplete={(field, nip94Tags) => {
|
||||
if (field === 'banner') setBannerNip94Tags(nip94Tags);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const storySection = (
|
||||
<FormSection title={t('campaignsCreate.story')} requirement="Recommended">
|
||||
<Textarea
|
||||
id="campaign-story"
|
||||
value={story}
|
||||
onChange={(e) => setStory(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setStory(e.target.value);
|
||||
// Auto-grow: reset then size to content so the box expands
|
||||
// downward as the user types instead of scrolling internally.
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
placeholder={t('campaignsCreate.storyPlaceholder')}
|
||||
rows={7}
|
||||
className="font-mono text-base md:text-sm"
|
||||
className={cn(
|
||||
'min-h-[200px] w-full resize-none overflow-hidden p-3',
|
||||
'text-lg leading-7 md:text-lg',
|
||||
// Match the muted, borderless look of the organization bio step.
|
||||
'rounded-lg border-2 border-transparent bg-muted/40',
|
||||
'hover:bg-muted/60 hover:border-border',
|
||||
'focus-visible:bg-transparent focus-visible:border-primary focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
'placeholder:text-muted-foreground/40 transition-colors duration-150',
|
||||
)}
|
||||
/>
|
||||
</FormSection>
|
||||
);
|
||||
|
||||
const goalSection = (
|
||||
@@ -1129,18 +1097,13 @@ export function CreateCampaignPage() {
|
||||
{
|
||||
title: t('campaignsCreate.wizard.titleStepTitle'),
|
||||
subtitle: t('campaignsCreate.wizard.titleStepSubtitle'),
|
||||
body: titleSection,
|
||||
body: campaignIdentitySection,
|
||||
},
|
||||
{
|
||||
title: t('campaignsCreate.wizard.walletStepTitle'),
|
||||
subtitle: t('campaignsCreate.wizard.walletStepSubtitle'),
|
||||
body: walletSection,
|
||||
},
|
||||
{
|
||||
title: t('campaignsCreate.wizard.bannerStepTitle'),
|
||||
subtitle: t('campaignsCreate.wizard.bannerStepSubtitle'),
|
||||
body: bannerSection,
|
||||
},
|
||||
{
|
||||
title: t('campaignsCreate.wizard.storyStepTitle'),
|
||||
subtitle: t('campaignsCreate.wizard.storyStepSubtitle'),
|
||||
@@ -1170,7 +1133,7 @@ export function CreateCampaignPage() {
|
||||
const titleProvided = title.trim().length > 0;
|
||||
const profileStep = needsCampaignProfile ? 1 : null;
|
||||
const titleStep = needsCampaignProfile ? 2 : 1;
|
||||
const launchStep = needsCampaignProfile ? 4 : 3;
|
||||
const launchStep = needsCampaignProfile ? 3 : 2;
|
||||
|
||||
return (
|
||||
<Wizard
|
||||
@@ -1178,8 +1141,8 @@ export function CreateCampaignPage() {
|
||||
step1Lead={orgChip}
|
||||
steps={wizardSteps}
|
||||
canAdvanceFromStep={(s) => {
|
||||
if (s === profileStep) return profileNameProvided && profileAvatarProvided && !isUploadingProfileAvatar;
|
||||
if (s === titleStep) return titleProvided;
|
||||
if (s === profileStep) return profileNameProvided && profileAvatarProvided && !profileImageUploading;
|
||||
if (s === titleStep) return titleProvided && !coverUploading;
|
||||
return true;
|
||||
}}
|
||||
onBeforeAdvance={async (s) => {
|
||||
@@ -1195,9 +1158,10 @@ export function CreateCampaignPage() {
|
||||
launchNowLabel={t('campaignsCreate.wizard.launchNow')}
|
||||
errorAlert={errorAlert}
|
||||
submitButtonContent={submitButtonContent}
|
||||
submitting={submitMutation.isPending || profileMutation.isPending || coverUploading || isUploadingProfileAvatar}
|
||||
submitting={submitMutation.isPending || profileMutation.isPending || coverUploading || profileImageUploading}
|
||||
onSubmit={handleSubmit}
|
||||
onClose={() => navigate(-1)}
|
||||
onBackFromFirstStep={onboardingRole === 'creator' ? () => startSignup() : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,15 +16,17 @@ import { VerifyTutorial } from '@/components/organizations/VerifyTutorial';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import {
|
||||
useSetVerifierStatement,
|
||||
useVerifierStatement,
|
||||
} from '@/hooks/useVerifierStatement';
|
||||
|
||||
/**
|
||||
* The /organizations page. A landing-style document modeled on the
|
||||
* The /verify page. A landing-style document modeled on the
|
||||
* /about page that doubles as a functional onboarding tool. Sections:
|
||||
*
|
||||
* 1. Hero (dark) — pitch + CTA that scrolls to the form
|
||||
@@ -191,6 +193,9 @@ function VerifierEditor() {
|
||||
const { user } = useCurrentUser();
|
||||
const { toast } = useToast();
|
||||
|
||||
const author = useAuthor(user?.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
|
||||
const { statement, isLoading } = useVerifierStatement(user?.pubkey);
|
||||
const { mutateAsync: setStatement, isPending } = useSetVerifierStatement();
|
||||
|
||||
@@ -204,8 +209,6 @@ function VerifierEditor() {
|
||||
}
|
||||
}, [hydrated, isLoading, statement]);
|
||||
|
||||
// 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 (
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
@@ -266,7 +269,6 @@ function VerifierEditor() {
|
||||
<div className="space-y-8">
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<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')}
|
||||
@@ -283,8 +285,6 @@ function VerifierEditor() {
|
||||
</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}
|
||||
@@ -324,9 +324,12 @@ function VerifierEditor() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Once the org's statement is live, teach them the actual
|
||||
verify gesture: the three-dots menu on any campaign card. */}
|
||||
{isPublished && <VerifyTutorial />}
|
||||
{isPublished && (
|
||||
<VerifyTutorial
|
||||
verifierName={metadata?.name ?? (user ? genUserName(user.pubkey) : undefined)}
|
||||
verifierPicture={metadata?.picture}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
@@ -948,15 +948,16 @@ function ProfileTabContent({
|
||||
|
||||
// ----- Main Component -----
|
||||
|
||||
// Desktop (lg+) keeps the focused 3-tab content set; the rail to the
|
||||
// Desktop (lg+) keeps the focused content set; the rail to the
|
||||
// left already shows the profile's Overview information (campaigns,
|
||||
// orgs, fields), so duplicating it as a tab would be redundant.
|
||||
const DESKTOP_TAB_LABEL_KEYS = ['activity', 'campaigns', 'pledges'] as const;
|
||||
// "Groups" and "Pledges" are temporarily hidden.
|
||||
const DESKTOP_TAB_LABEL_KEYS = ['activity', 'campaigns'] as const;
|
||||
|
||||
// Below lg the left rail is unavailable, so its content becomes the
|
||||
// default "Overview" tab and organizations get their own "Groups"
|
||||
// tab. Order matters — "Overview" is the default on first mount.
|
||||
const MOBILE_TAB_LABEL_KEYS = ['overview', 'activity', 'campaigns', 'groups', 'pledges'] as const;
|
||||
// default "Overview" tab. Order matters — "Overview" is the default on
|
||||
// first mount. "Groups" and "Pledges" are temporarily hidden.
|
||||
const MOBILE_TAB_LABEL_KEYS = ['overview', 'activity', 'campaigns'] as const;
|
||||
|
||||
// Map from label key → internal tab id.
|
||||
const CORE_TAB_IDS: Record<string, string> = {
|
||||
@@ -969,7 +970,7 @@ const CORE_TAB_IDS: Record<string, string> = {
|
||||
};
|
||||
|
||||
const KNOWN_TAB_IDS = new Set(['overview', 'verified', 'activity', 'campaigns', 'community', 'pledges']);
|
||||
const DESKTOP_TAB_IDS = new Set(['verified', 'activity', 'campaigns', 'pledges']);
|
||||
const DESKTOP_TAB_IDS = new Set(['verified', 'activity', 'campaigns']);
|
||||
|
||||
/**
|
||||
* Read the viewport at first render to pick the initial active tab.
|
||||
|
||||
@@ -316,6 +316,7 @@ export function ProfileSettings() {
|
||||
onChange={handleCardChange}
|
||||
onPickImage={handlePickImage}
|
||||
onRemoveAvatar={() => form.setValue('picture', '', { shouldDirty: true })}
|
||||
onRemoveBanner={() => form.setValue('banner', '', { shouldDirty: true })}
|
||||
showBadges={false}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user