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:
parent
dd4c87b548
commit
def88eacad
4 changed files with 252 additions and 4 deletions
15
.env.example
Normal file
15
.env.example
Normal 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=
|
||||||
199
src/components/contact/ContactForm.vue
Normal file
199
src/components/contact/ContactForm.vue
Normal 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>
|
||||||
20
src/components/contact/PrivacyBlurb.vue
Normal file
20
src/components/contact/PrivacyBlurb.vue
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue