[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:
parent
fd716222ad
commit
4ef8e7d232
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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%); }
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user