[feat] solution/frontend: 히어로 아래 발행 사이트 마퀴 — 입력칸만으론 뭐가 나오는지 모른다

상단에 입력칸 하나만 있으면 무엇이 만들어지는지 알 수 없다. 결과물을 바로 밑에서 흘린다.

- CSS 만으로 돈다(index.css o2o-marquee). setInterval 로 돌리면 탭이 백그라운드일 때
  프레임이 밀려 돌아왔을 때 툭 끊긴 것처럼 보인다
- 같은 목록을 두 벌 그리고 트랙을 -50% 까지만 민다 — 한 벌이면 끝에서 빈 화면이 지나간다
- 개수가 늘어도 흐르는 속도는 그대로(카드 수에 비례해 duration)
- 복제분은 aria-hidden·tabIndex=-1 — 스크린리더가 같은 목록을 두 번 읽지 않게
- hover 하면 멈추고, prefers-reduced-motion 이면 아예 안 움직인다
- CTA 문구를 [확인하기] 로(시안과 같게)

tsc·eslint·vite build 통과
This commit is contained in:
Mina Choi 2026-09-03 10:34:02 +09:00
parent fd716222ad
commit 4ef8e7d232
3 changed files with 94 additions and 3 deletions

View File

@ -1,4 +1,4 @@
import {useEffect, useState} from 'react';
import {useEffect, useState, type CSSProperties} from 'react';
import {Building2, Coffee, ImageOff, Stethoscope, UtensilsCrossed} from 'lucide-react';
import {PlaceCategory} from '@o2o/shared';
import {fetchShowcase, type ShowcaseItem} from './showcaseApi';
@ -100,3 +100,75 @@ function SkeletonCard() {
</div>
);
}
/**
* .
*
* **CSS ** (index.css `o2o-marquee`). setInterval
* .
* -50% .
* .
* . .
*/
export function ShowcasePeeks({limit = 10}: {limit?: number}) {
const [items, setItems] = useState<ShowcaseItem[]>([]);
useEffect(() => {
let alive = true;
fetchShowcase(limit)
.then((rows) => alive && setItems(rows))
.catch(() => undefined);
return () => {
alive = false;
};
}, [limit]);
if (items.length === 0) return null;
// 카드 수에 비례해 시간을 늘린다 — 개수가 늘어도 흐르는 **속도**는 그대로여야 한다.
const duration = `${Math.max(24, items.length * 6)}s`;
return (
<div
className="o2o-marquee relative mt-12 overflow-hidden [mask-image:linear-gradient(90deg,transparent,#000_8%,#000_92%,transparent)]"
aria-label="발행된 홈페이지"
>
<div
className="o2o-marquee-track flex w-max gap-4 py-1"
style={{'--marquee-duration': duration} as CSSProperties}
>
{[...items, ...items].map((item, index) => (
<a
key={`${item.url}-${index}`}
href={siteHref(item.url)}
target="_blank"
rel="noreferrer"
// 두 벌째는 첫 벌의 복제다 — 스크린리더가 같은 목록을 두 번 읽지 않게 한다.
aria-hidden={index >= items.length}
tabIndex={index >= items.length ? -1 : undefined}
className="w-52 shrink-0 overflow-hidden rounded-xl border border-border bg-card text-left transition-transform hover:-translate-y-0.5"
>
<div className="flex aspect-[16/10] items-center justify-center overflow-hidden bg-muted">
{item.thumbnailUrl ? (
<img
src={item.thumbnailUrl}
alt={`${item.name} 홈페이지`}
loading="lazy"
className="size-full object-cover"
/>
) : (
<ImageOff className="size-5 text-muted-foreground/40" aria-hidden />
)}
</div>
<div className="px-3 py-2.5">
<p className="truncate text-xs font-semibold tracking-tight">{item.name}</p>
<p className="truncate text-[11px] text-muted-foreground">
{[item.region, CATEGORY_LABEL[item.category]].filter(Boolean).join(' · ')}
</p>
</div>
</a>
))}
</div>
</div>
);
}

View File

@ -76,6 +76,22 @@ body {
}
}
/* 랜딩 히어로의 발행 사이트 마퀴. **CSS 만으로 돈다** 타이머를 쓰면 탭이 백그라운드일
프레임이 밀려 끊긴 것처럼 보인다. 트랙에 같은 목록을 넣고 -50% 까지 밀면 이음매가 없다. */
@keyframes o2o-marquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.o2o-marquee-track {
animation: o2o-marquee var(--marquee-duration, 48s) linear infinite;
}
.o2o-marquee:hover .o2o-marquee-track {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
.o2o-marquee-track { animation: none; }
}
/* 수집 진행바 shimmer — 0%(빈 트랙) 구간에서도 '진행 중'이 보이게 한다. */
@keyframes o2o-shimmer {
from { transform: translateX(-100%); }

View File

@ -3,7 +3,7 @@ import {Link, useNavigate} from 'react-router';
import {ArrowRight, Building2, Check, Coffee, Search, Stethoscope, UtensilsCrossed} from 'lucide-react';
import type {IndustryType} from '@o2o/shared';
import {MarketingShell, Section, SectionHead} from '@/components/layout/MarketingShell';
import {ShowcaseGrid} from '@/features/marketing/ShowcaseGrid';
import {ShowcaseGrid, ShowcasePeeks} from '@/features/marketing/ShowcaseGrid';
import {Button} from '@/components/ui/button';
const INDUSTRIES: {id: IndustryType; label: string; icon: typeof Building2}[] = [
@ -67,7 +67,7 @@ export function LandingPage() {
/>
</div>
<Button type="submit" variant="primary" size="lg" className="shrink-0">
</Button>
</form>
@ -97,6 +97,9 @@ export function LandingPage() {
</li>
))}
</ul>
{/* 입력칸만 있으면 무엇이 나오는지 알 수 없다 — 결과물 두 장을 먼저 보여준다. */}
<ShowcasePeeks />
</div>
</section>