This commit is contained in:
2026-09-04 11:38:24 +00:00
parent f4db22e828
commit bc5ced1843
13 changed files with 925 additions and 62 deletions
+257
View File
@@ -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>
);
}
+1
View File
@@ -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 = [
+184
View File
@@ -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>
);
}
+1
View File
@@ -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;
+61 -12
View File
@@ -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>