Compare commits

...

47 Commits

Author SHA1 Message Date
lemon 0f4e69344c Show logged-in user's avatar in verify page tutorial 2026-06-14 10:53:06 -07:00
lemon 3500afb3f9 Replace verify nav tab with CTA buttons on home and campaigns 2026-06-14 10:45:10 -07:00
lemon 8503cea367 Restore verify page editor 2026-06-14 10:29:35 -07:00
lemon 31ccaa2f99 Add verify navigation route 2026-06-14 10:29:35 -07:00
lemon b0a81b5d94 Reuse organization banner card for campaign title step 2026-06-14 10:29:35 -07:00
lemon 035119091b Put campaign banner above name and match the profile name field style 2026-06-14 10:29:35 -07:00
lemon 741ec3ed09 Restyle the campaign story field to match the org bio step
Swap the FormSection-wrapped mono textarea on "Tell your story" for the
borderless, auto-growing muted textarea used by the organization bio
step, so the two long-form surfaces look the same.
2026-06-14 10:29:35 -07:00
lemon 30d3d85743 Combine the campaign name and banner into one wizard step
Fold the standalone "Add a banner" step into the "Name your campaign"
step (title required, banner optional), removing one wizard step.
Adjust the launch-shortcut step index and block advancing while the
banner is still uploading.
2026-06-14 10:29:35 -07:00
lemon 1a835a5fe6 Reduce campaign profile step to avatar and name only
Add showBanner and a bioField "none" option to ProfileCard (threaded
through ProfileIdentityEditor), and drop the banner and bio from the
campaign creator's "Put a face to your campaign" step so it asks only
for an avatar and name.
2026-06-14 10:29:35 -07:00
lemon 4adbed2c1b Reuse the org identity card on the campaign profile step
Extract the verifier identity step's ProfileCard + crop/upload/paste/
remove machinery into a shared ProfileIdentityEditor, parameterized by
bioField ('website' for organizations, 'about' for campaigners) and an
aboutPlaceholder. VerifierIdentityStep now wraps it for the org flow.

The campaign creator's "Put a face to your campaign" wizard step now
renders the same banner + avatar + name + bio card instead of the old
plain name/avatar/collapsible-about form, with "A little about you…" as
the bio placeholder. The wizard already supplies the back arrow and
progress bar, and the published kind-0 now carries the banner too.
2026-06-14 10:29:35 -07:00
lemon e9bc52030f Move verifier withdraw to the profile "How We Verify" card
Remove the inline Withdraw button (and its props) from
VerifierStatementEditor, and drop the success toast from the onboarding
"Publish your verifier statement" step. Withdrawing now lives in the
top-right of the "How We Verify" card on the user's own profile — gated
on isOwnProfile, mirroring the Edit Profile affordance — with an
AlertDialog confirmation before retracting the kind 14672 statement.
2026-06-14 10:29:35 -07:00
lemon f3deb14c8b Share avatar/banner image menu with working Remove action
Deduplicate the avatar and banner edit menus into a single ImageEditMenu
in ProfileCard: "Upload file" (replacing "Change avatar"/"Change banner"),
optional "Paste URL", and a generic "Remove" (replacing "Remove avatar").
The banner gains the Remove action via a new onRemoveBanner prop.

Fix the non-working Remove in the organization setup step: the verifier
identity step never passed remove handlers, and its onChange ignored
picture/banner — so removing an image did nothing. Wire onRemoveAvatar /
onRemoveBanner to clear the draft fields directly. Pass onRemoveBanner in
ProfileSettings too.
2026-06-14 10:29:35 -07:00
lemon 85601bdca8 Hide the Groups and Pledges profile tabs for now 2026-06-14 10:29:35 -07:00
lemon 1d35f1fe63 Move "How We Verify" statement from Overview to the Verified tab 2026-06-14 10:29:35 -07:00
lemon 24842d5a05 Match verifier bio and statement editors, narrow org steps
The org bio textarea now uses the same muted, borderless fill as the
'Your name' field on the identity step, starts at min-h-200, and
auto-grows as the user types. The verifier statement (Milkdown) editor
gets the same muted wrapper and a scoped 200px min-height so the two
steps match. The bio, statement, and how-to steps drop from max-w-3xl
to max-w-xl so the boxes and the tutorial's last step aren't oversized.
2026-06-14 10:29:35 -07:00
lemon 48e18c16b6 Rasterize pasted images at a usable width for cropping
A pasted SVG (or any small source) was rasterized by the proxy at its
tiny intrinsic size, so the cropper showed a speck in the preview box.
Request a target width (1500 banner / 1024 avatar) with fit=inside so
small and vector sources are enlarged to a workable crop resolution.
2026-06-14 10:29:35 -07:00
lemon 5db139b930 Fetch pasted organization images as files before cropping
Pasted image URLs were handed to the cropper as a raw remote src, so
encodeImage's canvas fetch hit the origin directly and failed CORS
(e.g. nips.nostr.com SVGs, or any host when the image proxy is off).
Now the paste handler fetches the bytes through the image proxy into an
object URL and feeds them through the same crop -> Blossom-upload flow
as a local file, so the cropper only ever sees a same-origin blob:.
2026-06-14 10:29:35 -07:00
lemon 4fd320d5c0 Fix pasted organization image crop processing 2026-06-14 10:29:35 -07:00
lemon 656ea70492 Make organization setup card transparent 2026-06-14 10:29:35 -07:00
lemon ae8d3cea56 Open pasted organization images in crop editor 2026-06-14 10:29:35 -07:00
lemon 316f6dd8ec Gate campaigns button until tutorial completes 2026-06-14 10:29:35 -07:00
lemon c662db2ce0 Use two second verifier tutorial timing 2026-06-14 10:29:35 -07:00
lemon e562f7d0a2 Tune verifier tutorial reveal timing 2026-06-14 10:29:35 -07:00
lemon c2a80df9ed Slow verifier tutorial reveal timing 2026-06-14 10:29:35 -07:00
lemon 239c83f1a8 Simplify verifier tutorial timing loop 2026-06-14 10:29:35 -07:00
lemon 07927c1911 Fix verifier tutorial replay loop 2026-06-14 10:29:35 -07:00
lemon e806b373d3 Match verifier bio textarea to statement editor 2026-06-14 10:29:35 -07:00
lemon fea8166472 Make verify tutorial a passive looping demo
Drop the clickable step buttons that let users scrub/pause the demo.
The animation already auto-advances and wraps around, so the gesture
now replays on an endless loop and users learn purely by watching.
The step list becomes a non-interactive read-out synced to the
animation. Removes the goto/paused reducer state and the
resume-after-scrub timer.
2026-06-14 10:29:35 -07:00
lemon 6e017a88be Remove redundant label from verifier bio step
The 'About your organization' sub-header duplicated the step title.
Drop the visible Label and keep it as an aria-label so the textarea
stays accessible. Input text size already matches the verifier
statement editor (1.125rem / text-lg).
2026-06-14 10:29:35 -07:00
lemon 0645a60f3a Mirror a real campaign and the live verified badge in the verify tutorial
The how-to demo card now reflects a real published campaign (the Agora
App Development Fund) instead of invented placeholder copy: real title,
organizer, banner image, and goal/progress. The verified badge is now a
faithful copy of the live overlay CampaignVerificationBadge (dark
translucent pill, ring-bordered avatar, sky-300 check, no count) for a
single verifier, so the preview matches exactly how a verification
surfaces on a real card.

Drops the now-unused demo i18n keys (campaignTitle, campaignOrganizer,
verifiedBadge) across all locales.
2026-06-14 10:29:35 -07:00
lemon ccf64f5906 Match bio step sizing to the verifier statement step
The bio step now uses the same wide (max-w-3xl) layout and a large
text surface (min-h-[400px], text-lg) so it visually matches the
markdown statement editor, minus the markdown formatting.
2026-06-14 10:29:35 -07:00
lemon ab03489a3f Tighten verifier onboarding step subtitles 2026-06-14 10:29:35 -07:00
lemon 29a5ede59a Preview verifier badge on the how-to step and tighten step copy
Remove the badge preview from the org bio step and surface it on the
'How to verify a campaign' step instead, where the demo card now shows
the org's own avatar + name in place of the generic 'Verified by you'
label. Shorten the tutorial step descriptions.
2026-06-14 10:29:35 -07:00
lemon 425923a4dd Add 'Paste URL' option to org setup avatar and banner pickers 2026-06-14 10:29:35 -07:00
lemon 55a2321330 Drop 'your' from verifier role finder note 2026-06-14 10:29:35 -07:00
lemon 59196af579 Blend the verify tutorial into the step; remove it from /organizations
Add hideHeader, bare, and stacked props to VerifyTutorial. The how-to step
now renders it borderless with the header hidden (single step header) and
the demo card stacked full-width above the step list so it matches the
button width below. Remove the tutorial from /organizations, leaving just
the 'Start verifying' CTA card.
2026-06-14 10:29:35 -07:00
lemon 6b01a24248 Simplify the statement step; remove the editor from /organizations
Drop the 'Become a verifier' publish button: the statement step's primary
Continue button now publishes the kind 14672 statement and advances, with
an inline Withdraw for returning verifiers. Collapse the duplicated prompt /
disclaimer copy into a single header + subtext and make the editor
borderless. Replace the /organizations functional editor with a CTA that
launches the verifier onboarding flow.
2026-06-14 10:29:35 -07:00
lemon 8f4bea1210 Mirror the verification badge in the bio-step preview
Replace the plain avatar+name+website header with a preview that mirrors the
inline verification badge (stacked avatar + check + name), so the user sees
how their logo and name will surface to donors. Drops the website from the
preview.
2026-06-14 10:29:35 -07:00
lemon 90e06e328f Make website replace the bio slot; relax identity requirements
Add a bioField prop to ProfileCard so the editable slot below the name can
edit the website instead of the bio. The verifier identity step now edits
the website inline (no separate input) and only requires avatar + name;
banner and website are optional, with website still https-validated when
entered.
2026-06-14 10:29:35 -07:00
lemon fbed6aa0ff Tighten role-picker copy to single lines
Shorten the verifier description so it fits on one line and update the
donor finder note to '100% of your donation goes to the campaign.'
2026-06-14 10:29:35 -07:00
lemon f49ca00c09 Add verifier sub-flow step 4: how to verify + finish
Replace the placeholder shell with the real how-to step, reusing the
animated VerifyTutorial and a terminal 'View campaigns' CTA that cancels the
overlay and navigates to /campaigns. Widen the captive content wrapper for
the statement and how-to steps to fit the editor and two-column tutorial.
2026-06-14 10:29:35 -07:00
lemon 23773d352c Extract shared VerifierStatementEditor; embed as sub-flow step 3
Pull the kind 14672 publish/update/withdraw editor out of OrganizationsPage
into a reusable VerifierStatementEditor with an onPublishedChange callback.
OrganizationsPage now consumes it (logged-out gate and verify tutorial
unchanged), and the captive verifier sub-flow renders it as step 3 with a
Continue button that unlocks once a statement is published.
2026-06-14 10:29:35 -07:00
lemon 5d4b9cf2f5 Publish the verifier organization profile (kind 0)
Add usePublishOrgProfile, which merges the collected org draft (name,
website, picture, banner, about) onto any existing kind-0 via
fetchFreshEvent + prev, guarded by an expectedPubkey check so a failed
signup auto-login can't overwrite another account. The bio step's continue
now publishes the profile before advancing; failure is non-fatal (toast +
proceed).
2026-06-14 10:29:35 -07:00
lemon 72c7520a12 Add verifier sub-flow step 2: organization bio
A required bio textarea (kind-0 about) with a small avatar + name preview
header carried over from the identity step for continuity. The bio is added
to the shared profile draft; publishing of the assembled kind-0 profile is
wired up in the next commit.
2026-06-14 10:29:35 -07:00
lemon 8a908cd11c Add verifier sub-flow step 1: organization identity
Build the organization-identity screen on the shared ProfileCard (circular
avatar, rectangular banner, inline name) plus ImageCropDialog for uploads,
with a dedicated required website field. All four fields are required and
the website must be a valid https URL before continuing. The draft is held
in the captive overlay and published later; nothing is written here.
2026-06-14 10:29:35 -07:00
lemon a60a757f0f Scaffold the captive verifier sub-flow state machine
Add four sub-flow step keys (orgIdentity, orgBio, orgStatement,
orgVerifyHowto) that the verifier role enters instead of navigating away.
Wire the progress bar to the extended verifier step list, branch back/next
navigation through the sub-flow, and render placeholder shells. The
per-step UI lands in following commits.
2026-06-14 10:29:34 -07:00
lemon f2fa16c3bb Add 'Verify campaigns' as a third onboarding role
Extend OnboardingRole and StartSignupOptions to include 'verifier', add a
third RoleCard to the role picker, and wire the pick handler to branch on
the new role. For now the verifier pick routes to the public /organizations
onboarding tool; a later commit replaces this with a captive sub-flow.
2026-06-14 10:29:34 -07:00
40 changed files with 1839 additions and 576 deletions
+6 -5
View File
@@ -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. */}
+7 -3
View File
@@ -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);
}
+317 -23
View File
@@ -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
View File
@@ -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 && (
+14 -4
View File
@@ -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;
+172 -164
View File
@@ -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. */}
+15 -7
View File
@@ -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>
);
}
+1 -1
View File
@@ -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);
}, []);
+10 -6
View File
@@ -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);
+82
View File
@@ -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'] });
}
},
});
}
+9
View File
@@ -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); }
+80
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
}
},
+2 -5
View File
@@ -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
View File
@@ -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": "验证此活动"
}
}
},
+19 -2
View File
@@ -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>
+6
View File
@@ -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" />
+89 -125
View File
@@ -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}
/>
);
}
+12 -9
View File
@@ -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>
);
}
+8 -7
View File
@@ -914,7 +914,7 @@ function ProfileTabContent({
}
if (activeTab === 'verified') {
return <ProfileVerifiedTab pubkey={pubkey} displayName={displayName} />;
return <ProfileVerifiedTab pubkey={pubkey} displayName={displayName} isOwnProfile={isOwnProfile} />;
}
if (activeTab === 'campaigns') {
@@ -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.
+1
View File
@@ -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}
/>