From 81f0338f8009d89d0a0f2d6918171710b51bc546 Mon Sep 17 00:00:00 2001 From: seyun31 <2ne1jenna@naver.com> Date: Wed, 18 Feb 2026 19:13:13 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20=EC=A0=9C=EC=95=88=ED=95=98?= =?UTF-8?q?=EA=B8=B0/=EC=9E=AC=EC=A0=9C=EC=95=88=ED=95=98=EA=B8=B0=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 제안하기 항목 중복 선택 기능 수정 - 협찬 가능 제품 -> 개별 제품 -> 제안하기 기능 추가 - 재제안하기 페이지 UI 수정 --- app/components/form/SelectField.tsx | 9 +- .../detail/sponsorable-detail-content.tsx | 22 +- .../chat/resuggest/resuggest-content.tsx | 193 +++++++++--------- app/routes/home/components/BannerCarousel.tsx | 96 +++++++-- .../matching/components/ProposalModal.tsx | 6 +- .../create/components/SelectBottomSheet.tsx | 6 +- .../create/create-campaign-content.tsx | 178 ++++++++-------- app/routes/matching/suggest/create/schema.ts | 24 +-- 8 files changed, 321 insertions(+), 213 deletions(-) diff --git a/app/components/form/SelectField.tsx b/app/components/form/SelectField.tsx index e72e1f4c..db1f4f99 100644 --- a/app/components/form/SelectField.tsx +++ b/app/components/form/SelectField.tsx @@ -2,13 +2,18 @@ interface SelectFieldProps { placeholder: string; value?: string; onClick: () => void; + noTruncate?: boolean; } export default function SelectField({ placeholder, value, onClick, + noTruncate = false, }: SelectFieldProps) { + // 12글자 이상이면 ...으로 표시 (noTruncate가 false일 때만) + const displayValue = !noTruncate && value && value.length > 12 ? `${value.slice(0, 12)}...` : value; + return ( ); 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 056ab11b..0411b13b 100644 --- a/app/routes/brand-detail/sponsorable/detail/sponsorable-detail-content.tsx +++ b/app/routes/brand-detail/sponsorable/detail/sponsorable-detail-content.tsx @@ -12,6 +12,7 @@ import { LayoutContext } from "../../../layout-context"; import Button from "../../../../components/common/Button"; import { fetchSponsorProductDetail } from "../../api/api"; import LoadingSpinner from "../../../../components/common/LoadingSpinner"; +import { useCampaignProposalStore } from "../../../../stores/campaign-proposal"; const INTERVAL = 3000; @@ -152,6 +153,7 @@ export default function SponsorableDetailContent() { const navigate = useNavigate(); const location = useLocation(); const [sp] = useSearchParams(); + const setProposalData = useCampaignProposalStore((state) => state.setProposalData); const layout = useContext(LayoutContext); const state = (location.state ?? {}) as NavState; @@ -404,16 +406,16 @@ export default function SponsorableDetailContent() { variant="primary" size="lg" fullWidth - onClick={() => - navigate("/matching/suggest/create", { - state: { - brandId: data?.brandId, - productId: data?.productId, - brandName: data?.brandName, - productName: data?.productName, - }, - }) - } + onClick={() => { + setProposalData({ + brandId: data?.brandId ?? 0, + brandName: data?.brandName, + product: data?.productName, + productId: data?.productId, + domain: "BEAUTY", + }); + navigate("/matching/suggest/create?type=new"); + }} > {buttonText} diff --git a/app/routes/chat/resuggest/resuggest-content.tsx b/app/routes/chat/resuggest/resuggest-content.tsx index 4ac903ce..b43431a0 100644 --- a/app/routes/chat/resuggest/resuggest-content.tsx +++ b/app/routes/chat/resuggest/resuggest-content.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { useNavigate } from "react-router"; import { useForm, useWatch } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -84,36 +84,26 @@ export default function ReSuggestContent() { setValue("description", proposalData.campaignDescription || ""); if (proposalData.contentTags?.formats && proposalData.contentTags.formats.length > 0) { - const t = proposalData.contentTags.formats[0]; - const id = t.id ?? PROPOSAL_TAG_ID_BY_NAME[t.name]; - if (id) setValue("format", String(id)); + setValue("format", proposalData.contentTags.formats.map(f => String(f.id ?? PROPOSAL_TAG_ID_BY_NAME[f.name]))); } if (proposalData.contentTags?.categories && proposalData.contentTags.categories.length > 0) { - const t = proposalData.contentTags.categories[0]; - const id = t.id ?? PROPOSAL_TAG_ID_BY_NAME[t.name]; - if (id) setValue("category", String(id)); + setValue("category", proposalData.contentTags.categories.map(c => String(c.id ?? PROPOSAL_TAG_ID_BY_NAME[c.name]))); } if (proposalData.contentTags?.tones && proposalData.contentTags.tones.length > 0) { - const t = proposalData.contentTags.tones[0]; - const id = t.id ?? PROPOSAL_TAG_ID_BY_NAME[t.name]; - if (id) setValue("tone", String(id)); + setValue("tone", proposalData.contentTags.tones.map(t => String(t.id ?? PROPOSAL_TAG_ID_BY_NAME[t.name]))); } if (proposalData.contentTags?.involvements && proposalData.contentTags.involvements.length > 0) { - const t = proposalData.contentTags.involvements[0]; - const id = t.id ?? PROPOSAL_TAG_ID_BY_NAME[t.name]; - if (id) setValue("involvement", String(id)); + setValue("involvement", proposalData.contentTags.involvements.map(i => String(i.id ?? PROPOSAL_TAG_ID_BY_NAME[i.name]))); } if (proposalData.contentTags?.usageRanges && proposalData.contentTags.usageRanges.length > 0) { - const t = proposalData.contentTags.usageRanges[0]; - const id = t.id ?? PROPOSAL_TAG_ID_BY_NAME[t.name]; - if (id) setValue("usageScope", String(id)); + setValue("usageScope", proposalData.contentTags.usageRanges.map(u => String(u.id ?? PROPOSAL_TAG_ID_BY_NAME[u.name]))); } setValue("fee", proposalData.rewardAmount?.toString() || ""); const productIdValue = proposalData.productId && proposalData.productId > 0 ? proposalData.productId.toString() - : (proposalData.product || ""); - setValue("sponsorProduct", productIdValue || ""); + : (proposalData.product && proposalData.product !== "0" ? proposalData.product : ""); + setValue("sponsorProduct", productIdValue ? [productIdValue] : []); setValue("startDate", proposalData.startDate || ""); setValue("endDate", proposalData.endDate || ""); } @@ -136,16 +126,31 @@ export default function ReSuggestContent() { const involvementOptions = INVOLVEMENT_TAGS.map((t) => ({ value: String(t.id), label: t.name })); const usageScopeOptions = USAGE_RANGE_TAGS.map((t) => ({ value: String(t.id), label: t.name })); - const sponsorProductOptions = proposalData?.product - ? [{ value: proposalData.product, label: proposalData.product }] - : (proposalData?.products ?? []) - .filter((p) => p.id && p.name) - .map((p) => ({ value: String(p.id), label: p.name })); - - const findLabel = (options: { value: string; label: string }[], value?: string) => { - if (!value) return undefined; - const option = options.find((opt) => opt.value === value); - return option?.label || ""; + const sponsorProductOptions = useMemo(() => { + const baseOptions = proposalData?.product && proposalData.product !== "0" + ? [{ value: proposalData.product, label: proposalData.product }] + : (proposalData?.products ?? []) + .filter((p) => p.id && p.name && String(p.id) !== "0") + .map((p) => ({ value: String(p.id), label: p.name })); + + // formValues.sponsorProduct 배열에 있는데 options에 없는 항목들 추가 (0 제외) + const missingOptions = (formValues.sponsorProduct || []) + .filter(sp => sp !== "0" && !baseOptions.find(opt => opt.value === sp)) + .map(sp => ({ value: sp, label: sp })); + + return [...missingOptions, ...baseOptions]; + }, [proposalData, formValues.sponsorProduct]); + + // ID 배열로 label들 찾기 헬퍼 함수 + const findLabels = (options: { value: string; label: string }[], values?: string[]) => { + if (!values || values.length === 0) return undefined; + const labels = values + .map(value => { + const found = options.find((opt) => opt.value === value); + return found?.label || value; + }) + .filter(Boolean); + return labels.length > 0 ? labels.join(", ") : undefined; }; const onSubmit = () => { @@ -167,13 +172,13 @@ export default function ReSuggestContent() { campaignId: proposalData?.campaignId || null, campaignName: formData.campaignName || "", description: formData.description || "", - formats: formData.format ? [{ id: Number(formData.format) }] : [], - categories: formData.category ? [{ id: Number(formData.category) }] : [], - tones: formData.tone ? [{ id: Number(formData.tone) }] : [], - involvements: formData.involvement ? [{ id: Number(formData.involvement) }] : [], - usageRanges: formData.usageScope ? [{ id: Number(formData.usageScope) }] : [], + formats: formData.format?.map(f => ({ id: Number(f) })) || [], + categories: formData.category?.map(c => ({ id: Number(c) })) || [], + tones: formData.tone?.map(t => ({ id: Number(t) })) || [], + involvements: formData.involvement?.map(i => ({ id: Number(i) })) || [], + usageRanges: formData.usageScope?.map(u => ({ id: Number(u) })) || [], rewardAmount: Number(formData.fee) || 0, - productId: Number(formData.sponsorProduct) || 0, + productId: Number(formData.sponsorProduct?.[0]) || 0, startDate: formData.startDate || "", endDate: formData.endDate || "", }; @@ -188,46 +193,47 @@ export default function ReSuggestContent() { }; return (
- navigate(-1)} /> + navigate(-1)} />
{/* 커스텀 브랜드 카드 디자인 */} -
-
- {/* 로고 영역 */} -
- logo -
+
+ {/* 로고 영역 */} +
+ logo +
- {/* 정보 영역 */} -
-
+ {/* 정보 영역 */} +
+ {/* 첫 번째 줄: 브랜드명 및 매칭률 */} +
+
{brandDetail?.name || proposalData?.brandName || ""} - +
-
-
- {(brandDetail?.hashtags || []).map((tag) => ( - #{tag} - ))} -
- 검토 중 +
+ 매칭률 + {brandDetail?.matchRate ?? 99}%
-
- {/* 매칭률 영역 */} -
- 매칭률 - {brandDetail?.matchRate ?? 99}% + {/* 해시태그 및 상태값 */} +
+
+ {(brandDetail?.hashtags || []).map((tag) => ( + #{tag} + ))} +
+ 검토 중 +
@@ -249,7 +255,7 @@ export default function ReSuggestContent() { {/* 캠페인명 */}
-
@@ -314,7 +324,7 @@ export default function ReSuggestContent() {

관여도

setIsInvolvementSheetOpen(true)} />
@@ -322,7 +332,7 @@ export default function ReSuggestContent() {

활용 범위

setIsUsageScopeSheetOpen(true)} />
@@ -333,17 +343,17 @@ export default function ReSuggestContent() {
setIsSponsorProductSheetOpen(true)} />
setIsFormatSheetOpen(false)} title="형식" options={formatOptions} - selectedValues={formValues.format ? [formValues.format] : []} - onSubmit={(values) => setValue("format", values[0] || "", { shouldValidate: true })} - multiSelect={false} + selectedValues={formValues.format || []} + onSubmit={(values) => setValue("format", values, { shouldValidate: true })} + multiSelect={true} /> setIsCategorySheetOpen(false)} title="종류" options={categoryOptions} - selectedValues={formValues.category ? [formValues.category] : []} - onSubmit={(values) => setValue("category", values[0] || "", { shouldValidate: true })} - multiSelect={false} + selectedValues={formValues.category || []} + onSubmit={(values) => setValue("category", values, { shouldValidate: true })} + multiSelect={true} /> setIsToneSheetOpen(false)} title="톤" options={toneOptions} - selectedValues={formValues.tone ? [formValues.tone] : []} - onSubmit={(values) => setValue("tone", values[0] || "", { shouldValidate: true })} - multiSelect={false} + selectedValues={formValues.tone || []} + onSubmit={(values) => setValue("tone", values, { shouldValidate: true })} + multiSelect={true} /> setIsInvolvementSheetOpen(false)} title="관여도" options={involvementOptions} - selectedValues={formValues.involvement ? [formValues.involvement] : []} - onSubmit={(values) => setValue("involvement", values[0] || "", { shouldValidate: true })} - multiSelect={false} + selectedValues={formValues.involvement || []} + onSubmit={(values) => setValue("involvement", values, { shouldValidate: true })} + multiSelect={true} /> setIsUsageScopeSheetOpen(false)} title="활용 범위" options={usageScopeOptions} - selectedValues={formValues.usageScope ? [formValues.usageScope] : []} - onSubmit={(values) => setValue("usageScope", values[0] || "", { shouldValidate: true })} - multiSelect={false} + selectedValues={formValues.usageScope || []} + onSubmit={(values) => setValue("usageScope", values, { shouldValidate: true })} + multiSelect={true} /> setIsSponsorProductSheetOpen(false)} title="협찬품 선택" options={sponsorProductOptions} - selectedValues={formValues.sponsorProduct ? [formValues.sponsorProduct] : []} - onSubmit={(values) => setValue("sponsorProduct", values[0] || "", { shouldValidate: true })} - multiSelect={false} + selectedValues={formValues.sponsorProduct || []} + onSubmit={(values) => setValue("sponsorProduct", values, { shouldValidate: true })} + multiSelect={true} hasCustomInput={true} /> setIsConfirmDialogOpen(false)} onConfirm={handleConfirmSubmit} /> diff --git a/app/routes/home/components/BannerCarousel.tsx b/app/routes/home/components/BannerCarousel.tsx index 68dd739d..26932ff4 100644 --- a/app/routes/home/components/BannerCarousel.tsx +++ b/app/routes/home/components/BannerCarousel.tsx @@ -32,13 +32,19 @@ export default function BannerCarousel({ category: CategoryKey; }) { const banners = category === "beauty" ? beautyBanners : fashionBanners; + const displayBanners = [banners[banners.length - 1], ...banners, banners[0]]; - const [current, setCurrent] = useState(0); + const [current, setCurrent] = useState(1); + const [isDragging, setIsDragging] = useState(false); + const [isSilentJumping, setIsSilentJumping] = useState(false); + const [startX, setStartX] = useState(0); + const [dragOffset, setDragOffset] = useState(0); const timerRef = useRef | null>(null); const start = useCallback(() => { + stop(); timerRef.current = setInterval(() => { - setCurrent((prev) => (prev + 1) % banners.length); + setCurrent((prev) => prev + 1); }, INTERVAL); }, [banners.length]); @@ -54,19 +60,83 @@ export default function BannerCarousel({ return stop; }, [start, stop]); + const handleTransitionEnd = () => { + if (current === 0) { + setIsSilentJumping(true); + setCurrent(banners.length); + } else if (current === banners.length + 1) { + setIsSilentJumping(true); + setCurrent(1); + } + }; + + useEffect(() => { + if (isSilentJumping) { + const timeout = setTimeout(() => { + setIsSilentJumping(false); + }, 50); + return () => clearTimeout(timeout); + } + }, [isSilentJumping]); + + const handleStart = (clientX: number) => { + stop(); + setIsDragging(true); + setStartX(clientX); + setDragOffset(0); + }; + + const handleMove = (clientX: number) => { + if (!isDragging) return; + const offset = clientX - startX; + setDragOffset(offset); + }; + + const handleEnd = () => { + if (!isDragging) return; + + const threshold = 50; + if (dragOffset > threshold) { + setCurrent((prev) => prev - 1); + } else if (dragOffset < -threshold) { + setCurrent((prev) => prev + 1); + } + + setIsDragging(false); + setDragOffset(0); + start(); + }; + + const activeDotIndex = (current - 1 + banners.length) % banners.length; + return (
-
+
handleStart(e.clientX)} + onMouseMove={(e) => handleMove(e.clientX)} + onMouseUp={handleEnd} + onMouseLeave={handleEnd} + onTouchStart={(e) => handleStart(e.touches[0].clientX)} + onTouchMove={(e) => handleMove(e.touches[0].clientX)} + onTouchEnd={handleEnd} + >
- {banners.map((banner, i) => ( -
+ {displayBanners.map((banner, i) => ( +
{banner.alt}
))} @@ -77,14 +147,14 @@ export default function BannerCarousel({
diff --git a/app/routes/matching/components/ProposalModal.tsx b/app/routes/matching/components/ProposalModal.tsx index ff997880..47df9e21 100644 --- a/app/routes/matching/components/ProposalModal.tsx +++ b/app/routes/matching/components/ProposalModal.tsx @@ -44,8 +44,8 @@ export default function ProposalModal({

{isConfirm - ? (isSuggest ? "캠페인을 제안하시겠습니까?" : "지원하시겠습니까?") - : (isSuggest ? "제안하기 완료" : "지원 완료")} + ? (isSuggest ? "제안하시겠습니까?" : "지원하시겠습니까?") + : (isSuggest ? "제안 완료" : "지원 완료")}

{!isConfirm && ( @@ -72,7 +72,7 @@ export default function ProposalModal({ className="flex-[4] h-11 text-title7 rounded-xl" onClick={onConfirm} > - 지원하기 + {isSuggest ? "제안하기" : "지원하기"} ) : ( diff --git a/app/routes/matching/suggest/create/components/SelectBottomSheet.tsx b/app/routes/matching/suggest/create/components/SelectBottomSheet.tsx index 04193bc8..74f89c78 100644 --- a/app/routes/matching/suggest/create/components/SelectBottomSheet.tsx +++ b/app/routes/matching/suggest/create/components/SelectBottomSheet.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import FilterBottomSheet from "../../../../../components/common/FilterBottomSheet"; import Button from "../../../../../components/common/Button"; import { CheckIcon } from "../../../../auth/components/CheckIcon"; @@ -32,6 +32,10 @@ export default function SelectBottomSheet({ const [selected, setSelected] = useState(selectedValues); const [customInput, setCustomInput] = useState(""); + useEffect(() => { + setSelected(selectedValues); + }, [selectedValues, isOpen]); + const handleToggle = (value: string) => { if (multiSelect) { setSelected((prev) => diff --git a/app/routes/matching/suggest/create/create-campaign-content.tsx b/app/routes/matching/suggest/create/create-campaign-content.tsx index 17c4fba7..a64aefd4 100644 --- a/app/routes/matching/suggest/create/create-campaign-content.tsx +++ b/app/routes/matching/suggest/create/create-campaign-content.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { useNavigate, useSearchParams } from "react-router"; import { useForm, useWatch } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -85,37 +85,6 @@ export default function CreateCampaignContent() { let alive = true; - // proposalData가 있으면 우선 사용 - if (proposalData) { - if (proposalData.campaignTitle) setValue("campaignName", proposalData.campaignTitle); - if (proposalData.campaignDescription) setValue("description", proposalData.campaignDescription); - - // 태그 매핑 (ID를 문자열로 변환하여 사용) - if (proposalData.contentTags?.formats && proposalData.contentTags.formats.length > 0) { - setValue("format", String(proposalData.contentTags.formats[0].id)); - } - if (proposalData.contentTags?.categories && proposalData.contentTags.categories.length > 0) { - setValue("category", String(proposalData.contentTags.categories[0].id)); - } - if (proposalData.contentTags?.tones && proposalData.contentTags.tones.length > 0) { - setValue("tone", String(proposalData.contentTags.tones[0].id)); - } - if (proposalData.contentTags?.involvements && proposalData.contentTags.involvements.length > 0) { - setValue("involvement", String(proposalData.contentTags.involvements[0].id)); - } - if (proposalData.contentTags?.usageRanges && proposalData.contentTags.usageRanges.length > 0) { - setValue("usageScope", String(proposalData.contentTags.usageRanges[0].id)); - } - - const reward = proposalData.rewardAmount?.toString(); - if (reward) setValue("fee", reward); - - if (proposalData.product) setValue("sponsorProduct", proposalData.product); - if (proposalData.startDate) setValue("startDate", proposalData.startDate); - if (proposalData.endDate) setValue("endDate", proposalData.endDate); - return; - } - // URL 파라미터로 캠페인 조회 (기존 캠페인 제안 시) const campaignIdParam = searchParams.get("campaignId"); @@ -131,24 +100,24 @@ export default function CreateCampaignContent() { setValue("campaignName", detail.title); setValue("description", detail.description); setValue("fee", detail.rewardAmount.toString()); - setValue("sponsorProduct", detail.product); + setValue("sponsorProduct", detail.product ? [detail.product] : []); if (detail.startDate) setValue("startDate", detail.startDate); if (detail.endDate) setValue("endDate", detail.endDate); if (detail.contentTags?.formats?.length > 0) { - setValue("format", String(detail.contentTags.formats[0].id)); + setValue("format", detail.contentTags.formats.map(f => String(f.id))); } if (detail.contentTags?.categories?.length > 0) { - setValue("category", String(detail.contentTags.categories[0].id)); + setValue("category", detail.contentTags.categories.map(c => String(c.id))); } if (detail.contentTags?.tones?.length > 0) { - setValue("tone", String(detail.contentTags.tones[0].id)); + setValue("tone", detail.contentTags.tones.map(t => String(t.id))); } if (detail.contentTags?.involvements?.length > 0) { - setValue("involvement", String(detail.contentTags.involvements[0].id)); + setValue("involvement", detail.contentTags.involvements.map(i => String(i.id))); } if (detail.contentTags?.usageRanges?.length > 0) { - setValue("usageScope", String(detail.contentTags.usageRanges[0].id)); + setValue("usageScope", detail.contentTags.usageRanges.map(u => String(u.id))); } } catch (error) { console.error("캠페인 상세 조회 실패:", error); @@ -156,6 +125,38 @@ export default function CreateCampaignContent() { } })(); } + return () => { + alive = false; + }; + } + + if (proposalData) { + if (proposalData.campaignTitle) setValue("campaignName", proposalData.campaignTitle); + if (proposalData.campaignDescription) setValue("description", proposalData.campaignDescription); + + // 태그 매핑 (ID를 문자열로 변환하여 배열로 사용) + if (proposalData.contentTags?.formats && proposalData.contentTags.formats.length > 0) { + setValue("format", proposalData.contentTags.formats.map(f => String(f.id))); + } + if (proposalData.contentTags?.categories && proposalData.contentTags.categories.length > 0) { + setValue("category", proposalData.contentTags.categories.map(c => String(c.id))); + } + if (proposalData.contentTags?.tones && proposalData.contentTags.tones.length > 0) { + setValue("tone", proposalData.contentTags.tones.map(t => String(t.id))); + } + if (proposalData.contentTags?.involvements && proposalData.contentTags.involvements.length > 0) { + setValue("involvement", proposalData.contentTags.involvements.map(i => String(i.id))); + } + if (proposalData.contentTags?.usageRanges && proposalData.contentTags.usageRanges.length > 0) { + setValue("usageScope", proposalData.contentTags.usageRanges.map(u => String(u.id))); + } + + const reward = proposalData.rewardAmount?.toString(); + if (reward) setValue("fee", reward); + + if (proposalData.product) setValue("sponsorProduct", [proposalData.product]); + if (proposalData.startDate) setValue("startDate", proposalData.startDate); + if (proposalData.endDate) setValue("endDate", proposalData.endDate); } return () => { @@ -183,19 +184,33 @@ export default function CreateCampaignContent() { const involvementOptions = toOptions(INVOLVEMENT_TAGS, tags?.involvements); const usageScopeOptions = toOptions(USAGE_RANGE_TAGS, tags?.usageRanges); - const sponsorProductOptions = proposalData?.product - ? [{ value: proposalData.product, label: proposalData.product }] - : type === "new" - ? [] - : (proposalData?.products ?? []) - .filter((p) => String(p.id).trim() && String(p.name).trim()) - .map((p) => ({ value: String(p.id), label: String(p.name).trim() })); - - // ID로 label 찾기 헬퍼 함수 - const findLabel = (options: { value: string; label: string }[], value?: string) => { - if (!value) return undefined; - const found = options.find((opt) => opt.value === value); - return found?.label || value; + const sponsorProductOptions = useMemo(() => { + const baseOptions = proposalData?.product + ? [{ value: proposalData.product, label: proposalData.product }] + : type === "new" + ? [] + : (proposalData?.products ?? []) + .filter((p) => String(p.id).trim() && String(p.name).trim()) + .map((p) => ({ value: String(p.id), label: String(p.name).trim() })); + + // formValues.sponsorProduct 배열에 있는데 options에 없는 항목들 추가 + const missingOptions = (formValues.sponsorProduct || []) + .filter(sp => !baseOptions.find(opt => opt.value === sp)) + .map(sp => ({ value: sp, label: sp })); + + return [...missingOptions, ...baseOptions]; + }, [proposalData, type, formValues.sponsorProduct]); + + // ID 배열로 label들 찾기 헬퍼 함수 + const findLabels = (options: { value: string; label: string }[], values?: string[]) => { + if (!values || values.length === 0) return undefined; + const labels = values + .map(value => { + const found = options.find((opt) => opt.value === value); + return found?.label || value; + }) + .filter(Boolean); + return labels.length > 0 ? labels.join(", ") : undefined; }; const onSubmit = () => { @@ -229,13 +244,13 @@ export default function CreateCampaignContent() { campaignId, campaignName: formData.campaignName || "", description: formData.description || "", - formats: formData.format ? [{ id: Number(formData.format) }] : [], - categories: formData.category ? [{ id: Number(formData.category) }] : [], - tones: formData.tone ? [{ id: Number(formData.tone) }] : [], - involvements: formData.involvement ? [{ id: Number(formData.involvement) }] : [], - usageRanges: formData.usageScope ? [{ id: Number(formData.usageScope) }] : [], + formats: formData.format?.map(f => ({ id: Number(f) })) || [], + categories: formData.category?.map(c => ({ id: Number(c) })) || [], + tones: formData.tone?.map(t => ({ id: Number(t) })) || [], + involvements: formData.involvement?.map(i => ({ id: Number(i) })) || [], + usageRanges: formData.usageScope?.map(u => ({ id: Number(u) })) || [], rewardAmount: Number(formData.fee) || 0, - productId: Number(formData.sponsorProduct) || 0, + productId: Number(formData.sponsorProduct?.[0]) || 0, startDate: formData.startDate || "", endDate: formData.endDate || "", }; @@ -324,8 +339,9 @@ export default function CreateCampaignContent() {

형식

setIsFormatSheetOpen(true)} + noTruncate={true} /> {/* 종류 / 톤 */} @@ -334,7 +350,7 @@ export default function CreateCampaignContent() {

종류

setIsCategorySheetOpen(true)} />
@@ -342,7 +358,7 @@ export default function CreateCampaignContent() {

setIsToneSheetOpen(true)} />
@@ -354,7 +370,7 @@ export default function CreateCampaignContent() {

관여도

setIsInvolvementSheetOpen(true)} />
@@ -362,7 +378,7 @@ export default function CreateCampaignContent() {

활용 범위

setIsUsageScopeSheetOpen(true)} />
@@ -377,7 +393,7 @@ export default function CreateCampaignContent() { setIsSponsorProductSheetOpen(true)} />
@@ -434,9 +450,9 @@ export default function CreateCampaignContent() { onClose={() => setIsFormatSheetOpen(false)} title="형식" options={formatOptions} - selectedValues={formValues.format ? [formValues.format] : []} - onSubmit={(values) => setValue("format", values[0] || "")} - multiSelect={false} + selectedValues={formValues.format || []} + onSubmit={(values) => setValue("format", values)} + multiSelect={true} /> {/* 종류 선택 바텀시트 */} @@ -445,9 +461,9 @@ export default function CreateCampaignContent() { onClose={() => setIsCategorySheetOpen(false)} title="종류" options={categoryOptions} - selectedValues={formValues.category ? [formValues.category] : []} - onSubmit={(values) => setValue("category", values[0] || "")} - multiSelect={false} + selectedValues={formValues.category || []} + onSubmit={(values) => setValue("category", values)} + multiSelect={true} /> {/* 톤 선택 바텀시트 */} @@ -456,9 +472,9 @@ export default function CreateCampaignContent() { onClose={() => setIsToneSheetOpen(false)} title="톤" options={toneOptions} - selectedValues={formValues.tone ? [formValues.tone] : []} - onSubmit={(values) => setValue("tone", values[0] || "")} - multiSelect={false} + selectedValues={formValues.tone || []} + onSubmit={(values) => setValue("tone", values)} + multiSelect={true} /> {/* 관여도 선택 바텀시트 */} @@ -467,9 +483,9 @@ export default function CreateCampaignContent() { onClose={() => setIsInvolvementSheetOpen(false)} title="관여도" options={involvementOptions} - selectedValues={formValues.involvement ? [formValues.involvement] : []} - onSubmit={(values) => setValue("involvement", values[0] || "")} - multiSelect={false} + selectedValues={formValues.involvement || []} + onSubmit={(values) => setValue("involvement", values)} + multiSelect={true} /> {/* 활용 범위 선택 바텀시트 */} @@ -478,9 +494,9 @@ export default function CreateCampaignContent() { onClose={() => setIsUsageScopeSheetOpen(false)} title="활용 범위" options={usageScopeOptions} - selectedValues={formValues.usageScope ? [formValues.usageScope] : []} - onSubmit={(values) => setValue("usageScope", values[0] || "")} - multiSelect={false} + selectedValues={formValues.usageScope || []} + onSubmit={(values) => setValue("usageScope", values)} + multiSelect={true} /> {/* 협찬품 선택 바텀시트 */} @@ -489,9 +505,9 @@ export default function CreateCampaignContent() { onClose={() => setIsSponsorProductSheetOpen(false)} title="협찬품 선택" options={sponsorProductOptions} - selectedValues={formValues.sponsorProduct ? [formValues.sponsorProduct] : []} - onSubmit={(values) => setValue("sponsorProduct", values[0] || "")} - multiSelect={false} + selectedValues={formValues.sponsorProduct || []} + onSubmit={(values) => setValue("sponsorProduct", values)} + multiSelect={true} hasCustomInput={true} /> diff --git a/app/routes/matching/suggest/create/schema.ts b/app/routes/matching/suggest/create/schema.ts index c860c23f..3bc73a69 100644 --- a/app/routes/matching/suggest/create/schema.ts +++ b/app/routes/matching/suggest/create/schema.ts @@ -9,12 +9,12 @@ export const campaignFormSchema = z.object({ .string() .min(1, "설명을 입력해주세요") .max(300, "설명은 300자 이내로 입력해주세요"), - format: z.string().min(1, "형식을 선택해주세요"), - category: z.string().min(1, "종류를 선택해주세요"), - tone: z.string().min(1, "톤을 선택해주세요"), - involvement: z.string().min(1, "관여도를 선택해주세요"), - usageScope: z.string().min(1, "활용 범위를 선택해주세요"), - sponsorProduct: z.string().min(1, "협찬품을 선택해주세요"), + format: z.array(z.string()).min(1, "형식을 선택해주세요"), + category: z.array(z.string()).min(1, "종류를 선택해주세요"), + tone: z.array(z.string()).min(1, "톤을 선택해주세요"), + involvement: z.array(z.string()).min(1, "관여도를 선택해주세요"), + usageScope: z.array(z.string()).min(1, "활용 범위를 선택해주세요"), + sponsorProduct: z.array(z.string()).min(1, "협찬품을 선택해주세요"), fee: z .string() .min(1, "원고료를 입력해주세요") @@ -34,12 +34,12 @@ export type CampaignFormData = z.infer; export const defaultCampaignFormValues: CampaignFormData = { campaignName: "", description: "", - format: "", - category: "", - tone: "", - involvement: "", - usageScope: "", - sponsorProduct: "", + format: [], + category: [], + tone: [], + involvement: [], + usageScope: [], + sponsorProduct: [], fee: "", startDate: "", endDate: "", From 60d087bcf541a54e0c11664d30095b03ac451411 Mon Sep 17 00:00:00 2001 From: seyun31 <2ne1jenna@naver.com> Date: Wed, 18 Feb 2026 19:25:00 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EB=A6=B0=ED=8A=B8=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business/components/MatchingTabSection.tsx | 8 ++++---- app/routes/campaign-detail/route.tsx | 2 +- app/routes/home/components/BannerCarousel.tsx | 14 +++++++------- .../create/components/SelectBottomSheet.tsx | 15 +++++++-------- app/routes/notification/notification-content.tsx | 8 ++++---- 5 files changed, 23 insertions(+), 24 deletions(-) diff --git a/app/routes/business/components/MatchingTabSection.tsx b/app/routes/business/components/MatchingTabSection.tsx index 9d1c31f0..40103390 100644 --- a/app/routes/business/components/MatchingTabSection.tsx +++ b/app/routes/business/components/MatchingTabSection.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { searchCollaborations, type CampaignCollaboration } from "../calendar/api/calendar"; import searchIcon from "../../../assets/search2.svg"; import closeIcon from "../../../assets/cancel.svg"; @@ -33,7 +33,7 @@ export default function MatchingTabSection({ subTab, setSubTab, receivedCount, k const [isLoading, setIsLoading] = useState(false); // 캠페인 검색 함수 - const fetchCampaigns = async () => { + const fetchCampaigns = useCallback(async () => { if (!keyword.trim()) return; setIsLoading(true); try { @@ -47,7 +47,7 @@ export default function MatchingTabSection({ subTab, setSubTab, receivedCount, k } finally { setIsLoading(false); } - }; + }, [keyword, subTab]); // 검색 상태에 따라 캠페인 검색 useEffect(() => { @@ -56,7 +56,7 @@ export default function MatchingTabSection({ subTab, setSubTab, receivedCount, k } else { setCampaigns([]); } - }, [isSearching, keyword, subTab]); + }, [isSearching, keyword, subTab, fetchCampaigns]); if (isSearching) { return ( diff --git a/app/routes/campaign-detail/route.tsx b/app/routes/campaign-detail/route.tsx index 919d626c..e44e2375 100644 --- a/app/routes/campaign-detail/route.tsx +++ b/app/routes/campaign-detail/route.tsx @@ -124,7 +124,7 @@ export default function CampaignDetailRoute() { return () => { alive = false; }; - }, [resolvedBrandId, resolvedDomain]); + }, [resolvedBrandId, resolvedDomain, matchRateParam]); if (!campaignId) { return ( diff --git a/app/routes/home/components/BannerCarousel.tsx b/app/routes/home/components/BannerCarousel.tsx index 26932ff4..ec3739d8 100644 --- a/app/routes/home/components/BannerCarousel.tsx +++ b/app/routes/home/components/BannerCarousel.tsx @@ -41,13 +41,6 @@ export default function BannerCarousel({ const [dragOffset, setDragOffset] = useState(0); const timerRef = useRef | null>(null); - const start = useCallback(() => { - stop(); - timerRef.current = setInterval(() => { - setCurrent((prev) => prev + 1); - }, INTERVAL); - }, [banners.length]); - const stop = useCallback(() => { if (timerRef.current) { clearInterval(timerRef.current); @@ -55,6 +48,13 @@ export default function BannerCarousel({ } }, []); + const start = useCallback(() => { + stop(); + timerRef.current = setInterval(() => { + setCurrent((prev) => prev + 1); + }, INTERVAL); + }, [stop]); + useEffect(() => { start(); return stop; diff --git a/app/routes/matching/suggest/create/components/SelectBottomSheet.tsx b/app/routes/matching/suggest/create/components/SelectBottomSheet.tsx index 74f89c78..bd9ee5f5 100644 --- a/app/routes/matching/suggest/create/components/SelectBottomSheet.tsx +++ b/app/routes/matching/suggest/create/components/SelectBottomSheet.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import FilterBottomSheet from "../../../../../components/common/FilterBottomSheet"; import Button from "../../../../../components/common/Button"; import { CheckIcon } from "../../../../auth/components/CheckIcon"; @@ -19,7 +19,7 @@ interface SelectBottomSheetProps { hasCustomInput?: boolean; } -export default function SelectBottomSheet({ +function SelectBottomSheetInner({ isOpen, onClose, title, @@ -32,10 +32,6 @@ export default function SelectBottomSheet({ const [selected, setSelected] = useState(selectedValues); const [customInput, setCustomInput] = useState(""); - useEffect(() => { - setSelected(selectedValues); - }, [selectedValues, isOpen]); - const handleToggle = (value: string) => { if (multiSelect) { setSelected((prev) => @@ -57,8 +53,6 @@ export default function SelectBottomSheet({ }; const handleClose = () => { - setSelected(selectedValues); - setCustomInput(""); onClose(); }; @@ -124,3 +118,8 @@ export default function SelectBottomSheet({ ); } + +export default function SelectBottomSheet(props: SelectBottomSheetProps) { + // Reset component state when bottom sheet opens by changing key + return ; +} diff --git a/app/routes/notification/notification-content.tsx b/app/routes/notification/notification-content.tsx index 05d69037..b43e1ad7 100644 --- a/app/routes/notification/notification-content.tsx +++ b/app/routes/notification/notification-content.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { fetchNotifications, readNotification, readAllNotifications, type NotificationItem as NotificationType } from "./api/notification"; import { useHideHeader } from "../../hooks/useHideHeader"; import NavigationHeader from "../../components/common/NavigateHeader"; @@ -29,7 +29,7 @@ export default function NotificationContent() { useHideHeader(true); - const fetchNotificationsData = async () => { + const fetchNotificationsData = useCallback(async () => { setLoading(true); try { const data = await fetchNotifications(activeTab); @@ -42,11 +42,11 @@ export default function NotificationContent() { } finally { setLoading(false); } - }; + }, [activeTab]); useEffect(() => { fetchNotificationsData(); - }, [activeTab]); + }, [fetchNotificationsData]); // 전체 읽기 핸들러 const handleReadAll = async () => {