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

View File

@@ -18,11 +18,11 @@
type Props = {
initialValues: Values
onsubmit: (values: Values) => void
hideAddress?: boolean
isSignup?: boolean
footer: Snippet
}
const {initialValues, hideAddress, onsubmit, footer}: Props = $props()
const {initialValues, isSignup, onsubmit, footer}: Props = $props()
const values = $state(initialValues)
@@ -32,9 +32,25 @@
</script>
<form class="col-4" onsubmit={preventDefault(submit)}>
<div class="flex justify-center py-2">
<InputProfilePicture bind:file bind:url={values.profile.picture} />
</div>
{#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} />
</div>
{/if}
<Field>
{#snippet label()}
<p>Username</p>
@@ -63,7 +79,7 @@
Give a brief introduction to why you're here.
{/snippet}
</Field>
{#if !hideAddress}
{#if !isSignup}
<Field>
{#snippet label()}
<p>Nostr Address</p>
@@ -82,19 +98,24 @@
{/snippet}
</Field>
{/if}
<FieldInline>
{#snippet label()}
<p>Broadcast Profile</p>
{/snippet}
{#snippet input()}
<input type="checkbox" class="toggle toggle-primary" bind:checked={values.shouldBroadcast} />
{/snippet}
{#snippet info()}
<p>
If enabled, changes will be published to the broader nostr network in addition to spaces you
are a member of.
</p>
{/snippet}
</FieldInline>
{#if !isSignup}
<FieldInline>
{#snippet label()}
<p>Broadcast Profile</p>
{/snippet}
{#snippet input()}
<input
type="checkbox"
class="toggle toggle-primary"
bind:checked={values.shouldBroadcast} />
{/snippet}
{#snippet info()}
<p>
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()}
</form>

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import {Capacitor} from "@capacitor/core"
import {postJson} from "@welshman/lib"
import {isMobile, preventDefault} from "@lib/html"
import {preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte"
@@ -9,23 +8,12 @@
import Spinner from "@lib/components/Spinner.svelte"
import LogIn from "@app/components/LogIn.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 {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"
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 signupPassword = async () => {
@@ -50,7 +38,7 @@
}
}
const useKey = () => pushModal(SignUpKey)
const next = () => pushModal(SignUpProfile)
let email = $state("")
let password = $state("")
@@ -61,8 +49,8 @@
<h1 class="heading">Sign up with Nostr</h1>
<p class="m-auto max-w-sm text-center">
{PLATFORM_NAME} is built using the
<Button class="link" onclick={() => pushModal(InfoNostr)}>nostr protocol</Button>, which allows
you to own your social identity.
<Button class="link" onclick={() => pushModal(InfoNostr)}>nostr protocol</Button>, which gives
users control over their digital identity using <strong>cryptographic key pairs</strong>.
</p>
{#if BURROW_URL}
<FieldInline>
@@ -98,17 +86,10 @@
</p>
<Divider>Or</Divider>
{/if}
{#if Capacitor.isNativePlatform()}
<Button onclick={useKey} class="btn {email || password ? 'btn-neutral' : 'btn-primary'}">
<Icon icon="key" />
Generate a key
</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}
<Button onclick={next} class="btn {email || password ? 'btn-neutral' : 'btn-primary'}">
<Icon icon="key" />
Generate a key
</Button>
<div class="text-sm">
Already have an account?
<Button class="link" onclick={login}>Log in instead</Button>

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>

View File

@@ -1,81 +1,118 @@
<script lang="ts">
import {nsecEncode} from "nostr-tools/nip19"
import {encrypt} from "nostr-tools/nip49"
import {hexToBytes} from "@welshman/lib"
import {makeSecret} from "@welshman/signer"
import type {Profile} from "@welshman/util"
import {preventDefault, downloadText} from "@lib/html"
import Link from "@lib/components/Link.svelte"
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 SignUpKeyConfirm from "@app/components/SignUpKeyConfirm.svelte"
import SignUpComplete from "@app/components/SignUpComplete.svelte"
import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
type Props = {
profile: Profile
}
const {profile}: Props = $props()
const secret = makeSecret()
const back = () => history.back()
const next = () => {
if (password.length < 12) {
return pushToast({
theme: "error",
message: "Passwords must be at least 12 characters long.",
})
const downloadKey = () => {
if (usePassword) {
if (password.length < 12) {
return pushToast({
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)
downloadText("Nostr Secret Key.txt", ncryptsec)
pushModal(SignUpKeyConfirm, {secret, ncryptsec})
didDownload = true
}
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>
<form class="column gap-4" onsubmit={preventDefault(next)}>
<ModalHeader>
{#snippet title()}
<div>Welcome to Nostr!</div>
<div>Your Keys are Ready!</div>
{/snippet}
</ModalHeader>
<p>
<Link external href="https://nostr.com/" class="link">Nostr</Link> is way to build social apps that
talk to each other. Users own their social identity instead of renting it from a tech company, and
can take it with them.
A cryptographic key pair has two parts: your <strong>public key</strong> identifies your
account, while your <strong>private key</strong> acts sort of like a master password.
</p>
<p>
This means that instead of using a password to log in, you generate a <strong
>secret key</strong>
which gives you full control over your account.
Securing your private key is very important, so make sure to take the time to save your key in a
secure place (like a password manager).
</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>
<Field>
{#snippet label()}
Password*
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="key" />
<input bind:value={password} class="grow" type="password" />
</label>
{/snippet}
{#snippet info()}
<p>Passwords should be at least 12 characters long. Write this down!</p>
{/snippet}
</Field>
{#if usePassword}
<Field>
{#snippet label()}
Password*
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="key" />
<input bind:value={password} onchange={onPasswordChange} class="grow" type="password" />
</label>
{/snippet}
{#snippet info()}
<p>Passwords should be at least 12 characters long. Write this down!</p>
{/snippet}
</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>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button class="btn btn-primary" type="submit">
Download my key
<Button disabled={!didDownload} class="btn btn-primary" type="submit">
Continue
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>

View File

@@ -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>

View File

@@ -1,33 +1,36 @@
<script lang="ts">
import type {Profile} from "@welshman/util"
import {PROFILE, createProfile, makeProfile, makeEvent} from "@welshman/util"
import {loginWithNip01, publishThunk} from "@welshman/app"
import {makeProfile} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
import {INDEXER_RELAYS} from "@app/state"
type Props = {
secret: string
}
const {secret}: Props = $props()
import SignUpKey from "@app/components/SignUpKey.svelte"
import {pushModal} from "@app/modal"
const initialValues = {
profile: makeProfile(),
shouldBroadcast: true,
shouldBroadcast: false,
}
const onsubmit = ({profile, shouldBroadcast}: {profile: Profile; shouldBroadcast: boolean}) => {
const event = makeEvent(PROFILE, createProfile(profile))
const relays = shouldBroadcast ? INDEXER_RELAYS : []
const back = () => history.back()
loginWithNip01(secret)
publishThunk({event, relays})
}
const onsubmit = (values: {profile: Profile}) => pushModal(SignUpKey, values)
</script>
<ProfileEditForm hideAddress {initialValues} {onsubmit}>
{#snippet footer()}
<Button type="submit" class="btn btn-primary">Create Account</Button>
{/snippet}
</ProfileEditForm>
<div class="flex flex-col gap-4">
<ProfileEditForm isSignup {initialValues} {onsubmit}>
{#snippet footer()}
<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}
</ProfileEditForm>
</div>

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

View File

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