Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
114 changes: 114 additions & 0 deletions src/apis/likes.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { apiClient, normalizeApiError } from './common';

import type {
LikedItemCategory,
LikedItemQueryCategory,
} from '../pages/likes/types';

export interface LikedItemsParams {
category: LikedItemQueryCategory;
keyword?: string;
sort?: 'LATEST' | 'OLDEST';
cursorCreatedAt?: string;
cursorId?: number;
size?: number;
latitude?: number;
longitude?: number;
}

/** 서버 쿼리 파라미터 표기. EVENT는 서버에서 CONTENT로 받는다. */
const LIKED_ITEM_QUERY_CATEGORY_PARAM: Record<LikedItemQueryCategory, string> =
{
ALL: 'ALL',
COURSE: 'COURSE',
EVENT: 'CONTENT',
PLACE: 'PLACE',
};

/** 서버 응답 category 표기. CONTENT는 앱에서 EVENT로 다룬다. */
type LikedItemResponseCategory = 'COURSE' | 'CONTENT' | 'PLACE';

const LIKED_ITEM_RESPONSE_CATEGORY: Record<
LikedItemResponseCategory,
LikedItemCategory
> = {
COURSE: 'COURSE',
CONTENT: 'EVENT',
PLACE: 'PLACE',
};

interface RawLikedItemResponse {
id: number;
category: LikedItemResponseCategory;
title: string;
externalPlaceId?: string;
thumbnailImage: string | null;
duration: string | null;
startDate: string | null;
endDate: string | null;
location: string;
transportType?: string | null;
companionType?: string | null;
distance: number | null;
hashtags: string[];
likedAt: string;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

interface RawGetLikedItemsResponse {
items: RawLikedItemResponse[];
cursorValue: string | null;
cursorId: number | null;
hasNext: boolean;
}

export interface LikedItemResponse {
id: number;
category: LikedItemCategory;
title: string;
externalPlaceId?: string;
thumbnailImage: string | null;
duration: string | null;
startDate: string | null;
endDate: string | null;
location: string;
transportType?: string | null;
companionType?: string | null;
distance: number | null;
hashtags: string[];
likedAt: string;
}

export interface GetLikedItemsResponse {
items: LikedItemResponse[];
cursorValue: string | null;
cursorId: number | null;
hasNext: boolean;
}

export async function getLikedItems(
params: LikedItemsParams,
signal?: AbortSignal
): Promise<GetLikedItemsResponse> {
try {
const { data } = await apiClient.get<RawGetLikedItemsResponse>(
'/users/me/likes',
{
params: {
...params,
category: LIKED_ITEM_QUERY_CATEGORY_PARAM[params.category],
},
signal,
}
);

return {
...data,
items: data.items.map((item) => ({
...item,
category: LIKED_ITEM_RESPONSE_CATEGORY[item.category],
})),
};
} catch (error) {
throw normalizeApiError(error);
}
}
3 changes: 3 additions & 0 deletions src/assets/icons/near.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
133 changes: 108 additions & 25 deletions src/components/common/ContentCard.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,51 @@
import { useLayoutEffect, useRef, useState } from 'react';
import {
FaDog,
FaHeart,
FaPeopleGroup,
FaPeopleRoof,
FaUser,
} from 'react-icons/fa6';

import calendar from '../../assets/icons/calendar.svg';
import heart from '../../assets/icons/heart.svg';
import location from '../../assets/icons/location.svg';
import near from '../../assets/icons/near.svg';
import oheart from '../../assets/icons/oheart.svg';
import people from '../../assets/icons/people.svg';

import { useGlobalScale } from '../../hooks/useGlobalScale';

import TagChip, { type TagType } from './TagChip';

function getCompanionIcon(label?: string | null) {
if (!label) return null;

const key = label.trim().toUpperCase();

if (key === 'SOLO' || key === 'ALONE' || label.includes('혼자')) {
return FaUser;
}
if (key === 'FRIEND' || label.includes('친구')) {
return FaPeopleGroup;
}
if (key === 'COUPLE' || label.includes('연인')) {
return FaHeart;
}
if (key === 'FAMILY' || label.includes('가족')) {
return FaPeopleRoof;
}
if (
key === 'PET' ||
label.includes('반려동물') ||
label.includes('반려견')
) {
return FaDog;
}

return null;
}

// 모든 수치는 Figma 390 디자인 기준(카드 자체 폭 163 기준) 리터럴 px
const CARD_DESIGN_WIDTH = 163;
const CARD_HEIGHT = 222;
Expand All @@ -18,7 +54,14 @@ const CONTENT_HEIGHT = 107;
const CONTENT_PADDING = 8;
const TITLE_SIZE = 14;
const INFO_SIZE = 12;
/** 아이콘 슬롯. 아이콘은 원본 비율 그대로 이 슬롯 가운데에 놓는다(중심 x = 15). */
const ICON_SIZE = 14;
const ICON_GAP = 2;
/** 태그 줄이 없는 카드(장소)는 남는 높이만큼 정보 줄을 넓게 벌린다. */
const INFO_MARGIN_TOP = 8;
const INFO_ROW_GAP = 4;
const INFO_MARGIN_TOP_WIDE = 17;
const INFO_ROW_GAP_WIDE = 8;
const HEART_SIZE = 16;
const HEART_TOP = 8;
const HEART_RIGHT = 8;
Expand All @@ -32,6 +75,8 @@ interface ContentCardProps {
secondInfo: string;
/** 두 번째 정보 줄에 함께 붙는 보조 정보(예: 동행 유형). 없으면 렌더링하지 않는다. */
thirdInfo?: string;
/** 현 위치 기준 거리 줄(예: '현위치와 314KM'). 없으면 렌더링하지 않는다. */
distanceInfo?: string;
liked?: boolean;
className?: string;
tags?: TagType[];
Expand Down Expand Up @@ -101,12 +146,26 @@ function useResponsiveTagCount(tags: TagType[] | undefined) {
return { visibleContainerRef, hiddenContainerRef, visibleCount };
}

/** 아이콘은 늘리지 않고 원본 크기 그대로 14px 슬롯 가운데에 놓는다. */
function InfoIcon({ src }: { src: string }) {
return (
<span
className="flex shrink-0 items-center justify-center"
style={{ height: ICON_SIZE, width: ICON_SIZE }}
aria-hidden="true"
>
<img src={src} alt="" />
</span>
);
}

function ContentCard({
image,
title,
firstInfo,
secondInfo,
thirdInfo,
distanceInfo,
liked = false,
className = '',
tags,
Expand All @@ -118,6 +177,7 @@ function ContentCard({
const { visibleContainerRef, hiddenContainerRef, visibleCount } =
useResponsiveTagCount(tags);

const hasTags = Boolean(tags && tags.length > 0);
const isClickable = Boolean(onClick);
const hasCustomWidth = /(?:^|\s)(?:w-|min-w|max-w)/.test(className);

Expand Down Expand Up @@ -188,15 +248,18 @@ function ContentCard({
</h3>

{/* Info */}
<div className="mt-2 flex flex-col gap-1">
<div className="flex items-center gap-1">
<img
src={calendar}
alt=""
aria-hidden="true"
className="shrink-0"
style={{ height: ICON_SIZE, width: ICON_SIZE }}
/>
<div
className="flex flex-col"
style={{
marginTop: hasTags ? INFO_MARGIN_TOP : INFO_MARGIN_TOP_WIDE,
gap: hasTags ? INFO_ROW_GAP : INFO_ROW_GAP_WIDE,
}}
>
<div
className="flex items-center"
style={{ height: ICON_SIZE, gap: ICON_GAP }}
>
<InfoIcon src={calendar} />

<span
className="min-w-0 truncate leading-none font-medium text-[#7F7F7F]"
Expand All @@ -206,14 +269,11 @@ function ContentCard({
</span>
</div>

<div className="flex items-center gap-1">
<img
src={location}
alt=""
aria-hidden="true"
className="shrink-0"
style={{ height: ICON_SIZE, width: ICON_SIZE }}
/>
<div
className="flex items-center"
style={{ height: ICON_SIZE, gap: ICON_GAP }}
>
<InfoIcon src={location} />

<span
className="min-w-0 truncate leading-none font-medium text-[#7F7F7F]"
Expand All @@ -224,13 +284,20 @@ function ContentCard({

{thirdInfo ? (
<>
<img
src={people}
alt=""
aria-hidden="true"
className="shrink-0"
style={{ height: ICON_SIZE, width: ICON_SIZE }}
/>
{(() => {
const CompanionIcon = getCompanionIcon(thirdInfo);
return CompanionIcon ? (
<span
className="flex shrink-0 items-center justify-center text-[#7F7F7F]"
style={{ height: ICON_SIZE, width: ICON_SIZE }}
aria-hidden="true"
>
<CompanionIcon style={{ fontSize: 11 }} />
</span>
) : (
<InfoIcon src={people} />
);
})()}

<span
className="shrink-0 truncate leading-none font-medium text-[#7F7F7F]"
Expand All @@ -241,10 +308,26 @@ function ContentCard({
</>
) : null}
</div>

{distanceInfo ? (
<div
className="flex items-center"
style={{ height: ICON_SIZE, gap: ICON_GAP }}
>
<InfoIcon src={near} />

<span
className="min-w-0 truncate leading-none font-medium text-[#7F7F7F]"
style={{ fontSize: INFO_SIZE }}
>
{distanceInfo}
</span>
</div>
) : null}
</div>

{/* Tags */}
{tags && tags.length > 0 && (
{hasTags && tags && (
<>
<div
ref={hiddenContainerRef}
Expand Down
Loading