Build better onboarding

This commit is contained in:
Jon Staab
2025-08-18 15:02:17 -07:00
parent 38e0fc53ad
commit 4f6c08f8a2
8 changed files with 219 additions and 176 deletions
+28 -7
View File
@@ -18,11 +18,11 @@
type Props = { type Props = {
initialValues: Values initialValues: Values
onsubmit: (values: Values) => void onsubmit: (values: Values) => void
hideAddress?: boolean isSignup?: boolean
footer: Snippet footer: Snippet
} }
const {initialValues, hideAddress, onsubmit, footer}: Props = $props() const {initialValues, isSignup, onsubmit, footer}: Props = $props()
const values = $state(initialValues) const values = $state(initialValues)
@@ -32,9 +32,25 @@
</script> </script>
<form class="col-4" onsubmit={preventDefault(submit)}> <form class="col-4" onsubmit={preventDefault(submit)}>
<div class="flex justify-center py-2"> {#if isSignup}
<div class="grid grid-cols-2">
<div class="flex flex-col gap-2">
<p class="text-2xl">Create a Profile</p>
<p class="text-sm">
Give people something to go on — but remember, privacy matters! Be careful about sharing
sensitive information.
</p>
</div>
<div class="flex flex-col items-center justify-center gap-2">
<InputProfilePicture bind:file bind:url={values.profile.picture} />
<p class="text-xs">Upload an Avatar</p>
</div>
</div>
{:else}
<div class="flex items-center justify-center py-4">
<InputProfilePicture bind:file bind:url={values.profile.picture} /> <InputProfilePicture bind:file bind:url={values.profile.picture} />
</div> </div>
{/if}
<Field> <Field>
{#snippet label()} {#snippet label()}
<p>Username</p> <p>Username</p>
@@ -63,7 +79,7 @@
Give a brief introduction to why you're here. Give a brief introduction to why you're here.
{/snippet} {/snippet}
</Field> </Field>
{#if !hideAddress} {#if !isSignup}
<Field> <Field>
{#snippet label()} {#snippet label()}
<p>Nostr Address</p> <p>Nostr Address</p>
@@ -82,19 +98,24 @@
{/snippet} {/snippet}
</Field> </Field>
{/if} {/if}
{#if !isSignup}
<FieldInline> <FieldInline>
{#snippet label()} {#snippet label()}
<p>Broadcast Profile</p> <p>Broadcast Profile</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<input type="checkbox" class="toggle toggle-primary" bind:checked={values.shouldBroadcast} /> <input
type="checkbox"
class="toggle toggle-primary"
bind:checked={values.shouldBroadcast} />
{/snippet} {/snippet}
{#snippet info()} {#snippet info()}
<p> <p>
If enabled, changes will be published to the broader nostr network in addition to spaces you If enabled, changes will be published to the broader nostr network in addition to spaces
are a member of. you are a member of.
</p> </p>
{/snippet} {/snippet}
</FieldInline> </FieldInline>
{/if}
{@render footer()} {@render footer()}
</form> </form>
+7 -26
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import {Capacitor} from "@capacitor/core"
import {postJson} from "@welshman/lib" import {postJson} from "@welshman/lib"
import {isMobile, preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import FieldInline from "@lib/components/FieldInline.svelte" import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -9,23 +8,12 @@
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import LogIn from "@app/components/LogIn.svelte" import LogIn from "@app/components/LogIn.svelte"
import InfoNostr from "@app/components/InfoNostr.svelte" import InfoNostr from "@app/components/InfoNostr.svelte"
import SignUpKey from "@app/components/SignUpKey.svelte" import SignUpProfile from "@app/components/SignUpProfile.svelte"
import SignUpSuccess from "@app/components/SignUpSuccess.svelte" import SignUpSuccess from "@app/components/SignUpSuccess.svelte"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {BURROW_URL, PLATFORM_NAME, PLATFORM_ACCENT} from "@app/state" import {BURROW_URL, PLATFORM_NAME} from "@app/state"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
const params = new URLSearchParams({
an: PLATFORM_NAME,
ac: window.location.origin,
at: isMobile ? "android" : "web",
aa: PLATFORM_ACCENT.slice(1),
am: "dark",
asf: "yes",
})
const nstart = `https://start.njump.me/?${params.toString()}`
const login = () => pushModal(LogIn) const login = () => pushModal(LogIn)
const signupPassword = async () => { const signupPassword = async () => {
@@ -50,7 +38,7 @@
} }
} }
const useKey = () => pushModal(SignUpKey) const next = () => pushModal(SignUpProfile)
let email = $state("") let email = $state("")
let password = $state("") let password = $state("")
@@ -61,8 +49,8 @@
<h1 class="heading">Sign up with Nostr</h1> <h1 class="heading">Sign up with Nostr</h1>
<p class="m-auto max-w-sm text-center"> <p class="m-auto max-w-sm text-center">
{PLATFORM_NAME} is built using the {PLATFORM_NAME} is built using the
<Button class="link" onclick={() => pushModal(InfoNostr)}>nostr protocol</Button>, which allows <Button class="link" onclick={() => pushModal(InfoNostr)}>nostr protocol</Button>, which gives
you to own your social identity. users control over their digital identity using <strong>cryptographic key pairs</strong>.
</p> </p>
{#if BURROW_URL} {#if BURROW_URL}
<FieldInline> <FieldInline>
@@ -98,17 +86,10 @@
</p> </p>
<Divider>Or</Divider> <Divider>Or</Divider>
{/if} {/if}
{#if Capacitor.isNativePlatform()} <Button onclick={next} class="btn {email || password ? 'btn-neutral' : 'btn-primary'}">
<Button onclick={useKey} class="btn {email || password ? 'btn-neutral' : 'btn-primary'}">
<Icon icon="key" /> <Icon icon="key" />
Generate a key Generate a key
</Button> </Button>
{:else}
<a href={nstart} class="btn {email || password ? 'btn-neutral' : 'btn-primary'}">
<Icon icon="square-share-line" />
Create an account on Nstart
</a>
{/if}
<div class="text-sm"> <div class="text-sm">
Already have an account? Already have an account?
<Button class="link" onclick={login}>Log in instead</Button> <Button class="link" onclick={login}>Log in instead</Button>
+61
View File
@@ -0,0 +1,61 @@
<script lang="ts">
import type {Profile} from "@welshman/util"
import {createProfile, PROFILE, makeEvent} from "@welshman/util"
import {publishThunk, loginWithNip01} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {PROTECTED} from "@app/state"
type Props = {
secret: string
profile: Profile
}
const {secret, profile}: Props = $props()
const back = () => history.back()
const next = () => {
const template = createProfile(profile)
// Start out protected by default
template.tags.push(PROTECTED)
const event = makeEvent(PROFILE, template)
// Log in first, then publish
loginWithNip01(secret)
// Don't publish anywhere yet, wait until they join a space
publishThunk({event, relays: []})
}
</script>
<form class="column gap-4" onsubmit={preventDefault(next)}>
<ModalHeader>
{#snippet title()}
<div>You're all set!</div>
{/snippet}
</ModalHeader>
<p>
You've created your profile, saved your keys, and now you're ready to start chatting — all
without asking permission!
</p>
<p>
From your dashboard, you can use invite links, discover community spaces, and keep up-to-date on
groups you've already joined. Click below to get started!
</p>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button class="btn btn-primary" type="submit">
<Icon icon="home-smile" />
Go to Dashboard
</Button>
</ModalFooter>
</form>
+57 -20
View File
@@ -1,60 +1,83 @@
<script lang="ts"> <script lang="ts">
import {nsecEncode} from "nostr-tools/nip19"
import {encrypt} from "nostr-tools/nip49" import {encrypt} from "nostr-tools/nip49"
import {hexToBytes} from "@welshman/lib" import {hexToBytes} from "@welshman/lib"
import {makeSecret} from "@welshman/signer" import {makeSecret} from "@welshman/signer"
import type {Profile} from "@welshman/util"
import {preventDefault, downloadText} from "@lib/html" import {preventDefault, downloadText} from "@lib/html"
import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte" import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import SignUpKeyConfirm from "@app/components/SignUpKeyConfirm.svelte" import SignUpComplete from "@app/components/SignUpComplete.svelte"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
type Props = {
profile: Profile
}
const {profile}: Props = $props()
const secret = makeSecret() const secret = makeSecret()
const back = () => history.back() const back = () => history.back()
const next = () => { const downloadKey = () => {
if (usePassword) {
if (password.length < 12) { if (password.length < 12) {
return pushToast({ return pushToast({
theme: "error", theme: "error",
message: "Passwords must be at least 12 characters long.", message: "Your password must be at least 12 characters long.",
}) })
} }
const ncryptsec = encrypt(hexToBytes(secret), password) const ncryptsec = encrypt(hexToBytes(secret), password)
downloadText("Nostr Secret Key.txt", ncryptsec) downloadText("Nostr Secret Key.txt", ncryptsec)
} else {
const nsec = nsecEncode(hexToBytes(secret))
pushModal(SignUpKeyConfirm, {secret, ncryptsec}) downloadText("Nostr Secret Key.txt", nsec)
} }
let password = "" didDownload = true
}
const next = () => {
pushModal(SignUpComplete, {profile, secret})
}
const onPasswordChange = () => {
didDownload = false
}
const toggleUsePassword = () => {
usePassword = !usePassword
didDownload = false
}
let password = $state("")
let usePassword = $state(false)
let didDownload = $state(false)
</script> </script>
<form class="column gap-4" onsubmit={preventDefault(next)}> <form class="column gap-4" onsubmit={preventDefault(next)}>
<ModalHeader> <ModalHeader>
{#snippet title()} {#snippet title()}
<div>Welcome to Nostr!</div> <div>Your Keys are Ready!</div>
{/snippet} {/snippet}
</ModalHeader> </ModalHeader>
<p> <p>
<Link external href="https://nostr.com/" class="link">Nostr</Link> is way to build social apps that A cryptographic key pair has two parts: your <strong>public key</strong> identifies your
talk to each other. Users own their social identity instead of renting it from a tech company, and account, while your <strong>private key</strong> acts sort of like a master password.
can take it with them.
</p> </p>
<p> <p>
This means that instead of using a password to log in, you generate a <strong Securing your private key is very important, so make sure to take the time to save your key in a
>secret key</strong> secure place (like a password manager).
which gives you full control over your account.
</p>
<p>
Keeping this key safe is very important, so we encourage you to download an encrypted copy. To
do this, go ahead and fill in the password you'd like to use to secure your key below.
</p> </p>
{#if usePassword}
<Field> <Field>
{#snippet label()} {#snippet label()}
Password* Password*
@@ -62,20 +85,34 @@
{#snippet input()} {#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2"> <label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="key" /> <Icon icon="key" />
<input bind:value={password} class="grow" type="password" /> <input bind:value={password} onchange={onPasswordChange} class="grow" type="password" />
</label> </label>
{/snippet} {/snippet}
{#snippet info()} {#snippet info()}
<p>Passwords should be at least 12 characters long. Write this down!</p> <p>Passwords should be at least 12 characters long. Write this down!</p>
{/snippet} {/snippet}
</Field> </Field>
{/if}
<div class="flex flex-col">
<Button class="btn {didDownload ? 'btn-neutral' : 'btn-primary'}" onclick={downloadKey}>
Download my key
<Icon icon="arrow-down" />
</Button>
<Button class="btn btn-link no-underline" onclick={toggleUsePassword}>
{#if usePassword}
Nevermind, I want to download the plain version
{:else}
I want to download an encrypted version
{/if}
</Button>
</div>
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back}> <Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" /> <Icon icon="alt-arrow-left" />
Go back Go back
</Button> </Button>
<Button class="btn btn-primary" type="submit"> <Button disabled={!didDownload} class="btn btn-primary" type="submit">
Download my key Continue
<Icon icon="alt-arrow-right" /> <Icon icon="alt-arrow-right" />
</Button> </Button>
</ModalFooter> </ModalFooter>
@@ -1,65 +0,0 @@
<script lang="ts">
import {preventDefault, copyToClipboard} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import SignUpProfile from "@app/components/SignUpProfile.svelte"
import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
type Props = {
secret: string
ncryptsec: string
}
const {secret, ncryptsec}: Props = $props()
const back = () => history.back()
const copy = () => {
copyToClipboard(ncryptsec)
pushToast({message: "Your secret key has been copied to your clipboard!"})
}
const next = () => {
pushModal(SignUpProfile, {secret})
}
</script>
<form class="column gap-4" onsubmit={preventDefault(next)}>
<ModalHeader>
{#snippet title()}
<div>Download your key</div>
{/snippet}
</ModalHeader>
<p>
Great! We've encrypted your secret key and saved it to your device. If that didn't work, or if
you'd rather save your key somewhere else, you can find the encrypted version below:
</p>
<Field>
{#snippet label()}
Encrypted Secret Key
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="key" />
<input value={ncryptsec} class="ellipsize grow" />
<Button onclick={copy} class="flex items-center">
<Icon icon="copy" />
</Button>
</label>
{/snippet}
</Field>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button class="btn btn-primary" type="submit">
Fill out your profile
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
</form>
+22 -19
View File
@@ -1,33 +1,36 @@
<script lang="ts"> <script lang="ts">
import type {Profile} from "@welshman/util" import type {Profile} from "@welshman/util"
import {PROFILE, createProfile, makeProfile, makeEvent} from "@welshman/util" import {makeProfile} from "@welshman/util"
import {loginWithNip01, publishThunk} from "@welshman/app" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileEditForm from "@app/components/ProfileEditForm.svelte" import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
import {INDEXER_RELAYS} from "@app/state" import SignUpKey from "@app/components/SignUpKey.svelte"
import {pushModal} from "@app/modal"
type Props = {
secret: string
}
const {secret}: Props = $props()
const initialValues = { const initialValues = {
profile: makeProfile(), profile: makeProfile(),
shouldBroadcast: true, shouldBroadcast: false,
} }
const onsubmit = ({profile, shouldBroadcast}: {profile: Profile; shouldBroadcast: boolean}) => { const back = () => history.back()
const event = makeEvent(PROFILE, createProfile(profile))
const relays = shouldBroadcast ? INDEXER_RELAYS : []
loginWithNip01(secret) const onsubmit = (values: {profile: Profile}) => pushModal(SignUpKey, values)
publishThunk({event, relays})
}
</script> </script>
<ProfileEditForm hideAddress {initialValues} {onsubmit}> <div class="flex flex-col gap-4">
<ProfileEditForm isSignup {initialValues} {onsubmit}>
{#snippet footer()} {#snippet footer()}
<Button type="submit" class="btn btn-primary">Create Account</Button> <ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button class="btn btn-primary" type="submit">
Create Account
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
{/snippet} {/snippet}
</ProfileEditForm> </ProfileEditForm>
</div>
+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4L12 20M12 20L18 14M12 20L6 14" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 233 B

+2
View File
@@ -9,6 +9,7 @@
import {switcher} from "@welshman/lib" import {switcher} from "@welshman/lib"
import AddSquare from "@assets/icons/Add Square.svg?dataurl" import AddSquare from "@assets/icons/Add Square.svg?dataurl"
import ArrowsALogout2 from "@assets/icons/Arrows ALogout 2.svg?dataurl" import ArrowsALogout2 from "@assets/icons/Arrows ALogout 2.svg?dataurl"
import ArrowDown from "@assets/icons/Arrow Down.svg?dataurl"
import Bell from "@assets/icons/Bell.svg?dataurl" import Bell from "@assets/icons/Bell.svg?dataurl"
import Bookmark from "@assets/icons/Bookmark.svg?dataurl" import Bookmark from "@assets/icons/Bookmark.svg?dataurl"
import BillList from "@assets/icons/Bill List.svg?dataurl" import BillList from "@assets/icons/Bill List.svg?dataurl"
@@ -112,6 +113,7 @@
const data = switcher(icon, { const data = switcher(icon, {
"add-square": AddSquare, "add-square": AddSquare,
"arrows-a-logout-2": ArrowsALogout2, "arrows-a-logout-2": ArrowsALogout2,
"arrow-down": ArrowDown,
bell: Bell, bell: Bell,
bookmark: Bookmark, bookmark: Bookmark,
"bill-list": BillList, "bill-list": BillList,