Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ GitHub Actions가 하루 2회(UTC 05:00·17:00) 호출. `maxDuration = 60`. 부
- 일정 크롤: CloudFront CDN API(`d29dxerjsp82wz.cloudfront.net`)는 폐기됨(404) → 실질적으로 `www.ufc.com/events` HTML 파싱이 주 소스
- 예측 생성: `eventId`로 중복 체크 — 기존 예측 재사용, 새 이벤트만 Gemini 호출
- 파이터 이미지: `enrichFighterImages()` — UFC 선수 페이지 병렬 스크레이핑 (최대 20명)
5. **고석현 확정 경기 감지** (Phase 2.5): `detectKoConfirmedFight(events, existingConfirmed)`가 일정에서 고석현 매치(`isKoSeokhyeon` 매처)를 찾아 `confirmedFight` 생성 후 `opponent_predictions.data`에 부착. 상대·대회가 기존과 같으면 Gemini 재호출 생략, 신규/변경 시에만 `analyzeConfirmedOpponent()`로 한국어명·국적·스타일 보강. 감지 실패는 non-blocking
5. **고석현 확정 경기 감지** (Phase 2.5): `detectKoConfirmedFight(events, existingConfirmed)`가 일정에서 고석현 매치(`isKoSeokhyeon` 매처)를 찾아 `confirmedFight` 생성 후 `opponent_predictions.data`에 부착. 상대·대회가 기존과 같고 신체 스펙(`height`)까지 있으면 Gemini 재호출 생략, 신규/변경 또는 스펙 미보강(구버전 데이터)이면 `analyzeConfirmedOpponent()`로 한국어명·국적·스타일·나이·신장/체중/리치·전적 보강(Tale of the Tape 비교용). 전적은 크롤값 우선, 크롤에 없을 때만 Gemini 폴백. 감지 실패는 non-blocking

크롤 완료 후 `revalidatePath()` + `fetch` 워밍으로 `/`, `/schedule`, `/predictions`, `/rankings` 한/영 캐시 갱신.

Expand Down
14 changes: 13 additions & 1 deletion src/app/[locale]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import careerHighlights from "@/data/career-highlights.json";
import fighterBio from "@/data/fighter-bio.json";
import { getPredictions } from "@/lib/data/predictions";
import { getRankings } from "@/lib/data/rankings";
import { buildKoComparisonStats } from "@/lib/ko-stats";
import { fetchNews } from "@/lib/news";
import { searchYouTubeVideos } from "@/lib/youtube";
import type {
Expand Down Expand Up @@ -155,6 +156,13 @@ export default async function HomePage({
getSchedule(),
]);

// 확정 경기 카드·예측 프리뷰의 Tale of the Tape 비교용 고석현 데이터
const koStats = buildKoComparisonStats(
stats,
predictions.koFightMatrixRank,
predictions.lastFightDate
);

const siteOrigin = (() => {
const raw =
process.env.NEXT_PUBLIC_SITE_URL || "https://lets-ko.vercel.app";
Expand Down Expand Up @@ -197,7 +205,11 @@ export default async function HomePage({
stats={stats}
locale={locale}
/>
<PredictionPreview predictions={predictions} locale={locale} />
<PredictionPreview
predictions={predictions}
koStats={koStats}
locale={locale}
/>
<SchedulePreview schedule={schedule} locale={locale} />
<StatsCard stats={stats} />
<CareerTimeline
Expand Down
25 changes: 7 additions & 18 deletions src/app/[locale]/predictions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@ import { setRequestLocale } from "next-intl/server";
import PredictionDetail from "@/components/predictions/PredictionDetail";
import cachedStats from "@/data/cached-stats.json";
import { getPredictions } from "@/lib/data/predictions";
import { buildKoComparisonStats } from "@/lib/ko-stats";
import type { FighterStats } from "@/types/fighter";

import { differenceInYears } from "date-fns";

export const revalidate = 86400;

export async function generateMetadata({
Expand Down Expand Up @@ -125,22 +124,12 @@ export default async function PredictionsPage({
getFighterStats(),
]);

// 고석현 비교용 데이터 (cm/kg 단위)
const koFightMatrixRank =
stats.externalRankings?.find((r) => r.site === "FightMatrix")?.rank ??
predictions.koFightMatrixRank;

const koComparisonStats = {
record: `${stats.record.wins}-${stats.record.losses}-${stats.record.draws}`,
age: differenceInYears(new Date(), new Date("1993-09-24")),
height: "177.8cm",
weight: "77.1kg",
reach: "180.3cm",
style: { ko: "유도 / 삼보", en: "Judo / Sambo" },
fightMatrixRank: koFightMatrixRank,
// stats.fightHistory가 더 신뢰할 수 있는 ISO 형식이므로 우선 사용
lastFightDate: stats.fightHistory?.[0]?.date || predictions.lastFightDate,
};
// 고석현 비교용 데이터 (cm/kg 단위, 메인 페이지와 공통 빌더 사용)
const koComparisonStats = buildKoComparisonStats(
stats,
predictions.koFightMatrixRank,
predictions.lastFightDate
);

return (
<PredictionDetail
Expand Down
213 changes: 149 additions & 64 deletions src/components/fighter/PredictionPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,21 @@ import Link from "next/link";
import ConfirmedFightCard from "@/components/predictions/ConfirmedFightCard";
import { useInView } from "@/hooks/useInView";
import { formatKstLongDate } from "@/lib/date-utils";
import type { KoComparisonStats } from "@/lib/ko-stats";
import type { PredictionData } from "@/types/prediction";

interface PredictionPreviewProps {
predictions: PredictionData;
locale: string;
predictions: PredictionData; // AI 상대 예측 데이터 (확정 경기 포함 가능)
koStats: KoComparisonStats; // 카드 내 비교 표기용 고석현 측 데이터
locale: string; // "ko" | "en"
}

// 상대 이미지 없음/placeholder URL일 때 표시할 실루엣 (다크 배경용)
function FighterPlaceholder() {
return (
<div className="w-full h-full flex items-center justify-center bg-gray-100">
<div className="w-full h-full flex items-center justify-center bg-gray-800">
<svg
className="w-10 h-10 text-gray-300"
className="w-10 h-10 text-gray-600"
fill="currentColor"
viewBox="0 0 24 24"
>
Expand All @@ -27,8 +30,19 @@ function FighterPlaceholder() {
);
}

// "177.8cm" → "177.8" (미니 그리드의 KO 병기값은 공간 절약을 위해 단위 생략)
const stripUnit = (value: string): string => value.replace(/[^\d.]/g, "");

/**
* @description 메인 페이지 AI 다음 상대 예측 프리뷰 섹션. 확정 경기가 있으면 Tale of the Tape 카드,
* 없으면 후보 3인의 다크 카드(전적/나이/신장/리치 + 고석현 병기, AI 승률 바)를 표시.
* @param props.predictions - AI 상대 예측 데이터
* @param props.koStats - 고석현 측 비교 데이터 (전적/나이/신장/리치 등)
* @param props.locale - "ko" | "en"
*/
export default function PredictionPreview({
predictions,
koStats,
locale,
}: PredictionPreviewProps) {
const t = useTranslations("predictions");
Expand All @@ -42,6 +56,7 @@ export default function PredictionPreview({
<div className="max-w-5xl mx-auto">
<ConfirmedFightCard
fight={predictions.confirmedFight}
koStats={koStats}
locale={locale}
/>
</div>
Expand Down Expand Up @@ -126,72 +141,142 @@ export default function PredictionPreview({
</p>
</div>

{/* 카드 그리드 */}
{/* 후보 카드 그리드 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-5">
{predictions.opponents.map((opponent, index) => (
<Link
key={index}
href={`/${locale}/predictions`}
className="group relative p-5 sm:p-6 rounded-2xl bg-white border border-border/60 shadow-card hover:shadow-card-hover hover:border-primary/20 transition-all duration-500 cursor-pointer overflow-hidden"
style={{
opacity: isInView ? 1 : 0,
transform: isInView ? "translateY(0)" : "translateY(16px)",
transition: `opacity 0.5s ease ${150 + index * 100}ms, transform 0.5s ease ${150 + index * 100}ms`,
}}
>
{/* 배경 그라데이션 호버 효과 */}
<div className="absolute inset-0 bg-linear-to-b from-primary/2 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />

<div className="relative flex flex-col items-center text-center">
{/* 순위 배지 */}
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full bg-foreground text-white text-[11px] font-black mb-4">
#{index + 1}
</span>

{/* 프로필 이미지 */}
<div className="w-20 h-20 rounded-full bg-gray-50 overflow-hidden mb-3 ring-2 ring-border/50 group-hover:ring-primary/30 transition-all duration-500">
{opponent.imageUrl &&
!opponent.imageUrl.includes("placeholder") ? (
<img
src={opponent.imageUrl}
alt={opponent.name[lang]}
className="w-full h-full object-cover object-top"
loading="lazy"
/>
) : (
<FighterPlaceholder />
)}
</div>
{predictions.opponents.map((opponent, index) => {
const total =
opponent.record.wins +
opponent.record.losses +
opponent.record.draws;
const winRate =
total > 0 ? Math.round((opponent.record.wins / total) * 100) : 0;
const koProb = opponent.winProbability;

{/* 이름 */}
<h3 className="font-bold text-foreground group-hover:text-primary transition-colors duration-300 text-[15px]">
{opponent.name[lang]}
</h3>
// 미니 비교 그리드: 상대 값 + 고석현(KO) 병기값
const miniStats = [
{
label: t("record"),
value: `${opponent.record.wins}-${opponent.record.losses}-${opponent.record.draws}`,
sub: total > 0 ? `${t("winRate")} ${winRate}%` : "-",
},
{
label: t("age"),
value: String(opponent.age),
sub: `KO ${koStats.age}`,
},
{
label: t("height"),
value: opponent.height,
sub: `KO ${stripUnit(koStats.height)}`,
},
{
label: t("reach"),
value: opponent.reach,
sub: `KO ${stripUnit(koStats.reach)}`,
},
];

{/* 국적 + 랭킹 */}
<div className="flex items-center gap-1.5 mt-1.5">
<span className="text-[11px] text-muted font-medium">
{opponent.country}
return (
<Link
key={index}
href={`/${locale}/predictions`}
className="group relative rounded-3xl bg-linear-to-b from-[#0f1724] via-[#162033] to-[#0f1724] border border-white/5 shadow-xl hover:shadow-2xl hover:border-white/15 transition-all duration-500 cursor-pointer overflow-hidden"
style={{
opacity: isInView ? 1 : 0,
transform: isInView ? "translateY(0)" : "translateY(16px)",
transition: `opacity 0.5s ease ${150 + index * 100}ms, transform 0.5s ease ${150 + index * 100}ms`,
}}
>
{/* 상단 배경 글로우 */}
<div className="absolute -top-16 left-1/2 -translate-x-1/2 w-48 h-48 rounded-full bg-blue-500/10 blur-3xl group-hover:bg-primary/15 transition-colors duration-500 pointer-events-none" />

<div className="relative flex flex-col items-center text-center p-5 sm:p-6">
{/* 후보 순위 배지 */}
<span className="inline-flex items-center px-2.5 py-1 rounded-full bg-white/6 border border-white/10 text-white/70 text-[11px] font-black tracking-wide mb-4">
{t("candidate", { number: index + 1 })}
</span>
<span className="w-1 h-1 rounded-full bg-border" />
<span className="text-[11px] font-semibold text-primary/70">
#{opponent.fightMatrixRank}

{/* 프로필 이미지 */}
<div className="w-20 h-20 sm:w-24 sm:h-24 rounded-full overflow-hidden bg-gray-800 ring-2 ring-white/10 group-hover:ring-blue-400/50 transition-all duration-500 mb-3">
{opponent.imageUrl &&
!opponent.imageUrl.includes("placeholder") ? (
<img
src={opponent.imageUrl}
alt={opponent.name[lang]}
className="w-full h-full object-cover object-top"
loading="lazy"
/>
) : (
<FighterPlaceholder />
)}
</div>

{/* 이름 */}
<h3 className="text-white font-bold text-[15px] tracking-tight">
{opponent.name[lang]}
</h3>

{/* 국적 + 랭킹 */}
<div className="flex items-center gap-1.5 mt-1">
<span className="text-[11px] text-white/40 font-medium">
{opponent.country}
</span>
<span className="w-1 h-1 rounded-full bg-white/20" />
<span className="text-[11px] font-semibold text-white/40">
FightMatrix #{opponent.fightMatrixRank}
</span>
</div>

{/* 스타일 태그 */}
<span className="mt-3 text-[11px] font-medium text-violet-300 bg-violet-500/15 border border-violet-400/10 px-2.5 py-1 rounded-lg">
{opponent.fightingStyle[lang]}
</span>
</div>

{/* 스타일 태그 */}
<span className="mt-3 text-[11px] font-medium text-violet-600/80 bg-violet-50 px-2.5 py-1 rounded-lg">
{opponent.fightingStyle[lang]}
</span>

{/* 전적 */}
<p className="mt-2 text-xs text-muted tabular-nums">
{opponent.record.wins}W - {opponent.record.losses}L -{" "}
{opponent.record.draws}D
</p>
</div>
</Link>
))}
{/* 미니 비교 그리드 (상대 값 + KO 병기) */}
<div className="w-full grid grid-cols-4 divide-x divide-white/6 rounded-xl bg-white/4 border border-white/6 py-2.5 mt-4">
{miniStats.map((stat, i) => (
<div key={i} className="px-1">
<p className="text-[10px] text-white/35 font-bold uppercase tracking-wide">
{stat.label}
</p>
<p className="text-[12px] text-white font-bold tabular-nums mt-0.5">
{stat.value}
</p>
<p className="text-[10px] text-white/35 tabular-nums mt-0.5">
{stat.sub}
</p>
</div>
))}
</div>

{/* AI 승률 바 */}
<div className="w-full mt-4">
<p className="text-left text-[10px] text-white/30 font-bold uppercase tracking-wider mb-1.5">
{t("winProbability")}
</p>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[11px] font-bold text-red-300 tabular-nums">
{t("koName")} {koProb}%
</span>
<span className="text-[11px] font-bold text-blue-300 tabular-nums">
{100 - koProb}%
</span>
</div>
<div className="h-1.5 rounded-full bg-white/10 overflow-hidden flex">
<div
className="h-full bg-linear-to-r from-primary to-red-400"
style={{ width: `${koProb}%` }}
/>
<div
className="h-full bg-linear-to-r from-blue-400 to-blue-600"
style={{ width: `${100 - koProb}%` }}
/>
</div>
</div>
</div>
</Link>
);
})}
</div>

{/* 하단 CTA */}
Expand Down
Loading