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
846 changes: 693 additions & 153 deletions api.md

Large diffs are not rendered by default.

6 changes: 2 additions & 4 deletions app/api/axios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,9 @@ import type {
} from "axios";
import { tokenStorage } from "../lib/token";

const BASE_URL = import.meta.env.VITE_API_BASE_URL;

// Axios 인스턴스 생성
export const axiosInstance: AxiosInstance = axios.create({
baseURL: BASE_URL,
baseURL: "/api",
headers: {
"Content-Type": "application/json",
},
Expand Down Expand Up @@ -73,7 +71,7 @@ axiosInstance.interceptors.response.use(

// Refresh Token으로 새 Access Token 발급
const response = await axios.post(
`${BASE_URL}/api/v1/auth/refresh`,
`/api/v1/auth/refresh`,
{},
{
headers: {
Expand Down
7 changes: 2 additions & 5 deletions app/routes/auth/api/auth.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import axios from "axios";
import { apiClient } from "../../../api/axios";
import { tokenStorage } from "../../../lib/token";

const BASE_URL = import.meta.env.VITE_API_BASE_URL;
import type {
SignupCompleteRequest,
SignupCompleteResponse,
Expand Down Expand Up @@ -40,8 +37,8 @@ export const refreshToken = async (): Promise<SignupCompleteResponse> => {
throw new Error("No refresh token available");
}

const response = await axios.post<SignupCompleteResponse>(
`${BASE_URL}/api/v1/auth/refresh`,
const response = await apiClient.post<SignupCompleteResponse>(
"/api/v1/auth/refresh",
{},
{
headers: {
Expand Down
6 changes: 3 additions & 3 deletions app/routes/auth/login/components/SocialLoginSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,17 @@ const BASE_URL = import.meta.env.VITE_API_BASE_URL;

export function SocialLoginSection({ lastProvider }: SocialLoginSectionProps) {
const handleKakaoLogin = () => {
const redirectUri = `${window.location.origin}/auth/callback/kakao`;
const redirectUri = import.meta.env.VITE_KAKAO_REDIRECT_URI || `${window.location.origin}/auth/callback/kakao`;
window.location.href = `${BASE_URL}/oauth2/authorization/kakao?redirect_uri=${encodeURIComponent(redirectUri)}`;
};

const handleNaverLogin = () => {
const redirectUri = `${window.location.origin}/auth/callback/naver`;
const redirectUri = import.meta.env.VITE_NAVER_REDIRECT_URI || `${window.location.origin}/auth/callback/naver`;
window.location.href = `${BASE_URL}/oauth2/authorization/naver?redirect_uri=${encodeURIComponent(redirectUri)}`;
};

const handleGoogleLogin = () => {
const redirectUri = `${window.location.origin}/auth/callback/google`;
const redirectUri = import.meta.env.VITE_GOOGLE_REDIRECT_URI || `${window.location.origin}/auth/callback/google`;
window.location.href = `${BASE_URL}/oauth2/authorization/google?redirect_uri=${encodeURIComponent(redirectUri)}`;
};

Expand Down
14 changes: 13 additions & 1 deletion app/routes/business/campaign/$campaignId.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useParams, useNavigate } from "react-router";
import { useState, useEffect } from "react";
import Button from "../../../components/common/Button";

import CampaignBrandCard from "../components/CampaignBrandCard";
import CampaignInfoGroup from "../components/CampaignInfoGroup";
Expand Down Expand Up @@ -159,7 +160,18 @@ export default function CampaignContent() {
</CampaignInfoGroup>
</div>
</main>
</div>

<div className="sticky bottom-0 left-0 right-0 p-5 bg-white border-t border-bluegray-2">
<Button
variant="primary"
size="lg"
fullWidth
onClick={() => navigate("/matching/suggest/create?type=existing", { state: { campaign: data } })}
>
제안하기
</Button>
</div>
</div >
);
}

Expand Down
45 changes: 9 additions & 36 deletions app/routes/business/proposal/api/proposal.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import axios from "axios";
import { tokenStorage } from "../../../../lib/token";
import { axiosInstance } from "../../../../api/axios";
import type { BrandDetail } from "../../../../data/brand";

// 1. 응답 데이터의 공통 포맷 정의 (isSuccess 등을 포함)
Expand Down Expand Up @@ -32,53 +31,28 @@ export interface ProposalDetail {
}

export const getProposalDetail = async (proposalId: string): Promise<ProposalDetail> => {
const BASE_URL = "https://api.realmatch.co.kr";
// 1. tokenStorage 유틸을 사용하여 안전하게 토큰을 가져옵니다.
const token = tokenStorage.getAccessToken();

console.log("현재 보관된 토큰:", token);
console.log("토큰 만료 여부:", tokenStorage.isTokenExpired());
console.log("현재 사용자 ID:", tokenStorage.getUserId());
console.log("현재 사용자 역할(Role):", tokenStorage.getRole());

try {
const response = await axios.get(`${BASE_URL}/api/v1/campaigns/proposal/${proposalId}`, {
headers: {
// 2. 토큰이 있을 때만 Authorization 헤더를 추가합니다.
...(token && { Authorization: `Bearer ${token}` }),
"accept": "*/*"
}
});
const response = await axiosInstance.get<ApiResponse<ProposalDetail>>(
`/api/v1/campaigns/proposal/${proposalId}`
);

if (response.data.isSuccess) {
return response.data.result;
}


throw new Error(response.data.message || "데이터 로드 실패");
} catch (error) { // : any 삭제
if (axios.isAxiosError(error)) { // axios 에러인지 확인하는 가드 추가 (권장)
if (error.response?.status === 401) {
console.error("401 에러: 토큰이 유효하지 않거나 로그인이 필요합니다.");
}

}
} catch (error) {
console.error("제안 상세 조회 실패:", error);
throw error;
}
};

// 브랜드 상세 정보를 가져오는 API 함수 예시
export const getBrandDetail = async (brandId: number | string): Promise<BrandDetail> => {
const BASE_URL = "https://api.realmatch.co.kr";
const token = tokenStorage.getAccessToken();

try {
const response = await axios.get(`${BASE_URL}/api/v1/brands/${brandId}`, {
headers: {
...(token && { Authorization: `Bearer ${token}` }),
"accept": "*/*"
}
});
const response = await axiosInstance.get<ApiResponse<BrandDetail[]>>(
`/api/v1/brands/${brandId}`
);

if (response.data.isSuccess) {
// 스웨거 응답 구조상 result가 배열이므로 첫 번째 요소를 반환
Expand All @@ -87,7 +61,6 @@ export const getBrandDetail = async (brandId: number | string): Promise<BrandDet

throw new Error(response.data.message || "브랜드 정보 로드 실패");
} catch (error) {

console.error("브랜드 상세 조회 실패:", error);
throw error;
}
Expand Down
10 changes: 9 additions & 1 deletion app/routes/home/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,15 @@ export default function Home() {
}, []);

if (hasMatch === null) {
return null;
return (
<div className="flex items-center justify-center w-full h-full min-h-[50vh]">
<div className="flex flex-col items-center gap-2">
{/* Simple spinner or just text */}
<div className="w-8 h-8 border-4 border-gray-200 border-t-primary rounded-full animate-spin" />
<span className="text-gray-400 text-sm">로딩중...</span>
</div>
</div>
);
}

return hasMatch ? <HomeAfterMatch /> : <PreHome />;
Expand Down
21 changes: 12 additions & 9 deletions app/routes/matching/api/matching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export interface MatchingBrand {
isLiked: boolean;
category: string;
tags?: string[];
isRecruiting?: boolean;
}

interface MatchingCampaignResponse {
Expand Down Expand Up @@ -135,11 +136,12 @@ interface MatchCampaignRawItem {
interface MatchBrandRawItem {
brandId: number;
brandName: string;
logoUrl: string;
matchingRatio: number;
brandLogoUrl: string;
brandMatchingRatio: number;
brandIsLiked?: boolean;
brandIsRecruiting?: boolean;
category?: string;
tags?: string[];
brandTags?: string[];
}

// 브랜드 필터 타입
Expand Down Expand Up @@ -332,12 +334,13 @@ export const getMatchingBrands = async (
const brands = (response.data.result.brands || []).map((item) => ({
id: item.brandId,
name: item.brandName,
logoUrl: item.logoUrl,
matchRate: item.matchingRatio || 0,
matchingRatio: item.matchingRatio,
logoUrl: item.brandLogoUrl,
matchRate: item.brandMatchingRatio || 0,
matchingRatio: item.brandMatchingRatio,
isLiked: item.brandIsLiked || false,
category: item.category || category,
tags: item.tags || [],
tags: item.brandTags || [],
isRecruiting: item.brandIsRecruiting || false
}));

return {
Expand Down Expand Up @@ -467,8 +470,8 @@ export const createCampaignProposal = async (
): Promise<number> => {
try {
const response = await axiosInstance.post<CreateCampaignProposalResponse>(
"/api/v1/campaigns/proposal",
data,
"/api/v1/campaign/request",
data
);

if (!response.data.isSuccess) {
Expand Down
1 change: 1 addition & 0 deletions app/routes/matching/campaign/campaign-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ export default function CampaignContent() {
applicants={campaign.applicants}
isLiked={campaign.isLiked}
onLike={() => toggleLike(campaign.id)}
onClick={() => navigate(`/business/campaign/${campaign.id}`)}
logoUrl={campaign.logoUrl || `/dummy-logo-${campaign.id}.png`}
/>
))}
Expand Down
6 changes: 4 additions & 2 deletions app/routes/matching/campaign/components/CampaignCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface CampaignCardProps {
isLiked?: boolean;
logoUrl?: string;
onLike?: () => void;
onClick?: () => void;
}

export default function CampaignCard({
Expand All @@ -19,15 +20,16 @@ export default function CampaignCard({
applicants,
isLiked = false,
logoUrl,
onLike
onLike,
onClick
}: CampaignCardProps) {
// 금액 포맷팅
const formatReward = (amount: number) => {
return amount.toLocaleString('ko-KR');
};

return (
<div className="flex w-full p-2.5 bg-white border border-bluegray-2 rounded-2xl shadow-sm">
<div onClick={onClick} className="flex w-full p-2.5 bg-white border border-bluegray-2 rounded-2xl shadow-sm cursor-pointer">
{/* 왼쪽: 이미지 + 배지 */}
<div className="mr-4 flex-shrink-0 flex flex-col items-center gap-2 w-[100px]">
<BrandLogo src={logoUrl} alt={brandName} />
Expand Down
7 changes: 5 additions & 2 deletions app/routes/matching/suggest/components/SuggestHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useNavigate } from "react-router";
import { useNavigate, useLocation } from "react-router";

interface SuggestHeaderProps {
title: string;
Expand All @@ -7,12 +7,15 @@ interface SuggestHeaderProps {

export default function SuggestHeader({ title, onBack }: SuggestHeaderProps) {
const navigate = useNavigate();
const location = useLocation();

const handleBack = () => {
if (onBack) {
onBack();
} else if (location.pathname.includes("/create")) {
navigate("/matching/suggest");
} else {
navigate({ to: "/" });
navigate("/");
}
};

Expand Down
35 changes: 31 additions & 4 deletions app/routes/matching/suggest/create/create-campaign-content.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { useState, useEffect } from "react";
import { useNavigate, useSearchParams, useLocation } from "react-router";
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
Expand Down Expand Up @@ -73,6 +73,30 @@ export default function CreateCampaignContent() {
defaultValues: defaultCampaignFormValues,
});

const location = useLocation();

useEffect(() => {
if (type === "existing" && location.state?.campaign) {
const campaign = location.state.campaign;

// 캠페인 데이터 매핑
setValue("campaignName", campaign.title || "");
setValue("description", campaign.description || "");

// 태그 매핑 (단일 선택으로 가정하거나 첫 번째 항목 사용)
if (campaign.contentTags?.formats?.length > 0) setValue("format", campaign.contentTags.formats[0].name); // name 또는 id 매핑 필요 (현재는 name으로 가정)
if (campaign.contentTags?.categories?.length > 0) setValue("category", campaign.contentTags.categories[0].name);
if (campaign.contentTags?.tones?.length > 0) setValue("tone", campaign.contentTags.tones[0].name);
if (campaign.contentTags?.involvements?.length > 0) setValue("involvement", campaign.contentTags.involvements[0].name);
if (campaign.contentTags?.usageRanges?.length > 0) setValue("usageScope", campaign.contentTags.usageRanges[0].name);

setValue("fee", campaign.rewardAmount?.toString() || "");
setValue("sponsorProduct", campaign.productId?.toString() || ""); // productId로 매핑
setValue("startDate", campaign.startDate || "");
setValue("endDate", campaign.endDate || "");
}
}, [type, location.state, setValue]);

const formValues = useWatch({ control, defaultValue: defaultCampaignFormValues });

const handleToggleCampaign = (id: number) => {
Expand Down Expand Up @@ -159,7 +183,10 @@ export default function CreateCampaignContent() {
{/* 스크롤 영역 */}
<form
className="flex-1 overflow-y-auto overflow-x-hidden scrollbar-hide px-5"
onSubmit={handleSubmit(onSubmit)}
onSubmit={handleSubmit(onSubmit, (errors) => {
console.log(errors);
toast.error("모두 입력해주세요");
})}
>
{/* 제목 */}
<h2 className="text-title7 text-text-black mt-4 mb-2">{title}</h2>
Expand Down Expand Up @@ -308,7 +335,7 @@ export default function CreateCampaignContent() {

{/* 하단 버튼 */}
<div className="sticky bottom-0 left-0 right-0 p-5 bg-white">
<Button variant="primary" size="lg" fullWidth withLogo onClick={handleSubmit(onSubmit)} className="shadow-none">
<Button variant="primary" size="lg" fullWidth withLogo onClick={handleSubmit(onSubmit, () => toast.error("모두 입력해주세요"))} className="shadow-none">
캠페인 제안하기
</Button>
</div>
Expand Down
4 changes: 4 additions & 0 deletions vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
"outputDirectory": "build/client",
"framework": null,
"rewrites": [
{
"source": "/api/:path*",
"destination": "https://api.realmatch.co.kr/:path*"
},
{ "source": "/(.*)", "destination": "/index.html" }
]
}