From 73bcf089c3a005b017ca93454012052177b54c17 Mon Sep 17 00:00:00 2001 From: Vula Builder Date: Tue, 15 Sep 2026 18:02:55 +0000 Subject: [PATCH] Deploy --- src/hooks/useEnquiry.ts | 130 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 src/hooks/useEnquiry.ts diff --git a/src/hooks/useEnquiry.ts b/src/hooks/useEnquiry.ts new file mode 100644 index 0000000..454d8a6 --- /dev/null +++ b/src/hooks/useEnquiry.ts @@ -0,0 +1,130 @@ +'use client' +import { useEffect, useRef, useState } from 'react' + +export interface Enquiry { + name?: string + phone?: string + email?: string + message?: string +} +export type EnquiryChannel = 'whatsapp' | 'email' +export interface EnquiryResult { + ok: boolean + message: string +} + +/** The message as it will appear to her, whichever way it is sent. */ +export function enquiryText(businessName: string | null, e: Enquiry): string { + const name = (e.name ?? '').trim() + const phone = (e.phone ?? '').trim() + const email = (e.email ?? '').trim() + const greeting = 'Hi' + (businessName ? ' ' + businessName : '') + (name ? ', this is ' + name + '.' : '.') + return [greeting, (e.message ?? '').trim(), phone ? 'My number: ' + phone : '', email ? 'My email: ' + email : ''] + .filter(Boolean) + .join('\n\n') +} + +/** + * What the visitor is told afterwards. Written here rather than in each form, + * so no form can say a message arrived when it has not. + */ +export function resultMessage(via: EnquiryChannel, ok: boolean, businessName: string | null): string { + if (!ok) return 'Your message could not be sent. Please try again, or contact us another way.' + if (via === 'whatsapp') return "WhatsApp has opened with your message. Press send and we'll reply there." + return businessName + ? 'Your message has been sent to ' + businessName + '. They will reply to you directly.' + : 'Your message has been sent. We will reply to you directly.' +} + +/** Her number in international digits, with the message typed in. */ +export function whatsAppUrl(number: string, text: string): string { + return 'https://wa.me/' + number + '?text=' + encodeURIComponent(text) +} + +/** Phones first offer WhatsApp; laptops first offer email. Only what is switched on. */ +export function channelsFor(on: { whatsapp: boolean; email: boolean }, onPhone: boolean): EnquiryChannel[] { + const order: EnquiryChannel[] = onPhone ? ['whatsapp', 'email'] : ['email', 'whatsapp'] + return order.filter((c) => on[c]) +} + +/** + * Sends an enquiry to the business, the ways this site can: WhatsApp opened on + * the visitor's device with the message typed in, or email sent by the server + * when the business has it switched on. + * + * `available` is false when neither is on. Do not show a form then — a form + * that cannot reach her is worse than none. + * + * `channels` is what to offer, in order. On a laptop both are offered: whether + * WhatsApp is linked to that computer cannot be known from here. + * + * `send` resolves to what happened, with the words to show. WhatsApp "ok" + * means it OPENED — the visitor still presses send, and the message says so. + */ +export function useEnquiry() { + const [whatsapp, setWhatsapp] = useState(null) + const [email, setEmail] = useState(false) + const [businessName, setBusinessName] = useState(null) + const [onPhone, setOnPhone] = useState(true) + const [loading, setLoading] = useState(true) + const opened = useRef(0) + + useEffect(() => { + // A touch-first screen is a phone or a tablet, where WhatsApp lives. + setOnPhone(window.matchMedia('(pointer: coarse)').matches) + const base = process.env.NEXT_PUBLIC_VULA_COMMERCE_URL + const id = process.env.NEXT_PUBLIC_VULA_PROJECT_ID + if (!base || !id) { setLoading(false); return } + let live = true + fetch(base + '/api/public/profile/' + id) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { + const p = d?.profile + if (!live || !p) return + setWhatsapp(p.enquiry?.whatsapp === false ? null : p.whatsapp || null) + setEmail(p.enquiry?.email === true) + setBusinessName(p.businessName || null) + }) + .catch(() => { /* nothing to send through means no form, which is where we already were */ }) + .finally(() => { if (live) setLoading(false) }) + return () => { live = false } + }, []) + + const channels = channelsFor({ whatsapp: !!whatsapp, email }, onPhone) + + async function send(e: Enquiry, via: EnquiryChannel = channels[0]): Promise { + if (via === 'whatsapp') { + if (!whatsapp) return { ok: false, message: resultMessage(via, false, businessName) } + // Opened before anything is awaited. A browser only lets a page open a + // window in direct response to a tap, and the first await ends that. + // A second tap within a few seconds is the same enquiry: WhatsApp adds a + // link's text to an unsent draft, so opening it twice doubles the message. + const now = Date.now() + if (now - opened.current >= 5000) { + opened.current = now + window.open(whatsAppUrl(whatsapp, enquiryText(businessName, e)), '_blank', 'noopener,noreferrer') + } + return { ok: true, message: resultMessage(via, true, businessName) } + } + + if (!email) return { ok: false, message: resultMessage(via, false, businessName) } + try { + const base = process.env.NEXT_PUBLIC_VULA_COMMERCE_URL + const id = process.env.NEXT_PUBLIC_VULA_PROJECT_ID + const r = await fetch(base + '/api/public/enquiry/' + id, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(e), + }) + const d = r.ok ? await r.json() : null + // Sent only when the server says it sent. A reply that is merely "ok" + // is not the same claim, and the visitor is about to be told. + const sent = d?.sent === true + return { ok: sent, message: resultMessage(via, sent, businessName) } + } catch { + return { ok: false, message: resultMessage(via, false, businessName) } + } + } + + return { available: !loading && channels.length > 0, loading, channels, send } +}