updates
This commit is contained in:
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"context7": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@upstash/context7-mcp", "--api-key", "ctx7sk-200afd5d-1a75-4c28-a3fb-3f3a26a721a0"]
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -5,7 +5,7 @@
|
||||
# runner — lean runtime: production deps + dist + a plain Node server
|
||||
# (no build tooling ships in the final image)
|
||||
|
||||
ARG NODE_VERSION=24
|
||||
ARG NODE_VERSION=26
|
||||
|
||||
# ── deps ────────────────────────────────────────────────────
|
||||
FROM node:${NODE_VERSION}-trixie-slim AS deps
|
||||
@@ -24,7 +24,7 @@ RUN npm run build
|
||||
# ── runner ──────────────────────────────────────────────────
|
||||
FROM node:${NODE_VERSION}-trixie-slim AS runner
|
||||
LABEL org.opencontainers.image.title="Mozimo" \
|
||||
org.opencontainers.image.description="Mozimo — luxury chocolate storefront (TanStack Start + Vite)"
|
||||
org.opencontainers.image.description="Mozimo — luxury chocolate storefront (TanStack Start + Vite)"
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
@@ -47,7 +47,7 @@ USER node
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/',{redirect:'manual'}).then(r=>process.exit(r.status<500?0:1)).catch(()=>process.exit(1))"
|
||||
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/',{redirect:'manual'}).then(r=>process.exit(r.status<500?0:1)).catch(()=>process.exit(1))"
|
||||
|
||||
# Exec form: node runs as PID 1 and server.mjs handles SIGTERM gracefully.
|
||||
CMD ["node", "server.mjs"]
|
||||
|
||||
+56
-12
@@ -4,6 +4,7 @@ import Footer from "@/components/Footer";
|
||||
import PageHero from "@/components/PageHero";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import Marquee from "@/components/Marquee";
|
||||
import ContactForm from "@/components/ContactForm";
|
||||
|
||||
export const Route = createFileRoute("/bespoke")({ component: BespokePage });
|
||||
|
||||
@@ -49,12 +50,7 @@ function BespokePage() {
|
||||
>
|
||||
<Reveal delay={0.6}>
|
||||
<div className="mt-10 flex flex-wrap gap-4">
|
||||
<a
|
||||
href="https://shop.mozimo.in/pages/bulk-ordering"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-dark"
|
||||
>
|
||||
<a href="#enquiry" className="btn btn-dark">
|
||||
Start an Enquiry
|
||||
</a>
|
||||
<a href="tel:0172-4045414" className="btn btn-ghost">
|
||||
@@ -123,17 +119,65 @@ function BespokePage() {
|
||||
))}
|
||||
</ol>
|
||||
<Reveal delay={0.3} className="mt-14 text-center">
|
||||
<a
|
||||
href="https://shop.mozimo.in/pages/bulk-ordering"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-dark"
|
||||
>
|
||||
<a href="#enquiry" className="btn btn-dark">
|
||||
Start an Enquiry
|
||||
</a>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* enquiry form */}
|
||||
<section id="enquiry" className="bg-ivory-50" aria-label="Gifting enquiry">
|
||||
<div className="wrap grid gap-14 pb-24 lg:grid-cols-[1fr_1.35fr] lg:gap-20">
|
||||
<div>
|
||||
<Reveal>
|
||||
<p className="eyebrow text-orange-500">Bulk & bespoke gifting</p>
|
||||
</Reveal>
|
||||
<Reveal delay={0.05}>
|
||||
<h2 className="display mt-5 text-[clamp(2rem,4vw,3.2rem)] text-ink-900">
|
||||
Start the{" "}
|
||||
<em className="italic text-orange-500">conversation</em>
|
||||
</h2>
|
||||
</Reveal>
|
||||
<Reveal delay={0.1}>
|
||||
<p className="mt-6 max-w-md text-[0.98rem] leading-relaxed text-ink-500">
|
||||
Tell us the occasion and the scale — corporate hampers,
|
||||
wedding favours, a brand collaboration or packaging that is
|
||||
entirely yours. Our chocolatiers reply within one working
|
||||
day, with samples where it helps.
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal delay={0.15}>
|
||||
<div className="mt-10 border-t border-ink-900/10 pt-6">
|
||||
<p className="eyebrow text-[0.62rem]! text-ink-500">Prefer to talk?</p>
|
||||
<a
|
||||
href="tel:0172-4045414"
|
||||
className="link-quiet mt-3 inline-block font-display text-[1.35rem] italic text-ink-900 hover:text-orange-500"
|
||||
>
|
||||
0172-404 5414
|
||||
</a>
|
||||
<p className="mt-4 max-w-xs text-[0.85rem] leading-relaxed text-ink-500">
|
||||
For standard quantities, the{" "}
|
||||
<a
|
||||
href="https://shop.mozimo.in/pages/bulk-ordering"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="link-quiet font-medium text-orange-500"
|
||||
>
|
||||
bulk-ordering portal
|
||||
</a>{" "}
|
||||
is always open.
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
<Reveal delay={0.1}>
|
||||
<div className="border border-ink-900/10 bg-ivory-100 px-6 py-10 sm:px-10 sm:py-12">
|
||||
<ContactForm variant="bespoke" />
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import PageHero from "@/components/PageHero";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import ContactForm from "@/components/ContactForm";
|
||||
|
||||
export const Route = createFileRoute("/contact")({ component: ContactPage });
|
||||
|
||||
const DETAILS = [
|
||||
{
|
||||
label: "Visit",
|
||||
lines: ["SCO 8, Inner Market, 9-D", "Sector 9, Chandigarh 160009"],
|
||||
href: "https://www.google.com/maps/search/?api=1&query=Mozimo+Sector+9+Chandigarh",
|
||||
action: "Find us on the map",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
label: "Call",
|
||||
lines: ["0172-404 5414"],
|
||||
href: "tel:0172-4045414",
|
||||
action: "Call the atelier",
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
label: "Hours",
|
||||
lines: ["Monday – Sunday", "11:00 – 21:00"],
|
||||
},
|
||||
];
|
||||
|
||||
function ContactPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-ivory-50">
|
||||
<Header />
|
||||
<main>
|
||||
<PageHero
|
||||
eyebrow="Contact"
|
||||
title={
|
||||
<>
|
||||
We'd love to{" "}
|
||||
<em className="font-script text-[1.12em] font-normal italic text-orange-500">
|
||||
hear
|
||||
</em>{" "}
|
||||
from you
|
||||
</>
|
||||
}
|
||||
lead="A question about an order, a gift you're planning, or chocolate at scale — write to us and a person from the atelier will reply."
|
||||
/>
|
||||
|
||||
<section className="bg-ivory-50" aria-label="Contact form and details">
|
||||
<div className="wrap grid gap-16 pb-24 lg:grid-cols-[1fr_1.35fr] lg:gap-24">
|
||||
{/* details */}
|
||||
<div>
|
||||
<Reveal>
|
||||
<p className="eyebrow text-orange-500">The atelier</p>
|
||||
</Reveal>
|
||||
<dl className="mt-8 space-y-10">
|
||||
{DETAILS.map((d, i) => (
|
||||
<Reveal key={d.label} delay={i * 0.08}>
|
||||
<div className="border-t border-ink-900/10 pt-6">
|
||||
<dt className="eyebrow text-[0.62rem]! text-ink-500">{d.label}</dt>
|
||||
<dd className="mt-3 text-[1.05rem] leading-relaxed text-ink-900">
|
||||
{d.lines.map((line) => (
|
||||
<span key={line} className="block">
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
{d.href && (
|
||||
<a
|
||||
href={d.href}
|
||||
{...(d.external
|
||||
? { target: "_blank", rel: "noopener noreferrer" }
|
||||
: {})}
|
||||
className="link-quiet mt-3 inline-block text-[0.9rem] font-medium text-orange-500"
|
||||
>
|
||||
{d.action} →
|
||||
</a>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<Reveal delay={0.25}>
|
||||
<div className="mt-12 border-t border-ink-900/10 pt-8">
|
||||
<p className="max-w-sm text-[0.95rem] leading-relaxed text-ink-500">
|
||||
Planning corporate quantities, weddings or a bespoke
|
||||
commission?{" "}
|
||||
<a href="/bespoke" className="link-quiet font-medium text-orange-500">
|
||||
Use the gifting enquiry form
|
||||
</a>{" "}
|
||||
— it reaches the same hands.
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
{/* form */}
|
||||
<Reveal delay={0.1}>
|
||||
<div className="border border-ink-900/10 bg-ivory-100 px-6 py-10 sm:px-10 sm:py-12">
|
||||
<p className="eyebrow text-[0.62rem]! text-ink-500">Write to us</p>
|
||||
<h2 className="display mt-4 text-[clamp(1.7rem,3vw,2.4rem)] text-ink-900">
|
||||
Send a <em className="italic text-orange-500">message</em>
|
||||
</h2>
|
||||
<div className="mt-10">
|
||||
<ContactForm variant="contact" />
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+11
-26
@@ -4,7 +4,8 @@ import Footer from "@/components/Footer";
|
||||
import PageHero from "@/components/PageHero";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import ProductCard from "@/components/ProductCard";
|
||||
import { gifting, giftingCategories, seasonal } from "@/data/catalog";
|
||||
import GiftingTabs from "@/components/GiftingTabs";
|
||||
import { gifting, seasonal } from "@/data/catalog";
|
||||
|
||||
export const Route = createFileRoute("/gifting")({ component: GiftingPage });
|
||||
|
||||
@@ -42,35 +43,19 @@ function GiftingPage() {
|
||||
</Reveal>
|
||||
</PageHero>
|
||||
|
||||
{/* categories */}
|
||||
<section className="bg-ivory-50 text-ink-900" aria-label="Gifting categories">
|
||||
{/* who is it for — price-wise tabs */}
|
||||
<section className="bg-ivory-50 text-ink-900" aria-label="Gifting by price">
|
||||
<div className="wrap section-pad pb-0!">
|
||||
<Reveal>
|
||||
<p className="eyebrow text-orange-500">Who is it for?</p>
|
||||
</Reveal>
|
||||
<div className="mt-8 grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-7">
|
||||
{giftingCategories.map((c, i) => (
|
||||
<Reveal key={c.name} delay={i * 0.05}>
|
||||
<a
|
||||
href="https://shop.mozimo.in/collections/gift-collection"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="card-zoom group block text-center"
|
||||
>
|
||||
<img
|
||||
src={c.image}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
width={320}
|
||||
height={400}
|
||||
className="rounded-2xl aspect-[4/5] w-full object-cover"
|
||||
/>
|
||||
<span className="mt-4 block text-[0.9rem] font-medium tracking-wide transition-colors group-hover:text-orange-500">
|
||||
{c.name}
|
||||
</span>
|
||||
</a>
|
||||
</Reveal>
|
||||
))}
|
||||
<Reveal delay={0.05}>
|
||||
<h2 className="display mt-5 max-w-2xl text-[clamp(1.9rem,3.8vw,2.9rem)] text-ink-900">
|
||||
Gifts, <em className="italic text-orange-500">price by price</em>
|
||||
</h2>
|
||||
</Reveal>
|
||||
<div className="mt-10">
|
||||
<GiftingTabs />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useState, type FormEvent, type ReactNode } from "react";
|
||||
import { submitEnquiry, type EnquiryResult } from "@/data/enquiry";
|
||||
|
||||
/**
|
||||
* Editorial form used in two places:
|
||||
* variant="contact" — the Contact Us page
|
||||
* variant="bespoke" — bulk gifting / bespoke gifting enquiries
|
||||
*/
|
||||
|
||||
const CONTACT_TOPICS = [
|
||||
"General enquiry",
|
||||
"My order",
|
||||
"Feedback",
|
||||
"Press & collaborations",
|
||||
];
|
||||
|
||||
const BESPOKE_TYPES = [
|
||||
"Corporate & bulk gifting",
|
||||
"Weddings & celebrations",
|
||||
"Brand collaboration",
|
||||
"Events & experiences",
|
||||
"Custom packaging",
|
||||
];
|
||||
|
||||
const BUDGETS = [
|
||||
"Under ₹25,000",
|
||||
"₹25,000 – ₹1,00,000",
|
||||
"₹1,00,000 – ₹5,00,000",
|
||||
"Above ₹5,00,000",
|
||||
];
|
||||
|
||||
const inputClass =
|
||||
"mt-2 w-full border-b border-ink-900/20 bg-transparent pb-3 pt-1 text-[1.05rem] font-light text-ink-900 outline-none transition-colors duration-300 placeholder:text-ink-500/40 focus:border-orange-500";
|
||||
|
||||
function Field({
|
||||
label,
|
||||
error,
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
label: string;
|
||||
error?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<label className={`block ${className}`}>
|
||||
<span className="eyebrow text-[0.62rem]! text-ink-500">{label}</span>
|
||||
{children}
|
||||
{error && <span className="mt-2 block text-[0.8rem] text-orange-600">{error}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ContactForm({ variant }: { variant: "contact" | "bespoke" }) {
|
||||
const bespoke = variant === "bespoke";
|
||||
const [pending, setPending] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const f = new FormData(form);
|
||||
const get = (k: string) => String(f.get(k) ?? "").trim();
|
||||
|
||||
setPending(true);
|
||||
setErrors({});
|
||||
let result: EnquiryResult;
|
||||
try {
|
||||
result = await submitEnquiry({
|
||||
data: {
|
||||
form: variant,
|
||||
name: get("name"),
|
||||
email: get("email"),
|
||||
phone: get("phone"),
|
||||
company: get("company"),
|
||||
topic: bespoke ? undefined : get("topic"),
|
||||
enquiryType: bespoke ? get("enquiryType") : undefined,
|
||||
quantity: get("quantity"),
|
||||
budget: get("budget"),
|
||||
date: get("date"),
|
||||
message: get("message"),
|
||||
website: get("website"),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
result = { ok: false, errors: { form: "Something went wrong — please try again, or call the atelier." } };
|
||||
}
|
||||
setPending(false);
|
||||
|
||||
if (result.ok) {
|
||||
form.reset();
|
||||
setDone(true);
|
||||
} else {
|
||||
setErrors(result.errors);
|
||||
}
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<div className="border border-ink-900/10 bg-ivory-100 px-7 py-14 text-center sm:px-12">
|
||||
<p className="font-script text-[2.6rem] leading-none text-orange-500">Thank you</p>
|
||||
<p className="mx-auto mt-6 max-w-sm text-[0.98rem] leading-relaxed text-ink-500">
|
||||
Your {bespoke ? "enquiry" : "note"} has reached the atelier — we reply
|
||||
within one working day.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDone(false)}
|
||||
className="link-quiet eyebrow mt-8 text-ink-700 hover:text-orange-500"
|
||||
>
|
||||
Send another
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} noValidate={false}>
|
||||
{/* honeypot — hidden from real users */}
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden="true"
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="grid gap-x-8 gap-y-8 sm:grid-cols-2">
|
||||
<Field label="Your name *" error={errors.name}>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
minLength={2}
|
||||
maxLength={120}
|
||||
autoComplete="name"
|
||||
placeholder="Full name"
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Email *" error={errors.email}>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
maxLength={200}
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Phone" error={errors.phone}>
|
||||
<input
|
||||
type="tel"
|
||||
name="phone"
|
||||
maxLength={20}
|
||||
autoComplete="tel"
|
||||
placeholder={bespoke ? "Best number to reach you" : "Optional"}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
{bespoke ? (
|
||||
<Field label="Company / brand">
|
||||
<input
|
||||
type="text"
|
||||
name="company"
|
||||
maxLength={160}
|
||||
autoComplete="organization"
|
||||
placeholder="Optional"
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
) : (
|
||||
<Field label="What is this about?">
|
||||
<select name="topic" defaultValue={CONTACT_TOPICS[0]} className={`${inputClass} cursor-pointer`}>
|
||||
{CONTACT_TOPICS.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{bespoke && (
|
||||
<div className="mt-8 grid gap-x-8 gap-y-8 sm:grid-cols-2">
|
||||
<Field label="Enquiry type">
|
||||
<select name="enquiryType" defaultValue={BESPOKE_TYPES[0]} className={`${inputClass} cursor-pointer`}>
|
||||
{BESPOKE_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Approximate quantity">
|
||||
<input
|
||||
type="text"
|
||||
name="quantity"
|
||||
maxLength={60}
|
||||
placeholder="e.g. 150 boxes"
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Budget per gift">
|
||||
<select name="budget" defaultValue="" className={`${inputClass} cursor-pointer`}>
|
||||
<option value="" disabled>
|
||||
Select a range
|
||||
</option>
|
||||
{BUDGETS.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Date needed">
|
||||
<input type="date" name="date" className={`${inputClass} cursor-pointer`} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8">
|
||||
<Field label={bespoke ? "Tell us about the occasion *" : "Your message *"} error={errors.message}>
|
||||
<textarea
|
||||
name="message"
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={4000}
|
||||
rows={bespoke ? 5 : 4}
|
||||
placeholder={
|
||||
bespoke
|
||||
? "The occasion, who it's for, and the feeling you want it to leave behind."
|
||||
: "How can we help?"
|
||||
}
|
||||
className={`${inputClass} resize-y`}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{errors.form && <p className="mt-6 text-[0.85rem] text-orange-600">{errors.form}</p>}
|
||||
|
||||
<div className="mt-10 flex flex-wrap items-center gap-6">
|
||||
<button type="submit" disabled={pending} className="btn btn-dark disabled:opacity-60">
|
||||
{pending ? "Sending…" : bespoke ? "Send enquiry" : "Send message"}
|
||||
</button>
|
||||
<p className="text-[0.8rem] leading-relaxed text-ink-500">
|
||||
We reply within one working day.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ const EXPLORE = [
|
||||
{ label: "Bespoke", href: "/bespoke" },
|
||||
{ label: "Our World", href: "/our-world" },
|
||||
{ label: "The Journal", href: "/journal" },
|
||||
{ label: "Contact", href: "/contact" },
|
||||
];
|
||||
|
||||
const POLICIES = [
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import ProductCard from "@/components/ProductCard";
|
||||
import { giftingTabs, seasonal, type GiftingTab } from "@/data/catalog";
|
||||
|
||||
/**
|
||||
* "Who is it for?" — tabs ordered price-wise, from Signature (entry)
|
||||
* to Bespoke Gifting (made to order).
|
||||
*/
|
||||
|
||||
function TabCta({ tab }: { tab: GiftingTab }) {
|
||||
if (!tab.cta) return null;
|
||||
const { label, url, external } = tab.cta;
|
||||
if (external) {
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className="btn btn-dark">
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
const [to, hash] = url.split("#");
|
||||
return (
|
||||
<Link to={to} hash={hash || undefined} className="btn btn-dark">
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function EnquiryPanel({ tab }: { tab: GiftingTab }) {
|
||||
return (
|
||||
<div className="relative grain overflow-hidden rounded-2xl bg-bean-900 px-7 py-12 text-ivory-50 sm:px-12">
|
||||
<div className="max-w-xl">
|
||||
<p className="eyebrow text-orange-400">{tab.note}</p>
|
||||
<h3 className="display mt-5 text-[clamp(1.9rem,3.6vw,2.9rem)] text-ivory-50">
|
||||
{tab.title}
|
||||
</h3>
|
||||
<p className="mt-5 text-[1rem] leading-relaxed text-ivory-100/70">{tab.blurb}</p>
|
||||
<ul className="mt-8 space-y-3 text-[0.95rem] text-ivory-100/70">
|
||||
{[
|
||||
"Custom packaging & personalisation",
|
||||
"Volume pricing for teams & events",
|
||||
"Composed and delivered to order",
|
||||
].map((line) => (
|
||||
<li key={line} className="flex items-baseline gap-3">
|
||||
<span className="font-display text-[1.05rem] italic text-orange-400">—</span>
|
||||
{line}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-10 flex flex-wrap items-center gap-4">
|
||||
<TabCta tab={tab} />
|
||||
<a href="tel:0172-4045414" className="link-quiet eyebrow text-ivory-100/70 hover:text-ivory-50">
|
||||
or call 0172-404 5414
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
aria-hidden="true"
|
||||
className="display pointer-events-none absolute -bottom-6 -right-4 hidden select-none text-[10rem] leading-none text-ivory-100/[0.05] lg:block"
|
||||
>
|
||||
{tab.name.split(" ")[0]}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeasonalPanel() {
|
||||
return (
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{seasonal.map((s) => (
|
||||
<a
|
||||
key={s.name}
|
||||
href={s.url}
|
||||
{...(s.url.startsWith("http") ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||
className="card-zoom group relative block overflow-hidden rounded-2xl"
|
||||
>
|
||||
<img
|
||||
src={s.image}
|
||||
alt={s.name}
|
||||
loading="lazy"
|
||||
width={540}
|
||||
height={680}
|
||||
className="aspect-[4/5] w-full object-cover"
|
||||
/>
|
||||
<span className="absolute inset-0 bg-gradient-to-t from-bean-950/90 via-bean-950/15 to-transparent" />
|
||||
<span className="absolute inset-x-0 bottom-0 p-5">
|
||||
<span className="display block text-[1.35rem] text-ivory-50">{s.name}</span>
|
||||
<span className="mt-1 block text-[0.85rem] text-ivory-100/65">{s.blurb}</span>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GiftingTabs() {
|
||||
const [active, setActive] = useState(0);
|
||||
const tab = giftingTabs[active];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* tab bar */}
|
||||
<Reveal delay={0.05}>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Gifts by price"
|
||||
className="no-scrollbar -mx-1 flex gap-2 overflow-x-auto px-1 pb-2"
|
||||
>
|
||||
{giftingTabs.map((t, i) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`gifting-tab-${t.id}`}
|
||||
aria-selected={active === i}
|
||||
aria-controls={`gifting-panel-${t.id}`}
|
||||
onClick={() => setActive(i)}
|
||||
className={`shrink-0 rounded-full border px-5 py-2.5 text-[0.8rem] tracking-wide transition-all duration-400 ${
|
||||
active === i
|
||||
? "border-bean-900 bg-bean-900 text-ivory-50"
|
||||
: "border-ink-900/15 text-ink-700 hover:border-orange-500 hover:bg-orange-500 hover:text-ivory-50"
|
||||
}`}
|
||||
>
|
||||
{t.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* active tab panel */}
|
||||
<div
|
||||
key={tab.id}
|
||||
role="tabpanel"
|
||||
id={`gifting-panel-${tab.id}`}
|
||||
aria-labelledby={`gifting-tab-${tab.id}`}
|
||||
className="mt-12"
|
||||
>
|
||||
<div className="flex flex-wrap items-end justify-between gap-x-10 gap-y-4">
|
||||
<Reveal className="max-w-2xl">
|
||||
<p className="eyebrow text-orange-500">{tab.note}</p>
|
||||
<h3 className="display mt-4 text-[clamp(1.8rem,3.4vw,2.7rem)] text-ink-900">
|
||||
{tab.title}
|
||||
</h3>
|
||||
<p className="mt-4 text-[0.98rem] leading-relaxed text-ink-500">{tab.blurb}</p>
|
||||
</Reveal>
|
||||
{tab.cta && (
|
||||
<Reveal delay={0.1} className="hidden sm:block">
|
||||
<TabCta tab={tab} />
|
||||
</Reveal>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-10">
|
||||
{tab.mode === "products" && tab.products && (
|
||||
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{tab.products.map((p, i) => (
|
||||
<Reveal key={p.name} delay={i * 0.06}>
|
||||
<ProductCard product={p} />
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{tab.mode === "seasonal" && (
|
||||
<Reveal delay={0.08}>
|
||||
<SeasonalPanel />
|
||||
</Reveal>
|
||||
)}
|
||||
{tab.mode === "enquiry" && (
|
||||
<Reveal delay={0.08}>
|
||||
<EnquiryPanel tab={tab} />
|
||||
</Reveal>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tab.cta && (
|
||||
<Reveal delay={0.15} className="mt-10 text-center sm:hidden">
|
||||
<TabCta tab={tab} />
|
||||
</Reveal>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const NAV_LEFT = [
|
||||
const NAV_RIGHT = [
|
||||
{ label: "Our World", to: "/our-world" },
|
||||
{ label: "Bespoke", to: "/bespoke" },
|
||||
{ label: "Contact", to: "/contact" },
|
||||
];
|
||||
|
||||
const ANNOUNCE_H = 32;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import type { Collection } from "@/data/catalog";
|
||||
|
||||
@@ -8,12 +8,36 @@ type Props = {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
const SLIDE_MS = 3200;
|
||||
|
||||
/**
|
||||
* Editorial index: rows on the left, a sticky image panel on the right
|
||||
* that crossfades to the hovered row's photograph. No floating popups.
|
||||
* Editorial index: rows on the left, a sticky panel on the right that
|
||||
* crossfades to the hovered row's photographs — each collection rendered
|
||||
* as a three-image carousel. No floating popups.
|
||||
*/
|
||||
export default function IndexShowcase({ items, id }: Props) {
|
||||
const [active, setActive] = useState(0);
|
||||
const [slide, setSlide] = useState(0);
|
||||
|
||||
const images = items[active]?.images ?? [];
|
||||
|
||||
// a new collection always opens on its first photograph
|
||||
useEffect(() => {
|
||||
setSlide(0);
|
||||
}, [active]);
|
||||
|
||||
// gentle auto-advance while the collection is in view
|
||||
useEffect(() => {
|
||||
if (images.length < 2) return;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
const t = window.setInterval(
|
||||
() => setSlide((s) => (s + 1) % images.length),
|
||||
SLIDE_MS,
|
||||
);
|
||||
return () => window.clearInterval(t);
|
||||
}, [images.length]);
|
||||
|
||||
const current = images.length ? slide % images.length : 0;
|
||||
|
||||
return (
|
||||
<div id={id} className="grid gap-12 lg:grid-cols-[1.15fr_1fr] lg:gap-20">
|
||||
@@ -60,28 +84,53 @@ export default function IndexShowcase({ items, id }: Props) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* sticky crossfading panel */}
|
||||
{/* sticky crossfading carousel */}
|
||||
<div className="relative hidden lg:block">
|
||||
<div className="sticky top-32">
|
||||
<Reveal variant="clip">
|
||||
<div className="relative aspect-[4/5] overflow-hidden rounded-2xl bg-ivory-200">
|
||||
{items.map((item, i) => (
|
||||
{images.map((src, i) => (
|
||||
<img
|
||||
key={item.name}
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
key={`${src}-${i}`}
|
||||
src={src}
|
||||
alt={i === current ? items[active]?.name : ""}
|
||||
loading={i === 0 ? "eager" : "lazy"}
|
||||
width={800}
|
||||
height={1000}
|
||||
className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-700 ${
|
||||
active === i ? "opacity-100" : "opacity-0"
|
||||
current === i ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
{images.length > 1 && (
|
||||
<div className="absolute inset-x-0 bottom-5 flex items-center justify-center gap-2.5">
|
||||
{images.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setSlide(i)}
|
||||
aria-label={`Photo ${i + 1} of ${images.length}`}
|
||||
aria-pressed={current === i}
|
||||
className={`h-1.5 rounded-full transition-all duration-500 ${
|
||||
current === i
|
||||
? "w-7 bg-ivory-50"
|
||||
: "w-1.5 bg-ivory-50/55 hover:bg-ivory-50/80"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-5 flex items-baseline justify-between gap-6">
|
||||
<p className="eyebrow text-[0.62rem]! text-ink-500">
|
||||
{items[active]?.name} — {items[active]?.blurb}
|
||||
</p>
|
||||
{images.length > 1 && (
|
||||
<p className="font-display text-[0.95rem] italic text-orange-500" aria-hidden="true">
|
||||
{String(current + 1).padStart(2, "0")} / {String(images.length).padStart(2, "0")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="eyebrow mt-5 text-[0.62rem]! text-ink-500">
|
||||
{items[active]?.name} — {items[active]?.blurb}
|
||||
</p>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+116
-1
@@ -18,7 +18,10 @@ export type Product = {
|
||||
export type Collection = {
|
||||
name: string;
|
||||
blurb: string;
|
||||
/** primary still — used for thumbnails */
|
||||
image: string;
|
||||
/** carousel stills shown in the showcase panel (3 per collection) */
|
||||
images: string[];
|
||||
url: string;
|
||||
};
|
||||
|
||||
@@ -74,42 +77,49 @@ export const collections: Collection[] = [
|
||||
name: "Bars",
|
||||
blurb: "Single-origin tablets, 45% to 85%",
|
||||
image: "/products/01-dark85-60gm.jpg",
|
||||
images: ["/products/01-dark85-60gm.jpg", "/products/02-dark60-60gm.jpg", "/products/img-5469.jpg"],
|
||||
url: `${SHOP}/collections/bars`,
|
||||
},
|
||||
{
|
||||
name: "Barks",
|
||||
blurb: "Crackled slabs of nuts, fruit & crunch",
|
||||
image: "/products/01-almondbark60.jpg",
|
||||
url: `${SHOP}/collections/barks`,
|
||||
images: ["/products/01-almondbark60.jpg", "/products/02-almondbark45.jpg", "/products/20-almondpralinebark.jpg"],
|
||||
url: `${SHOP}/collections/bark`,
|
||||
},
|
||||
{
|
||||
name: "Pralines",
|
||||
blurb: "Hand-piped ganache, boxed like jewels",
|
||||
image: "/products/mozi-27.jpg",
|
||||
images: ["/products/mozi-27.jpg", "/products/irw05205.jpg", "/products/14-mozimopralinepanetela-9c9a80d8-e4fc-4c4b-9c56-ca7f42b57847.jpg"],
|
||||
url: `${SHOP}/collections/pralines`,
|
||||
},
|
||||
{
|
||||
name: "Spreads",
|
||||
blurb: "Stone-ground, silky, single origin",
|
||||
image: "/products/02-pistachiospread60.jpg",
|
||||
images: ["/products/02-pistachiospread60.jpg", "/products/03-hazelnutspread45.jpg", "/products/mozi-23.jpg"],
|
||||
url: `${SHOP}/collections/spreads`,
|
||||
},
|
||||
{
|
||||
name: "Dragees",
|
||||
blurb: "Roasted nuts under a thin chocolate shell",
|
||||
image: "/products/04-almonddragees60.jpg",
|
||||
images: ["/products/04-almonddragees60.jpg", "/products/02-hazelnutdragees45.jpg", "/products/03-pistachiodragees.jpg"],
|
||||
url: `${SHOP}/collections/dragees`,
|
||||
},
|
||||
{
|
||||
name: "Tuilles & Rocks",
|
||||
blurb: "Lace-thin wafers, orangettes & slivers",
|
||||
image: "/products/01-milk45-tuilles-100gm.jpg",
|
||||
images: ["/products/01-milk45-tuilles-100gm.jpg", "/products/02-whitechocolatetuilles-100gm.jpg", "/products/01-almondsliversrocks-100gm.jpg"],
|
||||
url: `${SHOP}/collections/tuilles`,
|
||||
},
|
||||
{
|
||||
name: "Gifting",
|
||||
blurb: "Hampers composed for every celebration",
|
||||
image: "/products/mozi20.jpg",
|
||||
images: ["/products/mozi20.jpg", "/products/mozi-33.jpg", "/products/mozi4.jpg"],
|
||||
url: `${SHOP}/collections/gift-collection`,
|
||||
},
|
||||
];
|
||||
@@ -121,36 +131,42 @@ export const signatures: Collection[] = [
|
||||
name: "The Signature Box",
|
||||
blurb: "Our most-loved assortment, composed by hand",
|
||||
image: "/products/mozi-33.jpg",
|
||||
images: ["/products/mozi-33.jpg", "/products/mozi-25.jpg", "/products/mozi-17.jpg"],
|
||||
url: `${SHOP}/collections/gift-collection`,
|
||||
},
|
||||
{
|
||||
name: "The Praline Collection",
|
||||
blurb: "Ganache jewels, piped and finished by hand",
|
||||
image: "/products/mozi-27.jpg",
|
||||
images: ["/products/mozi-27.jpg", "/products/irw05205.jpg", "/products/panetela.jpg"],
|
||||
url: `${SHOP}/collections/pralines`,
|
||||
},
|
||||
{
|
||||
name: "The Dark Collection",
|
||||
blurb: "Single-origin darkness, 60% to 85%",
|
||||
image: "/products/01-dark85-60gm.jpg",
|
||||
images: ["/products/01-dark85-60gm.jpg", "/products/02-dark60-60gm.jpg", "/products/03-dark45-60gm.jpg"],
|
||||
url: `${SHOP}/collections/bars`,
|
||||
},
|
||||
{
|
||||
name: "The Festive Collection",
|
||||
blurb: "Limited editions for the season's light",
|
||||
image: "/products/mozi-30.jpg",
|
||||
images: ["/products/mozi-30.jpg", "/products/mozi31.jpg", "/products/mozi18.jpg"],
|
||||
url: `${SHOP}/collections/all`,
|
||||
},
|
||||
{
|
||||
name: "The Chocolate Bar",
|
||||
blurb: "The bean itself, pressed into 60 grams",
|
||||
image: "/products/02-dark60-60gm.jpg",
|
||||
images: ["/products/02-dark60-60gm.jpg", "/products/01-nuttybarmilk-100gm.jpg", "/products/img-5469.jpg"],
|
||||
url: `${SHOP}/collections/bars`,
|
||||
},
|
||||
{
|
||||
name: "The Mozimo Gifts",
|
||||
blurb: "Hampers that say more than words",
|
||||
image: "/products/mozi20.jpg",
|
||||
images: ["/products/mozi20.jpg", "/products/mozi19.jpg", "/products/mozi16.jpg"],
|
||||
url: `${SHOP}/collections/gift-collection`,
|
||||
},
|
||||
];
|
||||
@@ -196,6 +212,105 @@ export const giftingCategories = [
|
||||
{ name: "Weddings", image: "/products/panetela.jpg" },
|
||||
];
|
||||
|
||||
/* ── Gifting tabs — price-wise, as shoppable tiers ──────────── */
|
||||
|
||||
export type GiftingTab = {
|
||||
id: string;
|
||||
name: string;
|
||||
/** price positioning line, shown above the tab's title */
|
||||
note: string;
|
||||
title: string;
|
||||
blurb: string;
|
||||
mode: "products" | "seasonal" | "enquiry";
|
||||
products?: Product[];
|
||||
cta?: { label: string; url: string; external?: boolean };
|
||||
};
|
||||
|
||||
export const giftingTabs: GiftingTab[] = [
|
||||
{
|
||||
id: "signature",
|
||||
name: "Signature",
|
||||
note: "From ₹265",
|
||||
title: "Small gestures, remembered.",
|
||||
blurb: "Thoughtful everyday gifting — a bar, a little box, a note of chocolate that says enough.",
|
||||
mode: "products",
|
||||
products: [
|
||||
product("Dark Chocolate Bar 85%", 265, "01-dark85-60gm.jpg", "mozimo-dark-chocolate-bar-85", { weight: "60 g", tag: "Signature" }),
|
||||
product("9 Pralines Box", 990, "mozi-27.jpg", "9-pralines-box", { tag: "Hand-piped" }),
|
||||
product("Bars Box", 1050, "mozi14.jpg", "bars-box"),
|
||||
],
|
||||
cta: { label: "View all Signature gifts", url: `${SHOP}/collections/gift-collection`, external: true },
|
||||
},
|
||||
{
|
||||
id: "best-sellers",
|
||||
name: "Best Sellers",
|
||||
note: "From ₹825",
|
||||
title: "The boxes they ask for by name.",
|
||||
blurb: "The spreads, dragees and bars our regulars return for — gifting that never misses.",
|
||||
mode: "products",
|
||||
products: [
|
||||
product("Pistachio Spread 60%", 825, "02-pistachiospread60.jpg", "pistachio-spread-60"),
|
||||
product("Almond Dragees 60%", 900, "04-almonddragees60.jpg", "mozimo-almond-dragees-60"),
|
||||
product("Kunafa Chocolate Bar", 1600, "mozi-23.jpg", "kunafa-chocolate-bar", { tag: "New" }),
|
||||
product("Spreads Box", 1650, "mozi16.jpg", "spreads-box"),
|
||||
],
|
||||
cta: { label: "Shop Best Sellers", url: `${SHOP}/collections/best-sellers`, external: true },
|
||||
},
|
||||
{
|
||||
id: "exquisite",
|
||||
name: "Exquisite",
|
||||
note: "From ₹2,400",
|
||||
title: "Celebrations, elevated.",
|
||||
blurb: "The Carnival boxes — layered, abundant and composed for the moments in between.",
|
||||
mode: "products",
|
||||
products: [
|
||||
product("Carnival Celebration · S", 2400, "mozi20.jpg", "mozimo-carnival-celebration-s"),
|
||||
product("Carnival Celebration · M", 3000, "mozi19.jpg", "mozimo-carnival-celebration-m"),
|
||||
],
|
||||
cta: { label: "View the full collection", url: `${SHOP}/collections/gift-collection`, external: true },
|
||||
},
|
||||
{
|
||||
id: "premium",
|
||||
name: "Premium",
|
||||
note: "From ₹3,900",
|
||||
title: "Generosity, composed.",
|
||||
blurb: "Our grandest hampers — the ones that arrive before you speak and say everything after.",
|
||||
mode: "products",
|
||||
products: [
|
||||
product("Carnival Celebration · L", 3900, "mozi18.jpg", "mozimo-carnival-celebration-l"),
|
||||
product("Celestial (S)", 4500, "mozi-33.jpg", "mozimo-celestial-s", { tag: "Grande" }),
|
||||
],
|
||||
cta: { label: "View the full collection", url: `${SHOP}/collections/gift-collection`, external: true },
|
||||
},
|
||||
{
|
||||
id: "seasonal",
|
||||
name: "Seasonal",
|
||||
note: "Limited editions",
|
||||
title: "Chocolate for the calendar.",
|
||||
blurb: "Diwali to Valentine's — editions composed for the season's light, here only while it lasts.",
|
||||
mode: "seasonal",
|
||||
cta: { label: "Shop the Seasonal Edit", url: `${SHOP}/collections/gift-collection`, external: true },
|
||||
},
|
||||
{
|
||||
id: "corporate",
|
||||
name: "Corporate & Bulk",
|
||||
note: "At scale",
|
||||
title: "Gratitude at scale.",
|
||||
blurb: "Branded hampers, volume pricing and door-step delivery for teams, clients and occasions of every size.",
|
||||
mode: "enquiry",
|
||||
cta: { label: "Start a corporate enquiry", url: "/bespoke#enquiry", external: false },
|
||||
},
|
||||
{
|
||||
id: "bespoke",
|
||||
name: "Bespoke Gifting",
|
||||
note: "Made to order",
|
||||
title: "Composed around your moment.",
|
||||
blurb: "Custom selections, packaging and personalisation — designed with our chocolatiers from the first conversation.",
|
||||
mode: "enquiry",
|
||||
cta: { label: "Begin a bespoke commission", url: "/bespoke#enquiry", external: false },
|
||||
},
|
||||
];
|
||||
|
||||
/* ── The Seasonal Edit ──────────────────────────────────────── */
|
||||
|
||||
export const seasonal = [
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
|
||||
/**
|
||||
* Shared receiver for the Contact Us form and the Bulk / Bespoke gifting
|
||||
* enquiry form. Validates on the server, then delivers the enquiry:
|
||||
*
|
||||
* 1. CONTACT_WEBHOOK_URL (if set) — POSTs the enquiry as JSON, so any
|
||||
* automation (Zapier, n8n, email service) can pick it up;
|
||||
* 2. otherwise — appends one JSON line to ./contact-submissions.jsonl
|
||||
* (best effort; skipped silently on read-only filesystems).
|
||||
*
|
||||
* Every enquiry is also logged to the process console.
|
||||
*/
|
||||
|
||||
export type EnquiryForm = "contact" | "bespoke";
|
||||
|
||||
export type EnquiryPayload = {
|
||||
form: EnquiryForm;
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
company?: string;
|
||||
/** contact variant: General enquiry · My order · Feedback · Press & collaborations */
|
||||
topic?: string;
|
||||
/** bespoke variant: Corporate & bulk gifting · Weddings · Brand collaboration · Events · Custom packaging */
|
||||
enquiryType?: string;
|
||||
quantity?: string;
|
||||
budget?: string;
|
||||
date?: string;
|
||||
message: string;
|
||||
/** honeypot — real users never fill this */
|
||||
website?: string;
|
||||
};
|
||||
|
||||
export type EnquiryResult =
|
||||
| { ok: true }
|
||||
| { ok: false; errors: Record<string, string> };
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
const str = (v: unknown) => (typeof v === "string" ? v.trim() : "");
|
||||
|
||||
export const submitEnquiry = createServerFn({ method: "POST" })
|
||||
.validator((input: unknown) => input as EnquiryPayload)
|
||||
.handler(async ({ data }): Promise<EnquiryResult> => {
|
||||
// honeypot: act successful, deliver nothing
|
||||
if (str(data?.website)) return { ok: true };
|
||||
|
||||
const errors: Record<string, string> = {};
|
||||
const name = str(data?.name);
|
||||
const email = str(data?.email);
|
||||
const message = str(data?.message);
|
||||
|
||||
if (name.length < 2) errors.name = "Please tell us your name.";
|
||||
if (!EMAIL_RE.test(email)) errors.email = "Please enter a valid email.";
|
||||
if (message.length < 10) errors.message = "A sentence or two helps us help you.";
|
||||
if (Object.keys(errors).length) return { ok: false, errors };
|
||||
|
||||
const record = {
|
||||
receivedAt: new Date().toISOString(),
|
||||
form: data.form === "bespoke" ? "bespoke" : "contact",
|
||||
name,
|
||||
email,
|
||||
phone: str(data?.phone),
|
||||
company: str(data?.company),
|
||||
topic: str(data?.topic) || str(data?.enquiryType),
|
||||
quantity: str(data?.quantity),
|
||||
budget: str(data?.budget),
|
||||
date: str(data?.date),
|
||||
message,
|
||||
};
|
||||
|
||||
console.log("[mozimo enquiry]", JSON.stringify(record));
|
||||
|
||||
const webhook = process.env.CONTACT_WEBHOOK_URL;
|
||||
if (webhook) {
|
||||
try {
|
||||
const res = await fetch(webhook, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(record),
|
||||
});
|
||||
if (!res.ok) console.error("[mozimo enquiry] webhook responded", res.status);
|
||||
} catch (error) {
|
||||
console.error("[mozimo enquiry] webhook failed", error);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const { appendFile } = await import("node:fs/promises");
|
||||
await appendFile("contact-submissions.jsonl", JSON.stringify(record) + "\n", "utf8");
|
||||
} catch {
|
||||
// read-only filesystem — the console log above is the record
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { Route as IndexRouteImport } from './app/index'
|
||||
import { Route as AboutRouteImport } from './app/about'
|
||||
import { Route as BespokeRouteImport } from './app/bespoke'
|
||||
import { Route as ChocolateRouteImport } from './app/chocolate'
|
||||
import { Route as ContactRouteImport } from './app/contact'
|
||||
import { Route as ExperiencesRouteImport } from './app/experiences'
|
||||
import { Route as GiftingRouteImport } from './app/gifting'
|
||||
import { Route as OurWorldRouteImport } from './app/our-world'
|
||||
@@ -39,6 +40,11 @@ const ChocolateRoute = ChocolateRouteImport.update({
|
||||
path: '/chocolate',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ContactRoute = ContactRouteImport.update({
|
||||
id: '/contact',
|
||||
path: '/contact',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ExperiencesRoute = ExperiencesRouteImport.update({
|
||||
id: '/experiences',
|
||||
path: '/experiences',
|
||||
@@ -70,6 +76,7 @@ export interface FileRoutesByFullPath {
|
||||
'/about': typeof AboutRoute
|
||||
'/bespoke': typeof BespokeRoute
|
||||
'/chocolate': typeof ChocolateRoute
|
||||
'/contact': typeof ContactRoute
|
||||
'/experiences': typeof ExperiencesRoute
|
||||
'/gifting': typeof GiftingRoute
|
||||
'/our-world': typeof OurWorldRoute
|
||||
@@ -81,6 +88,7 @@ export interface FileRoutesByTo {
|
||||
'/about': typeof AboutRoute
|
||||
'/bespoke': typeof BespokeRoute
|
||||
'/chocolate': typeof ChocolateRoute
|
||||
'/contact': typeof ContactRoute
|
||||
'/experiences': typeof ExperiencesRoute
|
||||
'/gifting': typeof GiftingRoute
|
||||
'/our-world': typeof OurWorldRoute
|
||||
@@ -93,6 +101,7 @@ export interface FileRoutesById {
|
||||
'/about': typeof AboutRoute
|
||||
'/bespoke': typeof BespokeRoute
|
||||
'/chocolate': typeof ChocolateRoute
|
||||
'/contact': typeof ContactRoute
|
||||
'/experiences': typeof ExperiencesRoute
|
||||
'/gifting': typeof GiftingRoute
|
||||
'/our-world': typeof OurWorldRoute
|
||||
@@ -106,6 +115,7 @@ export interface FileRouteTypes {
|
||||
| '/about'
|
||||
| '/bespoke'
|
||||
| '/chocolate'
|
||||
| '/contact'
|
||||
| '/experiences'
|
||||
| '/gifting'
|
||||
| '/our-world'
|
||||
@@ -117,6 +127,7 @@ export interface FileRouteTypes {
|
||||
| '/about'
|
||||
| '/bespoke'
|
||||
| '/chocolate'
|
||||
| '/contact'
|
||||
| '/experiences'
|
||||
| '/gifting'
|
||||
| '/our-world'
|
||||
@@ -128,6 +139,7 @@ export interface FileRouteTypes {
|
||||
| '/about'
|
||||
| '/bespoke'
|
||||
| '/chocolate'
|
||||
| '/contact'
|
||||
| '/experiences'
|
||||
| '/gifting'
|
||||
| '/our-world'
|
||||
@@ -140,6 +152,7 @@ export interface RootRouteChildren {
|
||||
AboutRoute: typeof AboutRoute
|
||||
BespokeRoute: typeof BespokeRoute
|
||||
ChocolateRoute: typeof ChocolateRoute
|
||||
ContactRoute: typeof ContactRoute
|
||||
ExperiencesRoute: typeof ExperiencesRoute
|
||||
GiftingRoute: typeof GiftingRoute
|
||||
OurWorldRoute: typeof OurWorldRoute
|
||||
@@ -177,6 +190,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ChocolateRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/contact': {
|
||||
id: '/contact'
|
||||
path: '/contact'
|
||||
fullPath: '/contact'
|
||||
preLoaderRoute: typeof ContactRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/experiences': {
|
||||
id: '/experiences'
|
||||
path: '/experiences'
|
||||
@@ -220,6 +240,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
AboutRoute: AboutRoute,
|
||||
BespokeRoute: BespokeRoute,
|
||||
ChocolateRoute: ChocolateRoute,
|
||||
ContactRoute: ContactRoute,
|
||||
ExperiencesRoute: ExperiencesRoute,
|
||||
GiftingRoute: GiftingRoute,
|
||||
OurWorldRoute: OurWorldRoute,
|
||||
|
||||
Reference in New Issue
Block a user