396 lines
15 KiB
TypeScript
396 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import Image from "next/image";
|
|
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
|
import { BARTENDER_PROFILE } from "@/data/portfolio-data";
|
|
import { HubButton } from "@/components/HubButton";
|
|
import { WhatsAppFloatingButton } from "@/components/WhatsAppFloatingButton";
|
|
|
|
const EASE = [0.22, 1, 0.36, 1] as const;
|
|
|
|
type Night = {
|
|
name: string;
|
|
place: string;
|
|
year: string;
|
|
guests: string;
|
|
hours: string;
|
|
bar: string[];
|
|
note: string;
|
|
image: string;
|
|
};
|
|
|
|
const NIGHTS: Night[] = [
|
|
{
|
|
name: "Tribeca",
|
|
place: "Loft privado, Manhattan",
|
|
year: "2025",
|
|
guests: "24",
|
|
hours: "4 h",
|
|
bar: ["Dry Martini", "Manhattan"],
|
|
note: "Cena en casa. Barra montada en la cocina, sin camareros de paso: sólo yo y veinticuatro copas.",
|
|
image: "/images/c3_01.jpg",
|
|
},
|
|
{
|
|
name: "West Village",
|
|
place: "Townhouse, Manhattan",
|
|
year: "2025",
|
|
guests: "70",
|
|
hours: "5 h",
|
|
bar: ["Negroni", "Daiquiri", "Vermut de la casa"],
|
|
note: "Pedida de mano. Tres plantas y una sola barra en el jardín trasero, con carta corta para que nadie subiera a esperar.",
|
|
image: "/images/c3_02.jpg",
|
|
},
|
|
{
|
|
name: "Dumbo",
|
|
place: "Azotea privada, Brooklyn",
|
|
year: "2024",
|
|
guests: "140",
|
|
hours: "6 h",
|
|
bar: ["Americano", "Gimlet", "Whisky Sour"],
|
|
note: "Fiesta de verano con el puente enfrente. Tragos largos y hielo de sobra: en julio, en una azotea, es la mitad del trabajo.",
|
|
image: "/images/c3_03.jpg",
|
|
},
|
|
{
|
|
name: "Hudson Valley",
|
|
place: "Granero privado, Nueva York",
|
|
year: "2024",
|
|
guests: "180",
|
|
hours: "7 h",
|
|
bar: ["Old Fashioned", "Whisky Sour", "Ponche de la casa"],
|
|
note: "Boda de fin de semana a dos horas de la ciudad. Todo subido en furgoneta, incluida el agua para el hielo.",
|
|
image: "/images/c3_04.jpg",
|
|
},
|
|
{
|
|
name: "Upper East Side",
|
|
place: "Penthouse, Manhattan",
|
|
year: "2024",
|
|
guests: "12",
|
|
hours: "2 h",
|
|
bar: ["Boulevardier", "Sidecar"],
|
|
note: "Cata privada en mesa. Doce personas, dos clásicos y cada receta escrita a mano para llevarse.",
|
|
image: "/images/c3_05.jpg",
|
|
},
|
|
];
|
|
|
|
/* A form written as a sentence. The other concepts use a grid of labelled
|
|
fields and a row of pills; this one has neither. */
|
|
function Blank({
|
|
id,
|
|
label,
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
type = "text",
|
|
min,
|
|
}: {
|
|
id: string;
|
|
label: string;
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
placeholder: string;
|
|
type?: string;
|
|
min?: string;
|
|
}) {
|
|
// A native date field renders dd/mm/aaaa plus a picker icon, and a number
|
|
// field a spinner: neither fits the width of its placeholder text.
|
|
const w =
|
|
type === "date" ? 14 : type === "number" ? 5 : Math.max(placeholder.length, value.length) + 1;
|
|
return (
|
|
<>
|
|
<label htmlFor={id} className="sr-only">
|
|
{label}
|
|
</label>
|
|
<input
|
|
id={id}
|
|
type={type}
|
|
min={min}
|
|
value={value}
|
|
placeholder={placeholder}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
style={{ width: `${w}ch` }}
|
|
className="max-w-full border-b border-[var(--ink)] bg-transparent px-0.5 pb-0.5 text-[var(--ink)] outline-none placeholder:text-[var(--dim)] focus:border-b-2"
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function SentenceForm() {
|
|
const [f, setF] = useState({ name: "", date: "", place: "", guests: "", phone: "" });
|
|
const set = (k: keyof typeof f) => (v: string) => setF((p) => ({ ...p, [k]: v }));
|
|
|
|
const send = () => {
|
|
const msg =
|
|
`Hola John, me llamo ${f.name || "—"}. ` +
|
|
`El ${f.date || "—"} doy una fiesta en ${f.place || "—"} para ${f.guests || "—"} personas. ` +
|
|
`Mi teléfono es ${f.phone || "—"}.`;
|
|
window.open(
|
|
`https://wa.me/${BARTENDER_PROFILE.whatsapp.replace(/[^0-9]/g, "")}?text=${encodeURIComponent(msg)}`,
|
|
"_blank"
|
|
);
|
|
};
|
|
|
|
return (
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
send();
|
|
}}
|
|
>
|
|
<p className="max-w-[26ch] text-[clamp(1.5rem,3.4vw,2.4rem)] leading-[1.7] sm:max-w-[34ch]">
|
|
Me llamo{" "}
|
|
<Blank id="f-name" label="Tu nombre" value={f.name} onChange={set("name")} placeholder="nombre" />. El{" "}
|
|
<Blank id="f-date" label="Fecha del evento" type="date" value={f.date} onChange={set("date")} placeholder="fecha" /> doy una
|
|
fiesta en{" "}
|
|
<Blank id="f-place" label="Dónde es" value={f.place} onChange={set("place")} placeholder="dónde" /> para{" "}
|
|
<Blank id="f-guests" label="Cuántos invitados" type="number" min="1" value={f.guests} onChange={set("guests")} placeholder="00" />{" "}
|
|
personas. Escríbeme al{" "}
|
|
<Blank id="f-phone" label="Tu teléfono" type="tel" value={f.phone} onChange={set("phone")} placeholder="teléfono" />.
|
|
</p>
|
|
|
|
<div className="mt-10 flex flex-wrap items-center gap-4">
|
|
<button type="submit" className="press bg-[var(--ink)] px-7 py-3.5 text-[15px] text-white">
|
|
Enviar por WhatsApp
|
|
</button>
|
|
<span className="text-[13px] text-[var(--dim)]">
|
|
Se abre WhatsApp con la frase escrita. La lees antes de mandarla.
|
|
</span>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export default function ConceptThreePage() {
|
|
const reduce = useReducedMotion();
|
|
const [i, setI] = useState(0);
|
|
const n = NIGHTS.length;
|
|
const night = NIGHTS[i];
|
|
|
|
const go = useCallback((d: number) => setI((p) => (p + d + n) % n), [n]);
|
|
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "ArrowRight") go(1);
|
|
if (e.key === "ArrowLeft") go(-1);
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [go]);
|
|
|
|
return (
|
|
<div className="c3 min-h-screen">
|
|
<HubButton currentConceptName="03. Atelier" theme="light" />
|
|
<WhatsAppFloatingButton conceptName="Atelier John Caiza" theme="editorial" />
|
|
|
|
{/* ── The index. One night at a time, on exactly one screen. ────── */}
|
|
<section className="relative grid h-[100svh] grid-rows-[auto_1fr_auto] overflow-hidden md:grid-cols-2 md:grid-rows-1">
|
|
{/* Type side */}
|
|
<div className="relative z-10 flex flex-col justify-between px-6 pb-6 pt-5 md:row-span-1 md:h-[100svh] md:px-10 md:pb-28 md:pt-7">
|
|
<div className="flex items-baseline justify-between">
|
|
<span className="micro">John Caiza — Atelier</span>
|
|
<span className="micro hidden md:inline">Manhattan / Brooklyn / Hudson Valley</span>
|
|
</div>
|
|
|
|
{/* Detail, centred in the empty middle */}
|
|
<div className="hidden md:block">
|
|
<AnimatePresence mode="wait" initial={false}>
|
|
<motion.dl
|
|
key={night.name}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: reduce ? 0 : 0.3, ease: EASE }}
|
|
className="max-w-[34ch] space-y-3 text-[14px]"
|
|
>
|
|
<div className="flex gap-6">
|
|
<dt className="w-24 shrink-0 text-[var(--dim)]">Invitados</dt>
|
|
<dd>{night.guests}</dd>
|
|
</div>
|
|
<div className="flex gap-6">
|
|
<dt className="w-24 shrink-0 text-[var(--dim)]">Servicio</dt>
|
|
<dd>{night.hours}</dd>
|
|
</div>
|
|
<div className="flex gap-6">
|
|
<dt className="w-24 shrink-0 text-[var(--dim)]">En la barra</dt>
|
|
<dd>{night.bar.join(", ")}</dd>
|
|
</div>
|
|
<div className="flex gap-6 pt-2">
|
|
<dt className="w-24 shrink-0 text-[var(--dim)]">Nota</dt>
|
|
<dd className="leading-[1.6] text-[var(--dim)]">{night.note}</dd>
|
|
</div>
|
|
</motion.dl>
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
{/* Name + index */}
|
|
<div>
|
|
<AnimatePresence mode="wait" initial={false}>
|
|
<motion.div
|
|
key={night.name}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: reduce ? 0 : 0.3, ease: EASE }}
|
|
>
|
|
<p className="micro mb-2">
|
|
{night.place} · {night.year}
|
|
</p>
|
|
<h1 className="tight text-[clamp(2rem,6.4vw,4.2rem)] uppercase">{night.name}</h1>
|
|
{/* The detail block is desktop-only; phones get it condensed. */}
|
|
<p className="mt-2.5 text-[13px] leading-[1.5] text-[var(--dim)] md:hidden">
|
|
{night.guests} invitados · {night.hours} · {night.bar.join(", ")}
|
|
</p>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
|
|
<div className="mt-5 flex items-center justify-between border-t border-[var(--rule)] pt-4">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => go(-1)}
|
|
aria-label="Noche anterior"
|
|
className="press flex h-9 w-9 items-center justify-center border border-[var(--rule)] text-[15px] hover:border-[var(--ink)]"
|
|
>
|
|
←
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => go(1)}
|
|
aria-label="Noche siguiente"
|
|
className="press flex h-9 w-9 items-center justify-center border border-[var(--rule)] text-[15px] hover:border-[var(--ink)]"
|
|
>
|
|
→
|
|
</button>
|
|
<ol className="ml-3 flex gap-1.5" aria-label="Índice de noches">
|
|
{NIGHTS.map((x, k) => (
|
|
<li key={x.name}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setI(k)}
|
|
aria-current={k === i}
|
|
aria-label={`${k + 1}. ${x.name}`}
|
|
className={`h-1.5 w-6 transition-colors ${
|
|
k === i ? "bg-[var(--ink)]" : "bg-[var(--rule)] hover:bg-[var(--dim)]"
|
|
}`}
|
|
/>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
<span className="micro">
|
|
{String(i + 1).padStart(2, "0")} / {String(n).padStart(2, "0")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Photograph side */}
|
|
<div className="relative row-start-2 md:row-start-auto md:h-[100svh]">
|
|
<AnimatePresence initial={false}>
|
|
<motion.div
|
|
key={night.image}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: reduce ? 0 : 0.45, ease: EASE }}
|
|
className="absolute inset-0"
|
|
>
|
|
<Image
|
|
src={night.image}
|
|
alt={`${night.name}, ${night.place}, ${night.year}`}
|
|
fill
|
|
priority={i === 0}
|
|
sizes="(max-width: 768px) 100vw, 50vw"
|
|
className="object-cover"
|
|
/>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── El atelier ────────────────────────────────────────────────── */}
|
|
<section className="border-t border-[var(--rule)] px-6 py-20 md:px-10 md:py-28">
|
|
<div className="grid gap-10 md:grid-cols-12">
|
|
<p className="micro md:col-span-3">El atelier</p>
|
|
<div className="md:col-span-9">
|
|
<p className="max-w-[42ch] text-[clamp(1.4rem,2.6vw,2rem)] leading-[1.25]">
|
|
Una barra, las botellas justas y nadie esperando. Todo lo que no ayuda a eso
|
|
sobra, y sale de la mesa.
|
|
</p>
|
|
<p className="mt-8 max-w-[54ch] text-[15px] leading-[1.7] text-[var(--dim)]">
|
|
Sólo hago eventos privados: casas, lofts, azoteas y fincas de Nueva York.
|
|
Coctelería clásica, proporciones conocidas, cítrico exprimido en el momento y
|
|
hielo cuidado. Monto, sirvo y recojo. A la mañana siguiente no queda rastro de
|
|
que hubo una barra.
|
|
</p>
|
|
|
|
<dl className="mt-12 grid max-w-2xl grid-cols-2 gap-x-10 gap-y-6 border-t border-[var(--rule)] pt-8 text-[14px] sm:grid-cols-4">
|
|
{BARTENDER_PROFILE.stats.map((s) => (
|
|
<div key={s.label}>
|
|
<dt className="text-[26px] leading-none">{s.value}</dt>
|
|
<dd className="mt-2 text-[13px] leading-snug text-[var(--dim)]">{s.label}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── Contacto ──────────────────────────────────────────────────── */}
|
|
<section id="reservar" className="border-t border-[var(--rule)] px-6 py-20 md:px-10 md:py-28">
|
|
<div className="grid gap-10 md:grid-cols-12">
|
|
<p className="micro md:col-span-3">Reservar</p>
|
|
<div className="md:col-span-9">
|
|
<h2 className="tight max-w-[16ch] text-[clamp(1.9rem,5vw,3.4rem)] uppercase">
|
|
Cuéntame qué noche es
|
|
</h2>
|
|
<p className="mt-6 max-w-[48ch] text-[15px] leading-[1.7] text-[var(--dim)]">
|
|
Dime la fecha, la dirección y cuántos sois. Trabajo en los cinco condados y subo
|
|
al valle del Hudson y a los Hamptons. Respondo el mismo día.
|
|
</p>
|
|
<div className="mt-10">
|
|
<SentenceForm />
|
|
</div>
|
|
|
|
<p className="mt-10 border-t border-[var(--rule)] pt-6 text-[13px] text-[var(--dim)]">
|
|
También en{" "}
|
|
<a
|
|
href={BARTENDER_PROFILE.instagramUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="underline underline-offset-4 hover:text-[var(--ink)]"
|
|
>
|
|
{BARTENDER_PROFILE.instagram}
|
|
</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div className="fixed inset-x-0 bottom-0 z-50 flex gap-2 border-t border-[var(--rule)] bg-[var(--paper)]/95 px-4 py-3 backdrop-blur-md sm:hidden">
|
|
<a
|
|
href={`https://wa.me/${BARTENDER_PROFILE.whatsapp.replace(/[^0-9]/g, "")}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="press flex-1 border border-[var(--rule)] py-3 text-center text-[15px]"
|
|
>
|
|
WhatsApp
|
|
</a>
|
|
<a
|
|
href="#reservar"
|
|
className="press flex-1 bg-[var(--ink)] py-3 text-center text-[15px] text-white"
|
|
>
|
|
Reservar
|
|
</a>
|
|
</div>
|
|
|
|
<footer className="border-t border-[var(--rule)] px-6 py-8 pb-24 sm:pb-8 md:px-10">
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:justify-between">
|
|
<span className="micro">John Caiza — Atelier</span>
|
|
<span className="micro">Nueva York / Eventos privados</span>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|