import type { RichCardImagePresentation } from '@linktr.ee/messaging-taxonomy' import classNames from 'classnames' import React from 'react' import { optimizeMessagingAttachmentUrl } from '../../utils/cdnImageUrl' // Reuse LinkAttachment's card chrome primitives — the rich card is a sibling // renderer over the same building blocks, not a from-scratch card. import type { LinkAttachmentVariant } from '../LinkAttachment/components/_shared/CardShell' import CardThumbnail from '../LinkAttachment/components/_shared/CardThumbnail' export interface RichCardImagesProps { variant: LinkAttachmentVariant presentation: RichCardImagePresentation images: string[] title?: string /** Hero-only: the palette-tracked URL (cleared when the hero image errors). */ heroUrl?: string onHeroError?: () => void } /** Placeholder fill behind the fanned images, per surface. */ const FAN_BG: Record = { dark: 'bg-white/[0.06]', light: 'bg-black/[0.04]', } // Deterministic fan geometry (no randomness — Chromatic snapshots must be // stable). Rotation + horizontal offset per slot, keyed on how many images are // actually shown (survivors after any load failures) — so a fan that drops to // two or one re-centres rather than leaving a gap. const FAN_ROTATION: Record<1 | 2 | 3, number[]> = { 1: [0], 2: [-10, 10], 3: [-13, 0, 13], } // Offsets sit a hair inside the mobile renderer's so the outer tiles' rotated // bounding box stays within the 290px shell's `overflow-hidden` edge (a `w-62%` // tile at ±40 + 13° rotation lands ~6px shy of the boundary, vs the old ±44 // which clipped). const FAN_OFFSET_PX: Record<1 | 2 | 3, number[]> = { 1: [0], 2: [-26, 26], 3: [-40, 0, 40], } // Paint z-order per slot so the **centre** card sits on top. Survivors render // left→right, so with the naive `zIndex: position` the right card overlaps the // middle; here the centre (slot 1 of a 3-fan) gets the highest layer. const FAN_ZORDER: Record<1 | 2 | 3, number[]> = { 1: [0], 2: [0, 1], 3: [1, 3, 2], } // The centre card of a 3-fan is nudged larger so it reads as the top of the // stack rather than one of three equals (mirrors the collection design). const FAN_SCALE: Record<1 | 2 | 3, number[]> = { 1: [1], 2: [1, 1], 3: [1, 1.08, 1], } /** * A fanned stack of 2–3 thumbnails for the `fan` presentation (collection * cards). An image that fails to load is dropped and the survivors re-fan (the * hero path recovers the same way via `useChinPalette`); if all fail the region * is left blank. An initial visual interpretation — the exact fan geometry * should be reconciled against the Figma collection frames when they land. */ const RichCardFan: React.FC<{ variant: LinkAttachmentVariant images: string[] }> = ({ variant, images }) => { const [failed, setFailed] = React.useState>({}) // Reset the per-tile failure tracking when the image set changes, so a // recycled card (same element, new collection) doesn't carry a previous // array's failed indexes onto a valid replacement. Adjusting state during // render on a key change is the endorsed alternative to a set-state effect // (mirrors how `useChinPalette` clears `heroFailed` when `thumbnailUrl` changes). const imagesKey = images.join('\n') const [trackedKey, setTrackedKey] = React.useState(imagesKey) if (imagesKey !== trackedKey) { setTrackedKey(imagesKey) setFailed({}) } const survivors = images .slice(0, 3) .map((src, index) => ({ src, index })) .filter(({ index }) => !failed[index]) const count = (survivors.length >= 3 ? 3 : survivors.length === 2 ? 2 : 1) as 1 | 2 | 3 const rotations = FAN_ROTATION[count] const offsets = FAN_OFFSET_PX[count] const zorder = FAN_ZORDER[count] const scales = FAN_SCALE[count] return (
{survivors.map(({ src, index }, position) => ( setFailed((prev) => ({ ...prev, [index]: true }))} // A uniform, slightly-landscape tile (`w-62% aspect-[3/2]`, matching // the mobile renderer) with `object-cover` so the fan stays tidy // whatever the source thumbnails are (OG scrape, upload, video frame) // — the tiles are decorative, so an even cover crop reads better than // per-image aspect. `rounded-[12px]` explicit, NOT `rounded-xl` — this // preset's `xl` radius is 4rem, which rounds a tile into an oval. // `bg-[#ffffff]` gives each tile an opaque holder so a transparent // (PNG/WebP) source composites over white instead of letting the // fan-region tint and the tiles beneath bleed through — and so the // drop shadow reads against a solid card, not the see-through image. // Arbitrary hex, not `bg-white`, since this package ships no Tailwind // utilities and consumers scan `dist` against their own theme (same // reason CardShell uses `bg-[#…]`). `shadow-md` (not `shadow-lg`) // keeps the elevation subtle, matching mobile's `Shadow100`. className="absolute left-1/2 top-1/2 w-[62%] aspect-[3/2] rounded-[12px] bg-[#ffffff] object-cover shadow-md" style={{ transform: `translate(-50%, -50%) translateX(${offsets[position]}px) rotate(${rotations[position]}deg) scale(${scales[position]})`, zIndex: zorder[position], }} /> ))}
) } /** Fixed image-region height. The chin flows below at its natural height (the * card grows rather than clipping tall content), so this is a fixed height, not * the 250px "hero absorbs the remainder" lock LinkAttachment uses. */ const IMAGE_REGION_CLASS = 'flex h-[180px] w-full shrink-0 flex-col overflow-hidden' /** * The rich card's image region. `hero` reuses `CardThumbnail` (single image, the * official/link-share card); `fan` renders the fanned stack (collection card). * Both fill a fixed-height flex column so `CardThumbnail`'s `flex-1` hero has a * height to occupy without the whole card being pinned to a fixed box. */ const RichCardImages: React.FC = ({ variant, presentation, images, title, heroUrl, onHeroError, }) => (
{presentation === 'fan' ? ( ) : ( // `heroUrl` is the palette-tracked source: it becomes `undefined` when the // hero image errors, so `CardThumbnail` falls through to its placeholder. // Do NOT `?? images[0]` here — that would re-instate the failed URL. )}
) export default RichCardImages