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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added app/assets/ad/ad-realmatch-detail-campagin.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/assets/ad/ad-realmatch-detail-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/assets/ad/ad-realmatch-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/assets/ad/ad-realtmatch-detail-banner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
82 changes: 82 additions & 0 deletions app/routes/brand-detail/api/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { apiClient } from "../../../api/axios";
//import realmatchDetailLogo from "../../../assets/ad/ad-realmatch-detail-logo.png";
//import realmatchDetailBanner from "../../../assets/ad/ad-realtmatch-detail-banner.png";
//import realmatchDetailCampaign from "../../../assets/ad/ad-realmatch-detail-campagin.png";
import type {
BrandDomain,
BrandDetailData,
Expand Down Expand Up @@ -176,6 +179,31 @@ export async function fetchSponsorProductList(params: {
}): Promise<SponsorProductsListDto[]> {
const { brandId } = params;

/*if (brandId === "0") {
return [
{
brandId: 0,
brandName: "리얼매치",
productId: 0,
productName: "리얼이 캐릭터 크림",
thumbnailImageUrl: realmatchDetailCampaign,
productImageUrls: [realmatchDetailCampaign],
categories: ["스킨케어"],
sponsorInfo: {
items: [
{
itemId: 0,
availableType: "FULL",
availableQuantity: 1,
availableSize: 0,
shippingType: "CREATOR_PAY",
},
],
},
},
];
}*/

const res = await apiClient.get<SponsorProductsApiResponse>(
`/api/v1/brands/${brandId}/sponsor-products`,
);
Expand All @@ -195,6 +223,28 @@ export async function fetchSponsorProductDetail(params: {
}): Promise<SponsorProductDetailResult> {
const { brandId, productId } = params;

/*if (brandId === "0" && String(productId) === "0") {
return {
brandId: 0,
brandName: "리얼매치",
productId: 0,
productName: "리얼이 캐릭터 크림",
productImageUrls: [realmatchDetailCampaign],
categories: ["스킨케어"],
sponsorInfo: {
items: [
{
itemId: 0,
availableType: "FULL",
availableQuantity: 1,
availableSize: 0,
shippingType: "CREATOR_PAY",
},
],
},
};
}*/

const res = await apiClient.get<SponsorProductDetailApiResponse>(
`/api/v1/brands/${brandId}/sponsor-products/${productId}`,
);
Expand All @@ -214,6 +264,38 @@ export async function fetchBrandDetail(params: {
}): Promise<BrandDetailData> {
const { brandId, domain } = params;

/*if (brandId === "0") {
return {
id: "0",
userId: 0,
brandUserId: undefined,
domain: domain ?? "beauty",
name: "리얼매치",
matchRate: 0,
heroImageUrl: realmatchDetailBanner,
brandImages: [realmatchDetailBanner],
logoText: "리얼매치",
logoImageUrl: realmatchDetailLogo,
homepageUrl: undefined,
simpleIntro: "크리에이터와 브랜드를 정밀 매칭하는 플랫폼",
hashtags: ["정밀매칭", "원스톱협업", "쌍방향제안"],
description: "크리에이터와 브랜드를 정밀 매칭하는 플랫폼",
categories: [],
tagSections: [],
isLiked: false,
ongoingCampaigns: [],
products: [
{
productId: 0,
productName: "리얼이 캐릭터 크림",
thumbnailImageUrl: realmatchDetailCampaign,
},
],
histories: [],
historiesHasNext: false,
};
}*/

const detailRes = await apiClient.get<BrandDetailApiResponse>(
`/api/v1/brands/${brandId}`,
);
Expand Down
119 changes: 88 additions & 31 deletions app/routes/brand-detail/brand-detail-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import HistoryRow from "./components/HistoryRow";
import SponsorableProductSection from "./components/SponsorableProductSection";

import { tokenStorage } from "../../lib/token";
import { toggleBrandLike } from "../matching/api/matching";
import { toggleBrandLike, toggleCampaignLike } from "../matching/api/matching";
import { useCampaignProposalStore } from "../../stores/campaign-proposal";

import { apiClient } from "../../api/axios";
Expand Down Expand Up @@ -143,7 +143,7 @@ const getNumberField = (
const rec = obj as Record<string, unknown>;
for (const k of keys) {
const v = rec[k];
if (typeof v === "number" && Number.isFinite(v) && v > 0) return v;
if (typeof v === "number" && Number.isFinite(v) && v >= 0) return v;
}
return null;
};
Expand Down Expand Up @@ -172,12 +172,14 @@ export default function BrandDetailContent({ data }: Props) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const brandId = Number(searchParams.get("brandId"));
const validBrandId = Number.isFinite(brandId) && brandId > 0;
const validBrandId = Number.isFinite(brandId) && brandId >= 0;

const setProposalData = useCampaignProposalStore(
(state) => state.setProposalData,
);

const isHardcodedBeauty = brandId === 0 && searchParams.get("domain") === "beauty";

const baseOngoingCampaigns = useMemo<OngoingCampaign[]>(
() => data.ongoingCampaigns ?? [],
[data.ongoingCampaigns],
Expand All @@ -187,19 +189,21 @@ export default function BrandDetailContent({ data }: Props) {
Record<number, boolean>
>({});


const ongoingCampaigns = useMemo<OngoingCampaign[]>(() => {
if (baseOngoingCampaigns.length === 0) return [];
const overrides = ongoingLikeOverrides;

return baseOngoingCampaigns.map((c) => {
const real = baseOngoingCampaigns.map((c) => {
const cid = getCampaignIdFromOngoing(c);
if (!cid) return c;
if (cid === null) return c;

if (Object.prototype.hasOwnProperty.call(overrides, cid)) {
return { ...(c as object), isLiked: overrides[cid] } as OngoingCampaign;
}
return c;
});

return real;
}, [baseOngoingCampaigns, ongoingLikeOverrides]);

const ongoingLikeInFlight = useRef<Set<number>>(new Set());
Expand All @@ -208,13 +212,17 @@ export default function BrandDetailContent({ data }: Props) {
ProductMiniCardItem[]
>([]);

const sponsorProducts = useMemo<ProductMiniCardItem[]>(
() => (validBrandId ? sponsorProductsRaw : []),
[validBrandId, sponsorProductsRaw],
);
const sponsorProducts = useMemo<ProductMiniCardItem[]>(() => {
if (!validBrandId) return [];
// brandId=0일 때는 data.products를 직접 사용
if (brandId === 0) return data.products ?? [];
return sponsorProductsRaw;
}, [validBrandId, brandId, data.products, sponsorProductsRaw]);

useEffect(() => {
if (!validBrandId) return;
// brandId=0일 때는 API 호출 건너뛰기 (data.products 사용)
//if (brandId === 0) return;

let alive = true;

Expand Down Expand Up @@ -278,16 +286,53 @@ export default function BrandDetailContent({ data }: Props) {

const domain = searchParams.get("domain");

setProposalData({
brandId,
campaignId: 0,
domain: domain || "beauty",
brandName: data.name,
products: sponsorProducts.map((p) => ({
id: String(p.productId),
name: p.productName,
})),
});
// brandId=0일 때는 광고 캠페인 정보 포함
if (brandId === 0) {
setProposalData({
brandId,
campaignId: 0,
domain: domain || "beauty",
brandName: data.name,
campaignTitle: "'리얼이 캐릭터 크림' 론칭 리뷰",
campaignDescription: "'리얼이 캐릭터 크림'\n겟레디윗미 영상에서 자연스럽게 노출",
rewardAmount: 200000,
product: "리얼이 캐릭터 크림 1개",
startDate: "2025-01-05",
endDate: "2025-01-22",
contentTags: {
formats: [{ id: 3, name: "인스타 릴스" }],
categories: [
{ id: 6, name: "리뷰" },
{ id: 7, name: "겟레디윗미" },
],
tones: [
{ id: 16, name: "일상적인" },
{ id: 17, name: "수다적인" },
],
usageRanges: [
{ id: 24, name: "크리에이터 1차활용" },
{ id: 25, name: "브랜드 2차활용" },
],
involvements: [{ id: 20, name: "가이드만 제공" }],
},
products: sponsorProducts.map((p) => ({
id: String(p.productId),
name: p.productName,
})),
});
} else {
// 일반 브랜드는 기존 로직 유지
setProposalData({
brandId,
campaignId: 0,
domain: domain || "beauty",
brandName: data.name,
products: sponsorProducts.map((p) => ({
id: String(p.productId),
name: p.productName,
})),
});
}

navigate("/matching/suggest");
};
Expand All @@ -306,7 +351,7 @@ export default function BrandDetailContent({ data }: Props) {

const handleSponsorableProductClick = (productId: number) => {
if (!validBrandId) return;
if (!Number.isFinite(productId) || productId <= 0) return;
if (!Number.isFinite(productId)) return;

navigate(
`/products/sponsorable/detail?brandId=${brandId}&productId=${productId}`,
Expand Down Expand Up @@ -335,8 +380,8 @@ export default function BrandDetailContent({ data }: Props) {
};

const goOngoingCampaignDetail = (c: OngoingCampaign) => {
const cid = getCampaignIdFromOngoing(c);
if (!cid) return;
const cid = getCampaignIdFromOngoing(c) ?? (c.campaignId === 0 ? 0 : null);
if (cid === null) return;

const domainParam = searchParams.get("domain");
const domain =
Expand All @@ -346,11 +391,11 @@ export default function BrandDetailContent({ data }: Props) {

const brandIdNum = validBrandId
? brandId
: Number.isFinite(Number(data.id)) && Number(data.id) > 0
: Number.isFinite(Number(data.id)) && Number(data.id) >= 0
? Number(data.id)
: null;

if (!brandIdNum) return;
if (brandIdNum === null) return;

navigate(
`/campaign?brandId=${brandIdNum}&campaignId=${cid}&domain=${domain}`,
Expand All @@ -365,7 +410,7 @@ export default function BrandDetailContent({ data }: Props) {
}

const clickedId = Number(id);
if (!Number.isFinite(clickedId) || clickedId <= 0) return;
if (!Number.isFinite(clickedId) || clickedId < 0) return;

const currentItem = ongoingCampaigns.find((c) => {
const cid = getCampaignIdFromOngoing(c);
Expand All @@ -374,7 +419,7 @@ export default function BrandDetailContent({ data }: Props) {
if (!currentItem) return;

const cid = getCampaignIdFromOngoing(currentItem);
if (!cid) return;
if (cid === null) return;

if (ongoingLikeInFlight.current.has(cid)) return;
ongoingLikeInFlight.current.add(cid);
Expand All @@ -385,14 +430,24 @@ export default function BrandDetailContent({ data }: Props) {

setOngoingLikeOverrides((m) => ({ ...m, [cid]: next }));

ongoingLikeInFlight.current.delete(cid);
try {
await toggleCampaignLike(cid);
} catch (error) {
console.error("Failed to toggle campaign like:", error);
setOngoingLikeOverrides((m) => ({ ...m, [cid]: prev }));
} finally {
ongoingLikeInFlight.current.delete(cid);
}
};

const PAGE_SIZE = 4;
const GROUP_SIZE = 4;

const histories = data.histories ?? [];
const hasNext = !!data.historiesHasNext;
const histories = useMemo(() => {
return data.histories ?? [];
}, [data.histories]);

const hasNext = isHardcodedBeauty ? false : !!data.historiesHasNext;

const [page, setPage] = useState(1);

Expand Down Expand Up @@ -457,8 +512,9 @@ export default function BrandDetailContent({ data }: Props) {
<BrandInfo
name={data.name}
matchRate={data.matchRate}
hashtags={(data.hashtags ?? []).slice(0, 2)}
hashtags={data.hashtags ?? []}
description={data.description}
isAd={brandId === 0}
/>

<div className="mt-3.5">
Expand Down Expand Up @@ -486,6 +542,7 @@ export default function BrandDetailContent({ data }: Props) {

<section>
{(tagSections ?? []).map((sec, idx) => {

const showTitle = showSectionTitle;

return (
Expand Down
17 changes: 12 additions & 5 deletions app/routes/brand-detail/components/BrandInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,32 @@ type Props = {
matchRate: number;
hashtags: string[];
description: string;
isAd?: boolean;
};

export default function BrandInfo({
name,
matchRate,
hashtags,
description,
isAd = false,
}: Props) {
return (
<div className="pt-11.5">
<div className="flex items-center justify-between">
<div className="text-title text-text-black">{name}</div>

<div className="flex items-center gap-2">
<span className="text-callout1 text-core-1 leading-none">매칭률</span>

<span className="text-title text-core-1 leading-none">
{matchRate}%
</span>
{isAd ? (
<span className="text-title text-core-1 leading-none">광고</span>
) : (
<>
<span className="text-callout1 text-core-1 leading-none">매칭률</span>
<span className="text-title text-core-1 leading-none">
{matchRate}%
</span>
</>
)}
</div>
</div>

Expand Down
Loading