diff --git a/app/routes/brand/api/api.ts b/app/routes/brand/api/api.ts index 12558d6..e3251a3 100644 --- a/app/routes/brand/api/api.ts +++ b/app/routes/brand/api/api.ts @@ -1,31 +1,44 @@ import { apiClient } from "../../../api/axios"; import type { BrandDomain, BrandDetailData, TagGroup } from "../types"; -// API 응답 타입 -interface BrandSkinCareTagDto { - brandSkinType?: string[]; - brandMainFunction?: string[]; -} - -interface BrandMakeUpTagDto { - brandMakeUpStyle?: string[]; - brandMakeUpColor?: string[]; -} - -interface BrandOnGoingCampaignDto { - brandId: number; +type BeautyResponseDto = { + categories: string[]; + skinType: string[]; + mainFunction: string[]; + makeUpStyle: string[]; +}; + +type FashionResponseDto = { + categories: string[]; + brandType: string[]; + brandStyle: string[]; +}; + +type BrandDetailItemDto = { + userId: number; brandName: string; - recruitingTotalNumber: number; - recruitedNumber: number; - campaginDescription: string; - campaginManuscriptFee: string; - campaignDDay?: number; logoUrl?: string; - isLiked?: boolean; -} + simpleIntro?: string; + detailIntro?: string; + homepageUrl?: string; + + brandTag: string | null; + brandMatchingRatio: number; + brandIsLiked: boolean; + brandDescriptionTags: string[]; + + beautyResponse: BeautyResponseDto | null; + fashionResponse: FashionResponseDto | null; +}; -// 진행 중인 캠페인 API 응답 (api.md 1839줄) -interface RecruitingCampaignCardDto { +type BrandDetailApiResponse = { + isSuccess: boolean; + code: string; + message: string; + result: BrandDetailItemDto[]; +}; + +type RecruitingCampaignCardDto = { campaignId: number; brandName: string; title: string; @@ -33,201 +46,167 @@ interface RecruitingCampaignCardDto { rewardAmount: number; imageUrl?: string; dday: number; -} - -interface RecruitingCampaignsApiResponse { - isSuccess: boolean; - code: string; - message: string; - result: { - campaigns: RecruitingCampaignCardDto[]; - }; -} - -interface AvailableSponsorProdDto { - productId: number; - productName: string; - productImageUrl?: string; - availableType?: string; - availableQuantity?: number; - availableSize?: number; -} +}; -interface BrandDetailResponseDto { - brandName: string; - brandTag?: string[]; - brandDescription?: string; - brandMatchingRatio?: number; - brandIsLiked?: boolean; - brandCategory?: string[]; - brandSkinCareTag?: BrandSkinCareTagDto; - brandMakeUpTag?: BrandMakeUpTagDto; - brandOnGoingCampaign?: BrandOnGoingCampaignDto[]; - availableSponsorProd?: AvailableSponsorProdDto[]; -} - -interface BrandDetailApiResponse { +type RecruitingCampaignsApiResponse = { isSuccess: boolean; code: string; message: string; - result: BrandDetailResponseDto[]; -} + result: { campaigns: RecruitingCampaignCardDto[] }; +}; -interface SponsorProductListResponseDto { +type SponsorProductListResponseDto = { id: number; name: string; thumbnailImageUrl: string; totalCount: number; currentCount: number; -} +}; -interface SponsorProductListApiResponse { +type SponsorProductListApiResponse = { isSuccess: boolean; code: string; message: string; result: SponsorProductListResponseDto[]; -} +}; -interface BrandCampaignResponseDto { +type BrandCampaignResponseDto = { campaignId: number; title: string; recruitStartDate: string; recruitEndDate: string; status: "UPCOMING" | "RECRUITING" | "CLOSED"; -} +}; -interface BrandCampaignSliceResponse { +type BrandCampaignSliceResponse = { campaigns: BrandCampaignResponseDto[]; nextCursor?: number; -} +}; -interface BrandCampaignApiResponse { +type BrandCampaignApiResponse = { isSuccess: boolean; code: string; message: string; result: BrandCampaignSliceResponse; -} +}; -// 날짜 포맷팅 헬퍼 -function formatHistoryDate(campaign: BrandCampaignResponseDto): { text: string; highlight: boolean } { +function formatHistoryDate( + campaign: BrandCampaignResponseDto +): { text: string; highlight: boolean } { if (campaign.status === "UPCOMING" || campaign.status === "RECRUITING") { const date = new Date(campaign.recruitStartDate); const month = date.getMonth() + 1; const day = date.getDate(); - return { - text: `${month}월 ${day}일 진행예정`, - highlight: true - }; - } else { - const date = new Date(campaign.recruitEndDate); - const month = date.getMonth() + 1; - const day = date.getDate(); - const year = date.getFullYear().toString().slice(2); - return { - text: `${month}/${day}/${year} 완료`, - highlight: false - }; + return { text: `${month}월 ${day}일 진행예정`, highlight: true }; } + const date = new Date(campaign.recruitEndDate); + const month = date.getMonth() + 1; + const day = date.getDate(); + const year = date.getFullYear().toString().slice(2); + return { text: `${month}/${day}/${year} 완료`, highlight: false }; } +function inferDomain(item: BrandDetailItemDto): BrandDomain { + return item.fashionResponse ? "fashion" : "beauty"; +} -export async function fetchBrandDetail(params: { - brandId: string; - domain?: BrandDomain; -}): Promise { - const { brandId, domain } = params; +function buildCategories(domain: BrandDomain): string[] { + if (domain === "fashion") return ["패션"]; + return ["스킨케어", "메이크업"]; +} - // 네 API 병렬 호출 (상세, 협찬제품, 캠페인 내역, 진행중인 캠페인) - const [detailResponse, productsResponse, campaignsResponse, recruitingResponse] = await Promise.all([ - apiClient.get(`/api/v1/brands/${brandId}`), - // 협찬 가능 제품 리스트 별도 호출 (api.md line 1652) - apiClient.get(`/api/v1/brands/${brandId}/sponsor-products`), - // 캠페인 내역 호출 (api.md line 1785) - apiClient.get(`/api/v1/brands/${brandId}/campaigns`), - // 진행 중인 캠페인 호출 (api.md line 1839) - apiClient.get(`/api/v1/brands/${brandId}/campaigns/recruiting`) - ]); +function buildTagSections(domain: BrandDomain, item: BrandDetailItemDto): Array<{ title: string; groups: TagGroup[] }> { + if (domain === "fashion") { + const f = item.fashionResponse; + if (!f) return []; + + const styleGroups: TagGroup[] = []; - if (!detailResponse.data.isSuccess || !detailResponse.data.result?.length) { - throw new Error("브랜드 상세 조회 실패"); + if (f.categories?.length) styleGroups.push({ label: "카테고리", chips: f.categories }); + if (f.brandType?.length) styleGroups.push({ label: "브랜드 타입", chips: f.brandType }); + if (f.brandStyle?.length) styleGroups.push({ label: "브랜드 스타일", chips: f.brandStyle }); + + return styleGroups.length ? [{ title: "스타일", groups: styleGroups }] : []; } - const data = detailResponse.data.result[0]; + const b = item.beautyResponse; + if (!b) return []; - // 협찬 제품 리스트 - const productList = productsResponse.data.isSuccess ? productsResponse.data.result : []; + const styleGroups: TagGroup[] = []; - // 캠페인 내역 리스트 - const historyList = campaignsResponse.data.isSuccess ? campaignsResponse.data.result.campaigns : []; + if (b.categories?.length) styleGroups.push({ label: "카테고리", chips: b.categories }); + if (b.skinType?.length) styleGroups.push({ label: "피부타입", chips: b.skinType }); + if (b.mainFunction?.length) styleGroups.push({ label: "주요 기능", chips: b.mainFunction }); + if (b.makeUpStyle?.length) styleGroups.push({ label: "메이크업 스타일", chips: b.makeUpStyle }); - // 진행 중인 캠페인 리스트 - const recruitingList = recruitingResponse.data.isSuccess ? recruitingResponse.data.result.campaigns : []; + return styleGroups.length ? [{ title: "스타일", groups: styleGroups }] : []; +} +export async function fetchBrandDetail(params: { brandId: string; domain?: BrandDomain }): Promise { + const { brandId, domain } = params; - // 태그 섹션 구성 - const tagSections: Array<{ title: string; groups: TagGroup[] }> = []; + const [detailRes, productsRes, campaignsRes, recruitingRes] = await Promise.all([ + apiClient.get(`/api/v1/brands/${brandId}`), + apiClient.get(`/api/v1/brands/${brandId}/sponsor-products`), + apiClient.get(`/api/v1/brands/${brandId}/campaigns`), + apiClient.get(`/api/v1/brands/${brandId}/campaigns/recruiting`), + ]); - if (data.brandSkinCareTag) { - const groups: TagGroup[] = []; - if (data.brandSkinCareTag.brandSkinType?.length) { - groups.push({ label: "피부타입", chips: data.brandSkinCareTag.brandSkinType }); - } - if (data.brandSkinCareTag.brandMainFunction?.length) { - groups.push({ label: "주요기능", chips: data.brandSkinCareTag.brandMainFunction }); - } - if (groups.length) { - tagSections.push({ title: "스킨케어 태그", groups }); - } - } + const detail = detailRes.data; + if (!detail.isSuccess || !detail.result?.length) throw new Error("브랜드 상세 조회 실패"); - if (data.brandMakeUpTag) { - const groups: TagGroup[] = []; - if (data.brandMakeUpTag.brandMakeUpStyle?.length) { - groups.push({ label: "메이크업 스타일", chips: data.brandMakeUpTag.brandMakeUpStyle }); - } - if (data.brandMakeUpTag.brandMakeUpColor?.length) { - groups.push({ label: "컬러", chips: data.brandMakeUpTag.brandMakeUpColor }); - } - if (groups.length) { - tagSections.push({ title: "메이크업 태그", groups }); - } - } + const item = detail.result[0]; + const resolvedDomain = domain ?? inferDomain(item); + + const safeDomain: BrandDomain = + resolvedDomain === "fashion" && !item.fashionResponse + ? "beauty" + : resolvedDomain === "beauty" && !item.beautyResponse + ? "fashion" + : resolvedDomain; + + const productList = productsRes.data.isSuccess ? productsRes.data.result : []; + const historyList = campaignsRes.data.isSuccess ? campaignsRes.data.result.campaigns : []; + const recruitingList = recruitingRes.data.isSuccess ? recruitingRes.data.result.campaigns : []; return { id: brandId, - domain: domain || "beauty", - name: data.brandName, - matchRate: data.brandMatchingRatio || 0, - heroImageUrl: "", // API에 없으면 빈 값 - logoText: data.brandName, - hashtags: data.brandTag || [], - description: data.brandDescription || "", - categories: data.brandCategory || [], - tagSections, - ongoingCampaigns: recruitingList.map((campaign) => ({ - campaignId: campaign.campaignId, - brandName: campaign.brandName, - title: campaign.title, - recruitQuota: campaign.recruitQuota, - rewardAmount: campaign.rewardAmount, - imageUrl: campaign.imageUrl, - dday: campaign.dday, + userId: item.userId, + domain: safeDomain, + + name: item.brandName, + matchRate: item.brandMatchingRatio ?? 0, + + heroImageUrl: "", + logoText: item.brandName, + logoImageUrl: item.logoUrl, + + hashtags: item.brandDescriptionTags ?? [], +description: item.simpleIntro ?? "", + + categories: buildCategories(safeDomain), + tagSections: buildTagSections(safeDomain, item), + + ongoingCampaigns: recruitingList.map((c) => ({ + campaignId: c.campaignId, + brandName: c.brandName, + title: c.title, + recruitQuota: c.recruitQuota, + rewardAmount: c.rewardAmount, + imageUrl: c.imageUrl, + dday: c.dday, isLiked: false, })), - products: productList.map((product) => ({ - id: String(product.id), - title: product.name, - imageUrl: product.thumbnailImageUrl || "", + + products: productList.map((p) => ({ + id: String(p.id), + title: p.name, + imageUrl: p.thumbnailImageUrl || "", })), - // 캠페인 내역 매핑 - histories: historyList.map(campaign => { - const { text, highlight } = formatHistoryDate(campaign); - return { - id: String(campaign.campaignId), - title: campaign.title, - rightText: text, - highlight - }; + + histories: historyList.map((c) => { + const { text, highlight } = formatHistoryDate(c); + return { id: String(c.campaignId), title: c.title, rightText: text, highlight }; }), }; } diff --git a/app/routes/brand/brand-detail-content.tsx b/app/routes/brand/brand-detail-content.tsx index 49463ae..31cbd76 100644 --- a/app/routes/brand/brand-detail-content.tsx +++ b/app/routes/brand/brand-detail-content.tsx @@ -1,4 +1,6 @@ import { useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; + import BrandHero from "./components/BrandHero"; import BrandInfo from "./components/BrandInfo"; import BrandActionBar from "./components/BrandActionBar"; @@ -8,49 +10,71 @@ import OngoingCampaignSection from "./components/OngoingCampaignSection"; import ProductMiniCard from "./components/ProductMiniCard"; import HistoryRow from "./components/HistoryRow"; +import { tokenStorage } from "../../lib/token"; +import { createOrGetDirectRoom } from "../rooms/api/rooms"; + import type { BrandDetailData } from "./types"; -type Props = { - data: BrandDetailData; -}; +type Props = { data: BrandDetailData }; export default function BrandDetailContent({ data }: Props) { const [historyLimit, setHistoryLimit] = useState(4); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + const brandId = Number(searchParams.get("brandId")); + + const handleLoadMoreHistory = () => setHistoryLimit((prev) => prev + 4); + + const handleChat = async () => { + const accessToken = tokenStorage.getAccessToken(); + if (!accessToken) { + navigate("/auth/login"); + return; + } + + if (!Number.isFinite(brandId) || brandId <= 0) return; - const handleLoadMoreHistory = () => { - setHistoryLimit((prev) => prev + 4); + const creatorId = Number(data.userId); + if (!Number.isFinite(creatorId) || creatorId <= 0) return; + + try { + const result = await createOrGetDirectRoom({ brandId, creatorId }); + + navigate(`/rooms/${result.roomId}`, { + state: { creatorId, brandId }, + }); + } catch (e) { + console.error(e); + } }; return (
- +
console.log("채팅하기")} + onChat={handleChat} onSuggest={() => console.log("제안하기")} onToggleHeart={() => console.log("하트")} /> - {/* 카테고리 */}
카테고리
- {data.categories.map((c) => ( + {(data.categories ?? []).map((c) => ( {c} @@ -60,21 +84,13 @@ export default function BrandDetailContent({ data }: Props) {
- {/* 태그 섹션(뷰티/패션 공통 렌더링) */}
- {data.tagSections.map((sec, idx) => ( -
+ {(data.tagSections ?? []).map((sec, idx) => ( +
{sec.title}
{sec.groups.map((g) => ( - + ))}
@@ -83,30 +99,21 @@ export default function BrandDetailContent({ data }: Props) { - {/* 진행 중인 캠페인 */} - console.log("캠페인 더보기")} - /> + console.log("캠페인 더보기")} /> - {/* 협찬 가능 제품 */}
협찬 가능 제품
-
- {data.products.map((p) => ( + {(data.products ?? []).map((p) => ( ))}
@@ -115,16 +122,15 @@ export default function BrandDetailContent({ data }: Props) { - {/* 캠페인 내역 */}
캠페인 내역
- {data.histories.slice(0, historyLimit).map((h) => ( + {(data.histories ?? []).slice(0, historyLimit).map((h) => ( ))} - {historyLimit < data.histories.length && ( + {historyLimit < (data.histories ?? []).length && (
@@ -148,7 +154,5 @@ export default function BrandDetailContent({ data }: Props) { } function DividerBlock() { - return ( -
- ); + return
; } diff --git a/app/routes/brand/mock.ts b/app/routes/brand/mock.ts deleted file mode 100644 index 0d8fcbf..0000000 --- a/app/routes/brand/mock.ts +++ /dev/null @@ -1,588 +0,0 @@ -import type { BrandDetailData } from "./types"; - -export const BRAND_DETAIL_MOCK: Record = { - // -------- beauty 3 -------- - beplain: { - id: "beplain", - domain: "beauty", - name: "비플레인", - matchRate: 98, - heroImageUrl: - "https://images.unsplash.com/photo-1522335789203-aabd1fc54bc9?auto=format&fit=crop&w=1200&q=80", - logoText: "beplain", - hashtags: ["#저자극", "#천연보습", "#민감성피부"], - description: "천연 유래 성분으로 민감 피부를 위한 저자극 스킨케어 브랜드", - categories: ["스킨케어", "메이크업"], - tagSections: [ - { - title: "스킨케어 태그", - groups: [ - { label: "피부타입", chips: ["건성", "지성", "복합성"] }, - { label: "주요 기능", chips: ["수분/보습", "진정"] }, - ], - }, - { - title: "메이크업 태그", - groups: [ - { label: "피부 타입", chips: ["건성", "민감성"] }, - { label: "메이크업 스타일", chips: ["내추럴", "글로우"] }, - ], - }, - ], - ongoingCampaigns: [ - { - id: "c1", - brandName: "beplain", - startAt: "7/10", - ddayLabel: "D-DAY", - matchRate: 98, - descText: "신제품 체험단 모집", - rewardText: "리워드 200,000원", - isLiked: false, - }, - { - id: "c2", - brandName: "beplain", - startAt: "5/10", - ddayLabel: "D-3", - matchRate: 98, - descText: "신제품 체험단 모집", - rewardText: "리워드 200,000원", - isLiked: true, - }, - { - id: "c3", - brandName: "beplain", - startAt: "4/1", - ddayLabel: "D-5", - matchRate: 98, - descText: "신제품 체험단 모집", - rewardText: "리워드 200,000원", - isLiked: false, - }, - ], - products: [ - { - id: "p1", - title: "녹두 약산성 클렌징폼", - imageUrl: - "https://images.unsplash.com/photo-1585232351009-aa87416fca90?auto=format&fit=crop&w=900&q=80", - }, - { - id: "p2", - title: "팔 콜라겐 팩투폼", - imageUrl: - "https://images.unsplash.com/photo-1611930022073-84f8f49f6f17?auto=format&fit=crop&w=900&q=80", - }, - { - id: "p3", - title: "레몬씨 글루타치온 톤업 크림", - imageUrl: - "https://images.unsplash.com/photo-1612810436541-336d6f2f1fd3?auto=format&fit=crop&w=900&q=80", - }, - ], - histories: [ - { - id: "h1", - title: "‘녹두 세럼’ 체험단 모집", - rightText: "1월 15일 진행예정", - highlight: true, - }, - { - id: "h2", - title: "‘녹두 토너’ 체험단 모집", - rightText: "1월 25일 진행예정", - highlight: true, - }, - { - id: "h3", - title: "‘레몬씨 글루타치온 톤업 크림’", - rightText: "12/15/24 완료", - }, - { - id: "h4", - title: "‘녹두 약산성 클렌징젤’ 체험단 모집", - rightText: "8/15/24 완료", - }, - ], - }, - - isntree: { - id: "isntree", - domain: "beauty", - name: "이즈앤트리", - matchRate: 98, - heroImageUrl: - "https://images.unsplash.com/photo-1611930022073-84f8f49f6f17?auto=format&fit=crop&w=1200&q=80", - logoText: "Isntree", - hashtags: ["#클린뷰티", "#저자극", "#성분 중심"], - description: "자연 유래 성분으로 피부의 힘을 키우는 비건 스킨케어 브랜드", - categories: ["스킨케어"], - tagSections: [ - { - title: "스킨케어 태그", - groups: [ - { label: "피부타입", chips: ["건성", "지성"] }, - { label: "주요 기능", chips: ["수분/보습", "트러블"] }, - ], - }, - ], - ongoingCampaigns: [ - { - id: "c1", - brandName: "Isntree", - startAt: "8/10", - ddayLabel: "D-DAY", - matchRate: 98, - descText: "어니언 뉴페어리 라인…", - rewardText: "리워드 200,000원", - isLiked: false, - }, - { - id: "c2", - brandName: "Isntree", - startAt: "6/10", - ddayLabel: "D-3", - matchRate: 98, - descText: "초저분자 히알루론…", - rewardText: "리워드 100,000원", - isLiked: true, - }, - { - id: "c3", - brandName: "Isntree", - startAt: "3/1", - ddayLabel: "D-5", - matchRate: 98, - descText: "하이알루론산 토너…", - rewardText: "리워드 200,000원", - isLiked: false, - }, - ], - products: [ - { - id: "p1", - title: "어니언 뉴페어리 젤", - imageUrl: - "https://images.unsplash.com/photo-1585232351009-aa87416fca90?auto=format&fit=crop&w=900&q=80", - }, - { - id: "p2", - title: "초저분자 히알루론", - imageUrl: - "https://images.unsplash.com/photo-1522335789203-aabd1fc54bc9?auto=format&fit=crop&w=900&q=80", - }, - { - id: "p3", - title: "하이알루론산 토너", - imageUrl: - "https://images.unsplash.com/photo-1612810436541-336d6f2f1fd3?auto=format&fit=crop&w=900&q=80", - }, - ], - histories: [ - { - id: "h1", - title: "‘어니언 뉴페어리 세럼’ 체험단…", - rightText: "1월 20일 진행예정", - highlight: true, - }, - { - id: "h2", - title: "‘어니언 뉴페어리 세럼’ 리뷰…", - rightText: "1월 25일 진행예정", - highlight: true, - }, - { - id: "h3", - title: "‘초저분자 히알루론 크림’…", - rightText: "12/15/24 완료", - }, - { - id: "h4", - title: "‘하이알루론산 토너’ 체험단…", - rightText: "8/15/24 완료", - }, - ], - }, - - roundlab: { - id: "roundlab", - domain: "beauty", - name: "라운드랩", - matchRate: 98, - heroImageUrl: - "https://images.unsplash.com/photo-1522335789203-aabd1fc54bc9?auto=format&fit=crop&w=1200&q=80", - logoText: "ROUND LAB", - hashtags: ["#정착템", "#저자극", "#심플한감성"], - description: "정직한 자연 성분으로 안심하고 쓸 수 있는 클린 뷰티 브랜드", - categories: ["스킨케어"], - tagSections: [ - { - title: "스킨케어 태그", - groups: [ - { label: "피부타입", chips: ["건성", "민감성"] }, - { label: "주요 기능", chips: ["수분/보습", "미백"] }, - ], - }, - ], - ongoingCampaigns: [ - { - id: "c1", - brandName: "ROUND LAB", - startAt: "9/10", - ddayLabel: "D-DAY", - matchRate: 98, - descText: "베스트 라인 체험단", - rewardText: "리워드 100,000원", - isLiked: false, - }, - { - id: "c2", - brandName: "ROUND LAB", - startAt: "4/5", - ddayLabel: "D-3", - matchRate: 98, - descText: "진정 라인 체험단", - rewardText: "리워드 100,000원", - isLiked: true, - }, - { - id: "c3", - brandName: "ROUND LAB", - startAt: "4/1", - ddayLabel: "D-5", - matchRate: 98, - descText: "수분 라인 체험단", - rewardText: "리워드 100,000원", - isLiked: false, - }, - ], - products: [ - { - id: "p1", - title: "비타 나이아신", - imageUrl: - "https://images.unsplash.com/photo-1585232351009-aa87416fca90?auto=format&fit=crop&w=900&q=80", - }, - { - id: "p2", - title: "자작나무 수분", - imageUrl: - "https://images.unsplash.com/photo-1611930022073-84f8f49f6f17?auto=format&fit=crop&w=900&q=80", - }, - { - id: "p3", - title: "1025 독도 토너", - imageUrl: - "https://images.unsplash.com/photo-1612810436541-336d6f2f1fd3?auto=format&fit=crop&w=900&q=80", - }, - ], - histories: [ - { - id: "h1", - title: "‘비타 나이아신 글로우’ 체험단…", - rightText: "1월 25일 진행예정", - highlight: true, - }, - { - id: "h2", - title: "‘자작나무 수분’ 더블…", - rightText: "1월 25일 진행예정", - highlight: true, - }, - { - id: "h3", - title: "‘1025 독도 토너’ 체험단…", - rightText: "12/15/24 완료", - }, - { - id: "h4", - title: "‘1025 독도 토너’ 체험단…", - rightText: "8/15/24 완료", - }, - ], - }, - - // -------- fashion 3 -------- - graceu: { - id: "graceu", - domain: "fashion", - name: "그레이스유", - matchRate: 98, - heroImageUrl: - "https://images.unsplash.com/photo-1520975958225-2b9d35f2f6f3?auto=format&fit=crop&w=1200&q=80", - logoText: "GRACE U", - hashtags: ["#데일리스", "#클래식", "#우아함"], - description: "전체를 실루엣과 고급 소재로 완성하는 미니멀 여성복 브랜드", - categories: ["의류"], - tagSections: [ - { - title: "의류 태그", - groups: [ - { label: "브랜드 종류", chips: ["디자이너 브랜드"] }, - { label: "브랜드 스타일", chips: ["페미닌", "미니멀"] }, - ], - }, - ], - ongoingCampaigns: [ - { - id: "c1", - brandName: "GRACE U", - startAt: "10/10", - ddayLabel: "D-DAY", - matchRate: 98, - descText: "가을 신상 체험단", - rewardText: "리워드 300,000원", - isLiked: false, - }, - { - id: "c2", - brandName: "GRACE U", - startAt: "4/5", - ddayLabel: "D-3", - matchRate: 98, - descText: "니트 라인 체험단", - rewardText: "리워드 300,000원", - isLiked: true, - }, - { - id: "c3", - brandName: "GRACE U", - startAt: "8/1", - ddayLabel: "D-5", - matchRate: 98, - descText: "원피스 라인 체험단", - rewardText: "리워드 300,000원", - isLiked: false, - }, - ], - products: [ - { - id: "p1", - title: "Lucy Tie Jacket", - imageUrl: - "https://images.unsplash.com/photo-1520975958225-2b9d35f2f6f3?auto=format&fit=crop&w=900&q=80", - }, - { - id: "p2", - title: "Lavina Knit Cardigan", - imageUrl: - "https://images.unsplash.com/photo-1520975867597-0df1b0d1f24f?auto=format&fit=crop&w=900&q=80", - }, - { - id: "p3", - title: "Anais Off-shoulder", - imageUrl: - "https://images.unsplash.com/photo-1520975682071-4f3f909cc053?auto=format&fit=crop&w=900&q=80", - }, - ], - histories: [ - { - id: "h1", - title: "Lucy Tie Cardigan 리뷰…", - rightText: "1월 15일 진행예정", - highlight: true, - }, - { - id: "h2", - title: "Lucy Tie Shirt 리뷰 테스트…", - rightText: "2월 25일 진행예정", - highlight: true, - }, - { - id: "h3", - title: "Lavina Knit Shirt 체험단…", - rightText: "12/15/24 완료", - }, - { - id: "h4", - title: "Anais Knit Skirt 체험단…", - rightText: "10/15/24 완료", - }, - ], - }, - - thetis: { - id: "thetis", - domain: "fashion", - name: "더티스", - matchRate: 88, - heroImageUrl: - "https://images.unsplash.com/photo-1485968579580-b6d095142e6e?auto=format&fit=crop&w=1200&q=80", - logoText: "TheTis", - hashtags: ["#러블리", "#트렌디", "#더티스_중심"], - description: "유니크한 디자이너 스토리로 무드를 담은 감각적인 패션 브랜드", - categories: ["의류"], - tagSections: [ - { - title: "의류 태그", - groups: [ - { label: "브랜드 종류", chips: ["디자이너 브랜드"] }, - { label: "브랜드 스타일", chips: ["페미닌", "러블리"] }, - ], - }, - ], - ongoingCampaigns: [ - { - id: "c1", - brandName: "TheTis", - startAt: "9/10", - ddayLabel: "D-DAY", - matchRate: 88, - descText: "TEDDY HOOD 체험단", - rewardText: "리워드 250,000원", - isLiked: false, - }, - { - id: "c2", - brandName: "TheTis", - startAt: "7/10", - ddayLabel: "D-3", - matchRate: 88, - descText: "ANGEL CABLE 체험단", - rewardText: "리워드 250,000원", - isLiked: true, - }, - { - id: "c3", - brandName: "TheTis", - startAt: "6/1", - ddayLabel: "D-5", - matchRate: 88, - descText: "BOUQUET 체험단", - rewardText: "리워드 250,000원", - isLiked: false, - }, - ], - products: [ - { - id: "p1", - title: "TEDDY HOOD…", - imageUrl: - "https://images.unsplash.com/photo-1485968579580-b6d095142e6e?auto=format&fit=crop&w=900&q=80", - }, - { - id: "p2", - title: "ANGEL CABL…", - imageUrl: - "https://images.unsplash.com/photo-1520975867597-0df1b0d1f24f?auto=format&fit=crop&w=900&q=80", - }, - { - id: "p3", - title: "BOUQUET L…", - imageUrl: - "https://images.unsplash.com/photo-1520975682071-4f3f909cc053?auto=format&fit=crop&w=900&q=80", - }, - ], - histories: [ - { - id: "h1", - title: "‘TEDDY HOOD FUR COA…", - rightText: "1월 15일 진행예정", - highlight: true, - }, - { - id: "h2", - title: "‘ANGEL CABLE SKIRT’ 체…", - rightText: "2월 25일 진행예정", - highlight: true, - }, - { id: "h3", title: "‘BOUQUET LAYERED SHI…", rightText: "12/15/24 완료" }, - { id: "h4", title: "‘BOUQUET LAYERED PA…", rightText: "8/15/24 완료" }, - ], - }, - - glowny: { - id: "glowny", - domain: "fashion", - name: "글로니", - matchRate: 78, - heroImageUrl: - "https://images.unsplash.com/photo-1520975916090-3105956dac38?auto=format&fit=crop&w=1200&q=80", - logoText: "GLOWNY", - hashtags: ["#클래식", "#편안함", "#러블리"], - description: "클래식한 실루엣에 트렌드를 더한 문턱 낮은 패션 브랜드", - categories: ["의류"], - tagSections: [ - { - title: "의류 태그", - groups: [ - { label: "브랜드 종류", chips: ["디자이너 브랜드", "중가 브랜드"] }, - { label: "브랜드 스타일", chips: ["러블리", "캐주얼"] }, - ], - }, - ], - ongoingCampaigns: [ - { - id: "c1", - brandName: "GLOWNY", - startAt: "9/10", - ddayLabel: "D-DAY", - matchRate: 78, - descText: "WILD TUBE TOP 체험단", - rewardText: "리워드 400,000원", - isLiked: false, - }, - { - id: "c2", - brandName: "GLOWNY", - startAt: "8/10", - ddayLabel: "D-3", - matchRate: 78, - descText: "SUGAR PUFF…", - rewardText: "리워드 400,000원", - isLiked: true, - }, - { - id: "c3", - brandName: "GLOWNY", - startAt: "5/1", - ddayLabel: "D-5", - matchRate: 78, - descText: "PEEKABOO…", - rewardText: "리워드 400,000원", - isLiked: false, - }, - ], - products: [ - { - id: "p1", - title: "WILD TUBE T…", - imageUrl: - "https://images.unsplash.com/photo-1520975916090-3105956dac38?auto=format&fit=crop&w=900&q=80", - }, - { - id: "p2", - title: "SUGAR PUFF …", - imageUrl: - "https://images.unsplash.com/photo-1520975867597-0df1b0d1f24f?auto=format&fit=crop&w=900&q=80", - }, - { - id: "p3", - title: "PEEKABOO …", - imageUrl: - "https://images.unsplash.com/photo-1520975682071-4f3f909cc053?auto=format&fit=crop&w=900&q=80", - }, - ], - histories: [ - { - id: "h1", - title: "‘WILD TUBE PANTS’ 체험…", - rightText: "1월 15일 진행예정", - highlight: true, - }, - { - id: "h2", - title: "‘WILD TUBE SKIRT’ 체험…", - rightText: "3월 25일 진행예정", - highlight: true, - }, - { id: "h3", title: "‘SUGAR PUFF SHIRT’ 리…", rightText: "12/15/24 완료" }, - { id: "h4", title: "‘SUGAR PUFF SHIRT’ 리…", rightText: "7/15/24 완료" }, - ], - }, -}; - -export function getBrandDetailMock(brandId: string): BrandDetailData { - return BRAND_DETAIL_MOCK[brandId] ?? BRAND_DETAIL_MOCK.beplain; -} diff --git a/app/routes/brand/query.ts b/app/routes/brand/query.ts index dc29935..d62af33 100644 --- a/app/routes/brand/query.ts +++ b/app/routes/brand/query.ts @@ -7,5 +7,6 @@ export function useBrandDetail(brandId: string, domain?: BrandDomain) { queryKey: ["brandDetail", brandId, domain], queryFn: () => fetchBrandDetail({ brandId, domain }), staleTime: 60_000, + enabled: Boolean(brandId), }); } diff --git a/app/routes/brand/types.ts b/app/routes/brand/types.ts index 75b2138..1e227ea 100644 --- a/app/routes/brand/types.ts +++ b/app/routes/brand/types.ts @@ -9,8 +9,8 @@ export type BrandOngoingCampaign = { campaignId: number; brandName: string; title: string; - recruitQuota: number; // 총 모집 인원 - rewardAmount: number; // 원고료 + recruitQuota: number; + rewardAmount: number; imageUrl?: string; dday: number; isLiked?: boolean; @@ -31,6 +31,7 @@ export type HistoryRowItem = { export type BrandDetailData = { id: string; + userId: number; domain: BrandDomain; name: string; diff --git a/app/routes/home/api/me.api.ts b/app/routes/home/api/me.api.ts new file mode 100644 index 0000000..7766e0a --- /dev/null +++ b/app/routes/home/api/me.api.ts @@ -0,0 +1,7 @@ +import { apiClient } from "../../../api/axios"; +import type { MeFeatureResponse } from "../types"; + +export const getMeFeature = async () => { + const res = await apiClient.get("/api/v1/me/feature"); + return res.data; +}; diff --git a/app/routes/home/components/CreatorProfileCard.tsx b/app/routes/home/components/CreatorProfileCard.tsx index 16e2298..1d8d879 100644 --- a/app/routes/home/components/CreatorProfileCard.tsx +++ b/app/routes/home/components/CreatorProfileCard.tsx @@ -1,53 +1,64 @@ +// CreatorProfileCard.tsx (홈 SectionHeader/리스트 비율에 맞춘 버전) - - - import type { CreatorProfileModel } from "../types"; +import type { CreatorProfileModel } from "../types"; import beautyIcon from "../../../assets/beauty-icon.svg"; import fashionIcon from "../../../assets/fashion-icon.svg"; import contentIcon from "../../../assets/content-icon.svg"; + const PRIMARY = "#5B5DEB"; type Props = { model: CreatorProfileModel; + onMyProfileClick?: () => void; }; -export default function CreatorProfileCard({ model }: Props) { - return ( -
- {/* 섹션 타이틀 (왼쪽 정렬) */} -
크리에이터 님의 프로필
+export default function CreatorProfileCard({ model, onMyProfileClick }: Props) { + const name = model.creatorName?.trim() || "크리에이터"; + const summary = model.summary?.trim() || "크리에이터"; - {/* 중앙 설명 문구 (캡쳐처럼 3줄 + 포인트 컬러 강조) */} -
-
크리에이터 님은
+ return ( +
+ {/* ✅ 홈 섹션 타이틀(SectionHeader title)과 동일한 크기/톤 */} +
+ 크리에이터 님의 프로필 +
+ {/* ✅ 홈 섹션 서브텍스트(SectionHeader subtitle) 리듬에 맞춤 */} +
- {model.summary} - - 입니다. + {name} + {" "} + 님은
-
- {model.highlightBrandText} + {summary} - 와 잘 어울릴 것으로 보여요. + 입니다
- {/* 아이콘 3개 (박스 없이 “그냥 일러스트”처럼) */} -
- - - + {/* ✅ 스크린샷 비율: 아이콘은 좀 크게, 라벨은 홈 카드 보조 텍스트와 동일 */} +
+ + +
- {/* 버튼 (캡쳐처럼 그라데이션 + 그림자 + 흰 글씨, 가운데, 폭 좁게) */} + {/* ✅ 스크린샷 비율: 버튼 폭/패딩을 홈 흐름에 맞게 */} @@ -58,9 +69,10 @@ export default function CreatorProfileCard({ model }: Props) { function IconBlock({ icon, label }: { icon: string; label: string }) { return (
- {/* 캡쳐처럼 아이콘이 크게 “떠있는” 느낌: 배경박스 제거 + 크기 키움 */} - {label} -
{label}
+ {label} +
+ {label} +
); } diff --git a/app/routes/home/home-after-match.tsx b/app/routes/home/home-after-match.tsx index 4f24ac5..58253c3 100644 --- a/app/routes/home/home-after-match.tsx +++ b/app/routes/home/home-after-match.tsx @@ -1,4 +1,6 @@ -import { useEffect, useState, useMemo } from "react"; +// src/routes/_home/home-after-match.tsx + +import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router"; import type { CategoryKey, CreatorProfileModel } from "./types"; import CategoryTabs from "./components/CategoryTabs"; @@ -14,78 +16,139 @@ import { type MatchingBrand, type MatchingCampaign, } from "../matching/api/matching"; -import { useMatchResultStore } from "../../stores/matching-result"; +import { apiClient } from "../../api/axios"; import bannerBeauty from "../../assets/home-banner/banner-beauty.svg"; import bannerFashion from "../../assets/home-banner/banner-fashion.svg"; +type ApiCategoryFilter = "ALL" | "FASHION" | "BEAUTY"; +type CampaignSort = "MATCH_SCORE" | "POPULARITY" | "REWARD_AMOUNT" | "D_DAY"; + +const toApiCategory = (ui: CategoryKey): ApiCategoryFilter => + ui === "beauty" ? "BEAUTY" : "FASHION"; + +/** ✅ /api/v1/users/me/profile-card (예시 기반) */ +type ProfileCardResponse = { + isSuccess: boolean; + code: string; + message: string; + result: { + nickname: string; + gender: "MALE" | "FEMALE" | string; + age: number; + interests: string[]; + snsAccount: string; + matchingResult: { + createrType: string; // 백엔드 오타 그대로 + fitBrand: string; + }; + myType: { + beautyType: { + skinType: string[]; + skinBrightness: string; + makeupStyle: string[]; + }; + fashionType: { + height: number; + bodyType: string; + upperSize: string; + bottomSize: number; + }; + contentsType: { + gender: string; + age: string; + averageLength: string; + averageView: string; + }; + }; + }; +}; + export default function HomeAfterMatchPage() { const navigate = useNavigate(); const [category, setCategory] = useState("beauty"); + const [brands, setBrands] = useState([]); const [campaigns, setCampaigns] = useState([]); const [popularCampaigns, setPopularCampaigns] = useState( [], ); - // 스토어에서 매칭 결과 가져오지만 -> api/v1/me/feature로 변경 - const matchResult = useMatchResultStore((s) => s.result); + const [profileCard, setProfileCard] = + useState(null); useEffect(() => { const fetchData = async () => { try { + const categoryFilter: ApiCategoryFilter = toApiCategory(category); + + // ✅ 홈 핵심 데이터는 먼저(프로필 API 실패해도 홈은 떠야 함) const [brandsData, campaignsData, popularData] = await Promise.all([ - getMatchingBrands("MATCH_SCORE", "ALL"), - getMatchingCampaigns("MATCH_SCORE", "ALL"), - getMatchingCampaigns("POPULARITY", "ALL"), + getMatchingBrands("MATCH_SCORE", categoryFilter), + getMatchingCampaigns("MATCH_SCORE" as CampaignSort, categoryFilter), + getMatchingCampaigns("POPULARITY" as CampaignSort, categoryFilter), ]); + setBrands(brandsData.brands); setCampaigns(campaignsData.campaigns); setPopularCampaigns(popularData.campaigns); + + // ✅ 프로필 카드 (경로 수정) + try { + const res = await apiClient.get( + "/api/v1/users/me/profile-card", + ); + + if (res.data?.isSuccess) { + setProfileCard(res.data.result); + } else { + setProfileCard(null); + } + } catch { + setProfileCard(null); + } } catch (error) { console.error("Failed to fetch matching data:", error); } }; fetchData(); - }, []); - - // 스토어에서 매칭 결과 가져오지만 -> api/v1/me/feature로 변경 - // 스토어에서 매칭 결과 가져오지만 -> api/v1/me/feature로 변경 - const profile = useMemo(() => { - if (matchResult?.apiResult) { - const apiResult = matchResult.apiResult; - return { - creatorName: "크리에이터 님", - creatorType: "creator", - summary: apiResult.userType || "크리에이터", - highlightBrandText: - apiResult.highMatchingBrandList?.brands[0]?.brandName || - "매칭된 브랜드", - traits: { - beauty: apiResult.typeTag?.[0] || "특성 1", - fashion: apiResult.typeTag?.[1] || "특성 2", - content: apiResult.typeTag?.[2] || "특성 3", - }, - } as CreatorProfileModel; - } else if (matchResult?.summary) { - // apiResult가 없으면 summary 사용 - return { - creatorName: "크리에이터 님", - creatorType: "creator", - summary: matchResult.summary.userName || "크리에이터", - highlightBrandText: - matchResult.summary.recommendedBrand || "매칭된 브랜드", - traits: { - beauty: matchResult.summary.traits.beauty || "특성 1", - fashion: matchResult.summary.traits.style || "특성 2", - content: matchResult.summary.traits.content || "특성 3", - }, - } as CreatorProfileModel; - } - return null; - }, [matchResult]); + }, [category]); + + const profile = useMemo(() => { + if (!profileCard) return null; + + const nickname = profileCard.nickname || "크리에이터 님"; + const creatorType = + profileCard.matchingResult?.createrType || "크리에이터"; + const fitBrand = profileCard.matchingResult?.fitBrand || "매칭된 브랜드"; + + const beautyTrait = + profileCard.myType?.beautyType?.makeupStyle?.[0] || + profileCard.myType?.beautyType?.skinType?.[0] || + "특성 1"; + const fashionTrait = + profileCard.myType?.fashionType?.bodyType || + (profileCard.myType?.fashionType?.upperSize + ? `상의 ${profileCard.myType.fashionType.upperSize}` + : "특성 2"); + const contentTrait = + profileCard.myType?.contentsType?.averageView || + profileCard.myType?.contentsType?.averageLength || + "특성 3"; + + return { + creatorName: nickname, + creatorType: "creator", + summary: creatorType, + highlightBrandText: fitBrand, + traits: { + beauty: beautyTrait, + fashion: fashionTrait, + content: contentTrait, + }, + }; + }, [profileCard]); - // 브랜드 좋아요 토글 const handleBrandLikeToggle = async (id: string) => { try { const brandId = Number(id); @@ -101,13 +164,11 @@ export default function HomeAfterMatchPage() { } }; - // 캠페인 좋아요 토글 const handleCampaignLikeToggle = async (id: string) => { try { const campaignId = Number(id); const newLikeStatus = await toggleBrandLike(campaignId); - // 매칭률 높은 캠페인 업데이트 setCampaigns((prev) => prev.map((campaign) => campaign.id === campaignId @@ -115,7 +176,7 @@ export default function HomeAfterMatchPage() { : campaign, ), ); - // 인기 캠페인도 업데이트 + setPopularCampaigns((prev) => prev.map((campaign) => campaign.id === campaignId @@ -148,26 +209,31 @@ export default function HomeAfterMatchPage() { navigate("/matching/brand")} + onMore={() => navigate(`/matching/brand?category=${category}`)} />
- {brands.slice(0, 10).map((brand) => ( + {brands.slice(0, 10).map((brand, i) => ( `#${t}`) + .join(" "), badgeText: "모집중", domain: brand.name?.toLowerCase() || "", isLiked: brand.isLiked, }} onClick={() => { navigate( - `/brand?brandId=${brand.id}&domain=${brand.name?.toLowerCase() || ""}`, + `/brand?brandId=${brand.id}&domain=${ + brand.name?.toLowerCase() || "" + }`, ); }} onLikeToggle={handleBrandLikeToggle} @@ -184,13 +250,15 @@ export default function HomeAfterMatchPage() { navigate("/matching/campaign")} + onMore={() => navigate(`/matching/campaign?category=${category}`)} />
- {campaigns.slice(0, 10).map((campaign) => ( + {campaigns.slice(0, 10).map((campaign, i) => (
- {/* 크리에이터 프로필 카드 */} - {profile && ( -
- -
- )} +{profile && ( +
+ navigate("/mypage")} + /> +
+)} {/* 인기 캠페인 */}
navigate("/matching/campaign?sortBy=POPULARITY")} + onMore={() => + navigate( + `/matching/campaign?sortBy=POPULARITY&category=${category}`, + ) + } />
- {popularCampaigns.slice(0, 10).map((campaign) => ( + {popularCampaigns.slice(0, 10).map((campaign, i) => ( { + if (code === "MATCH_TEST_NOT_COMPLETED") return true; + if (!message) return false; + return message.includes("매칭 테스트") || message.includes("테스트를 먼저"); +}; + +export class MatchingTestRequiredError extends Error { + constructor(message: string = "매칭 검사를 먼저 진행해주세요") { + super(message); + this.name = "MatchingTestRequiredError"; + } +} + +/* 매칭 결과 */ + export interface MatchResult { userType: string; typeTag: string[]; @@ -11,7 +30,6 @@ export interface MatchResult { }; } -// 스토어에서 사용하는 타입 (MatchResult와 동일하나 highMatchingBrandList가 필수) export interface MatchResponseDto { userType: string; typeTag: string[]; @@ -28,29 +46,15 @@ interface MatchResultResponse { result: MatchResult; } -// ... existing imports - -/** - * 매칭 분석 결과 조회 - * Get /api/v1/matches - */ export const getMatchAnalysis = async (): Promise => { try { const userId = tokenStorage.getUserId(); - if (!userId) { - throw new MatchingTestRequiredError(); - } + if (!userId) throw new MatchingTestRequiredError(); - const response = - await axiosInstance.get(`/api/v1/matches`); + const response = await apiClient.get(`/api/v1/matches`); if (!response.data.isSuccess) { - // 매칭 테스트 미완료 에러 체크 - if ( - response.data.code === "MATCH_TEST_NOT_COMPLETED" || - response.data.message.includes("매칭") || - response.data.message.includes("테스트") - ) { + if (isMatchTestNotCompleted(response.data.code, response.data.message)) { throw new MatchingTestRequiredError(); } throw new Error(response.data.message || "매칭 분석 결과 조회 실패"); @@ -58,33 +62,45 @@ export const getMatchAnalysis = async (): Promise => { return response.data.result; } catch (error: unknown) { - if (error instanceof MatchingTestRequiredError) { - throw error; + if (error instanceof MatchingTestRequiredError) throw error; + + const axiosError = error as ApiThrown; + const code = axiosError.response?.data?.code; + const message = axiosError.response?.data?.message; + if (isMatchTestNotCompleted(code, message)) { + throw new MatchingTestRequiredError(); } + console.error("매칭 분석 결과 조회 실패:", error); throw error; } }; -// 매칭 캠페인 응답 타입 (CampaignDto) +/* 매칭 리스트 types */ + export interface MatchingCampaign { id: number; + campaignId?: number; + brandId?: number; + brandName: string; name?: string; title?: string; campaignName?: string; category: string; - manuscriptFee?: number; // reward + + manuscriptFee?: number; reward?: number; - matchingRatio?: number; // matchRate + + matchingRatio?: number; matchRate?: number; + applicants: number; isLiked: boolean; - logoUrl?: string; // brandLogoUrl + logoUrl?: string; dDay?: number; } -// 매칭 브랜드 응답 타입 (BrandDto) export interface MatchingBrand { id: number; name: string; @@ -120,15 +136,20 @@ interface MatchingBrandResponse { interface MatchCampaignRawItem { brandId: number; campaignId?: number; + brandName: string; campaignName: string; category: string; + campaignManuscriptFee: number; brandMatchingRatio: number; + campaignTotalCurrentRecruit: number; campaignTotalRecruit: number; + brandIsLiked: boolean; brandLogoUrl: string; + campaignDDay: number; campaignDetail?: string; } @@ -136,15 +157,23 @@ interface MatchCampaignRawItem { interface MatchBrandRawItem { brandId: number; brandName: string; - brandLogoUrl: string; - brandMatchingRatio: number; + + brandLogoUrl?: string; + brandMatchingRatio?: number; + brandIsLike?: boolean; + brandTags?: string[]; + logoUrl?: string; + matchingRatio?: number; + brandIsLiked?: boolean; brandIsRecruiting?: boolean; + category?: string; - brandTags?: string[]; + tags?: string[]; } -// 브랜드 필터 타입 +/* 브랜드 필터 DTOs */ + export interface CategoryDto { categoryId: number; categoryName: string; @@ -183,23 +212,8 @@ interface BrandFilterResponse { result: BrandFilterResponseDto[]; } -// 커스텀 에러 클래스 -export class MatchingTestRequiredError extends Error { - constructor(message: string = "매칭 검사를 먼저 진행해주세요") { - super(message); - this.name = "MatchingTestRequiredError"; - } -} +/* 매칭 캠페인 */ -/** - * 매칭 캠페인 목록 조회 - * @param sortBy 정렬 기준 (MATCH_SCORE, POPULARITY, REWARD_AMOUNT, D_DAY) - * @param category 카테고리 필터 (ALL, FASHION, BEAUTY) - * @param tags 태그 필터 - * @param keyword 검색 키워드 (캠페인명 검색) - * @param page 페이지 번호 (0부터 시작) - * @param size 페이지 크기 (기본 20) - */ export const getMatchingCampaigns = async ( sortBy: string = "MATCH_SCORE", category: string = "ALL", @@ -210,9 +224,7 @@ export const getMatchingCampaigns = async ( ): Promise<{ campaigns: MatchingCampaign[]; count: number }> => { try { const userId = tokenStorage.getUserId(); - if (!userId) { - throw new MatchingTestRequiredError(); - } + if (!userId) throw new MatchingTestRequiredError(); const params: Record = { sortBy, @@ -220,68 +232,61 @@ export const getMatchingCampaigns = async ( page, size, }; + if (tags && tags.length > 0) params.tags = tags; + if (keyword) params.keyword = keyword; - if (tags && tags.length > 0) { - params.tags = tags; - } - - if (keyword) { - params.keyword = keyword; - } - - const response = await axiosInstance.get( + const response = await apiClient.get( `/api/v1/matches/campaigns`, { params }, ); if (!response.data.isSuccess) { - // 매칭 테스트 미완료 에러 체크 - if ( - response.data.code === "MATCH_TEST_NOT_COMPLETED" || - response.data.message.includes("매칭") || - response.data.message.includes("테스트") - ) { + if (isMatchTestNotCompleted(response.data.code, response.data.message)) { throw new MatchingTestRequiredError(); } throw new Error(response.data.message || "캠페인 목록 조회 실패"); } - // API 응답 필드를 인터페이스 형식으로 변환 - const campaigns = (response.data.result.brands || []).map((item) => ({ - id: item.brandId || item.campaignId || 0, - brandName: item.brandName, - name: item.campaignName || item.campaignDetail, - title: item.campaignName || item.campaignDetail, - campaignName: item.campaignName, - category: category, - manuscriptFee: item.campaignManuscriptFee, - reward: item.campaignManuscriptFee, - matchingRatio: item.brandMatchingRatio || 0, - matchRate: item.brandMatchingRatio || 0, - applicants: item.campaignTotalRecruit || 0, - isLiked: item.brandIsLiked || false, - logoUrl: item.brandLogoUrl, - dDay: item.campaignDDay, - })); + const campaigns = (response.data.result.brands || []).map((item) => { + const campaignId = item.campaignId ?? 0; + + return { + id: campaignId || 0, + campaignId: item.campaignId, + brandId: item.brandId, + + brandName: item.brandName, + name: item.campaignName || item.campaignDetail, + title: item.campaignName || item.campaignDetail, + campaignName: item.campaignName, + + category, + + manuscriptFee: item.campaignManuscriptFee, + reward: item.campaignManuscriptFee, + + matchingRatio: item.brandMatchingRatio || 0, + matchRate: item.brandMatchingRatio || 0, + + applicants: item.campaignTotalRecruit || 0, + + isLiked: item.brandIsLiked || false, + logoUrl: item.brandLogoUrl, + dDay: item.campaignDDay, + }; + }); return { campaigns, count: response.data.result.count || 0, }; } catch (error: unknown) { - if (error instanceof MatchingTestRequiredError) { - throw error; - } + if (error instanceof MatchingTestRequiredError) throw error; - const axiosError = error as { - response?: { status: number; data?: { message?: string } }; - }; - if ( - axiosError.response?.status === 404 || - axiosError.response?.status === 400 || - axiosError.response?.data?.message?.includes("매칭") || - axiosError.response?.data?.message?.includes("테스트") - ) { + const axiosError = error as ApiThrown; + const code = axiosError.response?.data?.code; + const message = axiosError.response?.data?.message; + if (isMatchTestNotCompleted(code, message)) { throw new MatchingTestRequiredError(); } @@ -290,12 +295,8 @@ export const getMatchingCampaigns = async ( } }; -/** - * 매칭 브랜드 목록 조회 - * @param sortBy 정렬 기준 (MATCH_SCORE, POPULARITY, NEWEST) - * @param category 카테고리 필터 (ALL, FASHION, BEAUTY) - * @param tags 태그 필터 - */ +/* 매칭 브랜드 */ + export const getMatchingBrands = async ( sortBy: string = "MATCH_SCORE", category: string = "ALL", @@ -303,78 +304,82 @@ export const getMatchingBrands = async ( ): Promise<{ brands: MatchingBrand[]; count: number }> => { try { const userId = tokenStorage.getUserId(); - if (!userId) { - throw new MatchingTestRequiredError(); - } + if (!userId) throw new MatchingTestRequiredError(); const params: Record = { sortBy, category, }; - if (tags && tags.length > 0) { - params.tags = tags; - } + if (tags && tags.length > 0) params.tags = tags; - const response = await axiosInstance.get( + const response = await apiClient.get( `/api/v1/matches/brands`, { params }, ); if (!response.data.isSuccess) { - if ( - response.data.code === "MATCH_TEST_NOT_COMPLETED" || - response.data.message.includes("매칭") || - response.data.message.includes("테스트") - ) { + if (isMatchTestNotCompleted(response.data.code, response.data.message)) { throw new MatchingTestRequiredError(); } throw new Error(response.data.message || "브랜드 목록 조회 실패"); } - const brands = (response.data.result.brands || []).map((item) => ({ - id: item.brandId, - name: item.brandName, - logoUrl: item.brandLogoUrl, - matchRate: item.brandMatchingRatio || 0, - matchingRatio: item.brandMatchingRatio, - isLiked: item.brandIsLiked || false, - category: item.category || category, - tags: item.brandTags || [], - isRecruiting: item.brandIsRecruiting || false - })); + const brands = (response.data.result.brands || []).map((item) => { + const matchingRatio = item.brandMatchingRatio ?? item.matchingRatio ?? 0; + + return { + id: item.brandId, + name: item.brandName, + + logoUrl: item.brandLogoUrl ?? item.logoUrl, + + matchRate: matchingRatio, + matchingRatio, + + isLiked: item.brandIsLike ?? item.brandIsLiked ?? false, + + category: item.category || category, + + tags: item.brandTags ?? item.tags ?? [], + isRecruiting: item.brandIsRecruiting ?? item.brandIsRecruiting ?? false, + }; + }); return { brands, count: response.data.result.count || 0, }; } catch (error: unknown) { - if (error instanceof MatchingTestRequiredError) { - throw error; - } + if (error instanceof MatchingTestRequiredError) throw error; - const axiosError = error as { - response?: { status: number; data?: { message?: string } }; - }; - if ( - axiosError.response?.status === 404 || - axiosError.response?.status === 400 || - axiosError.response?.data?.message?.includes("매칭") || - axiosError.response?.data?.message?.includes("테스트") - ) { + const axiosError = error as ApiThrown; + const status = axiosError.response?.status; + const code = axiosError.response?.data?.code; + const message = axiosError.response?.data?.message; + + if (isMatchTestNotCompleted(code, message)) { throw new MatchingTestRequiredError(); } - console.error("매칭 브랜드 조회 실패:", error); + if (status === 401 || status === 403) { + throw new Error("로그인이 필요하거나 권한이 없습니다."); + } + + console.error("매칭 브랜드 조회 실패:", { + status, + code, + message, + raw: axiosError.response?.data, + }); throw error; } }; -/** - * 브랜드 필터 옵션 조회 - */ +/* 브랜드 필터 */ + export const getBrandFilters = async (): Promise => { try { - const response = await axiosInstance.get( + const response = await apiClient.get( "/api/v1/brands/filters", ); @@ -389,7 +394,8 @@ export const getBrandFilters = async (): Promise => { } }; -// 브랜드 좋아요 응답 타입 +/* 브랜드 좋아요 */ + export interface BrandLikeResponseDto { brandIsLiked: boolean; } @@ -403,16 +409,15 @@ interface BrandLikeResponse { export const toggleBrandLike = async (brandId: number): Promise => { try { - const response = await axiosInstance.post( + const response = await apiClient.post( `/api/v1/brands/${brandId}/like`, - {}, // 빈 객체 body 추가 + {}, ); if (!response.data.isSuccess) { throw new Error(response.data.message || "브랜드 좋아요 토글 실패"); } - // 응답이 배열 형태이므로 첫 번째 요소의 brandIsLiked 반환 return response.data.result[0]?.brandIsLiked || false; } catch (error: unknown) { console.error("브랜드 좋아요 토글 실패:", error); @@ -420,21 +425,14 @@ export const toggleBrandLike = async (brandId: number): Promise => { } }; -/** - * 카테고리별 태그 이름 가져오기 - * @param category 카테고리 (BEAUTY, FASHION 등) - * @returns 태그 이름 배열 - */ -export const getTagNamesByCategory = async ( - _category: string, -): Promise => { - void _category; - // 카테고리별로 기본 태그를 반환하여 모든 태그 포함 - // 실제로는 API에서 카테고리별 태그를 가져와야 할 수 있음 +/* Tag names (stub) */ + +export const getTagNamesByCategory = async (): Promise => { return []; }; -// 캠페인 제안 요청 타입 +/* 캠페인 제안하기 */ + export interface CreateCampaignProposalRequest { brandId: number; creatorId: number; @@ -452,7 +450,6 @@ export interface CreateCampaignProposalRequest { endDate: string; } -// 캠페인 제안 응답 타입 interface CreateCampaignProposalResponse { isSuccess: boolean; code: string; @@ -462,16 +459,13 @@ interface CreateCampaignProposalResponse { }; } -/** - * 캠페인 제안하기 (역제안) - */ export const createCampaignProposal = async ( data: CreateCampaignProposalRequest, ): Promise => { try { - const response = await axiosInstance.post( - "/api/v1/campaign/request", - data + const response = await apiClient.post( + "/api/v1/campaigns/proposal", + data, ); if (!response.data.isSuccess) { @@ -485,7 +479,8 @@ export const createCampaignProposal = async ( } }; -// 매칭 테스트 요청 DTO +/* 매칭 테스트 request */ + export interface MatchRequestDto { userId: string | number; sex: string; @@ -525,15 +520,9 @@ export interface MatchRequestDto { }; } -/** - * 매칭 테스트 결과 분석 요청 - * POST /api/v1/matches - */ -export const analyzeMatch = async ( - data: MatchRequestDto, -): Promise => { +export const analyzeMatch = async (data: MatchRequestDto): Promise => { try { - const response = await axiosInstance.post( + const response = await apiClient.post( "/api/v1/matches", data, ); diff --git a/app/routes/rooms/$chatId.tsx b/app/routes/rooms/$chatId.tsx index 129d00e..d2a9366 100644 --- a/app/routes/rooms/$chatId.tsx +++ b/app/routes/rooms/$chatId.tsx @@ -1,12 +1,9 @@ import { useParams } from "react-router"; import ChattingRoom from "./chatting-room"; -export default function ChatRoomRoute() { +export default function RoomEntry() { const { chatId } = useParams(); - - const roomId = Number(chatId); - if (!chatId || Number.isNaN(roomId)) { - return null; - } - return ; + const brandId = Number(chatId); + if (!Number.isFinite(brandId)) return null; + return ; } diff --git a/app/routes/rooms/api/rooms.ts b/app/routes/rooms/api/rooms.ts index 25a9cbd..510a401 100644 --- a/app/routes/rooms/api/rooms.ts +++ b/app/routes/rooms/api/rooms.ts @@ -1,5 +1,35 @@ import { axiosInstance } from "../../../api/axios"; + +type CreateOrGetDirectRoomResponse = { + isSuccess: boolean; + code: string; + message: string; + result: { + roomId: number; + roomKey: string; + createdAt: string; + }; +}; + +export async function createOrGetDirectRoom(body: { + brandId: number; + creatorId: number; +}): Promise { + const res = await axiosInstance.post( + "/api/v1/chat/rooms", + body + ); + + const data = res.data; + if (!data?.isSuccess) { + throw new Error(data?.message ?? "채팅방 생성/조회 실패"); + } + + return data.result; +} + + //채팅룸 상세조회 export interface ChatRoomDetailResponse { diff --git a/app/routes/rooms/brand-room-page.tsx b/app/routes/rooms/brand-room-page.tsx new file mode 100644 index 0000000..4818419 --- /dev/null +++ b/app/routes/rooms/brand-room-page.tsx @@ -0,0 +1,9 @@ +import { useParams } from "react-router-dom"; +import ChattingRoom from "./chatting-room"; // 실제 파일명에 맞게 + +export default function BrandRoomPage() { + const { brandId } = useParams<{ brandId: string }>(); + const id = Number(brandId); + if (!Number.isFinite(id)) return null; + return ; +} diff --git a/app/routes/rooms/chatting-room.tsx b/app/routes/rooms/chatting-room.tsx index db281e3..9aefb6d 100644 --- a/app/routes/rooms/chatting-room.tsx +++ b/app/routes/rooms/chatting-room.tsx @@ -1,4 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; + +import { tokenStorage } from "../../lib/token"; import NavigationHeader from "../../components/common/NavigateHeader"; import ChatComposer from "./components/ChatComposer"; import AttachmentSheet, { type AttachmentAction } from "./components/AttachmentSheet"; @@ -8,50 +10,82 @@ import { formatKoreanDateTime } from "../../utils/dateTime"; import CollaborationSummaryBar from "./components/CollaborationBar"; import { useHideBottomTab } from "../../hooks/useHideBottomTab"; import { useHideHeader } from "../../hooks/useHideHeader"; -import { getChatRoomDetail, type ChatRoomDetailResponse, getChatMessages, type ChatMessage } from "./api/rooms"; + +import { + createOrGetDirectRoom, + getChatRoomDetail, + type ChatRoomDetailResponse, + getChatMessages, + type ChatMessage, +} from "./api/rooms"; + import { useAuthStore } from "../../stores/auth-store"; type Props = { - roomId: number; + brandId: number; }; -export default function ChattingRoom( {roomId} : Props ) { +export default function ChattingRoom({ brandId }: Props) { const kb = useKeyboardOffset(); const [isSheetOpen, setIsSheetOpen] = useState(false); const [text, setText] = useState(""); const sheetHeight = kb > 0 ? kb : 240; + const [roomId, setRoomId] = useState(null); const [messages, setMessages] = useState([]); - const [message] = useState(null); const [detail, setDetail] = useState(null); - const { dateText, timeText } = useMemo(() => { - if (!message) { - return { dateText: "", timeText: "" }; - } - return formatKoreanDateTime(message.createdAt); - }, [message]); - const createdAt =`${dateText}\n${timeText}`; + const inputRef = useRef(null); + const listRef = useRef(null); + + useHideBottomTab(true); + useHideHeader(true); + const accessToken = tokenStorage.getAccessToken?.(); const myUserId = useAuthStore((s) => Number(s.me?.id ?? 0)); + const partnerName = detail?.opponentName ?? ""; const partnerAvatarUrl = detail?.opponentProfileImageUrl ?? ""; const isCollaborating = detail?.isCollaborating ?? false; const collabTitle = detail?.campaignSummary?.campaignTitle ?? ""; const collabSubtitle = detail?.campaignSummary?.brandName ?? ""; - const collabThumb = detail?.campaignSummary?.campaignImageUrl ?? partnerAvatarUrl; // 콜라보 상품 이미지 - + const collabThumb = detail?.campaignSummary?.campaignImageUrl ?? partnerAvatarUrl; const summaryBarHeight = isCollaborating ? 64 : 0; - const listRef = useRef(null); - const inputRef = useRef(null); + const createdAt = useMemo(() => { + const now = new Date().toISOString(); + const { dateText, timeText } = formatKoreanDateTime(now); + return `${dateText}\n${timeText}`; + }, []); + + useEffect(() => { + if (!accessToken) { + window.location.href = "/auth/login"; + } + }, [accessToken]); + + useEffect(() => { + if (!accessToken) return; + if (!Number.isFinite(brandId) || brandId <= 0) return; + if (!Number.isFinite(myUserId) || myUserId <= 0) return; + + const run = async () => { + try { + const result = await createOrGetDirectRoom({ brandId, creatorId: myUserId }); + setRoomId(result.roomId); + } catch (e) { + console.error("createOrGetDirectRoom failed:", e); + setRoomId(null); + } + }; - //const [cursor, setCursor] = useState(null); - //const [hasNext, setHasNext] = useState(false); + run(); + }, [accessToken, brandId, myUserId]); useEffect(() => { - if (!Number.isFinite(roomId)) return; + if (!accessToken) return; + if (!roomId) return; const run = async () => { try { @@ -64,30 +98,30 @@ export default function ChattingRoom( {roomId} : Props ) { }; run(); - }, [roomId]); + }, [accessToken, roomId]); useEffect(() => { - if (!Number.isFinite(roomId)) return; - - const run = async () => { - try { - const data = await getChatMessages({ roomId, size: 20 }); - setMessages(data.messages.slice().reverse()); - //setCursor(data.nextCursor); - //setHasNext(data.hasNext); - } catch (e) { - console.error(e); - setMessages([]); - //setCursor(null); - //setHasNext(false); - } - }; + if (!accessToken) return; + if (!roomId) return; + + const run = async () => { + try { + const data = await getChatMessages({ roomId, size: 20 }); + setMessages(data.messages.slice().reverse()); + } catch (e) { + console.error(e); + setMessages([]); + } + }; - run(); -}, [roomId]); + run(); + }, [accessToken, roomId]); - useHideBottomTab(true); - useHideHeader(true); + useEffect(() => { + const el = listRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + }, [messages.length]); const actions: AttachmentAction[] = useMemo( () => [ @@ -98,23 +132,10 @@ export default function ChattingRoom( {roomId} : Props ) { [] ); - const scrollToBottom = () => { - const el = listRef.current; - if (!el) return; - el.scrollTop = el.scrollHeight; - }; - - useEffect(() => { - scrollToBottom(); - }, [messages.length]); - const handleToggleSheet = () => { setIsSheetOpen((prev) => { const next = !prev; - if (next) { - // 시트 열릴 때 키보드 내려감 - inputRef.current?.blur(); - } + if (next) inputRef.current?.blur(); return next; }); }; @@ -124,47 +145,65 @@ export default function ChattingRoom( {roomId} : Props ) { const handleSend = () => { const trimmed = text.trim(); if (!trimmed) return; + if (!roomId) return; - const tempId = -Date.now(); // 임시 messageId (음수) + const tempId = -Date.now(); const clientId = crypto.randomUUID(); - const generalText: ChatMessage = { + const generalText: ChatMessage = { messageId: tempId, roomId, - senderId: myUserId, // 내 유저 id + senderId: myUserId, senderType: "USER", messageType: "TEXT", content: trimmed, attachment: null, systemMessage: null, - createdAt: createdAt, + createdAt, clientMessageId: clientId, }; - //일반 메시지 전송 - setMessages((prev) => [ ...prev, generalText]); - - setText(""); // 입력창 비우기 + setMessages((prev) => [...prev, generalText]); + setText(""); setIsSheetOpen(false); - requestAnimationFrame(() => inputRef.current?.focus()); // 한 프레임 뒤 focus 복귀 + requestAnimationFrame(() => inputRef.current?.focus()); }; + if (!accessToken) { + return ( +
+ history.back()} /> +
로그인이 필요합니다.
+
+ ); + } + + if (accessToken && (!myUserId || myUserId <= 0)) { + return ( +
+ history.back()} /> +
로그인 정보 불러오는 중...
+
+ ); + } + + if (!roomId) { + return ( +
+ history.back()} /> +
채팅방을 여는 중...
+
+ ); + } + return (
- history.back()} - /> + history.back()} /> {detail?.campaignSummary && ( - + )} - {/* 메시지 영역 */}
@@ -190,7 +228,6 @@ export default function ChattingRoom( {roomId} : Props ) {
- {/* 입력창 */} - {/* 첨부/기능 시트 */} { - // TODO: 여기서 업로드/기능 연결 console.log("action:", key); setIsSheetOpen(false); }} @@ -215,4 +250,4 @@ export default function ChattingRoom( {roomId} : Props ) { />
); -} \ No newline at end of file +} diff --git a/app/routes/rooms/route.tsx b/app/routes/rooms/route.tsx index 785f31c..dea12e3 100644 --- a/app/routes/rooms/route.tsx +++ b/app/routes/rooms/route.tsx @@ -1,5 +1,30 @@ -import { Outlet } from "react-router"; +import { Outlet, useLocation, useNavigate } from "react-router"; +import { useEffect } from "react"; +import { tokenStorage } from "../../lib/token"; +import { useAuthStore } from "../../stores/auth-store"; + +type RoomsNavState = { + creatorId?: number; + brandId?: number; +}; export default function RoomsLayout() { + const navigate = useNavigate(); + const { state } = useLocation() as { state: RoomsNavState | null }; + + const setMe = useAuthStore((s) => s.setMe); + + useEffect(() => { + const accessToken = tokenStorage.getAccessToken(); + if (!accessToken) { + navigate("/auth/login", { replace: true }); + return; + } + + if (state?.creatorId) { + setMe({ id: String(state.creatorId) }); + } + }, [navigate, setMe, state?.creatorId]); + return ; }