diff --git a/app/routes/brand-detail/api/api.ts b/app/routes/brand-detail/api/api.ts index 9a243315..12814829 100644 --- a/app/routes/brand-detail/api/api.ts +++ b/app/routes/brand-detail/api/api.ts @@ -6,6 +6,9 @@ import type { BrandCampaignsApiResponse, SponsorProductDetailApiResponse, SponsorProductDetailResult, + SponsorProductsApiResponse, + SponsorProductsListDto, + ProductMiniCardItem, } from "../types"; type BeautyResponseDto = { @@ -60,21 +63,6 @@ type RecruitingCampaignsApiResponse = { result: { campaigns: RecruitingCampaignCardDto[] }; }; -export type SponsorProductListResponseDto = { - id: number; - name: string; - thumbnailImageUrl: string; - totalCount: number; - currentCount: number; -}; - -type SponsorProductListApiResponse = { - isSuccess: boolean; - code: string; - message: string; - result: SponsorProductListResponseDto[]; -}; - type BrandCampaignDto = BrandCampaignsApiResponse["result"]["campaigns"][number]; @@ -113,10 +101,7 @@ function inferDomain(item: BrandDetailItemDto): BrandDomain { return item.fashionResponse ? "fashion" : "beauty"; } -function buildCategories( - domain: BrandDomain, - item: BrandDetailItemDto, -): string[] { +function buildCategories(domain: BrandDomain, item: BrandDetailItemDto): string[] { if (domain === "fashion") { const cats = unique(stripHash(item.fashionResponse?.categories)); return cats.length ? cats : ["의류", "가방", "신발", "주얼리", "패션 소품"]; @@ -137,10 +122,8 @@ function buildTagSections( const brandStyle = unique(stripHash(f.brandStyle)); const groups: TagGroup[] = []; - if (brandType.length) - groups.push({ label: "브랜드 종류", chips: brandType }); - if (brandStyle.length) - groups.push({ label: "브랜드 스타일", chips: brandStyle }); + if (brandType.length) groups.push({ label: "브랜드 종류", chips: brandType }); + if (brandStyle.length) groups.push({ label: "브랜드 스타일", chips: brandStyle }); return groups.length ? [{ title: "의류 태그", groups }] : []; } @@ -158,25 +141,21 @@ function buildTagSections( if (cats.includes("스킨케어") || cats.includes("바디")) { const groups: TagGroup[] = []; if (skinType.length) groups.push({ label: "피부타입", chips: skinType }); - if (mainFunction.length) - groups.push({ label: "주요기능", chips: mainFunction }); + if (mainFunction.length) groups.push({ label: "주요기능", chips: mainFunction }); if (groups.length) sections.push({ title: "스킨케어 태그", groups }); } if (cats.includes("메이크업")) { const groups: TagGroup[] = []; - if (makeUpStyle.length) - groups.push({ label: "메이크업 스타일", chips: makeUpStyle }); + if (makeUpStyle.length) groups.push({ label: "메이크업 스타일", chips: makeUpStyle }); if (groups.length) sections.push({ title: "메이크업 태그", groups }); } if (!sections.length) { const groups: TagGroup[] = []; if (skinType.length) groups.push({ label: "피부타입", chips: skinType }); - if (mainFunction.length) - groups.push({ label: "주요기능", chips: mainFunction }); - if (makeUpStyle.length) - groups.push({ label: "메이크업 스타일", chips: makeUpStyle }); + if (mainFunction.length) groups.push({ label: "주요기능", chips: mainFunction }); + if (makeUpStyle.length) groups.push({ label: "메이크업 스타일", chips: makeUpStyle }); return groups.length ? [{ title: "태그", groups }] : []; } @@ -193,10 +172,10 @@ async function safeGet(p: Promise): Promise { export async function fetchSponsorProductList(params: { brandId: string; -}): Promise { +}): Promise { const { brandId } = params; - const res = await apiClient.get( + const res = await apiClient.get( `/api/v1/brands/${brandId}/sponsor-products`, ); @@ -254,7 +233,7 @@ export async function fetchBrandDetail(params: { : resolvedDomain; const productsRes = await safeGet( - apiClient.get( + apiClient.get( `/api/v1/brands/${brandId}/sponsor-products`, ), ); @@ -271,9 +250,8 @@ export async function fetchBrandDetail(params: { ), ); - const productList = productsRes?.data?.isSuccess - ? productsRes.data.result - : []; + const productList: SponsorProductsListDto[] = + productsRes?.data?.isSuccess ? productsRes.data.result ?? [] : []; const historyList = campaignsRes?.data?.isSuccess ? campaignsRes.data.result.campaigns @@ -286,6 +264,18 @@ export async function fetchBrandDetail(params: { ? recruitingRes.data.result.campaigns : []; + const products: ProductMiniCardItem[] = productList.map((p) => { + const fallback = + (p.productImageUrls ?? []).find(Boolean) ?? + (p.thumbnailImageUrl ?? ""); + + return { + productId: p.productId, + productName: p.productName ?? "", + thumbnailImageUrl: (p.thumbnailImageUrl ?? "") || fallback, + }; + }); + return { id: brandId, userId: item.userId, @@ -300,12 +290,17 @@ export async function fetchBrandDetail(params: { logoText: item.brandName, logoImageUrl: item.logoUrl, + homepageUrl: item.homepageUrl, + simpleIntro: item.simpleIntro, + hashtags: unique(stripHash(item.brandDescriptionTags)), description: item.simpleIntro ?? "", categories: buildCategories(safeDomain, item), tagSections: buildTagSections(safeDomain, item), + isLiked: item.brandIsLiked, + ongoingCampaigns: recruitingList.map((c) => ({ campaignId: c.campaignId, brandName: c.brandName, @@ -317,11 +312,7 @@ export async function fetchBrandDetail(params: { isLiked: false, })), - products: productList.map((p) => ({ - id: String(p.id), - title: p.name, - imageUrl: p.thumbnailImageUrl || "", - })), + products, histories: historyList.map((c) => { const { text, highlight } = formatHistoryDate(c); diff --git a/app/routes/brand-detail/brand-detail-content.tsx b/app/routes/brand-detail/brand-detail-content.tsx index 77e11128..42e0c9c0 100644 --- a/app/routes/brand-detail/brand-detail-content.tsx +++ b/app/routes/brand-detail/brand-detail-content.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import BrandHero from "./components/BrandHero"; @@ -7,14 +7,17 @@ import BrandActionBar from "./components/BrandActionBar"; import PillChip from "./components/PillChip"; import TagGroup from "./components/TagGroup"; import OngoingCampaignSection from "./components/OngoingCampaignSection"; -import ProductMiniCard from "./components/ProductMiniCard"; import HistoryRow from "./components/HistoryRow"; +import SponsorableProductSection from "./components/SponsorableProductSection"; import { tokenStorage } from "../../lib/token"; import { toggleBrandLike } from "../matching/api/matching"; import { useCampaignProposalStore } from "../../stores/campaign-proposal"; +import { apiClient } from "../../api/axios"; + import type { BrandDetailData } from "./types"; +import type { ProductMiniCardItem } from "./components/ProductMiniCard"; type Props = { data: BrandDetailData }; @@ -88,20 +91,80 @@ function DoubleArrowRightIcon() { ); } -type ProductWithSubtitle = { - id: number | string; - title: string; - imageUrl: string; - subtitle?: string; -}; +type TagGroupLike = { label: string; chips: unknown[] }; +type TagSectionLike = { title: string; groups: TagGroupLike[] }; + +function normalizeChips(chips: unknown[]) { + return (chips ?? []) + .map((c) => { + if (typeof c === "string") return `s:${c}`; + if (typeof c === "number") return `n:${c}`; + if (c && typeof c === "object") { + const rec = c as Record; + if (typeof rec.id === "number" || typeof rec.id === "string") { + return `id:${String(rec.id)}`; + } + if (typeof rec.name === "string") { + return `name:${rec.name}`; + } + return `o:${JSON.stringify(rec)}`; + } + return `u:${String(c)}`; + }) + .sort(); +} -function getSubtitle(p: unknown): string { - if (typeof p !== "object" || p === null) return ""; - if (!("subtitle" in p)) return ""; - const v = (p as Record).subtitle; - return typeof v === "string" ? v : ""; +function groupSignature(g: TagGroupLike) { + const chips = normalizeChips(g.chips).join("|"); + return `label:${g.label}__chips:${chips}`; } +function sectionGroupsSignature(sec: TagSectionLike) { + return (sec.groups ?? []).map(groupSignature).sort().join("||"); +} + +function shouldShowTitleByRules(sections: TagSectionLike[]) { + if (sections.length <= 1) return false; + + const firstSig = sectionGroupsSignature(sections[0]); + const allSame = sections.every((s) => sectionGroupsSignature(s) === firstSig); + if (allSame) return false; + + return true; +} + +type OngoingCampaign = NonNullable[number]; + +const getNumberField = ( + obj: unknown, + keys: readonly string[], +): number | null => { + if (!obj || typeof obj !== "object") return null; + const rec = obj as Record; + for (const k of keys) { + const v = rec[k]; + if (typeof v === "number" && Number.isFinite(v) && v > 0) return v; + } + return null; +}; + +const getCampaignIdFromOngoing = (c: OngoingCampaign): number | null => + getNumberField(c, ["campaignId", "campaign_id", "id"]); + +type SponsorProductsListItem = { + productId: number; + productName: string; + thumbnailImageUrl?: string | null; + productImageUrls?: string[] | null; +}; + +type SponsorProductsApiResponse = { + isSuccess: boolean; + code: string; + message: string; + result: SponsorProductsListItem[]; +}; + export default function BrandDetailContent({ data }: Props) { const heroUrl = data.brandImages?.[0] ?? data.heroImageUrl; const [isHearted, setIsHearted] = useState(data.isLiked ?? false); @@ -109,7 +172,90 @@ export default function BrandDetailContent({ data }: Props) { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const brandId = Number(searchParams.get("brandId")); - const setProposalData = useCampaignProposalStore((state) => state.setProposalData); + const validBrandId = Number.isFinite(brandId) && brandId > 0; + + const setProposalData = useCampaignProposalStore( + (state) => state.setProposalData, + ); + + const baseOngoingCampaigns = useMemo( + () => data.ongoingCampaigns ?? [], + [data.ongoingCampaigns], + ); + + const [ongoingLikeOverrides, setOngoingLikeOverrides] = useState< + Record + >({}); + + const ongoingCampaigns = useMemo(() => { + if (baseOngoingCampaigns.length === 0) return []; + const overrides = ongoingLikeOverrides; + + return baseOngoingCampaigns.map((c) => { + const cid = getCampaignIdFromOngoing(c); + if (!cid) return c; + + if (Object.prototype.hasOwnProperty.call(overrides, cid)) { + return { ...(c as object), isLiked: overrides[cid] } as OngoingCampaign; + } + return c; + }); + }, [baseOngoingCampaigns, ongoingLikeOverrides]); + + const ongoingLikeInFlight = useRef>(new Set()); + + const [sponsorProductsRaw, setSponsorProductsRaw] = useState< + ProductMiniCardItem[] + >([]); + + const sponsorProducts = useMemo( + () => (validBrandId ? sponsorProductsRaw : []), + [validBrandId, sponsorProductsRaw], + ); + + useEffect(() => { + if (!validBrandId) return; + + let alive = true; + + (async () => { + try { + const res = await apiClient.get( + `/api/v1/brands/${brandId}/sponsor-products`, + ); + + if (!alive) return; + + if (!res.data?.isSuccess) { + setSponsorProductsRaw([]); + return; + } + + const mapped: ProductMiniCardItem[] = (res.data.result ?? []).map( + (p) => { + const fallback = + (p.productImageUrls ?? []).find(Boolean) ?? + (p.thumbnailImageUrl ?? ""); + + return { + productId: p.productId, + productName: p.productName ?? "", + thumbnailImageUrl: (p.thumbnailImageUrl ?? "") || fallback, + }; + }, + ); + + setSponsorProductsRaw(mapped); + } catch { + if (!alive) return; + setSponsorProductsRaw([]); + } + })(); + + return () => { + alive = false; + }; + }, [brandId, validBrandId]); const handleChat = () => { const accessToken = tokenStorage.getAccessToken(); @@ -117,7 +263,7 @@ export default function BrandDetailContent({ data }: Props) { navigate("/auth/login"); return; } - if (!Number.isFinite(brandId) || brandId <= 0) return; + if (!validBrandId) return; navigate(`/rooms/brand/${brandId}`); }; @@ -127,7 +273,7 @@ export default function BrandDetailContent({ data }: Props) { navigate("/auth/login"); return; } - if (!Number.isFinite(brandId) || brandId <= 0) return; + if (!validBrandId) return; const domain = searchParams.get("domain"); @@ -136,34 +282,44 @@ export default function BrandDetailContent({ data }: Props) { campaignId: 0, domain: domain || "beauty", brandName: data.name, - products: (data.products ?? []).map((p) => ({ id: p.id, name: p.title })), + products: sponsorProducts.map((p) => ({ + id: String(p.productId), + name: p.productName, + })), }); navigate("/matching/suggest"); }; const handleGoSponsorableProducts = () => { - if (!Number.isFinite(brandId) || brandId <= 0) return; + if (!validBrandId) return; navigate(`/products/sponsorable?brandId=${brandId}`, { state: { brandId, brandName: data.name, - products: (data.products ?? []).map((p) => { - const pp = p as unknown as ProductWithSubtitle; - return { - id: Number(pp.id), - title: pp.title, - subtitle: getSubtitle(p), - imageUrl: pp.imageUrl, - }; - }), + products: sponsorProducts, }, }); }; + const handleSponsorableProductClick = (productId: number) => { + if (!validBrandId) return; + if (!Number.isFinite(productId) || productId <= 0) return; + + navigate( + `/products/sponsorable/detail?brandId=${brandId}&productId=${productId}`, + { + state: { + brandId, + brandName: data.name, + }, + }, + ); + }; + const handleToggleHeart = async () => { - if (!Number.isFinite(brandId) || brandId <= 0) return; + if (!validBrandId) return; const prev = isHearted; const next = !prev; @@ -177,6 +333,61 @@ export default function BrandDetailContent({ data }: Props) { } }; + const goOngoingCampaignDetail = (c: OngoingCampaign) => { + const cid = getCampaignIdFromOngoing(c); + if (!cid) return; + + const domainParam = searchParams.get("domain"); + const domain = + domainParam === "fashion" || domainParam === "beauty" + ? domainParam + : "beauty"; + + const brandIdNum = + validBrandId + ? brandId + : Number.isFinite(Number(data.id)) && Number(data.id) > 0 + ? Number(data.id) + : null; + + if (!brandIdNum) return; + + navigate( + `/campaign?brandId=${brandIdNum}&campaignId=${cid}&domain=${domain}`, + ); + }; + + const handleOngoingLikeToggle = async (id: string) => { + const accessToken = tokenStorage.getAccessToken(); + if (!accessToken) { + navigate("/auth/login"); + return; + } + + const clickedId = Number(id); + if (!Number.isFinite(clickedId) || clickedId <= 0) return; + + const currentItem = ongoingCampaigns.find((c) => { + const cid = getCampaignIdFromOngoing(c); + return cid === clickedId; + }); + if (!currentItem) return; + + const cid = getCampaignIdFromOngoing(currentItem); + if (!cid) return; + + if (ongoingLikeInFlight.current.has(cid)) return; + ongoingLikeInFlight.current.add(cid); + + const prev = + (currentItem as unknown as { isLiked?: boolean }).isLiked ?? false; + const next = !prev; + + setOngoingLikeOverrides((m) => ({ ...m, [cid]: next })); + + ongoingLikeInFlight.current.delete(cid); + }; + const PAGE_SIZE = 4; const GROUP_SIZE = 4; @@ -198,6 +409,11 @@ export default function BrandDetailContent({ data }: Props) { const canPrevGroup = groupStart > 1; + const goPage = (p: number) => { + if (p < 1) return; + setPage(p); + }; + const goPrevGroup = () => { if (!canPrevGroup) return; goPage(groupStart - GROUP_SIZE); @@ -210,11 +426,6 @@ export default function BrandDetailContent({ data }: Props) { const startIdx = (page - 1) * PAGE_SIZE; const pageItems = histories.slice(startIdx, startIdx + PAGE_SIZE); - const goPage = (p: number) => { - if (p < 1) return; - setPage(p); - }; - const goPrev = () => { if (!canPrev) return; goPage(page - 1); @@ -230,19 +441,19 @@ export default function BrandDetailContent({ data }: Props) { goPage(groupStart + GROUP_SIZE); }; + const tagSections = (data.tagSections ?? []) as unknown as TagSectionLike[]; + const showSectionTitle = shouldShowTitleByRules(tagSections); + return (
-
+
-
+
- +
+ +
+ +
+ +
+
+
카테고리
+
+ {(data.categories ?? []).map((c) => ( + + {c} + + ))} +
+
-
+
+ {(tagSections ?? []).map((sec, idx) => { + const showTitle = showSectionTitle; + + return ( +
+ {showTitle ? ( +
+ {sec.title} +
+ ) : null} + +
+ {(sec.groups ?? []).map((g, gi) => ( + + ))} +
+
+ ); + })} +
+
-
-
카테고리
-
- {(data.categories ?? []).map((c) => ( - - {c} - - ))} -
-
- -
- {(data.tagSections ?? []).map((sec, idx) => ( -
-
{sec.title}
-
- {sec.groups.map((g) => ( - - ))} -
-
- ))} -
+ + + {}} + onCampaignClick={goOngoingCampaignDetail} + onLikeToggle={handleOngoingLikeToggle} + /> - {!data.ongoingCampaigns || data.ongoingCampaigns.length === 0 ? ( -
-
- 진행 중인 캠페인 -
-
-
- 진행 중인 캠페인이 없어요 -
-
-
- ) : ( - {}} - /> - )} + -
-
-
협찬 가능 제품
+
+
캠페인 내역
+ + {histories.length === 0 ? ( +
+
+
+
+
+ 진행한 캠페인이 없어요 +
+
+
+
+
+ ) : ( + <> +
+ {pageItems.map((h) => ( + + ))} +
+ +
+ {page > GROUP_SIZE && ( + + )} + + + +
+ {displayPages.map((p) => { + const disabledPage = p > totalPages && !hasNext; + const active = p === page; + + return ( -
- - {!data.products || data.products.length === 0 ? ( -
-
- 협찬 가능한 제품이 없어요. -
-
- ) : ( -
-
-
- {data.products.map((p) => ( - - ))} -
-
-
- )} -
- - - -
-
캠페인 내역
+ ); + })} +
- {histories.length === 0 ? ( -
-
- 진행한 캠페인이 없어요 -
-
- ) : ( - <> -
- {pageItems.map((h) => ( - - ))} -
- -
- {page > GROUP_SIZE && ( - - )} - - - -
- {displayPages.map((p) => { - const disabledPage = p > totalPages && !hasNext; - const active = p === page; - - return ( - - ); - })} -
+ + + +
+ + )} +
- - - -
- - )} -
@@ -433,5 +626,5 @@ export default function BrandDetailContent({ data }: Props) { } function DividerBlock() { - return
; + return
; } diff --git a/app/routes/brand-detail/components/BrandActionBar.tsx b/app/routes/brand-detail/components/BrandActionBar.tsx index 7aabbfad..4b8a85db 100644 --- a/app/routes/brand-detail/components/BrandActionBar.tsx +++ b/app/routes/brand-detail/components/BrandActionBar.tsx @@ -14,11 +14,11 @@ export default function BrandActionBar({ onToggleHeart, }: Props) { return ( -
+
@@ -26,12 +26,12 @@ export default function BrandActionBar({ -
+
diff --git a/app/routes/brand-detail/components/HistoryRow.tsx b/app/routes/brand-detail/components/HistoryRow.tsx index 93ce62f5..5489a1cc 100644 --- a/app/routes/brand-detail/components/HistoryRow.tsx +++ b/app/routes/brand-detail/components/HistoryRow.tsx @@ -10,18 +10,23 @@ export type HistoryRowItem = { type Props = { item: HistoryRowItem; }; +function ellipsis(text: string, max: number) { + if (!text) return ""; + return text.length > max ? text.slice(0, max) + "..." : text; +} + export default function HistoryRow({ item }: Props) { return (
-
- {item.title} +
+ {ellipsis(item.title, 30)}
{item.rightText && (
{item.rightText} diff --git a/app/routes/brand-detail/components/OngoingCampaignSection.tsx b/app/routes/brand-detail/components/OngoingCampaignSection.tsx index 8dd42636..e58f4c02 100644 --- a/app/routes/brand-detail/components/OngoingCampaignSection.tsx +++ b/app/routes/brand-detail/components/OngoingCampaignSection.tsx @@ -5,56 +5,89 @@ import type { BrandOngoingCampaign } from "../types"; type Props = { campaigns: BrandOngoingCampaign[]; onMore?: () => void; + onCampaignClick?: (c: BrandOngoingCampaign) => void; + onLikeToggle?: (id: string) => void; }; -export default function OngoingCampaignSection({ campaigns, onMore }: Props) { +export default function OngoingCampaignSection({ + campaigns, + onMore, + onCampaignClick, + onLikeToggle, +}: Props) { const isEmpty = campaigns.length === 0; return ( -
- {isEmpty ? ( -
-
- 캠페인 내역 -
+
+
+
진행 중인 캠페인
+ + {onMore ? ( + + ) : ( +
+ )} +
-
-
+ {isEmpty ? ( +
+
+
-
- 진행한 캠페인이 없어요 +
+ 진행 중인 캠페인이 없어요
) : ( - <> -
-
진행 중인 캠페인
- - {onMore ? ( - - ) : ( -
- )} -
- -
-
- {campaigns.map((c) => ( - - ))} -
+ onCampaignClick?.(c)} + onLikeToggle={onLikeToggle} + /> +
+ ))}
- +
)}
); diff --git a/app/routes/brand-detail/components/PillChip.tsx b/app/routes/brand-detail/components/PillChip.tsx index 3d560369..b12f1b77 100644 --- a/app/routes/brand-detail/components/PillChip.tsx +++ b/app/routes/brand-detail/components/PillChip.tsx @@ -7,7 +7,7 @@ export default function PillChip({ children, variant = "outline" }: Props) { if (variant === "filled") { // 카테고리 칩 return ( - + {children} ); @@ -15,7 +15,7 @@ export default function PillChip({ children, variant = "outline" }: Props) { // 태그 칩 return ( - + {children} ); diff --git a/app/routes/brand-detail/components/ProductListCard.tsx b/app/routes/brand-detail/components/ProductListCard.tsx index 7e9323ff..a9a68758 100644 --- a/app/routes/brand-detail/components/ProductListCard.tsx +++ b/app/routes/brand-detail/components/ProductListCard.tsx @@ -1,9 +1,9 @@ -interface Props { +type Props = { title: string; subtitle: string; imageUrl: string; onClick?: () => void; -} +}; export default function ProductListCard({ title, @@ -13,24 +13,18 @@ export default function ProductListCard({ }: Props) { return ( + ) : null} +
+ + {isEmpty ? ( +
+
+
+
+
+ 협찬 가능한 제품이 없어요 +
+
+
+
+
+ ) : ( +
+
+
+ {products.map((p, idx) => ( + onProductClick?.(p.productId)} + /> + ))} +
+
+
+ )} +
+ ); +} diff --git a/app/routes/brand-detail/components/TagGroup.tsx b/app/routes/brand-detail/components/TagGroup.tsx index 300d6169..56a09373 100644 --- a/app/routes/brand-detail/components/TagGroup.tsx +++ b/app/routes/brand-detail/components/TagGroup.tsx @@ -7,11 +7,11 @@ type Props = { export default function TagGroup({ label, chips }: Props) { return ( -
-
+
+
{label}
-
+
{chips.map((c) => ( {c.startsWith("#") ? c : `#${c}`} diff --git a/app/routes/brand-detail/sponsorable/detail/sponsorable-detail-content.tsx b/app/routes/brand-detail/sponsorable/detail/sponsorable-detail-content.tsx index 6d049e38..055b5dec 100644 --- a/app/routes/brand-detail/sponsorable/detail/sponsorable-detail-content.tsx +++ b/app/routes/brand-detail/sponsorable/detail/sponsorable-detail-content.tsx @@ -11,7 +11,7 @@ type SponsorAvailableItem = { availableType: string; availableQuantity: number; availableSize: number; - sizeUnit: string; + shippingType: string; }; type SponsorProductDetailResult = { @@ -19,16 +19,10 @@ type SponsorProductDetailResult = { brandName: string; productId: number; productName: string; - productDescription: string; productImageUrls: string[]; categories: string[]; sponsorInfo: { items: SponsorAvailableItem[]; - shippingType: string; - }; - action: { - canProposeCampaign: boolean; - proposeCampaignCtaText: string; }; }; @@ -48,7 +42,7 @@ type NavState = { function Pill({ children }: { children: string }) { return ( - + {children} ); @@ -65,18 +59,48 @@ function mapType(t: string) { } } -function formatItems(items: SponsorAvailableItem[]) { - return items +function formatItems(productName: string, items: SponsorAvailableItem[]) { + const name = (productName ?? "").toString(); + + return (items ?? []) + .map((it) => { + const type = mapType((it.availableType ?? "").toString().trim()); + + const qty = + typeof it.availableQuantity === "number" && it.availableQuantity > 0 + ? `${it.availableQuantity}개` + : ""; + + const size = + typeof it.availableSize === "number" && it.availableSize > 0 + ? `${it.availableSize}ml` + : ""; + + const left = [name, type, qty].filter(Boolean).join(" ").trim(); + const right = size.trim(); + + return [left, right].filter(Boolean).join(" / "); + }) + .filter(Boolean) + .join(" / "); +} + +function formatSubtitleNoQty(productName: string, items: SponsorAvailableItem[]) { + const name = (productName ?? "").toString(); + + return (items ?? []) .map((it) => { - const type = mapType(it.availableType); - const qty = Number.isFinite(it.availableQuantity) - ? `${it.availableQuantity}개` - : ""; + const type = mapType((it.availableType ?? "").toString().trim()); + const size = - Number.isFinite(it.availableSize) && it.sizeUnit - ? `${it.availableSize}${it.sizeUnit}` + typeof it.availableSize === "number" && it.availableSize > 0 + ? `${it.availableSize}ml` : ""; - return `${type} ${qty}${size ? ` / ${size}` : ""}`.trim(); + + const left = [name, type].filter(Boolean).join(" ").trim(); + const right = size.trim(); + + return [left, right].filter(Boolean).join(" / "); }) .filter(Boolean) .join(" · "); @@ -93,6 +117,20 @@ function formatShipping(t: string) { } } +function resolveShipping(items: SponsorAvailableItem[]) { + const types = Array.from( + new Set( + (items ?? []) + .map((it) => (it.shippingType ?? "").toString().trim()) + .filter(Boolean), + ), + ); + + if (types.length === 0) return ""; + if (types.length === 1) return formatShipping(types[0]); + return types.map(formatShipping).join(" · "); +} + export default function SponsorableDetailContent() { const navigate = useNavigate(); const location = useLocation(); @@ -116,12 +154,8 @@ export default function SponsorableDetailContent() { useEffect(() => { if (!layout) return; - layout.setHideHeader(true); - - return () => { - layout.setHideHeader(false); - }; + return () => layout.setHideHeader(false); }, [layout]); useEffect(() => { @@ -169,33 +203,36 @@ export default function SponsorableDetailContent() { const heroUrl = state.heroImageUrl || data?.productImageUrls?.[0] || ""; const itemsText = useMemo(() => { - const items = data?.sponsorInfo?.items ?? []; - return items.length ? formatItems(items) : ""; + if (!data) return ""; + const items = data.sponsorInfo?.items ?? []; + return items.length ? formatItems(data.productName, items) : ""; + }, [data]); + + const subtitleText = useMemo(() => { + if (!data) return ""; + const items = data.sponsorInfo?.items ?? []; + return items.length ? formatSubtitleNoQty(data.productName, items) : ""; }, [data]); const shippingText = useMemo(() => { - return data ? formatShipping(data.sponsorInfo?.shippingType ?? "") : ""; + if (!data) return ""; + const items = data.sponsorInfo?.items ?? []; + return resolveShipping(items); }, [data]); - const showButton = !!data?.action?.canProposeCampaign; + const showButton = true; const buttonText = "제안하기"; return ( -
-
- {/* 헤더 */} +
+
- navigate(-1)} - /> + navigate(-1)} />
+
- {/* 스크롤 영역 */} -
- {loading && ( - - )} +
+ {loading && } {!loading && errorText && (
@@ -204,33 +241,47 @@ export default function SponsorableDetailContent() { )} {!loading && !errorText && data && ( -
+
{heroUrl ? ( - {data.productName} +
+ {data.productName} +
+ + + +
+
) : ( -
+
)} -
-
- {data.brandName} -
-
- {data.productName} -
-
- {data.productDescription} -
+
+
+
+ {data.brandName} +
-
+
+ {data.productName} +
-
+ {subtitleText ? ( +
+ {subtitleText} +
+ ) : null} +
+
+
+ +
+
카테고리
{(data.categories ?? []).map((c) => ( @@ -239,55 +290,56 @@ export default function SponsorableDetailContent() {
-
-
협찬 설명
+
+
협찬 설명
-
-
-
+
+
+
품목
-
+
{itemsText}
-
-
+
+
배송
-
+
{shippingText}
+ + {/* ✅ 버튼 영역: 컨텐츠와 분리 (피그마: px-6 pt-14 pb-24) */} + {showButton && ( +
+ +
+ )}
)}
- - {showButton && ( -
- -
- )}
); diff --git a/app/routes/brand-detail/sponsorable/sponsorable-content.tsx b/app/routes/brand-detail/sponsorable/sponsorable-content.tsx index b3b7eaa9..e7e16935 100644 --- a/app/routes/brand-detail/sponsorable/sponsorable-content.tsx +++ b/app/routes/brand-detail/sponsorable/sponsorable-content.tsx @@ -6,11 +6,13 @@ import { LayoutContext } from "../../layout-context"; import { fetchSponsorProductList } from "../../brand-detail/api/api"; import LoadingSpinner from "../../../components/common/LoadingSpinner"; -type SponsorProduct = { - id: number; - title: string; - subtitle: string; +import type { SponsorProductsListDto } from "../../brand-detail/types"; + +type UiSponsorProduct = { + productId: number; + productName: string; imageUrl: string; + subtitle: string; }; type NavState = { @@ -18,20 +20,53 @@ type NavState = { brandName?: string; }; -type SponsorProductListResponseDto = { - id: number; - name: string; - thumbnailImageUrl: string; - totalCount: number; - currentCount: number; +const mapType = (t: string) => { + switch (t) { + case "FULL": + return "본품"; + case "SAMPLE": + return "샘플"; + default: + return t; + } +}; + +const buildSubtitle = (dto: SponsorProductsListDto) => { + const name = (dto.productName ?? "").toString(); + const items = dto.sponsorInfo?.items ?? []; + + if (!items.length) return ""; + + const parts = items + .map((it) => { + const type = mapType((it.availableType ?? "").toString().trim()); + + const qty = + typeof it.availableQuantity === "number" && it.availableQuantity > 0 + ? `${it.availableQuantity}개` + : ""; + + const size = + typeof it.availableSize === "number" && it.availableSize > 0 + ? `${it.availableSize}ml` + : ""; + + const left = [name, type, qty].filter(Boolean).join(" ").trim(); + const right = size.trim(); + + return [left, right].filter(Boolean).join(" / "); + }) + .filter(Boolean); + + return parts.join(" / "); }; -function toUiProducts(list: SponsorProductListResponseDto[]): SponsorProduct[] { - return list.map((p) => ({ - id: p.id, - title: p.name, - subtitle: `${p.currentCount}/${p.totalCount}개 남음`, - imageUrl: p.thumbnailImageUrl, +function toUiProducts(list: SponsorProductsListDto[]): UiSponsorProduct[] { + return (list ?? []).map((p) => ({ + productId: p.productId, + productName: p.productName ?? "", + imageUrl: p.thumbnailImageUrl ?? "", + subtitle: buildSubtitle(p), })); } @@ -48,7 +83,7 @@ export default function SponsorableContent() { (Number.isFinite(brandIdFromQuery) ? brandIdFromQuery : undefined); const [brandName] = useState(state.brandName ?? ""); - const [products, setProducts] = useState([]); + const [products, setProducts] = useState([]); const [loading, setLoading] = useState(false); const [errorText, setErrorText] = useState(null); @@ -59,7 +94,6 @@ export default function SponsorableContent() { [brandId], ); - // ✅ 헤더 숨김: “헤더만” 숨기고 화면(Outlet)은 계속 보여야 함 useEffect(() => { if (!layout?.setHideHeader) return; @@ -80,15 +114,14 @@ export default function SponsorableContent() { const list = await fetchSponsorProductList({ brandId: String(brandId), }); + if (cancelled) return; - setProducts(toUiProducts(list)); + setProducts(toUiProducts(list as SponsorProductsListDto[])); } catch (e: unknown) { if (cancelled) return; setErrorText( - e instanceof Error - ? e.message - : "협찬 가능 제품을 불러오지 못했어요.", + e instanceof Error ? e.message : "협찬 가능 제품을 불러오지 못했어요.", ); } finally { if (!cancelled) setLoading(false); @@ -100,47 +133,44 @@ export default function SponsorableContent() { }; }, [brandId, canFetch]); - const goDetail = (product: SponsorProduct) => { + const goDetail = (product: UiSponsorProduct) => { if (!brandId || !Number.isFinite(brandId) || brandId <= 0) return; - if (!Number.isFinite(product.id) || product.id <= 0) return; + if (!Number.isFinite(product.productId) || product.productId <= 0) return; navigate( - `/products/sponsorable/detail?brandId=${brandId}&productId=${product.id}`, + `/products/sponsorable/detail?brandId=${brandId}&productId=${product.productId}`, { state: { brandId, brandName, heroImageUrl: product.imageUrl, - productName: product.title, + productName: product.productName, }, }, ); }; return ( -
-
+
+
- navigate(-1)} - /> + navigate(-1)} />
-
+
{brandName || "브랜드"}
-
- 협찬 가능 리스트 +
+
+ 협찬 가능 리스트 +
-
-
- {loading && ( - - )} +
+
+ {loading && } {!loading && errorText && (
@@ -158,8 +188,8 @@ export default function SponsorableContent() { !errorText && products.map((p) => ( goDetail(p)} diff --git a/app/routes/brand-detail/types.ts b/app/routes/brand-detail/types.ts index de59e9c7..e46908e1 100644 --- a/app/routes/brand-detail/types.ts +++ b/app/routes/brand-detail/types.ts @@ -17,9 +17,9 @@ export type BrandOngoingCampaign = { }; export type ProductMiniCardItem = { - id: string; - title: string; - imageUrl: string; + productId: number; + productName: string; + thumbnailImageUrl: string; }; export type HistoryRowItem = { @@ -99,6 +99,7 @@ export type BrandDetailData = { histories: HistoryRowItem[]; historiesHasNext?: boolean; }; + export type BrandCampaignStatus = "UPCOMING" | "RECRUITING" | "CLOSED"; export type BrandCampaignDto = { @@ -119,32 +120,38 @@ export type BrandCampaignsApiResponse = { }; }; -export type SponsorProductDto = { - id: number; - name: string; - thumbnailImageUrl: string; - totalCount: number; - currentCount: number; -}; - -export type SponsorProductsApiResponse = { - isSuccess: boolean; - code: string; - message: string; - result: SponsorProductDto[]; -}; +export type SponsorShippingType = "CREATOR_PAY" | "BRAND_PAY" | string; export type SponsorAvailableItem = { itemId: number; - availableType: string; // 백엔드 enum 나오면 union으로 좁히기 + availableType: string; availableQuantity: number; availableSize: number; - sizeUnit: string; // "ml", "g" 등 + sizeUnit?: string; + shippingType: SponsorShippingType; }; export type SponsorInfo = { items: SponsorAvailableItem[]; - shippingType: string; // 백엔드 enum 나오면 union으로 좁히기 + shippingType?: string; +}; + +export type SponsorProductsListDto = { + thumbnailImageUrl: string; + brandId: number; + brandName: string; + productId: number; + productName: string; + productImageUrls: string[]; + categories: string[]; + sponsorInfo: SponsorInfo; +}; + +export type SponsorProductsApiResponse = { + isSuccess: boolean; + code: string; + message: string; + result: SponsorProductsListDto[]; }; export type SponsorProductAction = { @@ -158,13 +165,11 @@ export type SponsorProductDetailResult = { productId: number; productName: string; - productDescription: string; productImageUrls: string[]; categories: string[]; sponsorInfo: SponsorInfo; - action: SponsorProductAction; }; export type SponsorProductDetailApiResponse = { diff --git a/app/routes/campaign-detail/campaign-detail.tsx b/app/routes/campaign-detail/campaign-detail.tsx index 9db30e01..beef3264 100644 --- a/app/routes/campaign-detail/campaign-detail.tsx +++ b/app/routes/campaign-detail/campaign-detail.tsx @@ -1,9 +1,9 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, useRef } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import MiniLogo from "../../assets/logo/mini-logo.svg"; import BrandHero from "../brand-detail/components/BrandHero"; import BrandInfo from "../brand-detail/components/BrandInfo"; -import CampaingActionBar from "./components/CampaignActionBar"; +import CampaignActionBar from "./components/CampaignActionBar"; import OngoingCampaignSection from "../brand-detail/components/OngoingCampaignSection"; import { tokenStorage } from "../../lib/token"; @@ -25,6 +25,8 @@ type Props = { campaignId: number; }; +type OngoingCampaign = NonNullable[number]; + const fmtMoney = (n?: number) => Number.isFinite(n) ? `${Number(n).toLocaleString()}원` : "-"; @@ -49,6 +51,37 @@ const toDdayText = (dday?: number) => { return `D-${dday}`; }; +const getNumberField = ( + obj: unknown, + keys: readonly string[], +): number | null => { + if (!obj || typeof obj !== "object") return null; + const rec = obj as Record; + for (const k of keys) { + const v = rec[k]; + if (typeof v === "number" && Number.isFinite(v) && v > 0) return v; + } + return null; +}; + +const getNestedNumberField = ( + obj: unknown, + outerKey: string, + innerKeys: readonly string[], +): number | null => { + if (!obj || typeof obj !== "object") return null; + const rec = obj as Record; + const nested = rec[outerKey]; + return getNumberField(nested, innerKeys); +}; + +const getCampaignIdFromOngoing = (c: OngoingCampaign): number | null => + getNumberField(c, ["campaignId", "campaign_id", "id"]); + +const getBrandIdFromOngoing = (c: OngoingCampaign): number | null => + getNumberField(c, ["brandId", "brand_id"]) ?? + getNestedNumberField(c, "brand", ["brandId", "id"]); + export default function CampaignDetailContent({ brandData, campaignId, @@ -64,6 +97,16 @@ export default function CampaignDetailContent({ const [campaign, setCampaign] = useState(null); const [campaignError, setCampaignError] = useState(null); + const [ongoingCampaigns, setOngoingCampaigns] = useState( + brandData.ongoingCampaigns ?? [], + ); + + useEffect(() => { + setOngoingCampaigns(brandData.ongoingCampaigns ?? []); + }, [brandData.ongoingCampaigns]); + + const ongoingLikeInFlight = useRef>(new Set()); + useEffect(() => { let alive = true; @@ -85,6 +128,15 @@ export default function CampaignDetailContent({ setCampaign(res.data.result); setCampaignError(null); + + const liked = (() => { + const r: unknown = res.data.result; + if (!r || typeof r !== "object") return false; + const rec = r as Record; + return rec["isLiked"] === true; + })(); + + setIsCampaignLiked(liked); } catch { if (!alive) return; setCampaignError("캠페인 정보를 불러오지 못했어요."); @@ -129,8 +181,6 @@ export default function CampaignDetailContent({ ]; }, [campaign]); - const ongoing = useMemo(() => brandData.ongoingCampaigns ?? [], [brandData]); - const handleChat = () => { const accessToken = tokenStorage.getAccessToken(); if (!accessToken) { @@ -139,6 +189,7 @@ export default function CampaignDetailContent({ } navigate(`/rooms/brand/${brandData.id}`); }; + const handleToggleHeart = async (next: boolean) => { const accessToken = tokenStorage.getAccessToken(); if (!accessToken) { @@ -164,6 +215,67 @@ export default function CampaignDetailContent({ } }; + const handleOngoingLikeToggle = async (id: string) => { + const accessToken = tokenStorage.getAccessToken(); + if (!accessToken) { + navigate("/auth/login"); + return; + } + + const clickedId = Number(id); + if (!Number.isFinite(clickedId) || clickedId <= 0) return; + + const currentItem = ongoingCampaigns.find((c) => { + const cid = getCampaignIdFromOngoing(c); + return cid === clickedId; + }); + if (!currentItem) return; + + const cid = getCampaignIdFromOngoing(currentItem); + if (!cid) return; + + if (ongoingLikeInFlight.current.has(cid)) return; + ongoingLikeInFlight.current.add(cid); + + const prev = + (currentItem as unknown as { isLiked?: boolean }).isLiked ?? false; + const next = !prev; + + setOngoingCampaigns((prevList) => + prevList.map((c) => { + const eachId = getCampaignIdFromOngoing(c); + if (eachId !== clickedId) return c; + return { ...(c as object), isLiked: next } as OngoingCampaign; + }), + ); + + try { + const serverStatus = await toggleCampaignLike(cid); + if (typeof serverStatus === "boolean") { + setOngoingCampaigns((prevList) => + prevList.map((c) => { + const eachId = getCampaignIdFromOngoing(c); + if (eachId !== clickedId) return c; + return { + ...(c as object), + isLiked: serverStatus, + } as OngoingCampaign; + }), + ); + } + } catch { + setOngoingCampaigns((prevList) => + prevList.map((c) => { + const eachId = getCampaignIdFromOngoing(c); + if (eachId !== clickedId) return c; + return { ...(c as object), isLiked: prev } as OngoingCampaign; + }), + ); + } finally { + ongoingLikeInFlight.current.delete(cid); + } + }; + const handleSuggest = () => { const accessToken = tokenStorage.getAccessToken(); if (!accessToken) { @@ -237,6 +349,35 @@ export default function CampaignDetailContent({ navigate("/matching/apply"); }; + const goOngoingCampaignDetail = (c: OngoingCampaign) => { + const cid = getCampaignIdFromOngoing(c); + if (!cid) return; + + const domainParam = searchParams.get("domain"); + const domain = + domainParam === "fashion" || domainParam === "beauty" + ? domainParam + : "beauty"; + + const bidFromItem = getBrandIdFromOngoing(c); + const brandIdFromQuery = Number(searchParams.get("brandId")); + const fallbackBrandId = Number(brandData.id); + + const brandIdNum = + bidFromItem ?? + (Number.isFinite(brandIdFromQuery) && brandIdFromQuery > 0 + ? brandIdFromQuery + : Number.isFinite(fallbackBrandId) && fallbackBrandId > 0 + ? fallbackBrandId + : null); + + if (!brandIdNum) return; + + navigate( + `/campaign?brandId=${brandIdNum}&campaignId=${cid}&domain=${domain}`, + ); + }; + if (campaignError) { return (
@@ -291,7 +432,7 @@ export default function CampaignDetailContent({
-
-
+
{campaign.title}
-
+
상세 설명
-
+
{detailRows.map((row) => (
-
+
콘텐츠
-
+
{contentRows.map((row) => (
@@ -370,7 +511,7 @@ export default function CampaignDetailContent({
- {}} /> + {}} + onCampaignClick={goOngoingCampaignDetail} + onLikeToggle={handleOngoingLikeToggle} + />
diff --git a/app/routes/campaign-detail/components/CampaignActionBar.tsx b/app/routes/campaign-detail/components/CampaignActionBar.tsx index 00bdc607..7b19f821 100644 --- a/app/routes/campaign-detail/components/CampaignActionBar.tsx +++ b/app/routes/campaign-detail/components/CampaignActionBar.tsx @@ -7,18 +7,18 @@ type Props = { onToggleHeart: (next: boolean) => void; }; -export default function BrandActionBar({ +export default function CampaignActionBar({ isHearted, onChat, onSuggest, onToggleHeart, }: Props) { return ( -
+
@@ -26,12 +26,12 @@ export default function BrandActionBar({ -
+
diff --git a/app/routes/home/components/HeartButton.tsx b/app/routes/home/components/HeartButton.tsx index 222f59ea..f2a29b0a 100644 --- a/app/routes/home/components/HeartButton.tsx +++ b/app/routes/home/components/HeartButton.tsx @@ -13,10 +13,15 @@ export default function HeartButton({ onChange?.(!pressed); }; + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + toggle(); + }; + return (