Staff enter a customer's name, phone number and product type, and get a shareable link. The customer opens that link, completes the specification for that product, and the answers are saved to Convex.
/— the staff dashboard, behind an admin password. Fill in name + phone and press Generate form link. Product type is optional: set it to fix the job, or leave it as Customer chooses and the form asks for it.- You get a URL with the prefill in its query string:
http://localhost:3000/f/J36QEMTCYE?name=Jane+Cooper&phone=%2B44+7700+900123&qty=500 /f/<token>— the customer's form, a one-question-at-a-time wizard with Back / Next, a progress bar and a review screen before sending. It is scoped to the chosen product, so it asks only that product's questions.- On submit the specification is stored and the customer sees a reference such
as
PW-20260901-SHDD. Every submission appears back on the dashboard.
lib/prefill.ts defines the parameters: name, phone, email, qty. They
are a convenience only — the customer can edit every one of them, values are
length-capped on read, and everything is validated again on submit, so editing
the URL cannot put a bad value in the database.
The product type is deliberately not a parameter. It comes from the stored link, so the form's question set and validation rules cannot be swapped by tampering with the URL. If the query string is stripped, the form falls back to the values saved with the link.
buildSteps() in lib/quoteSpec.ts turns a product into screens: the product
type (only when the link left it open), contact details, then one question per
visible field, then a review.
When the link has no product type, the customer picks it on the very first
screen — with their name and phone already prefilled behind it — and the rest of
the questions appear once they choose. A product fixed on the link always wins:
submitQuote ignores any productType sent by the client in that case, so the
question set cannot be swapped by a hand-rolled request. Steps are rebuilt
as answers change, so Cover Paper Weight appears mid-flow the moment
Cover heavier and inner lighter is chosen, and the step count updates with it.
Next will not advance while the current step has an error, and only that step's errors are shown, so the customer is never faced with a wall of red. Selects and radios both render as large tap targets rather than dropdowns, quantity offers preset chips, and the review screen has an Edit button per answer that jumps to that step and comes straight back.
Four things are done deliberately to stop mobile Safari zooming:
- 16px inputs. Safari zooms in on focus when a text field is under 16px.
touch-action: manipulationon buttons, labels, links and the option radios. Without it, two quick taps count as double-tap-to-zoom and the page ends up zoomed after a few option taps. Text inputs are deliberately excluded so double-tap still selects a word.- Option-card radios are full-size and transparent, not
sr-only. Safari zooms toward a focused element smaller than the tap area, and a 1×1 clipped radio inside a 56px card triggers exactly that. viewportFit: "cover"in the rootviewportexport.env(safe-area-inset-*)reports 0 without it, which would leave the bottom bar under the home indicator.
Pinch-to-zoom is left enabled on purpose. maximum-scale=1 / user-scalable=no
would also stop the zooming, by making the page inaccessible to anyone who needs
to magnify it.
Also: 48px minimum tap targets, and no horizontal scrolling — long URLs scroll inside their own box.
lib/quoteSpec.ts is the single source of truth — product list, per-product
fields, and every rule. The browser imports it for live inline errors, and the
Convex mutations import the same functions and re-check everything, so a request
that skips the UI is rejected the same way.
What is enforced:
- Required fields per product, plus allowed-value checks on every select and radio (a value not in the option list is refused).
- Conditional fields —
Cover Paper Weightis only asked, and only required, whenBooklet / Brochure Typeis Cover heavier and inner lighter. Hidden fields are neither validated nor stored. - "Other please complete" / "Please describe" open a companion text box that becomes required (2–200 chars). The stored answer is the text that was typed.
- Quantity — whole number, 1 to 1,000,000.
- Name — 2–80 characters, letters/spaces/apostrophes/hyphens.
- Phone — 7–15 digits, allowing
+ ( ) -and spaces. - Email — optional everywhere, format-checked when supplied.
- Length caps on free text (additional info 2000, label size 100, note 1000).
On the customer wizard, errors appear when Next is pressed and block the
step until fixed. On the staff dashboard they appear per field on blur. In both
cases server-side failures come back as a ConvexError carrying per-field
messages, which the UI maps onto the same inputs.
Submitted specifications are visible only on the password-gated dashboard, and
listQuotes refuses to return anything without a valid session — the gate is
server-side, not just a hidden UI section.
The list has a free-text search (name, phone, email, reference, product and any
answer value) plus a product dropdown that only offers products that actually
have submissions. Matching lives in lib/quoteFilter.ts so it can be tested on
its own. Filtering happens in the browser over the 100 most recent rows that
listQuotes returns; past that it would need to move into the query.
The dashboard is gated by a single shared password held in a Convex environment variable, so it never reaches the browser:
npx convex env set ADMIN_PASSWORD "your-password"Signing in exchanges the password for a session token (valid 12 hours, kept in
localStorage). createLink, listLinks, listQuotes and deleteLink all
verify that token server-side — the gate is not just the UI. The listing
queries return null when the session is missing or expired so the dashboard
can show the sign-in screen; the mutations reject outright.
The password is compared without an early exit so the response time does not reveal how much of it was right, and more than 20 failed attempts in a minute are refused. That throttle is global rather than per-IP, which means a determined attacker could keep the admin locked out in one-minute stretches — an acceptable trade against unlimited password guessing, but worth replacing with real auth if this becomes business-critical.
For other systems (a CRM, a website form, a WhatsApp bot) to issue links
without a browser session. Defined in convex/http.ts.
Base URL is the Convex site domain — .convex.site, not .convex.cloud.
It is in .env.local as NEXT_PUBLIC_CONVEX_SITE_URL.
npx convex env set QUOTE_API_KEY "<a long random key>"
npx convex env set APP_BASE_URL "https://quotes.example.com"APP_BASE_URL is what the API prefixes onto the returned path; without it the
response still gives you path and url comes back null.
| Field | Required | Notes |
|---|---|---|
customerName |
yes | 2–80 characters |
phone |
yes | 7–15 digits, + ( ) - and spaces allowed |
productType |
yes | must match a product name exactly |
quantity |
no | whole number 1–1,000,000; accepts a JSON number or string |
email |
no | format-checked when supplied |
notes |
no | internal only, never shown to the customer |
Authenticate with Authorization: Bearer <key> or x-api-key: <key>.
curl -X POST "$CONVEX_SITE_URL/api/links" -H "authorization: Bearer $QUOTE_API_KEY" -H "content-type: application/json" -d '{
"customerName": "Jane Cooper",
"phone": "+44 7700 900123",
"productType": "Booklet",
"quantity": 500,
"email": "jane@example.com"
}'201 Created:
{
"token": "BJ3GHGS8UC",
"path": "/f/BJ3GHGS8UC?name=Jane+Cooper&phone=%2B44+7700+900123&email=jane%40example.com&qty=500",
"url": "https://quotes.example.com/f/BJ3GHGS8UC?name=Jane+Cooper&...",
"productType": "Booklet",
"customerName": "Jane Cooper"
}Send url to the customer.
| Status | error |
When |
|---|---|---|
| 400 | invalid_body |
body is not a JSON object |
| 400 | validation_failed |
a field failed validation — see fields |
| 401 | unauthorized |
missing or wrong API key |
| 503 | not_configured |
QUOTE_API_KEY is not set on the deployment |
validation_failed names each bad field, using the same rules as the UI:
{
"error": "validation_failed",
"message": "Please correct the highlighted fields.",
"fields": {
"phone": "Phone number is too short.",
"productType": "Choose a product type from the list."
}
}- Server-to-server only. There are no CORS headers, deliberately: an API key in browser JavaScript is public. Call this from your backend.
- The key is compared without an early exit, but unlike the admin password it is not rate limited — it is expected to be long and random.
- Valid product names are the keys of
PRODUCTSinlib/quoteSpec.ts.
Every submitted specification is POSTed to an external endpoint — set it with:
npx convex env set QUOTE_WEBHOOK_URL "https://..."
npx convex env set QUOTE_WEBHOOK_SECRET "..." # optional, sent as x-webhook-secret
npx convex env set QUOTE_TIMEZONE "Asia/Kolkata" # optional, dates in the messageA Convex mutation cannot make network calls, so submitQuote schedules the
action in convex/notify.ts instead. That is also the right behaviour for the
customer: their submit never waits on, or fails because of, the receiving
system. Scheduled work only runs if the mutation commits, so a rejected
submission never fires a webhook.
The customer's number plus the summary. The text is written to the customer -
a receipt-style confirmation with a read-back of their spec, built by
lib/quoteMessage.ts using *bold* markup:
{
"phone": "+44 7700 900123",
"phoneDigits": "447700900123",
"message": "Thanks for your quote request. We have got it and will come back to you shortly with a price.
*Your reference:* PW-20260901-MLLB
*Received:* 01 Sept 2026, 14:30
*What you asked for*
..."
}phone is exactly what the customer typed. phoneDigits is the same number
stripped of spaces and punctuation, for APIs that want bare digits.
phoneDigits carries a country code only if the customer typed one. The
form accepts local formats, so 07700900456 stays 07700900456 and
(0044) 7700-900123 becomes 00447700900123 - neither will dial on WhatsApp.
Either prefix a default country code in the receiving automation, or make the
form require a leading +.
content-type is application/json, and x-webhook-secret is added when
QUOTE_WEBHOOK_SECRET is set. Nothing internal appears in the message: no
staff note, no form URL, no token, and the customer's own phone and email are
not read back at them inside the text.
Dates in the message follow QUOTE_TIMEZONE, defaulting to Europe/London.
Up to 3 attempts, backing off 10s then 60s, each with a 10s timeout. Anything
outside 2xx counts as a failure. The outcome is recorded on the quote
(webhookStatus, webhookAttempts, webhookError), and the dashboard shows a
Webhook failed badge with the error, so a lost notification is visible
rather than silent. With no URL configured, delivery is marked skipped.
quoteLinks— one row per issued link: token, customer name, phone, email, product type, optional quantity/note,submissionCount,lastSubmittedAt.quotes— one row per submission: reference, contact details, product, quantity, andanswersas an ordered{ key, label, value }list, so a stored quote stays readable even if the field spec later changes.adminSessions/adminLoginAttempts— sign-in sessions and the failed-attempt timestamps behind the throttle. Both are pruned on login.
Quotes also carry webhookStatus / webhookAttempts / webhookError /
webhookSentAt, all optional so rows created before the webhook stay valid.
Tokens are 10 characters from an alphabet with 0/O and 1/I removed, so they
survive being read over the phone.
npx convex dev # keep running: pushes functions and watches for changes
npm run dev # http://localhost:3000.env.local already holds CONVEX_DEPLOYMENT and NEXT_PUBLIC_CONVEX_URL.
- Change the admin password. A placeholder is currently set on the dev
deployment; replace it with
npx convex env set ADMIN_PASSWORD "...", and set it separately on prod (--prod) — Convex environment variables do not carry across deployments. - One shared password means no per-user accounts and no audit trail of who issued or deleted a link. Move to Convex Auth if you need either.
- The
/f/<token>form is public by design; the 10-character token is the only thing protecting it, which is appropriate for a quote request but not for anything confidential. - Set
APP_BASE_URL,QUOTE_API_KEYandQUOTE_WEBHOOK_URLon prod too (npx convex env set --prod ...). Convex environment variables do not carry between deployments, and on prodAPP_BASE_URLmust be your real domain or the API will hand out localhost links.