|
| 1 | +import {Icon} from "../../shared/components/Icon.jsx"; |
| 2 | + |
| 3 | +const MARQUEE_CSS = ` |
| 4 | +@keyframes hbx-logos-marquee { |
| 5 | + from { transform: translateX(0); } |
| 6 | + to { transform: translateX(-50%); } |
| 7 | +} |
| 8 | +.hbx-logos-track { |
| 9 | + animation: hbx-logos-marquee var(--hbx-marquee-dur, 30s) linear infinite; |
| 10 | + will-change: transform; |
| 11 | +} |
| 12 | +.hbx-logos-track:hover { animation-play-state: paused; } |
| 13 | +`; |
| 14 | + |
| 15 | +// Slot widths keep every logo at a consistent visual footprint regardless of SVG aspect ratio |
| 16 | +const SLOT_W = {sm: "w-24", md: "w-32", lg: "w-40"}; |
| 17 | +// Max-width caps very wide SVGs inside their slot (height × ~2.5) |
| 18 | +const MAX_W = {sm: "3.5rem", md: "5rem", lg: "7rem"}; |
| 19 | +// Heights passed as inline style so Icon.jsx's auto-sizing fires correctly |
| 20 | +const HEIGHT = {sm: "1.75rem", md: "2.5rem", lg: "3.5rem"}; |
| 21 | + |
| 22 | +// Full-string Tailwind filter classes — no dynamic concatenation |
| 23 | +const FILTER = { |
| 24 | + grayscale: "grayscale opacity-60 hover:grayscale-0 hover:opacity-100 transition-all duration-300", |
| 25 | + white: "brightness-0 invert transition-all duration-300", |
| 26 | + color: "transition-all duration-300", |
| 27 | +}; |
| 28 | + |
| 29 | +function renderText(text) { |
| 30 | + if (!text) return ""; |
| 31 | + return String(text) |
| 32 | + .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>") |
| 33 | + .replace(/\*(.*?)\*/g, "<em>$1</em>"); |
| 34 | +} |
| 35 | + |
| 36 | +function LogoItem({item, idx, logoStyle, logoSize, icon_svgs, item_images, padY = "py-3"}) { |
| 37 | + const iconSvg = item.icon ? (icon_svgs?.[item.icon] ?? null) : null; |
| 38 | + const imgData = item_images?.[String(idx)] ?? null; |
| 39 | + const height = HEIGHT[logoSize] ?? HEIGHT.md; |
| 40 | + const maxW = MAX_W[logoSize] ?? MAX_W.md; |
| 41 | + const slotW = SLOT_W[logoSize] ?? SLOT_W.md; |
| 42 | + |
| 43 | + // Per-item overrides: style and scale |
| 44 | + const itemStyle = item.style ?? logoStyle; |
| 45 | + const filter = FILTER[itemStyle] ?? FILTER.grayscale; |
| 46 | + // scale: compensates for SVG intrinsic whitespace or atypical artwork areas. |
| 47 | + // e.g. scale: 1.3 enlarges a logo whose SVG has excessive padding baked into its viewBox. |
| 48 | + const scale = typeof item.scale === "number" ? item.scale : 1; |
| 49 | + const scaleStyle = scale !== 1 ? `;transform:scale(${scale})` : ""; |
| 50 | + |
| 51 | + let visual; |
| 52 | + if (iconSvg) { |
| 53 | + visual = ( |
| 54 | + <Icon |
| 55 | + svg={iconSvg} |
| 56 | + attributes={{ |
| 57 | + class: `inline-block ${filter}`, |
| 58 | + style: `height:${height};max-width:${maxW}${scaleStyle}`, |
| 59 | + title: item.name || undefined, |
| 60 | + }} |
| 61 | + /> |
| 62 | + ); |
| 63 | + } else if (imgData) { |
| 64 | + visual = ( |
| 65 | + <img |
| 66 | + src={imgData.src} |
| 67 | + alt={item.name || ""} |
| 68 | + class={`object-contain ${filter}`} |
| 69 | + style={`height:${height};width:auto;max-width:${maxW}${scaleStyle}`} |
| 70 | + /> |
| 71 | + ); |
| 72 | + } else if (item.name) { |
| 73 | + visual = <span class="font-semibold text-gray-400 dark:text-gray-500 text-sm">{item.name}</span>; |
| 74 | + } else { |
| 75 | + return null; |
| 76 | + } |
| 77 | + |
| 78 | + const isExt = item.url && (item.url.startsWith("http://") || item.url.startsWith("https://")); |
| 79 | + // Fixed-width slot: every logo occupies the same footprint → consistent visual weight |
| 80 | + const cls = `flex items-center justify-center flex-shrink-0 ${slotW} ${padY}`; |
| 81 | + |
| 82 | + return item.url ? ( |
| 83 | + <a href={item.url} class={cls} aria-label={item.name || undefined} |
| 84 | + {...(isExt ? {target: "_blank", rel: "noopener noreferrer"} : {})}> |
| 85 | + {visual} |
| 86 | + </a> |
| 87 | + ) : ( |
| 88 | + <div class={cls} aria-label={item.name || undefined}>{visual}</div> |
| 89 | + ); |
| 90 | +} |
| 91 | + |
| 92 | +function LogoRow({items, logoStyle, logoSize, icon_svgs, item_images}) { |
| 93 | + return ( |
| 94 | + <div class="flex flex-wrap items-center justify-center"> |
| 95 | + {items.map((item, i) => ( |
| 96 | + <LogoItem key={i} item={item} idx={i} logoStyle={logoStyle} logoSize={logoSize} |
| 97 | + icon_svgs={icon_svgs} item_images={item_images} /> |
| 98 | + ))} |
| 99 | + </div> |
| 100 | + ); |
| 101 | +} |
| 102 | + |
| 103 | +function LogoGrid({items, logoStyle, logoSize, icon_svgs, item_images}) { |
| 104 | + const cols = |
| 105 | + items.length <= 3 ? "grid-cols-3" : |
| 106 | + items.length <= 4 ? "grid-cols-2 sm:grid-cols-4" : |
| 107 | + items.length <= 6 ? "grid-cols-3 sm:grid-cols-6" : |
| 108 | + "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6"; |
| 109 | + return ( |
| 110 | + <div class={`grid ${cols} gap-6 items-center`}> |
| 111 | + {items.map((item, i) => ( |
| 112 | + <LogoItem key={i} item={item} idx={i} logoStyle={logoStyle} logoSize={logoSize} |
| 113 | + icon_svgs={icon_svgs} item_images={item_images} /> |
| 114 | + ))} |
| 115 | + </div> |
| 116 | + ); |
| 117 | +} |
| 118 | + |
| 119 | +function LogoMarquee({items, logoStyle, logoSize, icon_svgs, item_images, speed}) { |
| 120 | + const doubled = [...items, ...items]; |
| 121 | + return ( |
| 122 | + <> |
| 123 | + <style>{MARQUEE_CSS}</style> |
| 124 | + {/* |
| 125 | + mask-image fades edges without depending on the section background colour — |
| 126 | + works on any bg (white, gray-50, dark, gradient, etc.) |
| 127 | + */} |
| 128 | + <div |
| 129 | + class="overflow-hidden" |
| 130 | + style="mask-image: linear-gradient(to right, transparent, black 8%, black 92%, transparent)" |
| 131 | + > |
| 132 | + <div |
| 133 | + class="hbx-logos-track flex w-max items-center" |
| 134 | + style={`--hbx-marquee-dur:${speed ?? 30}s`} |
| 135 | + > |
| 136 | + {doubled.map((item, i) => ( |
| 137 | + <LogoItem key={i} item={item} idx={i % items.length} logoStyle={logoStyle} |
| 138 | + logoSize={logoSize} icon_svgs={icon_svgs} item_images={item_images} /> |
| 139 | + ))} |
| 140 | + </div> |
| 141 | + </div> |
| 142 | + </> |
| 143 | + ); |
| 144 | +} |
| 145 | + |
| 146 | +export const LogosBlock = ({content = {}, design = {}, icon_svgs = {}, item_images = {}}) => { |
| 147 | + const rawItems = Array.isArray(content.items) ? content.items |
| 148 | + : Array.isArray(content.logos) ? content.logos |
| 149 | + : []; |
| 150 | + |
| 151 | + const {title, subtitle, cta} = content; |
| 152 | + const layout = design.layout || design.display_mode || "row"; |
| 153 | + const logoStyle = design.logo_style || "grayscale"; |
| 154 | + const logoSize = design.logo_size || "md"; |
| 155 | + const speed = design.marquee_speed ?? 30; |
| 156 | + |
| 157 | + const isExtCta = cta?.url && (cta.url.startsWith("http://") || cta.url.startsWith("https://")); |
| 158 | + |
| 159 | + return ( |
| 160 | + <div class="py-12 sm:py-16 px-4 sm:px-6 lg:px-8"> |
| 161 | + <div class="max-w-7xl mx-auto"> |
| 162 | + {(title || subtitle) && ( |
| 163 | + <div class="text-center mb-8"> |
| 164 | + {title && ( |
| 165 | + <p |
| 166 | + class="text-xs font-semibold uppercase tracking-widest text-gray-400 dark:text-gray-500 mb-1.5" |
| 167 | + dangerouslySetInnerHTML={{__html: renderText(title)}} |
| 168 | + /> |
| 169 | + )} |
| 170 | + {subtitle && ( |
| 171 | + <p |
| 172 | + class="text-sm text-gray-400 dark:text-gray-500" |
| 173 | + dangerouslySetInnerHTML={{__html: renderText(subtitle)}} |
| 174 | + /> |
| 175 | + )} |
| 176 | + </div> |
| 177 | + )} |
| 178 | + |
| 179 | + {layout === "marquee" && ( |
| 180 | + <LogoMarquee items={rawItems} logoStyle={logoStyle} logoSize={logoSize} |
| 181 | + icon_svgs={icon_svgs} item_images={item_images} speed={speed} /> |
| 182 | + )} |
| 183 | + {layout === "grid" && ( |
| 184 | + <LogoGrid items={rawItems} logoStyle={logoStyle} logoSize={logoSize} |
| 185 | + icon_svgs={icon_svgs} item_images={item_images} /> |
| 186 | + )} |
| 187 | + {layout !== "marquee" && layout !== "grid" && ( |
| 188 | + <LogoRow items={rawItems} logoStyle={logoStyle} logoSize={logoSize} |
| 189 | + icon_svgs={icon_svgs} item_images={item_images} /> |
| 190 | + )} |
| 191 | + |
| 192 | + {cta?.text && cta?.url && ( |
| 193 | + <div class="mt-6 text-center"> |
| 194 | + <a |
| 195 | + href={cta.url} |
| 196 | + {...(isExtCta ? {target: "_blank", rel: "noopener noreferrer"} : {})} |
| 197 | + class="inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 transition-colors" |
| 198 | + > |
| 199 | + {cta.text} |
| 200 | + <span aria-hidden="true">→</span> |
| 201 | + </a> |
| 202 | + </div> |
| 203 | + )} |
| 204 | + </div> |
| 205 | + </div> |
| 206 | + ); |
| 207 | +}; |
0 commit comments