feat: FAQ two-column layout, team swipe & counter, showcase improvements
- FAQ: split into 2 columns on desktop to reduce section height - Team: remove description line-clamp, show full bio - ShowcaseLayout: add swipe navigation on mobile, optional counter - Counter shows current/total with gold accent styling Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -22,43 +22,55 @@ export function FAQ() {
|
|||||||
<SectionHeading centered>{faq.title}</SectionHeading>
|
<SectionHeading centered>{faq.title}</SectionHeading>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<div className="mx-auto mt-14 max-w-3xl">
|
<div className="mx-auto mt-14 grid max-w-5xl gap-x-10 lg:grid-cols-2">
|
||||||
{faq.items.map((item, i) => (
|
{[0, 1].map((col) => {
|
||||||
<Reveal key={i}>
|
const half = Math.ceil(faq.items.length / 2);
|
||||||
|
const items = col === 0 ? faq.items.slice(0, half) : faq.items.slice(half);
|
||||||
|
const offset = col === 0 ? 0 : half;
|
||||||
|
return (
|
||||||
|
<div key={col}>
|
||||||
|
{items.map((item, i) => {
|
||||||
|
const idx = offset + i;
|
||||||
|
return (
|
||||||
|
<Reveal key={idx}>
|
||||||
<div
|
<div
|
||||||
className={`border-b border-neutral-200 dark:border-white/[0.06] ${
|
className={`border-b border-neutral-200 dark:border-white/[0.06] ${
|
||||||
i === 0 ? "border-t" : ""
|
i === 0 ? "border-t" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => toggle(i)}
|
onClick={() => toggle(idx)}
|
||||||
className="flex w-full items-center justify-between gap-6 py-6 text-left transition-colors"
|
className="flex w-full items-center justify-between gap-4 py-5 text-left transition-colors"
|
||||||
>
|
>
|
||||||
<span className="text-base font-medium text-neutral-900 dark:text-white sm:text-lg">
|
<span className="text-sm font-medium text-neutral-900 dark:text-white sm:text-base">
|
||||||
{item.question}
|
{item.question}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-100 transition-all duration-300 dark:bg-white/[0.06]">
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-neutral-100 transition-all duration-300 dark:bg-white/[0.06]">
|
||||||
{openIndex === i ? (
|
{openIndex === idx ? (
|
||||||
<Minus size={16} className="text-[#c9a96e]" />
|
<Minus size={14} className="text-[#c9a96e]" />
|
||||||
) : (
|
) : (
|
||||||
<Plus size={16} className="text-neutral-400 dark:text-neutral-500" />
|
<Plus size={14} className="text-neutral-400 dark:text-neutral-500" />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
className={`grid transition-all duration-300 ease-out ${
|
className={`grid transition-all duration-300 ease-out ${
|
||||||
openIndex === i ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
|
openIndex === idx ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
<div className="pb-6 pr-14 text-sm leading-relaxed text-neutral-600 dark:text-neutral-400 sm:text-base whitespace-pre-line">
|
<div className="pb-5 pr-10 text-sm leading-relaxed text-neutral-600 dark:text-neutral-400 whitespace-pre-line">
|
||||||
{item.answer}
|
{item.answer}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function Team() {
|
|||||||
activeIndex={activeIndex}
|
activeIndex={activeIndex}
|
||||||
onSelect={select}
|
onSelect={select}
|
||||||
onHoverChange={setHovering}
|
onHoverChange={setHovering}
|
||||||
|
counter
|
||||||
renderDetail={(member) => (
|
renderDetail={(member) => (
|
||||||
<div className="relative aspect-[3/4] max-h-[600px] w-full overflow-hidden rounded-2xl">
|
<div className="relative aspect-[3/4] max-h-[600px] w-full overflow-hidden rounded-2xl">
|
||||||
<Image
|
<Image
|
||||||
@@ -65,7 +66,7 @@ export function Team() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{member.description && (
|
{member.description && (
|
||||||
<p className="mt-4 text-sm leading-relaxed text-white/70 line-clamp-4 sm:line-clamp-6">
|
<p className="mt-4 text-sm leading-relaxed text-white/70">
|
||||||
{member.description}
|
{member.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useEffect, useState } from "react";
|
import { useRef, useEffect, useState, useCallback } from "react";
|
||||||
|
|
||||||
interface ShowcaseLayoutProps<T> {
|
interface ShowcaseLayoutProps<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
@@ -9,6 +9,7 @@ interface ShowcaseLayoutProps<T> {
|
|||||||
onHoverChange?: (hovering: boolean) => void;
|
onHoverChange?: (hovering: boolean) => void;
|
||||||
renderDetail: (item: T, index: number) => React.ReactNode;
|
renderDetail: (item: T, index: number) => React.ReactNode;
|
||||||
renderSelectorItem: (item: T, index: number, isActive: boolean) => React.ReactNode;
|
renderSelectorItem: (item: T, index: number, isActive: boolean) => React.ReactNode;
|
||||||
|
counter?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShowcaseLayout<T>({
|
export function ShowcaseLayout<T>({
|
||||||
@@ -18,9 +19,11 @@ export function ShowcaseLayout<T>({
|
|||||||
onHoverChange,
|
onHoverChange,
|
||||||
renderDetail,
|
renderDetail,
|
||||||
renderSelectorItem,
|
renderSelectorItem,
|
||||||
|
counter = false,
|
||||||
}: ShowcaseLayoutProps<T>) {
|
}: ShowcaseLayoutProps<T>) {
|
||||||
const selectorRef = useRef<HTMLDivElement>(null);
|
const selectorRef = useRef<HTMLDivElement>(null);
|
||||||
const activeItemRef = useRef<HTMLButtonElement>(null);
|
const activeItemRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const detailRef = useRef<HTMLDivElement>(null);
|
||||||
const [isUserInteracting, setIsUserInteracting] = useState(false);
|
const [isUserInteracting, setIsUserInteracting] = useState(false);
|
||||||
|
|
||||||
// Auto-scroll selector only when item is out of view
|
// Auto-scroll selector only when item is out of view
|
||||||
@@ -56,6 +59,32 @@ export function ShowcaseLayout<T>({
|
|||||||
}
|
}
|
||||||
}, [activeIndex, isUserInteracting]);
|
}, [activeIndex, isUserInteracting]);
|
||||||
|
|
||||||
|
// Swipe support on detail area
|
||||||
|
const touchStart = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
|
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||||
|
touchStart.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTouchEnd = useCallback(
|
||||||
|
(e: React.TouchEvent) => {
|
||||||
|
if (!touchStart.current) return;
|
||||||
|
const dx = e.changedTouches[0].clientX - touchStart.current.x;
|
||||||
|
const dy = e.changedTouches[0].clientY - touchStart.current.y;
|
||||||
|
touchStart.current = null;
|
||||||
|
|
||||||
|
// Only trigger if horizontal swipe is dominant and > 50px
|
||||||
|
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5) {
|
||||||
|
if (dx < 0 && activeIndex < items.length - 1) {
|
||||||
|
onSelect(activeIndex + 1);
|
||||||
|
} else if (dx > 0 && activeIndex > 0) {
|
||||||
|
onSelect(activeIndex - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeIndex, items.length, onSelect],
|
||||||
|
);
|
||||||
|
|
||||||
function handleMouseEnter() {
|
function handleMouseEnter() {
|
||||||
setIsUserInteracting(true);
|
setIsUserInteracting(true);
|
||||||
onHoverChange?.(true);
|
onHoverChange?.(true);
|
||||||
@@ -74,9 +103,28 @@ export function ShowcaseLayout<T>({
|
|||||||
>
|
>
|
||||||
{/* Detail area */}
|
{/* Detail area */}
|
||||||
<div className="lg:w-[60%]">
|
<div className="lg:w-[60%]">
|
||||||
<div key={activeIndex} className="showcase-detail-enter">
|
<div
|
||||||
|
ref={detailRef}
|
||||||
|
key={activeIndex}
|
||||||
|
className="showcase-detail-enter"
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
>
|
||||||
{renderDetail(items[activeIndex], activeIndex)}
|
{renderDetail(items[activeIndex], activeIndex)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Counter */}
|
||||||
|
{counter && (
|
||||||
|
<div className="mt-3 flex items-center justify-center gap-2 lg:justify-start">
|
||||||
|
<span className="text-xs font-medium tabular-nums text-[#c9a96e]">
|
||||||
|
{String(activeIndex + 1).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
<span className="h-[1px] w-8 bg-white/10" />
|
||||||
|
<span className="text-xs tabular-nums text-neutral-500">
|
||||||
|
{String(items.length).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selector */}
|
{/* Selector */}
|
||||||
|
|||||||
Reference in New Issue
Block a user