first commit
This commit is contained in:
395
src/app/concept-3/page.tsx
Normal file
395
src/app/concept-3/page.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user