Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ VITE_API_BASE_URL=https://api.yeogido.kr/api/v1
VITE_KAKAO_MAP_API_KEY=your_kakao_javascript_key
KAKAO_REST_API_KEY=your_kakao_rest_api_key
VITE_NAVER_CLIENT_ID=your_naver_client_id
GOOGLE_MAPS_API_KEY=발급받은_키
41 changes: 41 additions & 0 deletions api/google-places/hours.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {
InvalidPlaceHoursRequestError,
lookupPlaceHours,
type PlaceHoursRequest,
} from './placeHours';

export const config = { runtime: 'edge' };

function json(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json; charset=utf-8' },
});
}
export default async function handler(request: Request): Promise<Response> {
if (request.method !== 'POST') {
return json({ message: 'Method not allowed.' }, 405);
}

const apiKey = process.env.GOOGLE_MAPS_API_KEY;
if (!apiKey) {
return json({ message: 'Google Places API is not configured.' }, 503);
}

let requestBody: PlaceHoursRequest;
try {
requestBody = (await request.json()) as PlaceHoursRequest;
} catch {
return json({ message: 'Invalid request body.' }, 400);
}

try {
return json(await lookupPlaceHours(requestBody, apiKey));
} catch (error) {
if (error instanceof InvalidPlaceHoursRequestError) {
return json({ message: error.message }, 400);
}

return json({ message: 'Google Places request failed.' }, 502);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
122 changes: 122 additions & 0 deletions api/google-places/placeHours.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
export interface PlaceHoursRequest {
readonly name?: unknown;
readonly address?: unknown;
readonly latitude?: unknown;
readonly longitude?: unknown;
}
export interface PlaceHoursResponse {
readonly currentWeekdayDescriptions: readonly string[];
readonly regularWeekdayDescriptions: readonly string[];
readonly openNow?: boolean;
readonly nextOpenTime?: string;
readonly nextCloseTime?: string;
}

interface GoogleOpeningHours {
readonly weekdayDescriptions?: readonly string[];
readonly openNow?: boolean;
readonly nextOpenTime?: string;
readonly nextCloseTime?: string;
}

interface GooglePlaceDetails {
readonly currentOpeningHours?: GoogleOpeningHours;
readonly regularOpeningHours?: GoogleOpeningHours;
}

interface GoogleTextSearchResponse {
readonly places?: readonly { readonly id?: string }[];
}

export class InvalidPlaceHoursRequestError extends Error {}

function toOptionalString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}

function toOptionalCoordinate(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}

export async function lookupPlaceHours(
requestBody: PlaceHoursRequest,
apiKey: string
): Promise<PlaceHoursResponse | null> {
const name = toOptionalString(requestBody.name);
const address = toOptionalString(requestBody.address);
const latitude = toOptionalCoordinate(requestBody.latitude);
const longitude = toOptionalCoordinate(requestBody.longitude);

if (!name) {
throw new InvalidPlaceHoursRequestError('Place name is required.');
}

const textSearchBody: Record<string, unknown> = {
textQuery: [name, address].filter(Boolean).join(' '),
languageCode: 'ko',
};

if (latitude !== undefined && longitude !== undefined) {
textSearchBody.locationBias = {
circle: {
center: { latitude, longitude },
radius: 1_000,
},
};
}

const searchResponse = await fetch(
'https://places.googleapis.com/v1/places:searchText',
{
method: 'POST',
headers: {
'content-type': 'application/json',
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'places.id',
},
body: JSON.stringify(textSearchBody),
}
);

if (!searchResponse.ok) {
throw new Error('Google place search failed.');
}

const searchResult =
(await searchResponse.json()) as GoogleTextSearchResponse;
const placeId = searchResult.places?.[0]?.id;

if (!placeId) {
return null;
Comment thread
KJun-2 marked this conversation as resolved.
}

const detailsResponse = await fetch(
`https://places.googleapis.com/v1/places/${encodeURIComponent(placeId)}`,
{
headers: {
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'currentOpeningHours,regularOpeningHours',
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
);

if (!detailsResponse.ok) {
throw new Error('Google place details failed.');
}

const details = (await detailsResponse.json()) as GooglePlaceDetails;
const currentHours = details.currentOpeningHours;
const regularHours = details.regularOpeningHours;

if (!currentHours && !regularHours) {
return null;
}

return {
currentWeekdayDescriptions: currentHours?.weekdayDescriptions ?? [],
regularWeekdayDescriptions: regularHours?.weekdayDescriptions ?? [],
openNow: currentHours?.openNow,
nextOpenTime: currentHours?.nextOpenTime,
nextCloseTime: currentHours?.nextCloseTime,
};
}
31 changes: 31 additions & 0 deletions src/apis/googlePlacesHours.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export interface PlaceHoursLookup {
readonly name: string;
readonly address?: string;
readonly latitude?: number;
readonly longitude?: number;
}
export interface PlaceHours {
readonly currentWeekdayDescriptions: readonly string[];
readonly regularWeekdayDescriptions: readonly string[];
readonly openNow?: boolean;
readonly nextOpenTime?: string;
readonly nextCloseTime?: string;
}

export async function getPlaceHours(
lookup: PlaceHoursLookup,
signal?: AbortSignal
): Promise<PlaceHours | null> {
const response = await fetch('/google-places/hours', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(lookup),
signal,
});

if (!response.ok) {
throw new Error('Failed to retrieve place opening hours.');
}

return (await response.json()) as PlaceHours | null;
}
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.
Loading