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>
|
||||
<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>
|
||||
<h1 class="mt-3 font-serif text-4xl font-light tracking-tight md:text-5xl">
|
||||
Get in touch.
|
||||
Begin a conversation.
|
||||
</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>
|
||||
</template>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue