diff --git a/apps/web/src/core/shared/util/date/index.ts b/apps/web/src/core/shared/util/date/index.ts index 245b83b7..2c81e11a 100644 --- a/apps/web/src/core/shared/util/date/index.ts +++ b/apps/web/src/core/shared/util/date/index.ts @@ -10,3 +10,4 @@ export { type RelativeTimeOptions, } from "./format-relative-time"; export { formatTime, formatTimeRange } from "./format-time"; +export { parseHour, parseMinute, parseSecond } from "./parse-time"; diff --git a/apps/web/src/core/shared/util/date/parse-time.ts b/apps/web/src/core/shared/util/date/parse-time.ts new file mode 100644 index 00000000..13f63229 --- /dev/null +++ b/apps/web/src/core/shared/util/date/parse-time.ts @@ -0,0 +1,59 @@ +/** + * Time Parsing Utilities + * + * 시간 문자열 파싱 유틸리티 함수들 + */ + +/** + * 시간 문자열에서 시(hour) 파싱 + * + * @param hourStr - 시간 문자열 (예: "11", "09") + * @returns 0-23 범위의 숫자, 유효하지 않으면 null + * + * @example + * parseHour("11") // 11 + * parseHour("09") // 9 + * parseHour("25") // null (범위 초과) + * parseHour("abc") // null (숫자 아님) + */ +export function parseHour(hourStr: string): number | null { + const hour = Number.parseInt(hourStr, 10); + if (Number.isNaN(hour) || hour < 0 || hour > 23) return null; + return hour; +} + +/** + * 시간 문자열에서 분(minute) 파싱 + * + * @param minuteStr - 분 문자열 (예: "30", "05") + * @returns 0-59 범위의 숫자, 유효하지 않으면 null + * + * @example + * parseMinute("30") // 30 + * parseMinute("05") // 5 + * parseMinute("60") // null (범위 초과) + */ +export function parseMinute(minuteStr: string): number | null { + const minute = Number.parseInt(minuteStr, 10); + if (Number.isNaN(minute) || minute < 0 || minute > 59) return null; + return minute; +} + +/** + * 시간 문자열에서 초(second) 파싱 + * + * @param secondStr - 초 문자열 (선택적, 예: "00", "45") + * @returns 0-59 범위의 숫자, 없으면 0, 유효하지 않으면 0 + * + * @example + * parseSecond("45") // 45 + * parseSecond("00") // 0 + * parseSecond(undefined) // 0 + * parseSecond("60") // 0 (범위 초과) + */ +export function parseSecond(secondStr: string | undefined): number { + if (!secondStr) return 0; + const second = Number.parseInt(secondStr, 10); + if (Number.isNaN(second) || second < 0 || second > 59) return 0; + return second; +} diff --git a/apps/web/src/domains/cafeteria/data/mapper/cafeteria.mapper.ts b/apps/web/src/domains/cafeteria/data/mapper/cafeteria.mapper.ts index ad65b8a0..8aa67452 100644 --- a/apps/web/src/domains/cafeteria/data/mapper/cafeteria.mapper.ts +++ b/apps/web/src/domains/cafeteria/data/mapper/cafeteria.mapper.ts @@ -3,6 +3,7 @@ */ import { PageInfoSchema } from "@core/types"; +import { parseHour, parseMinute, parseSecond } from "@core/utils/date"; // Import Entity classes from Domain Layer import { type BusinessHours, @@ -58,19 +59,23 @@ export function businessHoursDtoToDomain( // Helper to validate and convert LocalTime DTO const convertLocalTime = (time: any) => { - if ( - !time || - typeof time.hour !== "number" || - typeof time.minute !== "number" - ) { - return null; + if (!time) return null; + + // 문자열 형식 처리 ("11:30:00" 또는 "11:30") + if (typeof time === "string") { + const parts = time.split(":"); + if (parts.length < 2) return null; + + const hour = parseHour(parts[0]); + const minute = parseMinute(parts[1]); + const second = parseSecond(parts[2]); + + if (hour === null || minute === null) return null; + + return { hour, minute, second, nano: 0 }; } - return { - hour: time.hour, - minute: time.minute, - second: time.second ?? 0, - nano: time.nano ?? 0, - }; + + return null; }; // Helper to convert TimeRange DTO diff --git a/apps/web/src/domains/cafeteria/data/remote/api/cafeteria-remote-data-source-impl.ts b/apps/web/src/domains/cafeteria/data/remote/api/cafeteria-remote-data-source-impl.ts index 62714d51..f08b7449 100644 --- a/apps/web/src/domains/cafeteria/data/remote/api/cafeteria-remote-data-source-impl.ts +++ b/apps/web/src/domains/cafeteria/data/remote/api/cafeteria-remote-data-source-impl.ts @@ -30,6 +30,7 @@ import type { SuccessResponseRegisterCafeteriaMenuResponse, SuccessResponseRegisterCafeteriaResponse, } from "../dto"; +import { CafeteriaRemoteDataSourceMockImpl } from "./cafeteria-remote-data-source-mock-impl"; export class CafeteriaRemoteDataSourceImpl implements CafeteriaRemoteDataSource @@ -109,7 +110,7 @@ export class CafeteriaRemoteDataSourceImpl * 구내식당 메뉴 타임라인 조회 (무한 스크롤) * * TODO: 백엔드 API 완성되면 실제 엔드포인트로 교체 - * TEMPORARY: 현재는 stub 구현에서만 사용됨 + * TEMPORARY: Mock 데이터 사용 */ async getCafeteriaMenuTimeline( _id: string, @@ -121,6 +122,9 @@ export class CafeteriaRemoteDataSourceImpl data: GetCafeteriaMenuTimelineResponse[]; pageInfo: PageInfo; }> { + // TEMPORARY: 백엔드 API 준비 전까지 Mock 데이터 사용 + const mockImpl = new CafeteriaRemoteDataSourceMockImpl(); + return mockImpl.getCafeteriaMenuTimeline(); // TODO: 실제 API 구현 시 아래 코드로 교체 // const response = await this.httpClient.get( // `/api/v1/cafeterias/${id}/menus/timeline`, @@ -128,9 +132,9 @@ export class CafeteriaRemoteDataSourceImpl // ); // return { data: response.data.data || [], pageInfo: response.data.pageInfo || ... }; - throw new Error( - "getCafeteriaMenuTimeline is not yet implemented in real API - use stub repository", - ); + // throw new Error( + // "getCafeteriaMenuTimeline is not yet implemented in real API - use stub repository", + // ); } /** diff --git a/apps/web/src/domains/cafeteria/di/cafeteria-client-container.ts b/apps/web/src/domains/cafeteria/di/cafeteria-client-container.ts index ea0d9fec..5fea7d1c 100644 --- a/apps/web/src/domains/cafeteria/di/cafeteria-client-container.ts +++ b/apps/web/src/domains/cafeteria/di/cafeteria-client-container.ts @@ -4,7 +4,7 @@ // Data Layer import { - CafeteriaRemoteDataSourceMockImpl, + CafeteriaRemoteDataSourceImpl, CafeteriaRepositoryImpl, CafeteriaReviewRemoteDataSourceMockImpl, CafeteriaReviewRepositoryImpl, @@ -34,6 +34,12 @@ import { type RegisterCafeteriaUseCase, RegisterCafeteriaUseCaseImpl, } from "@cafeteria/domain"; +// Infrastructure Layer +import { AuthenticatedHttpClient } from "@core/infrastructure/http/authenticated-http-client"; +import { ClientTokenProvider } from "@core/infrastructure/http/client-token-provider"; +import { FetchHttpClient } from "@core/infrastructure/http/fetch-http-client"; +import type { HttpClient } from "@core/infrastructure/http/http-client.interface"; +import { ClientSessionManager } from "@core/infrastructure/storage/client-session-manager"; export interface CafeteriaClientContainer { // Cafeteria UseCase Getters @@ -52,31 +58,56 @@ export interface CafeteriaClientContainer { } class CafeteriaClientContainerImpl implements CafeteriaClientContainer { - // Data Layer (Lazy) - private _cafeteriaDataSource?: CafeteriaRemoteDataSourceMockImpl; + private _sessionManager?: ClientSessionManager; + private _tokenProvider?: ClientTokenProvider; + private _baseClient?: FetchHttpClient; + private _httpClient?: HttpClient; + + private _cafeteriaDataSource?: CafeteriaRemoteDataSourceImpl; private _cafeteriaRepository?: CafeteriaRepositoryImpl; private _reviewDataSource?: CafeteriaReviewRemoteDataSourceMockImpl; private _reviewRepository?: CafeteriaReviewRepositoryImpl; - // Cafeteria UseCases (Lazy) - private _getCafeteriasWithMenuUseCase?: GetCafeteriasWithMenuUseCase; - private _getCafeteriaByIdUseCase?: GetCafeteriaByIdUseCase; - private _getCafeteriaMenuByDateUseCase?: GetCafeteriaMenuByDateUseCase; - private _getCafeteriaMenuTimelineUseCase?: GetCafeteriaMenuTimelineUseCase; - private _getCafeteriaMenuAvailabilityUseCase?: GetCafeteriaMenuAvailabilityUseCase; - private _registerCafeteriaUseCase?: RegisterCafeteriaUseCase; - private _registerCafeteriaMenuUseCase?: RegisterCafeteriaMenuUseCase; - - // Review UseCases (Lazy) - private _createReviewUseCase?: CreateReviewUseCase; - private _getReviewCommentsUseCase?: GetReviewCommentsUseCase; - private _createReviewCommentUseCase?: CreateReviewCommentUseCase; - private _createReviewCommentReplyUseCase?: CreateReviewCommentReplyUseCase; - - // Data Layer Getters (Private - Lazy Initialization) - private getCafeteriaDataSource(): CafeteriaRemoteDataSourceMockImpl { + private getSessionManager(): ClientSessionManager { + if (!this._sessionManager) { + this._sessionManager = new ClientSessionManager(); + } + return this._sessionManager; + } + + private getTokenProvider(): ClientTokenProvider { + if (!this._tokenProvider) { + this._tokenProvider = new ClientTokenProvider(this.getSessionManager()); + } + return this._tokenProvider; + } + + private getBaseClient(): FetchHttpClient { + if (!this._baseClient) { + this._baseClient = new FetchHttpClient({ + baseUrl: process.env.NEXT_PUBLIC_API_URL || "", + }); + } + return this._baseClient; + } + + private getHttpClient(): HttpClient { + if (!this._httpClient) { + this._httpClient = new AuthenticatedHttpClient( + this.getBaseClient(), + this.getTokenProvider(), + this.getSessionManager(), + undefined, + ); + } + return this._httpClient; + } + + private getCafeteriaDataSource(): CafeteriaRemoteDataSourceImpl { if (!this._cafeteriaDataSource) { - this._cafeteriaDataSource = new CafeteriaRemoteDataSourceMockImpl(); + this._cafeteriaDataSource = new CafeteriaRemoteDataSourceImpl( + this.getHttpClient(), + ); } return this._cafeteriaDataSource; } @@ -106,103 +137,53 @@ class CafeteriaClientContainerImpl implements CafeteriaClientContainer { return this._reviewRepository; } - // Cafeteria UseCase Getters (Public - Lazy Initialization) getGetCafeteriasWithMenu(): GetCafeteriasWithMenuUseCase { - if (!this._getCafeteriasWithMenuUseCase) { - this._getCafeteriasWithMenuUseCase = new GetCafeteriasWithMenuUseCaseImpl( - this.getCafeteriaRepository(), - ); - } - return this._getCafeteriasWithMenuUseCase; + return new GetCafeteriasWithMenuUseCaseImpl(this.getCafeteriaRepository()); } getGetCafeteriaById(): GetCafeteriaByIdUseCase { - if (!this._getCafeteriaByIdUseCase) { - this._getCafeteriaByIdUseCase = new GetCafeteriaByIdUseCaseImpl( - this.getCafeteriaRepository(), - ); - } - return this._getCafeteriaByIdUseCase; + return new GetCafeteriaByIdUseCaseImpl(this.getCafeteriaRepository()); } getGetCafeteriaMenuByDate(): GetCafeteriaMenuByDateUseCase { - if (!this._getCafeteriaMenuByDateUseCase) { - this._getCafeteriaMenuByDateUseCase = - new GetCafeteriaMenuByDateUseCaseImpl(this.getCafeteriaRepository()); - } - return this._getCafeteriaMenuByDateUseCase; + return new GetCafeteriaMenuByDateUseCaseImpl(this.getCafeteriaRepository()); } getGetCafeteriaMenuTimeline(): GetCafeteriaMenuTimelineUseCase { - if (!this._getCafeteriaMenuTimelineUseCase) { - this._getCafeteriaMenuTimelineUseCase = - new GetCafeteriaMenuTimelineUseCaseImpl(this.getCafeteriaRepository()); - } - return this._getCafeteriaMenuTimelineUseCase; + return new GetCafeteriaMenuTimelineUseCaseImpl( + this.getCafeteriaRepository(), + ); } getGetCafeteriaMenuAvailability(): GetCafeteriaMenuAvailabilityUseCase { - if (!this._getCafeteriaMenuAvailabilityUseCase) { - this._getCafeteriaMenuAvailabilityUseCase = - new GetCafeteriaMenuAvailabilityUseCaseImpl( - this.getCafeteriaRepository(), - ); - } - return this._getCafeteriaMenuAvailabilityUseCase; + return new GetCafeteriaMenuAvailabilityUseCaseImpl( + this.getCafeteriaRepository(), + ); } getRegisterCafeteria(): RegisterCafeteriaUseCase { - if (!this._registerCafeteriaUseCase) { - this._registerCafeteriaUseCase = new RegisterCafeteriaUseCaseImpl( - this.getCafeteriaRepository(), - ); - } - return this._registerCafeteriaUseCase; + return new RegisterCafeteriaUseCaseImpl(this.getCafeteriaRepository()); } getRegisterCafeteriaMenu(): RegisterCafeteriaMenuUseCase { - if (!this._registerCafeteriaMenuUseCase) { - this._registerCafeteriaMenuUseCase = new RegisterCafeteriaMenuUseCaseImpl( - this.getCafeteriaRepository(), - ); - } - return this._registerCafeteriaMenuUseCase; + return new RegisterCafeteriaMenuUseCaseImpl(this.getCafeteriaRepository()); } - // Review UseCase Getters (Public - Lazy Initialization) + // Review UseCase Getters (Public - 매번 새로 생성) getCreateReview(): CreateReviewUseCase { - if (!this._createReviewUseCase) { - this._createReviewUseCase = new CreateReviewUseCaseImpl( - this.getReviewRepository(), - ); - } - return this._createReviewUseCase; + return new CreateReviewUseCaseImpl(this.getReviewRepository()); } getGetReviewComments(): GetReviewCommentsUseCase { - if (!this._getReviewCommentsUseCase) { - this._getReviewCommentsUseCase = new GetReviewCommentsUseCaseImpl( - this.getReviewRepository(), - ); - } - return this._getReviewCommentsUseCase; + return new GetReviewCommentsUseCaseImpl(this.getReviewRepository()); } getCreateReviewComment(): CreateReviewCommentUseCase { - if (!this._createReviewCommentUseCase) { - this._createReviewCommentUseCase = new CreateReviewCommentUseCaseImpl( - this.getReviewRepository(), - ); - } - return this._createReviewCommentUseCase; + return new CreateReviewCommentUseCaseImpl(this.getReviewRepository()); } getCreateReviewCommentReply(): CreateReviewCommentReplyUseCase { - if (!this._createReviewCommentReplyUseCase) { - this._createReviewCommentReplyUseCase = - new CreateReviewCommentReplyUseCaseImpl(this.getReviewRepository()); - } - return this._createReviewCommentReplyUseCase; + return new CreateReviewCommentReplyUseCaseImpl(this.getReviewRepository()); } } diff --git a/apps/web/src/domains/cafeteria/di/cafeteria-server-container.ts b/apps/web/src/domains/cafeteria/di/cafeteria-server-container.ts index 19b05316..e7f6375f 100644 --- a/apps/web/src/domains/cafeteria/di/cafeteria-server-container.ts +++ b/apps/web/src/domains/cafeteria/di/cafeteria-server-container.ts @@ -6,7 +6,7 @@ import "server-only"; // Data Layer import { - CafeteriaRemoteDataSourceMockImpl, + CafeteriaRemoteDataSourceImpl, CafeteriaRepositoryImpl, CafeteriaReviewRemoteDataSourceMockImpl, CafeteriaReviewRepositoryImpl, @@ -77,8 +77,8 @@ class CafeteriaServerContainer { refreshTokenService, // Server-side: RefreshTokenService 주입 ); - // Data Layer - Use mock DataSource for both cafeteria and reviews (for development) - const cafeteriaDataSource = new CafeteriaRemoteDataSourceMockImpl(); + // Data Layer + const cafeteriaDataSource = new CafeteriaRemoteDataSourceImpl(_httpClient); const cafeteriaRepository = new CafeteriaRepositoryImpl( cafeteriaDataSource, ); diff --git a/apps/web/src/domains/cafeteria/domain/entities/business-hours.entity.ts b/apps/web/src/domains/cafeteria/domain/entities/business-hours.entity.ts index b7a02327..c5bcb4ab 100644 --- a/apps/web/src/domains/cafeteria/domain/entities/business-hours.entity.ts +++ b/apps/web/src/domains/cafeteria/domain/entities/business-hours.entity.ts @@ -44,6 +44,11 @@ export interface BusinessHours { */ hasDinner(): boolean; + /** + * 영업시간 비고 정보가 있는지 확인 + */ + hasNote(): boolean; + /** * 특정 시각에 영업 중인지 확인 * @param hour - 시간 (0-23) @@ -165,6 +170,10 @@ export class BusinessHoursEntity implements BusinessHours { return this._dinner !== null; } + hasNote(): boolean { + return this._note !== null; + } + isOpenAt( hour: number, minute: number, diff --git a/apps/web/src/domains/cafeteria/domain/entities/cafeteria.entity.ts b/apps/web/src/domains/cafeteria/domain/entities/cafeteria.entity.ts index a165cae4..d9bdbbdf 100644 --- a/apps/web/src/domains/cafeteria/domain/entities/cafeteria.entity.ts +++ b/apps/web/src/domains/cafeteria/domain/entities/cafeteria.entity.ts @@ -131,6 +131,13 @@ export class CafeteriaEntity { return this._mealTicketPrice !== null && this._mealTicketPrice > 0; } + /** + * 영업시간 비고 정보가 있는지 확인 + */ + hasNote(): boolean { + return this._businessHours?.hasNote() ?? false; + } + // === Operating Status === /** @@ -194,13 +201,6 @@ export class CafeteriaEntity { return this._takeoutAvailable; } - /** - * 식권 사용 가능 여부 - */ - acceptsMealTicket(): boolean { - return this._mealTicketPrice !== null && this._mealTicketPrice > 0; - } - // === Computed Values === /** diff --git a/apps/web/src/domains/cafeteria/presentation/client/hooks/queries/get-cafeteria-detail.query.ts b/apps/web/src/domains/cafeteria/presentation/client/hooks/queries/get-cafeteria-detail.query.ts new file mode 100644 index 00000000..fdf697d1 --- /dev/null +++ b/apps/web/src/domains/cafeteria/presentation/client/hooks/queries/get-cafeteria-detail.query.ts @@ -0,0 +1,27 @@ +"use client"; + +import { CafeteriaAdapter } from "@cafeteria/presentation/shared/adapters"; +import { cafeteriaKeys } from "@cafeteria/presentation/shared/constants"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { getCafeteriaClientContainer } from "@/src/domains/cafeteria/di/cafeteria-client-container"; + +/** + * 식당 상세 정보를 조회하는 Query Hook + * + * @param cafeteriaId - 조회할 식당 ID + * @returns CafeteriaDetailItem을 포함한 Suspense Query 결과 + */ +export function useGetCafeteriaDetail(cafeteriaId: string) { + const container = getCafeteriaClientContainer(); + const getCafeteriaByIdUseCase = container.getGetCafeteriaById(); + + return useSuspenseQuery({ + queryKey: cafeteriaKeys.detail(cafeteriaId), + queryFn: async () => { + const entity = await getCafeteriaByIdUseCase.execute(cafeteriaId); + return CafeteriaAdapter.toUiDetailItem(entity); + }, + staleTime: 30 * 60 * 1000, // 30분 + gcTime: 60 * 60 * 1000, // 1시간 + }); +} diff --git a/apps/web/src/domains/cafeteria/presentation/shared/adapters/cafeteria.adapter.ts b/apps/web/src/domains/cafeteria/presentation/shared/adapters/cafeteria.adapter.ts index bc84a8ce..eca5e01b 100644 --- a/apps/web/src/domains/cafeteria/presentation/shared/adapters/cafeteria.adapter.ts +++ b/apps/web/src/domains/cafeteria/presentation/shared/adapters/cafeteria.adapter.ts @@ -173,10 +173,10 @@ export const CafeteriaAdapter = { hasLocation: cafeteria.hasLocation(), hasPhone: cafeteria.hasPhone(), hasMealTicketPrice: cafeteria.hasMealTicketPrice(), + hasNote: cafeteria.hasNote(), isOpenNow: cafeteria.isOpenNow(), currentPeriod: cafeteria.getCurrentPeriod(), canTakeout: cafeteria.canTakeout(), - acceptsMealTicket: cafeteria.acceptsMealTicket(), }; }, @@ -399,10 +399,8 @@ export const CafeteriaAdapter = { return "영업시간 정보 없음"; } - const hoursText = parts.join(" / "); - const note = businessHours.getNote(); - - return note ? `${hoursText} (${note})` : hoursText; + // note는 별도로 표시하므로 영업시간만 반환 + return parts.join(" / "); }, /** diff --git a/apps/web/src/domains/cafeteria/presentation/shared/types/cafeteria.ts b/apps/web/src/domains/cafeteria/presentation/shared/types/cafeteria.ts index 3f574e76..829f2eee 100644 --- a/apps/web/src/domains/cafeteria/presentation/shared/types/cafeteria.ts +++ b/apps/web/src/domains/cafeteria/presentation/shared/types/cafeteria.ts @@ -64,10 +64,10 @@ export interface CafeteriaDetailItem extends CafeteriaItem { hasLocation: boolean; hasPhone: boolean; hasMealTicketPrice: boolean; + hasNote: boolean; isOpenNow: boolean; currentPeriod: "lunch" | "dinner" | "closed"; canTakeout: boolean; - acceptsMealTicket: boolean; } export interface CafeteriaMenuItem { diff --git a/apps/web/src/domains/cafeteria/presentation/shared/ui/components/cafeteria-info-card/index.tsx b/apps/web/src/domains/cafeteria/presentation/shared/ui/components/cafeteria-info-card/index.tsx index ea428a71..bc3c8879 100644 --- a/apps/web/src/domains/cafeteria/presentation/shared/ui/components/cafeteria-info-card/index.tsx +++ b/apps/web/src/domains/cafeteria/presentation/shared/ui/components/cafeteria-info-card/index.tsx @@ -1,4 +1,4 @@ -import type { CafeteriaItem } from "@cafeteria/presentation/shared/types"; +import type { CafeteriaDetailItem } from "@cafeteria/presentation/shared/types"; import { HeartIcon, ShareIcon } from "@nugudi/assets-icons"; import { Body, @@ -11,7 +11,7 @@ import { import * as styles from "./index.css"; interface CafeteriaInfoCardProps { - cafeteria: CafeteriaItem; + cafeteria: CafeteriaDetailItem; } export const CafeteriaInfoCard = ({ cafeteria }: CafeteriaInfoCardProps) => { @@ -19,10 +19,10 @@ export const CafeteriaInfoCard = ({ cafeteria }: CafeteriaInfoCardProps) => { - {cafeteria.takeoutAvailable && } - + {cafeteria.canTakeout && } + - + diff --git a/apps/web/src/domains/cafeteria/presentation/shared/ui/components/cafeteria-info-tab/index.css.ts b/apps/web/src/domains/cafeteria/presentation/shared/ui/components/cafeteria-info-tab/index.css.ts index c7cfdd1f..c92a83a9 100644 --- a/apps/web/src/domains/cafeteria/presentation/shared/ui/components/cafeteria-info-tab/index.css.ts +++ b/apps/web/src/domains/cafeteria/presentation/shared/ui/components/cafeteria-info-tab/index.css.ts @@ -17,3 +17,7 @@ export const mapPlaceholder = style({ export const infoIcon = style({ color: vars.colors.$scale.zinc[500], }); + +export const infoLabel = style({ + wordBreak: "keep-all", +}); diff --git a/apps/web/src/domains/cafeteria/presentation/shared/ui/components/cafeteria-info-tab/index.tsx b/apps/web/src/domains/cafeteria/presentation/shared/ui/components/cafeteria-info-tab/index.tsx index 0118701d..613c6193 100644 --- a/apps/web/src/domains/cafeteria/presentation/shared/ui/components/cafeteria-info-tab/index.tsx +++ b/apps/web/src/domains/cafeteria/presentation/shared/ui/components/cafeteria-info-tab/index.tsx @@ -29,9 +29,6 @@ type BusinessInfoProps = { }; const BusinessInfo = ({ cafeteria }: BusinessInfoProps) => { - const fullHours = cafeteria.fullBusinessHours; - const phone = cafeteria.phone; - return ( @@ -43,13 +40,18 @@ const BusinessInfo = ({ cafeteria }: BusinessInfoProps) => { borderRadius="md" className={styles.infoSection} > - <InfoRow icon="⏰" label={fullHours} /> - <InfoRow - icon="💰" - label={`가격 ${formatPriceWithCurrency(cafeteria.mealTicketPrice ?? 0)}`} - /> - {cafeteria.takeoutAvailable && <InfoRow icon="📦" label="포장 가능" />} - {phone && <InfoRow icon="📞" label={phone} />} + <InfoRow icon="⏰" label={cafeteria.fullBusinessHours} /> + {cafeteria.hasNote && ( + <InfoRow icon="📝" label={cafeteria.businessHours!.note!} /> + )} + {cafeteria.hasMealTicketPrice && ( + <InfoRow + icon="💰" + label={`가격 ${formatPriceWithCurrency(cafeteria.mealTicketPrice!)}`} + /> + )} + {cafeteria.canTakeout && <InfoRow icon="📦" label="포장 가능" />} + {cafeteria.hasPhone && <InfoRow icon="📞" label={cafeteria.phone!} />} </VStack> </VStack> ); @@ -60,7 +62,7 @@ type LocationInfoProps = { }; const LocationInfo = ({ cafeteria }: LocationInfoProps) => { - const hasCoordinates = cafeteria.latitude && cafeteria.longitude; + const hasCoordinates = cafeteria.hasLocation; return ( <VStack gap={12}> @@ -118,7 +120,7 @@ const InfoRow = ({ icon, label }: InfoRowProps) => { return ( <HStack gap={8} align="center"> <Box className={styles.infoIcon}>{icon}</Box> - <Body fontSize="b3" colorShade={700}> + <Body fontSize="b3" colorShade={700} className={styles.infoLabel}> {label} </Body> </HStack> diff --git a/apps/web/src/domains/cafeteria/presentation/shared/ui/sections/cafeteria-hero-section/index.tsx b/apps/web/src/domains/cafeteria/presentation/shared/ui/sections/cafeteria-hero-section/index.tsx index 930b53b8..66d7d189 100644 --- a/apps/web/src/domains/cafeteria/presentation/shared/ui/sections/cafeteria-hero-section/index.tsx +++ b/apps/web/src/domains/cafeteria/presentation/shared/ui/sections/cafeteria-hero-section/index.tsx @@ -1,10 +1,8 @@ "use client"; -import { CafeteriaAdapter } from "@cafeteria/presentation/shared/adapters"; +import { useGetCafeteriaDetail } from "@cafeteria/presentation/client/hooks/queries/get-cafeteria-detail.query"; import { VStack } from "@nugudi/react-components-layout"; -import { useQuery } from "@tanstack/react-query"; import Image from "next/image"; -import { getCafeteriaClientContainer } from "@/src/domains/cafeteria/di/cafeteria-client-container"; import { CafeteriaInfoCard } from "../../components/cafeteria-info-card"; import * as styles from "./index.css"; @@ -15,45 +13,21 @@ interface CafeteriaHeroSectionProps { export const CafeteriaHeroSection = ({ cafeteriaId, }: CafeteriaHeroSectionProps) => { - const container = getCafeteriaClientContainer(); - const getCafeteriaByIdUseCase = container.getGetCafeteriaById(); - - const { - data: cafeteria, - isLoading, - isError, - } = useQuery({ - queryKey: ["cafeteria", "detail", cafeteriaId], - queryFn: async () => { - const entity = await getCafeteriaByIdUseCase.execute(cafeteriaId); - return CafeteriaAdapter.toUiDetailItem(entity); - }, - staleTime: 5 * 60 * 1000, - gcTime: 30 * 60 * 1000, - }); - - if (isLoading) { - return ( - <VStack width="full"> - <HeroImage cafeteriaName="로딩 중..." /> - <div>Loading...</div> - </VStack> - ); - } - - if (isError || !cafeteria) { - return null; - } + const { data: cafeteria } = useGetCafeteriaDetail(cafeteriaId); return ( <VStack width="full"> - <HeroImage cafeteriaName={cafeteria.name || ""} /> + <HeroImage cafeteriaName={cafeteria.name} /> <CafeteriaInfoCard cafeteria={cafeteria} /> </VStack> ); }; -const HeroImage = ({ cafeteriaName }: { cafeteriaName: string }) => { +interface HeroImageProps { + cafeteriaName: string; +} + +const HeroImage = ({ cafeteriaName }: HeroImageProps) => { return ( <Image src="/images/cafeterias-test.png" diff --git a/apps/web/src/domains/cafeteria/presentation/shared/ui/sections/cafeteria-tab-section/index.tsx b/apps/web/src/domains/cafeteria/presentation/shared/ui/sections/cafeteria-tab-section/index.tsx index c115f953..54bd51de 100644 --- a/apps/web/src/domains/cafeteria/presentation/shared/ui/sections/cafeteria-tab-section/index.tsx +++ b/apps/web/src/domains/cafeteria/presentation/shared/ui/sections/cafeteria-tab-section/index.tsx @@ -1,9 +1,7 @@ "use client"; -import { CafeteriaAdapter } from "@cafeteria/presentation/shared/adapters"; +import { useGetCafeteriaDetail } from "@cafeteria/presentation/client/hooks/queries/get-cafeteria-detail.query"; import { Tabs } from "@nugudi/react-components-tab"; -import { useQuery } from "@tanstack/react-query"; -import { getCafeteriaClientContainer } from "@/src/domains/cafeteria/di/cafeteria-client-container"; import { CafeteriaInfoTab } from "../../components/cafeteria-info-tab"; import { CafeteriaMenuTab } from "../../components/cafeteria-menu-tab"; @@ -14,30 +12,7 @@ interface CafeteriaTabSectionProps { export const CafeteriaTabSection = ({ cafeteriaId, }: CafeteriaTabSectionProps) => { - const container = getCafeteriaClientContainer(); - const getCafeteriaByIdUseCase = container.getGetCafeteriaById(); - - const { - data: cafeteria, - isLoading, - isError, - } = useQuery({ - queryKey: ["cafeteria", "detail", cafeteriaId], - queryFn: async () => { - const entity = await getCafeteriaByIdUseCase.execute(cafeteriaId); - return CafeteriaAdapter.toUiDetailItem(entity); - }, - staleTime: 5 * 60 * 1000, - gcTime: 30 * 60 * 1000, - }); - - if (isLoading) { - return <div>Loading tabs...</div>; - } - - if (isError || !cafeteria) { - return null; - } + const { data: cafeteria } = useGetCafeteriaDetail(cafeteriaId); return ( <Tabs defaultValue="info" scrollOffset={42}> diff --git a/apps/web/src/domains/cafeteria/presentation/shared/ui/views/cafeteria-detail-view/error.tsx b/apps/web/src/domains/cafeteria/presentation/shared/ui/views/cafeteria-detail-view/error.tsx new file mode 100644 index 00000000..57684b25 --- /dev/null +++ b/apps/web/src/domains/cafeteria/presentation/shared/ui/views/cafeteria-detail-view/error.tsx @@ -0,0 +1,11 @@ +import { VStack } from "@nugudi/react-components-layout"; + +export const CafeteriaDetailError = () => { + return ( + <VStack width="full" gap={16}> + <div className="flex h-96 w-full items-center justify-center bg-zinc-100"> + <p className="text-zinc-500">식당 정보를 불러올 수 없습니다.</p> + </div> + </VStack> + ); +}; diff --git a/apps/web/src/domains/cafeteria/presentation/shared/ui/views/cafeteria-detail-view/index.tsx b/apps/web/src/domains/cafeteria/presentation/shared/ui/views/cafeteria-detail-view/index.tsx index e25694fc..2301a31a 100644 --- a/apps/web/src/domains/cafeteria/presentation/shared/ui/views/cafeteria-detail-view/index.tsx +++ b/apps/web/src/domains/cafeteria/presentation/shared/ui/views/cafeteria-detail-view/index.tsx @@ -1,7 +1,11 @@ import { NavBar } from "@core/ui/components/nav-bar"; import { VStack } from "@nugudi/react-components-layout"; +import { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; import { CafeteriaHeroSection } from "../../sections/cafeteria-hero-section"; import { CafeteriaTabSection } from "../../sections/cafeteria-tab-section"; +import { CafeteriaDetailError } from "./error"; +import { CafeteriaDetailSkeleton } from "./skeleton"; interface CafeteriaDetailViewProps { cafeteriaId: string; @@ -13,10 +17,14 @@ export const CafeteriaDetailView = ({ return ( <VStack w="full"> <NavBar /> - <VStack gap={16} w="full"> - <CafeteriaHeroSection cafeteriaId={cafeteriaId} /> - <CafeteriaTabSection cafeteriaId={cafeteriaId} /> - </VStack> + <ErrorBoundary fallback={<CafeteriaDetailError />}> + <Suspense fallback={<CafeteriaDetailSkeleton />}> + <VStack gap={16} w="full"> + <CafeteriaHeroSection cafeteriaId={cafeteriaId} /> + <CafeteriaTabSection cafeteriaId={cafeteriaId} /> + </VStack> + </Suspense> + </ErrorBoundary> </VStack> ); }; diff --git a/apps/web/src/domains/cafeteria/presentation/shared/ui/views/cafeteria-detail-view/skeleton.tsx b/apps/web/src/domains/cafeteria/presentation/shared/ui/views/cafeteria-detail-view/skeleton.tsx new file mode 100644 index 00000000..ff613311 --- /dev/null +++ b/apps/web/src/domains/cafeteria/presentation/shared/ui/views/cafeteria-detail-view/skeleton.tsx @@ -0,0 +1,14 @@ +import { VStack } from "@nugudi/react-components-layout"; + +export const CafeteriaDetailSkeleton = () => { + return ( + <VStack width="full" gap={16}> + {/* Hero Section Skeleton */} + <div className="h-60 w-full animate-pulse bg-zinc-200" /> + <div className="h-32 w-full animate-pulse rounded-lg bg-zinc-100" /> + {/* Tab Section Skeleton */} + <div className="h-12 w-full animate-pulse bg-zinc-200" /> + <div className="h-64 w-full animate-pulse rounded-lg bg-zinc-100" /> + </VStack> + ); +}; diff --git a/biome.json b/biome.json index d5382b8e..ebb29249 100644 --- a/biome.json +++ b/biome.json @@ -31,6 +31,7 @@ } }, "style": { + "noNonNullAssertion": "off", "useSelfClosingElements": "error" }, "suspicious": { diff --git a/packages/react/components/tab/src/style.css.ts b/packages/react/components/tab/src/style.css.ts index abc266e0..ad9ca8ca 100644 --- a/packages/react/components/tab/src/style.css.ts +++ b/packages/react/components/tab/src/style.css.ts @@ -5,7 +5,6 @@ import { recipe } from "@vanilla-extract/recipes"; export const tabsContainerStyle = style({ width: "100%", WebkitTapHighlightColor: "transparent", - overflowX: "hidden", }); export const tabListWrapper = style({ @@ -15,14 +14,12 @@ export const tabListWrapper = style({ zIndex: 100, width: "100%", borderBottom: `1px solid ${vars.colors.$scale.zinc[200]}`, - overflowX: "hidden", }); export const tabListStyle = recipe({ base: { display: "flex", width: "100%", - overflowX: "hidden", }, });