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
+41 -20
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}
<InputProfilePicture bind:file bind:url={values.profile.picture} /> <div class="grid grid-cols-2">
</div> <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} />
</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}
<FieldInline> {#if !isSignup}
{#snippet label()} <FieldInline>
<p>Broadcast Profile</p> {#snippet label()}
{/snippet} <p>Broadcast Profile</p>
{#snippet input()} {/snippet}
<input type="checkbox" class="toggle toggle-primary" bind:checked={values.shouldBroadcast} /> {#snippet input()}
{/snippet} <input
{#snippet info()} type="checkbox"
<p> class="toggle toggle-primary"
If enabled, changes will be published to the broader nostr network in addition to spaces you bind:checked={values.shouldBroadcast} />
are a member of. {/snippet}
</p> {#snippet info()}
{/snippet} <p>
</FieldInline> If enabled, changes will be published to the broader nostr network in addition to spaces
you are a member of.
</p>
{/snippet}
</FieldInline>
{/if}
{@render footer()} {@render footer()}
</form> </form>
+10 -29
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>
+78 -41
View File
@@ -1,81 +1,118 @@
<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 (password.length < 12) { if (usePassword) {
return pushToast({ if (password.length < 12) {
theme: "error", return pushToast({
message: "Passwords must be at least 12 characters long.", theme: "error",
}) message: "Your password must be at least 12 characters long.",
})
}
const ncryptsec = encrypt(hexToBytes(secret), password)
downloadText("Nostr Secret Key.txt", ncryptsec)
} else {
const nsec = nsecEncode(hexToBytes(secret))
downloadText("Nostr Secret Key.txt", nsec)
} }
const ncryptsec = encrypt(hexToBytes(secret), password) didDownload = true
downloadText("Nostr Secret Key.txt", ncryptsec)
pushModal(SignUpKeyConfirm, {secret, ncryptsec})
} }
let password = "" 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>
<p> {#if usePassword}
Keeping this key safe is very important, so we encourage you to download an encrypted copy. To <Field>
do this, go ahead and fill in the password you'd like to use to secure your key below. {#snippet label()}
</p> Password*
<Field> {/snippet}
{#snippet label()} {#snippet input()}
Password* <label class="input input-bordered flex w-full items-center gap-2">
{/snippet} <Icon icon="key" />
{#snippet input()} <input bind:value={password} onchange={onPasswordChange} class="grow" type="password" />
<label class="input input-bordered flex w-full items-center gap-2"> </label>
<Icon icon="key" /> {/snippet}
<input bind:value={password} class="grow" type="password" /> {#snippet info()}
</label> <p>Passwords should be at least 12 characters long. Write this down!</p>
{/snippet} {/snippet}
{#snippet info()} </Field>
<p>Passwords should be at least 12 characters long. Write this down!</p> {/if}
{/snippet} <div class="flex flex-col">
</Field> <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>
+24 -21
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">
{#snippet footer()} <ProfileEditForm isSignup {initialValues} {onsubmit}>
<Button type="submit" class="btn btn-primary">Create Account</Button> {#snippet footer()}
{/snippet} <ModalFooter>
</ProfileEditForm> <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}
</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,