Shop Lookbook
«Compra el look»: imagen con puntos numerados que abren fichas con añadir a la bolsa, con lista accesible equivalente.
lookbookhotspotsecommerceshoppable
Vista Previa en Vivo
Lookbook
Shop the look
A Sunday uniform: a soft overshirt, a tote that goes everywhere and the mug that starts the day.
- 01Linen Overshirt€145.00
- 02Field Tote€120.00
- 03Kiln Mug€32.00
The complete look€297.00
Uso
Copia y pega este código directamente en tu componente React:
tsx
import { ShopLookbook } from 'react-component-kit-ui/sections';
<ShopLookbook title="Shop the *look*" spots={[
{ x: 40, y: 32, product: { slug: 'overshirt', name: 'Linen Overshirt', price: 145, category: 'clothing', icon: 'shirt' }, href: '/product/overshirt/' },
]} />Instalación Manual
Si no estás usando el paquete npm, crea un archivo nuevo y pega el siguiente código: src/components/sections/ShopLookbook.tsx + ShopLookbook.css:
ShopLookbook.tsx
'use client';
import { useEffect, useRef } from 'react';
import type { CSSProperties } from 'react';
import ProductMedia from '../ecommerce/ProductMedia';
import { formatMoney } from '../ecommerce/cart-store';
import type { Product } from '../ecommerce/types';
import { bindAddToCart } from '../ecommerce/cart-store';
import './ShopLookbook.css';
/** class:list → className */
function cx(...args: unknown[]): string {
const out: string[] = [];
const walk = (v: unknown) => {
if (!v) return;
if (typeof v === 'string' || typeof v === 'number') out.push(String(v));
else if (Array.isArray(v)) v.forEach(walk);
else if (typeof v === 'object') for (const [k, on] of Object.entries(v as Record<string, unknown>)) if (on) out.push(k);
};
args.forEach(walk);
return out.join(' ');
}
/** "a: b; --c: d" → style object */
function st(css: string | undefined): CSSProperties {
const o: Record<string, string> = {};
for (const decl of String(css ?? '').split(';')) {
const i = decl.indexOf(':');
if (i < 0) continue;
const k = decl.slice(0, i).trim();
if (k) o[k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = decl.slice(i + 1).trim();
}
return o as CSSProperties;
}
interface Spot { x: number; y: number; product: Product; href?: string }
/**
* ShopLookbook — "Shop the look": a large editorial image with numbered hotspots. Each hotspot
* opens a small product card (name, price, link and add to bag) and highlights it in the side list.
*
* @param {string} eyebrow - Small label. Default 'Lookbook'.
* @param {string} title - Heading (use *asterisks* for italics). Default 'Shop the *look*'.
* @param {string} text - Intro copy.
* @param {{icon?: string, hue?: number, image?: string, alt?: string}} media - Scene visual.
* @param {Array<{x: number, y: number, product: Product, href?: string}>} spots - Hotspots; x/y in % of the image.
* @param {string} currency - ISO currency. Default 'EUR'.
* @param {string} locale - Number format locale. Default 'en-GB'.
* @param {string} cartKey - Cart storage key. Default 'ack-cart'.
* @param {string} variant - Layout (image-left|image-right). Default 'image-left'.
* @param {string} id - Base id. Default 'lookbook'.
* @param {string} className - Extra class.
*
* Accessibility: hotspots are buttons with full labels and aria-expanded; the same products are
* listed as plain links next to the image, so the look is usable without the hotspots.
*/
export interface ShopLookbookProps {
eyebrow?: string;
title?: string;
text?: string;
media?: { icon?: string; hue?: number; image?: string; alt?: string };
spots?: Spot[];
currency?: string;
locale?: string;
cartKey?: string;
variant?: 'image-left' | 'image-right';
id?: string;
className?: string;
}
export function ShopLookbook({
eyebrow = 'Lookbook',
title = 'Shop the *look*',
text = 'A Sunday uniform: a soft overshirt, a tote that goes everywhere and the mug that starts the day.',
media = { icon: 'jacket', hue: 22 },
spots = [
{ x: 38, y: 34, product: { slug: 'linen-overshirt', name: 'Linen Overshirt', price: 145, category: 'clothing', icon: 'shirt', hue: 30 } },
{ x: 66, y: 62, product: { slug: 'field-tote', name: 'Field Tote', price: 120, category: 'bags', icon: 'bag', hue: 18 } },
{ x: 24, y: 76, product: { slug: 'kiln-mug', name: 'Kiln Mug', price: 32, category: 'objects', icon: 'mug', hue: 14 } },
],
currency = 'EUR',
locale = 'en-GB',
cartKey = 'ack-cart',
variant = 'image-left',
id = 'lookbook',
className = '',
}: ShopLookbookProps) {
const money = { currency, locale };
const parts = title.split(/\*(.+?)\*/g);
const total = spots.reduce((n, s) => n + s.product.price, 0);
// Ported from the Astro <script>: runs once after mount (DOM logic kept as in the Astro kit).
const mounted = useRef(false);
useEffect(() => {
if (mounted.current) return;
mounted.current = true;
bindAddToCart(document);
document.querySelectorAll<HTMLElement>('[data-lookbook]:not([data-bound])').forEach((lb) => {
lb.dataset.bound = '';
const dots = Array.from(lb.querySelectorAll<HTMLButtonElement>('[data-spot]'));
const rows = Array.from(lb.querySelectorAll<HTMLElement>('[data-row]'));
const close = () => {
dots.forEach((d) => {
d.setAttribute('aria-expanded', 'false');
const p = document.getElementById(d.getAttribute('aria-controls') || '');
if (p) p.hidden = true;
});
rows.forEach((r) => r.classList.remove('is-active'));
};
dots.forEach((d, i) => {
d.addEventListener('click', (e) => {
e.stopPropagation();
const open = d.getAttribute('aria-expanded') !== 'true';
close();
if (!open) return;
d.setAttribute('aria-expanded', 'true');
const p = document.getElementById(d.getAttribute('aria-controls') || '');
if (p) p.hidden = false;
rows[i]?.classList.add('is-active');
});
rows[i]?.addEventListener('mouseenter', () => d.classList.add('is-hint'));
rows[i]?.addEventListener('mouseleave', () => d.classList.remove('is-hint'));
});
document.addEventListener('click', (e) => { if (!lb.contains(e.target as Node)) close(); });
lb.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
const open = dots.find((d) => d.getAttribute('aria-expanded') === 'true');
close();
open?.focus();
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<div className="slb-cq" data-rk-e29b78="">
<section className={cx(['slb', `slb--${variant}`, className])} data-lookbook data-rk-e29b78="">
<div className="slb__stage" data-rk-e29b78="">
<ProductMedia image={media.image} alt={media.alt ?? ''} icon={media.icon} hue={media.hue} ratio="4 / 5" variant="soft" size="lg" />
{spots.map((s, i) => (
<div key={i} className="slb__spot" style={st(`left: ${s.x}%; top: ${s.y}%`)} data-rk-e29b78="">
<button type="button" className="slb__dot" aria-expanded="false" aria-controls={`${id}-pop-${i}`} aria-label={`${i + 1}: ${s.product.name}, ${formatMoney(s.product.price, money)}`} data-spot={i} data-rk-e29b78="">{i + 1}</button>
<div className={cx(['slb__pop', { 'slb__pop--left': s.x > 60 }])} id={`${id}-pop-${i}`} hidden data-pop data-rk-e29b78="">
<p className="slb__pop-name" data-rk-e29b78="">{s.product.name}</p>
<p className="slb__pop-price" data-rk-e29b78="">{formatMoney(s.product.price, money)}</p>
<div className="slb__pop-actions" data-rk-e29b78="">
<a href={s.href ?? '#'} data-rk-e29b78="">View</a>
<button type="button" data-add-to-cart={JSON.stringify({ id: s.product.slug, name: s.product.name, price: s.product.price, icon: s.product.icon, hue: s.product.hue, variant: s.product.colors?.[0]?.name })} data-cart-key={cartKey} data-open-cart="false" data-added-label="Added" data-rk-e29b78=""><span data-label data-rk-e29b78="">Add to bag</span></button>
</div>
</div>
</div>
))}
</div>
<div className="slb__copy" data-rk-e29b78="">
{eyebrow && <p className="slb__eyebrow" data-rk-e29b78="">{eyebrow}</p>}
<h2 className="slb__title" data-rk-e29b78="">{parts.map((p, i) => (i % 2 ? <em data-rk-e29b78="">{p}</em> : p))}</h2>
{text && <p className="slb__text" data-rk-e29b78="">{text}</p>}
<ol className="slb__list" data-rk-e29b78="">
{spots.map((s, i) => (
<li key={i} className="slb__row" data-row={i} data-rk-e29b78="">
<span className="slb__row-num" aria-hidden="true" data-rk-e29b78="">{String(i + 1).padStart(2, '0')}</span>
<a className="slb__row-name" href={s.href ?? '#'} data-rk-e29b78="">{s.product.name}</a>
<span className="slb__row-price" data-rk-e29b78="">{formatMoney(s.product.price, money)}</span>
</li>
))}
</ol>
<p className="slb__total" data-rk-e29b78=""><span data-rk-e29b78="">The complete look</span><strong data-rk-e29b78="">{formatMoney(total, money)}</strong></p>
</div>
</section>
</div>
</>
);
}
export default ShopLookbook;ShopLookbook.css
/* ShopLookbook — ported from astro-component-kit (sections/ShopLookbook.astro) */
.slb-cq:where([data-rk-e29b78]) { container-type: inline-size; }
.slb:where([data-rk-e29b78]) {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
gap: clamp(1.5rem, 5vw, 5rem);
align-items: center;
color: var(--c-text-1, #fff);
}
.slb--image-right:where([data-rk-e29b78]) .slb__stage:where([data-rk-e29b78]) { order: 2; }
.slb__stage:where([data-rk-e29b78]) { position: relative; border-radius: var(--r-sm, 2px); }
.slb__stage:where([data-rk-e29b78]) > .p-media { border-radius: var(--r-sm, 2px); }
.slb__spot:where([data-rk-e29b78]) { position: absolute; z-index: 2; transform: translate(-50%, -50%); }
.slb__dot:where([data-rk-e29b78]) {
position: relative;
display: grid;
place-items: center;
width: 40px;
height: 40px;
border: 0;
border-radius: 50%;
background: var(--c-text-1, #fff);
color: var(--c-bg, #050505);
font: inherit;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
box-shadow: 0 10px 30px -8px rgba(0, 0, 0, 0.6);
transition: transform 0.2s ease, background 0.2s ease, color 0.2s ease;
}
.slb__dot:where([data-rk-e29b78])::before { content: ''; position: absolute; inset: -6px; border-radius: 50%; border: 1px solid var(--c-text-1, #fff); opacity: 0.6; animation: shop-lookbook-slb-ring 2.4s ease-out infinite; }
.slb__dot:hover:where([data-rk-e29b78]), .slb__dot.is-hint:where([data-rk-e29b78]), .slb__dot[aria-expanded='true']:where([data-rk-e29b78]) { transform: scale(1.12); background: var(--shop-accent, var(--c-primary-light, #d4a017)); color: var(--c-on-primary, #050505); }
@keyframes shop-lookbook-slb-ring { 0% { transform: scale(0.8); opacity: 0.7; } 100% { transform: scale(1.5); opacity: 0; } }
.slb__pop:where([data-rk-e29b78]) {
position: absolute;
top: 50%;
left: calc(100% + 12px);
width: 220px;
padding: var(--sp-4, 1rem);
transform: translateY(-50%);
background: var(--c-bg-1, #0a0a0a);
border: 1px solid var(--c-border, rgba(255, 255, 255, 0.12));
box-shadow: 0 24px 50px -20px rgba(0, 0, 0, 0.6);
animation: shop-lookbook-slb-pop 0.2s ease-out;
}
.slb__pop--left:where([data-rk-e29b78]) { left: auto; right: calc(100% + 12px); }
.slb__pop[hidden]:where([data-rk-e29b78]) { display: none; }
@keyframes shop-lookbook-slb-pop { from { opacity: 0; transform: translateY(-46%); } }
.slb__pop-name:where([data-rk-e29b78]) { margin: 0; font-family: var(--font-display, Georgia, serif); font-size: 1.05rem; }
.slb__pop-price:where([data-rk-e29b78]) { margin: 2px 0 var(--sp-3, 0.75rem); font-size: 0.85rem; color: var(--c-text-2, #a3a3a3); }
.slb__pop-actions:where([data-rk-e29b78]) { display: flex; gap: var(--sp-2, 0.5rem); }
.slb__pop-actions:where([data-rk-e29b78]) a:where([data-rk-e29b78]), .slb__pop-actions:where([data-rk-e29b78]) button:where([data-rk-e29b78]) {
flex: 1;
display: inline-grid;
place-items: center;
min-height: 40px;
border: 1px solid var(--c-text-1, #fff);
border-radius: var(--r-sm, 2px);
background: transparent;
color: var(--c-text-1, #fff);
font: inherit;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
text-decoration: none;
cursor: pointer;
}
.slb__pop-actions:where([data-rk-e29b78]) button:where([data-rk-e29b78]) { background: var(--c-text-1, #fff); color: var(--c-bg, #050505); }
.slb__pop-actions:where([data-rk-e29b78]) button.is-added:where([data-rk-e29b78]) { background: var(--shop-accent, var(--c-primary-light, #d4a017)); border-color: transparent; color: var(--c-on-primary, #050505); }
.slb:where([data-rk-e29b78]) :is(a, button):focus-visible:where([data-rk-e29b78]) { outline: 2px solid var(--shop-accent, var(--c-primary-light, #d4a017)); outline-offset: 3px; }
.slb__eyebrow:where([data-rk-e29b78]) { margin: 0 0 var(--sp-3, 0.75rem); font-size: 0.72rem; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase; color: var(--c-text-2, #a3a3a3); }
.slb__title:where([data-rk-e29b78]) { margin: 0; font-family: var(--font-display, Georgia, 'Times New Roman', serif); font-size: clamp(2.4rem, 5.5vw, 4.5rem); font-weight: 400; letter-spacing: -0.04em; line-height: 0.95; }
.slb__title:where([data-rk-e29b78]) em:where([data-rk-e29b78]) { font-style: italic; color: var(--shop-accent, var(--c-primary-light, #d4a017)); }
.slb__text:where([data-rk-e29b78]) { max-width: 28rem; margin: var(--sp-5, 1.25rem) 0 0; line-height: 1.65; color: var(--c-text-2, #a3a3a3); }
.slb__list:where([data-rk-e29b78]) { margin: var(--sp-8, 2rem) 0 0; padding: 0; list-style: none; border-top: 1px solid var(--c-border, rgba(255, 255, 255, 0.12)); }
.slb__row:where([data-rk-e29b78]) { display: grid; grid-template-columns: 40px 1fr auto; align-items: center; gap: var(--sp-3, 0.75rem); min-height: 56px; border-bottom: 1px solid var(--c-border, rgba(255, 255, 255, 0.12)); transition: background 0.2s ease, padding 0.2s ease; }
.slb__row.is-active:where([data-rk-e29b78]) { background: rgb(var(--ink-rgb, 255 255 255) / 0.05); padding-inline: var(--sp-2, 0.5rem); }
.slb__row-num:where([data-rk-e29b78]) { font-size: 0.75rem; color: var(--c-text-2, #a3a3a3); font-variant-numeric: tabular-nums; }
.slb__row-name:where([data-rk-e29b78]) { font-family: var(--font-display, Georgia, serif); font-size: 1.15rem; color: inherit; text-decoration: none; }
.slb__row-name:hover:where([data-rk-e29b78]) { color: var(--shop-accent, var(--c-primary-light, #d4a017)); }
.slb__row-price:where([data-rk-e29b78]) { font-variant-numeric: tabular-nums; }
.slb__total:where([data-rk-e29b78]) { display: flex; justify-content: space-between; margin: var(--sp-4, 1rem) 0 0; font-size: 0.85rem; color: var(--c-text-2, #a3a3a3); }
.slb__total:where([data-rk-e29b78]) strong:where([data-rk-e29b78]) { font-size: 1.1rem; color: var(--c-text-1, #fff); }
@container (max-width: 860px) {
.slb:where([data-rk-e29b78]) { grid-template-columns: 1fr; }
.slb--image-right:where([data-rk-e29b78]) .slb__stage:where([data-rk-e29b78]) { order: 0; }
.slb__pop:where([data-rk-e29b78]) { left: 50%; top: calc(100% + 10px); transform: translateX(-50%); width: 200px; }
.slb__pop--left:where([data-rk-e29b78]) { right: auto; left: 50%; }
@keyframes shop-lookbook-slb-pop { from { opacity: 0; } }
}
@media (prefers-reduced-motion: reduce) {
.slb__dot:where([data-rk-e29b78])::before, .slb__pop:where([data-rk-e29b78]) { animation: none; }
}