Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
119 changes: 99 additions & 20 deletions src/components/HomeTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ type RecommendItem = {
clothesId?: number | null;
outfitId?: number | null;
bookId?: number | null;
top?: any;
bottom?: any;
outer?: any;
purchaseUrl?: string;
};

Expand Down Expand Up @@ -163,9 +166,16 @@ export default function HomeTab({
// we don't use toastMessage directly here

// 추천 목록에서 중복된 clothesId를 제거하는 헬퍼
const uniqueItems = useCallback(<T extends { clothesId?: number | null; id: string }>(items: T[]): T[] => {
const uniqueItems = useCallback(<T extends { clothesId?: number | null; id: string; top?: any; bottom?: any; outer?: any; shoes?: any }>(items: T[]): T[] => {
const seen = new Set();
return items.filter(item => {
// OOTD 조합인 경우 구성 요소들의 ID 조합으로 중복 체크 가능
if (item.top || item.bottom || item.outer || item.shoes) {
const comboKey = [item.top?.clothesId, item.bottom?.clothesId, item.outer?.clothesId, item.shoes?.clothesId].filter(Boolean).sort().join(',');
if (seen.has(comboKey)) return false;
seen.add(comboKey);
return true;
}
// clothesId가 있으면 그것을 키로 쓰고, 없으면 id를 키로 씀
const key = item.clothesId != null ? String(item.clothesId) : item.id;
if (seen.has(key)) return false;
Expand All @@ -174,13 +184,41 @@ export default function HomeTab({
});
}, []);

const ownedClothes = useMemo(() => clothes.filter((item) => !item.isWishlist), [clothes]);

const selectedRecommendations = useMemo(() => {
if (activeLabel === "match" || activeLabel === "similar" || activeLabel === "aimd") return [];
const items = activeLabel === "ootd" ? ootdItems : styleItems;
return uniqueItems(items);
}, [activeLabel, ootdItems, styleItems, uniqueItems]);
const uniques = uniqueItems(items);

const ownedClothes = useMemo(() => clothes.filter((item) => !item.isWishlist), [clothes]);
if (activeLabel !== 'ootd') return uniques;

// For OOTD: prefer outfits that use user's own clothes, then fill with others up to 6
const ownedSet = new Set<number>();
ownedClothes.forEach((c) => {
const num = parseBeClothesId(c.id);
if (num != null) ownedSet.add(num);
});

const owned = [] as typeof uniques;
const notOwned = [] as typeof uniques;
uniques.forEach((it) => {
// outfit을 구성하는 아이템 중 하나라도 보유 중이면 owned로 분류
const itemClothesIds = [
it.top?.clothesId,
it.bottom?.clothesId,
it.outer?.clothesId,
it.shoes?.clothesId,
it.clothesId,
].filter((id): id is number => id != null);

const isOwned = itemClothesIds.some(id => ownedSet.has(id));
if (isOwned) owned.push(it);
else notOwned.push(it);
});

return [...owned, ...notOwned].slice(0, 6);
}, [activeLabel, ootdItems, styleItems, uniqueItems, ownedClothes]);
const matchEligibleOwnedClothes = useMemo(
() => ownedClothes.filter((item) => parseBeClothesId(item.id) != null),
[ownedClothes],
Expand Down Expand Up @@ -257,26 +295,46 @@ export default function HomeTab({
}

const res = await fetchOotdRecommendations(wardrobeId, currentTemp ?? 20);
const outfits = res?.combinations || res?.outfits || (Array.isArray(res) ? res : []);
let outfits = res?.combinations || res?.outfits || (Array.isArray(res) ? res : []);
console.log('[OOTD DEBUG] raw outfits count:', outfits.length, outfits);
const weatherLabel = res?.weatherLabel || "";

// 랜덤성 추가 (조합 셔플링)
if (Array.isArray(outfits)) {
outfits = [...outfits].sort(() => Math.random() - 0.5);
}

if (cancelled) return;
setOotdError(null);
const combos = outfits.map((item: any) => ({
top: item.top ?? null,
bottom: item.bottom ?? null,
outer: item.outer ?? null,
totalScore: item.totalScore ?? null,
weatherLabel: weatherLabel || item.weatherLabel,
outfitId: item.outfitId ?? null,
bookId: currentBookId || null,
}));

const generateOotdId = (item: any, idx: number) => {
if (item.outfitId) return `ootd-outfit-${item.outfitId}`;
// outfitId가 없는 경우 구성 요소들의 ID 조합으로 고유 ID 생성 (안정성)
const components = [item.top?.clothesId, item.bottom?.clothesId, item.outer?.clothesId, item.shoes?.clothesId].filter(Boolean).sort().join('-');
return components ? `ootd-comp-${components}` : `ootd-${idx}`;
};

const combos = outfits.map((item: any, idx: number) => {
const id = generateOotdId(item, idx);
return {
id,
top: item.top ?? null,
bottom: item.bottom ?? null,
outer: item.outer ?? null,
shoes: item.shoes ?? null,
totalScore: item.totalScore ?? null,
weatherLabel: weatherLabel || item.weatherLabel,
outfitId: item.outfitId ?? null,
bookId: currentBookId || null,
};
});

const mapped = outfits.map((item: any, idx: number) => {
const id = generateOotdId(item, idx);
const mainItem = item.top || item.outer || item.bottom || item;
const title = [item.top?.name, item.bottom?.name].filter(Boolean).join(' + ') || (item.name ?? item.title ?? `추천 코디 ${idx + 1}`);
return {
id: item.outfitId ? `ootd-outfit-${item.outfitId}` : `ootd-${idx}`,
id,
title,
category: mainItem.category ? (mainItem.category === 'TOP' ? 'Top' : mainItem.category === 'BOTTOM' ? 'Bottom' : mainItem.category === 'OUTER' ? 'Outer' : 'Shoes') : 'Outer',
style: STYLE_LABELS[(item.styleCodes && item.styleCodes[0]) || item.style] ?? (item.style || '—'),
Expand All @@ -291,6 +349,10 @@ export default function HomeTab({
clothesId: mainItem.clothesId ?? null,
outfitId: item.outfitId ?? null,
bookId: currentBookId || null,
top: item.top,
bottom: item.bottom,
outer: item.outer,
shoes: item.shoes,
} as RecommendItem;
});
setOotdCombinations(combos);
Expand All @@ -307,11 +369,29 @@ export default function HomeTab({
if (activeLabel === 'style' && styleItems.length === 0) {
setStyleLoading(true);
try {
const res = await fetchWardrobeRecommendations(wardrobeId);
let currentTemp: number | undefined = undefined;
try {
const weather = await fetchWeather(region || '서울');
if (Array.isArray(weather) && weather.length > 0) {
const raw = weather[0].temp as string;
const parsed = parseFloat(raw.replace(/°\s*C/i, '').trim());
if (!isNaN(parsed)) currentTemp = parsed;
} else if (weather && typeof weather === 'object') {
currentTemp = (weather.currentTemp ?? weather.temp ?? weather.temperature) as number | undefined;
}
} catch (e) {
console.error('[DEBUG] weather fetch failed:', e);
}

const res = await fetchWardrobeRecommendations(wardrobeId, currentTemp ?? 20);
if (cancelled) return;
setStyleError(null);
const items = Array.isArray(res) ? res : [];
const mapped = items.slice(0, DEFAULT_RECOMMENDATIONS_PER_CATEGORY).map((item: any, idx: number) => ({

// 다양성을 위해 결과 셔플링 (상위 10개는 유지하되 그 안에서 섞거나, 전체를 섞음)
const shuffled = [...items].sort(() => Math.random() - 0.5);

const mapped = shuffled.slice(0, DEFAULT_RECOMMENDATIONS_PER_CATEGORY).map((item: any, idx: number) => ({
id: `style-${item.clothesId ?? idx}`,
title: item.title,
category: item.category ? (item.category === 'TOP' ? 'Top' : item.category === 'BOTTOM' ? 'Bottom' : item.category === 'OUTER' ? 'Outer' : 'Shoes') : 'Top',
Expand Down Expand Up @@ -623,7 +703,7 @@ export default function HomeTab({
key={item.id}
onClick={() => {
if (activeLabel === 'ootd') {
const combo = ootdCombinations[selectedRecommendations.indexOf(item)] ?? null;
const combo = ootdCombinations.find(c => c.id === item.id) ?? null;
setSelectedCombo(combo);
setSelectedItem(null);
} else {
Expand All @@ -636,8 +716,7 @@ export default function HomeTab({
{activeLabel === 'ootd' ? (
<div className="h-44 sm:h-52 lg:h-72 bg-slate-100 relative overflow-hidden">
{(() => {
const idx = selectedRecommendations.indexOf(item);
const combo = ootdCombinations[idx];
const combo = ootdCombinations.find(c => c.id === item.id);
if (combo && !combo.outer && combo.top && combo.bottom) {
return (
<div className="w-full h-full flex flex-col">
Expand Down
45 changes: 45 additions & 0 deletions src/components/OutfitDetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,30 @@ export default function OutfitDetailModal({
showToast('error', '코디북 정보를 불러오지 못했습니다.')
return
}

setFavLoading(true)
try {
// 중복 체크
const existingOutfits = await fetchOutfits(editCombo.bookId)
const currentItemIds = [
editCombo.top?.clothesId,
editCombo.bottom?.clothesId,
editCombo.outer?.clothesId,
editCombo.shoes?.clothesId
].filter(Boolean).sort()

const isDuplicate = existingOutfits.some(outfit => {
const outfitItemIds = outfit.items.map(it => it.clothes.clothesId).filter(Boolean).sort()
if (currentItemIds.length !== outfitItemIds.length) return false
return currentItemIds.every((id, idx) => id === outfitItemIds[idx])
})

if (isDuplicate) {
showToast('info', '이미 코디북에 동일한 코디가 있습니다.')
setFavLoading(false)
return
}

const payload = {
title: editCombo.title || [editCombo.top?.name, editCombo.bottom?.name].filter(Boolean).join(' + ') || '추천 코디',
description: editCombo.description || editCombo.weatherLabel || '추천 코디',
Expand Down Expand Up @@ -188,6 +210,29 @@ export default function OutfitDetailModal({
}
setSaving(true)
try {
if (!editCombo.outfitId) {
// 새로 저장하는 경우 중복 체크
const existingOutfits = await fetchOutfits(editCombo.bookId)
const currentItemIds = [
editCombo.top?.clothesId,
editCombo.bottom?.clothesId,
editCombo.outer?.clothesId,
editCombo.shoes?.clothesId
].filter(Boolean).sort()

const isDuplicate = existingOutfits.some(outfit => {
const outfitItemIds = outfit.items.map(it => it.clothes.clothesId).filter(Boolean).sort()
if (currentItemIds.length !== outfitItemIds.length) return false
return currentItemIds.every((id, idx) => id === outfitItemIds[idx])
})

if (isDuplicate) {
showToast('info', '이미 코디북에 동일한 코디가 있습니다.')
setSaving(false)
return
}
}

const payload = {
title: editCombo.title || [editCombo.top?.name, editCombo.bottom?.name].filter(Boolean).join(' + ') || '추천 코디',
description: editCombo.description || editCombo.weatherLabel || '추천 코디',
Expand Down