feat: contact form — method selector, zod validation, Nostr delivery

ContactForm wires vee-validate + zod against the inquiry payload:
optional name, required method (Email/WhatsApp/Signal/Telegram/Nostr),
contact value validated per method (email regex, phone-or-handle,
@handle, npub1 prefix), and a 10-2000 char message. On submit the
form calls submitInquiry() from the Nostr feature and toasts the
result — partial relay acceptance still counts as success and is
surfaced to the visitor.

PrivacyBlurb sits above the form explaining the model in plain
language: encrypted in the browser, delivered through Nostr, no
server in between. Lock icon plus terse copy — the goal is to put a
non-Nostr-native visitor at ease without a wall of jargon.

.env.example documents the two build-time vars (VITE_OWNER_NPUB,
VITE_NOSTR_RELAYS) the form depends on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-27 11:24:32 +02:00
commit def88eacad
4 changed files with 252 additions and 4 deletions

15
.env.example Normal file
View file

@ -0,0 +1,15 @@
# Earth Walker Design — site env vars
#
# Both are inlined at build time by Vite. To rotate either, edit
# values here, then rebuild + redeploy.
# The Nostr public key that receives encrypted inquiry submissions.
# Bech32 npub1... form. Generate via `nak key generate` (fiatjaf/nak)
# or any Nostr client.
VITE_OWNER_NPUB=
# Optional. Comma-separated wss:// relay URLs the inquiry form
# publishes to. If unset, defaults to:
# wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band
# The submission succeeds if at least one relay accepts the event.
VITE_NOSTR_RELAYS=

View file

@ -0,0 +1,199 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { submitInquiry, type ContactMethod } from '@/features/nostr/submitInquiry'
const METHODS: { value: ContactMethod; label: string; placeholder: string }[] = [
{ value: 'email', label: 'Email', placeholder: 'you@example.com' },
{ value: 'whatsapp', label: 'WhatsApp', placeholder: '+1 555 123 4567' },
{ value: 'signal', label: 'Signal', placeholder: '+1 555 123 4567 or @username' },
{ value: 'telegram', label: 'Telegram', placeholder: '@yourhandle' },
{ value: 'nostr', label: 'Nostr', placeholder: 'npub1…' },
]
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const PHONE_RE = /^\+?[\d\s().-]{7,}$/
const HANDLE_RE = /^@?[A-Za-z0-9_.]{3,32}$/
const Schema = z
.object({
name: z
.string()
.trim()
.max(80, 'Keep it under 80 characters.')
.optional()
.or(z.literal('')),
contactMethod: z.enum(['email', 'whatsapp', 'signal', 'telegram', 'nostr'], {
message: 'Choose how the studio should reach you.',
}),
contactValue: z.string().trim().min(2, 'Required.').max(200, 'Too long.'),
message: z
.string()
.trim()
.min(10, 'A sentence or two helps the studio respond well.')
.max(2000, 'Keep it under 2000 characters.'),
})
.superRefine((data, ctx) => {
const v = data.contactValue
const fail = (msg: string) =>
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['contactValue'], message: msg })
switch (data.contactMethod) {
case 'email':
if (!EMAIL_RE.test(v)) fail('That does not look like an email address.')
break
case 'whatsapp':
case 'signal':
if (!PHONE_RE.test(v) && !HANDLE_RE.test(v))
fail('Use a phone number or a handle.')
break
case 'telegram':
if (!HANDLE_RE.test(v)) fail('Use a Telegram handle, like @yourname.')
break
case 'nostr':
if (!/^npub1[0-9a-z]{30,}$/.test(v)) fail('That does not look like an npub.')
break
}
})
type FormValues = z.input<typeof Schema>
const { handleSubmit, isSubmitting, resetForm, values } = useForm<FormValues>({
validationSchema: toTypedSchema(Schema),
initialValues: { name: '', contactMethod: 'email', contactValue: '', message: '' },
})
const submitting = ref(false)
const onSubmit = handleSubmit(async (vals) => {
submitting.value = true
try {
const result = await submitInquiry({
name: vals.name?.trim() || undefined,
contactMethod: vals.contactMethod as ContactMethod,
contactValue: vals.contactValue.trim(),
message: vals.message.trim(),
})
if (result.ok) {
toast.success('Inquiry sent', {
description:
result.acceptedBy === result.attempted
? 'The studio will be in touch.'
: `Delivered to ${result.acceptedBy} of ${result.attempted} relays — the studio will still receive it.`,
})
resetForm()
} else {
toast.error('Could not reach any relay', {
description: 'Check your network and try again.',
})
}
} catch (err) {
toast.error('Inquiry failed', {
description: err instanceof Error ? err.message : 'Unknown error.',
})
} finally {
submitting.value = false
}
})
function placeholderFor(method: string | undefined): string {
return METHODS.find((m) => m.value === method)?.placeholder ?? ''
}
</script>
<template>
<form class="space-y-7" novalidate @submit="onSubmit">
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>Your name <span class="text-muted-foreground">(optional)</span></FormLabel>
<FormControl>
<Input
type="text"
autocomplete="name"
placeholder="First name, or however you'd like to be called"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<div class="grid gap-7 sm:grid-cols-[180px_1fr]">
<FormField v-slot="{ componentField }" name="contactMethod">
<FormItem>
<FormLabel>Reach me via</FormLabel>
<Select v-bind="componentField">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Choose…" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem v-for="m in METHODS" :key="m.value" :value="m.value">
{{ m.label }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="contactValue">
<FormItem>
<FormLabel>Where to send the reply</FormLabel>
<FormControl>
<Input
type="text"
:placeholder="placeholderFor(values.contactMethod)"
v-bind="componentField"
/>
</FormControl>
<FormDescription class="text-xs">
Whatever is easiest for you the studio will use exactly this.
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</div>
<FormField v-slot="{ componentField }" name="message">
<FormItem>
<FormLabel>Your message</FormLabel>
<FormControl>
<Textarea
rows="6"
placeholder="Tell the studio a little about your space and what you are hoping for."
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<div class="flex items-center justify-end pt-2">
<Button type="submit" :disabled="isSubmitting || submitting" class="min-w-36">
{{ isSubmitting || submitting ? 'Sending…' : 'Send inquiry' }}
</Button>
</div>
</form>
</template>

View file

@ -0,0 +1,20 @@
<script setup lang="ts">
import { Lock } from '@lucide/vue'
</script>
<template>
<aside
class="border-border/60 text-muted-foreground bg-muted/30 rounded-sm border p-5 text-sm leading-relaxed"
>
<div class="text-foreground mb-2 flex items-center gap-2 text-xs uppercase tracking-[0.18em]">
<Lock class="h-3.5 w-3.5" />
<span>How this works</span>
</div>
<p>
Your message is encrypted in your browser and delivered through the
<span class="text-foreground">Nostr network</span> directly to the studio. No server
between us stores or sees it, and the studio replies through the contact method
you choose.
</p>
</aside>
</template>

View file

@ -1,11 +1,25 @@
<script setup lang="ts"></script> <script setup lang="ts">
import ContactForm from '@/components/contact/ContactForm.vue'
import PrivacyBlurb from '@/components/contact/PrivacyBlurb.vue'
</script>
<template> <template>
<div class="mx-auto max-w-2xl px-6 py-20 md:py-28"> <div class="mx-auto max-w-2xl px-6 pt-20 pb-28 md:pt-28 md:pb-32">
<p class="eyebrow">Contact</p> <p class="eyebrow">Contact</p>
<h1 class="mt-3 font-serif text-4xl font-light tracking-tight md:text-5xl"> <h1 class="mt-3 font-serif text-4xl font-light tracking-tight md:text-5xl">
Get in touch. Begin a conversation.
</h1> </h1>
<p class="text-muted-foreground mt-6">Form arriving in the next commit.</p> <p class="text-foreground/70 mt-6 max-w-prose text-base md:text-lg">
The studio takes on a small number of new homes each year. Send a few notes about
your space and we'll be in touch through your preferred channel.
</p>
<div class="mt-10">
<PrivacyBlurb />
</div>
<div class="mt-10">
<ContactForm />
</div>
</div> </div>
</template> </template>