Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
57bae9a
feat: College.FREE_MAJOR(자유전공학부) 단과대학 추가
chlwjddls0923 May 11, 2026
9280408
feat(api): CORS_ORIGIN 콤마 구분 다중 origin 지원
chlwjddls0923 May 11, 2026
6fa9148
chore(seed): 광운대 일대 식당 108개 + 단과대학 제휴 81건 일괄 임포트
chlwjddls0923 May 11, 2026
6745854
fix(web): 필터·줌 변화에도 마커 클러스터링 일관 동작
chlwjddls0923 May 11, 2026
244e21a
feat(web): 마커 가시성 개선 — dot 탭 크기 + 줌별 클러스터 gridSize 5단계
chlwjddls0923 May 11, 2026
e615083
feat(web): 검색 결과 클릭 시 상세 페이지 대신 지도 이동 + 마커 선택
chlwjddls0923 May 11, 2026
240127f
feat(web): 바텀시트 인트로 애니메이션 + 스크롤 영역 viewport 정합
chlwjddls0923 May 11, 2026
d0597ac
fix(web): WelcomeStats 통계를 전체 식당 기준으로 (가시영역과 무관)
chlwjddls0923 May 11, 2026
b885ce1
feat(web): 관리자 식당 관리 페이지에 이름 검색창
chlwjddls0923 May 11, 2026
c7da346
feat: 영업시간에 자정 넘김 + 브레이크타임 + 자유 비고 지원
chlwjddls0923 May 11, 2026
18c6b53
chore(seed): 영업시간 일괄 업데이트 + 신규 식당 5개 + 1개 rename
chlwjddls0923 May 11, 2026
06f0f53
feat: Menu 카테고리·옵션가격 + Restaurant 대표사진·외부메뉴URL 스키마
chlwjddls0923 May 12, 2026
dbcf72d
feat(web): 관리자 폼에 대표사진·외부메뉴URL 입력 추가
chlwjddls0923 May 12, 2026
936e908
feat(web): 메뉴 카테고리 가로 탭 + 옵션가격 표기 + 대표사진 hero
chlwjddls0923 May 12, 2026
97f3886
chore(seed): 식당 메뉴 일괄 임포트 + 대표사진 자산 + 잘못된 식당 제거
chlwjddls0923 May 12, 2026
dd80ee6
feat(web): 선택된 식당 마커 펄스 하이라이트 + 핀 강화
chlwjddls0923 May 12, 2026
c1be0b5
chore(seed): 메뉴 데이터 추가 — 큰맘할매순대국·오쎄·큰집닭강정·포 레오 + 카츠백 정정
chlwjddls0923 May 12, 2026
af63eee
chore(seed): 체인점 externalMenuUrl 6개 일괄 적용 SQL (멱등)
chlwjddls0923 May 12, 2026
9c1dac1
feat: API priceOptions/category 응답 + featuredMenu 입력순 + Python 메뉴 임포터
chlwjddls0923 May 12, 2026
ba23ec9
feat(web): 메뉴 카테고리 가로 탭 우선순위 정렬 (메인 → 주력 → 사이드 → 기타 → 음료)
chlwjddls0923 May 12, 2026
00e0b11
feat(web): 지도 뷰포트 보존 + 마커 선택 시각화 개선 + 클러스터 → 상세 이동
chlwjddls0923 May 12, 2026
2bfb401
fix(web): TS 빌드 에러 해결 — kakao Map 타입 보완 + Map.values for-of iteration
chlwjddls0923 May 12, 2026
3deeee9
fix: packageManager pnpm@10.33.2 핀 — Docker 빌드 시 corepack 이 최신 pnpm 1…
chlwjddls0923 May 12, 2026
5542377
fix(ci): pnpm/action-setup 의 version 필드 제거 — package.json packageMana…
chlwjddls0923 May 12, 2026
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
8 changes: 3 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ concurrency:
cancel-in-progress: true

env:
PNPM_VERSION: 10
# pnpm 버전은 package.json 의 packageManager 필드 참고 (action-setup 이 읽음)
NODE_VERSION: 20

jobs:
Expand All @@ -36,9 +36,8 @@ jobs:
steps:
- uses: actions/checkout@v4

# pnpm 버전은 package.json 의 packageManager 필드에서 자동 추출
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}

- uses: actions/setup-node@v4
with:
Expand Down Expand Up @@ -68,9 +67,8 @@ jobs:
steps:
- uses: actions/checkout@v4

# pnpm 버전은 package.json 의 packageManager 필드에서 자동 추출
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}

- uses: actions/setup-node@v4
with:
Expand Down
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,12 @@ apps/web/playwright/.cache/
.cache/
tmp/
temp/

# 메뉴판 OCR 원본 (대용량 로컬 참조 자료)
docs/메뉴판 사진/
docs/메뉴판 사진 정문/
docs/기술과 경영_메뉴판 정리.xlsx

# Python bytecode
__pycache__/
*.pyc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "College" ADD VALUE 'FREE_MAJOR';
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Restaurant" ADD COLUMN "coverImageUrl" TEXT;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Menu" ADD COLUMN "category" TEXT,
ADD COLUMN "priceOptions" JSONB;

-- CreateIndex
CREATE INDEX "Menu_restaurantId_category_idx" ON "Menu"("restaurantId", "category");
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Restaurant" ADD COLUMN "externalMenuUrl" TEXT;
28 changes: 18 additions & 10 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ enum College {
ELECTRONICS_INFO
HUMANITIES_SOCIAL
POLICY_LAW
FREE_MAJOR
}

enum NoticeCategory {
Expand All @@ -59,6 +60,8 @@ model Restaurant {
phone String?
businessHours Json
isPartner Boolean @default(false)
coverImageUrl String?
externalMenuUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
menus Menu[]
Expand All @@ -85,20 +88,25 @@ model RestaurantPartnership {
@@index([college])
}

/// 사이즈/옵션별 가격 표현 시 Menu.priceOptions 에 [{"label":"소","price":30000},...] 저장.
/// Menu.category 는 메뉴 카테고리 라벨 (예: "커피", "찌개류"). 없으면 "기타" 그룹으로 처리.
model Menu {
id String @id @default(cuid())
restaurantId String
name String
price Int
imageUrl String?
isSignature Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
restaurant Restaurant @relation(fields: [restaurantId], references: [id], onDelete: Cascade)
reports Report[]
id String @id @default(cuid())
restaurantId String
name String
price Int
priceOptions Json?
category String?
imageUrl String?
isSignature Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
restaurant Restaurant @relation(fields: [restaurantId], references: [id], onDelete: Cascade)
reports Report[]

@@index([restaurantId])
@@index([restaurantId, isSignature])
@@index([restaurantId, category])
}

model Category {
Expand Down
107 changes: 98 additions & 9 deletions apps/api/src/common/utils/business-hours.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,115 @@ interface DayHours {
open?: string;
close?: string;
closed?: boolean;
breakStart?: string;
breakEnd?: string;
}

type BusinessHoursMap = Partial<Record<DayKey, DayHours>>;

const DAY_KEYS: DayKey[] = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];

function kstNow(): Date {
// UTC 메서드 호출 시 KST 값이 나오도록 +9h 시프트
return new Date(Date.now() + 9 * 60 * 60 * 1000);
}

function timeStr(d: Date): string {
const hh = String(d.getUTCHours()).padStart(2, '0');
const mm = String(d.getUTCMinutes()).padStart(2, '0');
return `${hh}:${mm}`;
}

/**
* 영업 중 여부 판정.
*
* 지원하는 형태:
* - 일반: open ≤ now ≤ close
* - 자정 넘김: close < open (예: 14:30 ~ 02:30) — 자정 이후 시간은 0X 시로 표기
* · 어제의 close 가 오늘 새벽이면 그 구간도 영업중으로 인정
* - 브레이크: breakStart ≤ now < breakEnd 구간은 영업 중에 있어도 false
*/
export function isRestaurantOpen(businessHours: unknown): boolean {
if (!businessHours || typeof businessHours !== 'object') return false;

const hours = businessHours as BusinessHoursMap;

// KST = UTC+9
const now = new Date(Date.now() + 9 * 60 * 60 * 1000);
const dayKey = DAY_KEYS[now.getUTCDay()];
const hh = String(now.getUTCHours()).padStart(2, '0');
const mm = String(now.getUTCMinutes()).padStart(2, '0');
const currentTime = `${hh}:${mm}`;
const now = kstNow();
const todayIdx = now.getUTCDay();
const yesterdayIdx = (todayIdx + 6) % 7;
const t = timeStr(now);

// 어제 영업이 자정 넘어 오늘 새벽까지 이어진 경우
const yesterday = hours[DAY_KEYS[yesterdayIdx]];
if (
yesterday &&
!yesterday.closed &&
yesterday.open &&
yesterday.close &&
yesterday.close < yesterday.open && // 자정 넘김
t <= yesterday.close
) {
return true;
}

const today = hours[dayKey];
// 오늘 영업
const today = hours[DAY_KEYS[todayIdx]];
if (!today || today.closed || !today.open || !today.close) return false;

return currentTime >= today.open && currentTime <= today.close;
// 오늘 영업 시간 내인지 (일반 vs 자정 넘김 분기)
const inHours =
today.close < today.open
? t >= today.open // 오늘 open ~ 23:59 까지 영업중 (자정 이후는 위 yesterday 분기로 처리)
: t >= today.open && t <= today.close;

if (!inHours) return false;

// 브레이크 중이면 영업 중이 아님
if (today.breakStart && today.breakEnd) {
if (t >= today.breakStart && t < today.breakEnd) return false;
}

return true;
}

/**
* 다음 영업 시작 시각을 ISO datetime (UTC) 으로 반환.
* 마감 상태 / 휴무일 / 브레이크 중일 때의 "다음 열림 시각" 을 계산.
* 7일 안에 영업 시작이 없으면 null.
*/
export function getNextOpenAt(businessHours: unknown): string | null {
if (!businessHours || typeof businessHours !== 'object') return null;
const hours = businessHours as BusinessHoursMap;

const nowKst = kstNow();
const nowT = timeStr(nowKst);

for (let dayOffset = 0; dayOffset < 7; dayOffset++) {
const candidate = new Date(nowKst);
candidate.setUTCDate(candidate.getUTCDate() + dayOffset);

const dayKey = DAY_KEYS[candidate.getUTCDay()];
const day = hours[dayKey];
if (!day || day.closed || !day.open) continue;

// 후보 시각 목록: open 시작 / 브레이크 종료
const candidateTimes: string[] = [day.open];
if (day.breakStart && day.breakEnd) candidateTimes.push(day.breakEnd);

for (const targetT of candidateTimes.sort()) {
// 오늘이면 미래 시각만 허용
if (dayOffset === 0 && targetT <= nowT) continue;

const [hh, mm] = targetT.split(':').map(Number);
if (Number.isNaN(hh) || Number.isNaN(mm)) continue;

const next = new Date(candidate);
next.setUTCHours(hh, mm, 0, 0);

// KST 표현을 실제 UTC 로 변환 (-9h)
const actualUtc = new Date(next.getTime() - 9 * 60 * 60 * 1000);
return actualUtc.toISOString();
}
}

return null;
}
6 changes: 5 additions & 1 deletion apps/api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);

// CORS_ORIGIN 은 콤마로 여러 origin 지원 (예: "http://localhost:3000,http://192.168.0.10:3000")
app.enableCors({
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
origin: (process.env.CORS_ORIGIN ?? 'http://localhost:3000')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
credentials: true,
});

Expand Down
18 changes: 18 additions & 0 deletions apps/api/src/restaurants/dto/create-restaurant.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,24 @@ export class CreateRestaurantDto {
@IsBoolean()
isPartner?: boolean;

@ApiPropertyOptional({
example: 'https://cdn.example.com/cover/abc.jpg',
description: '식당 대표 사진 URL (S3 업로드 결과)',
})
@IsOptional()
@IsString()
@MaxLength(500)
coverImageUrl?: string;

@ApiPropertyOptional({
example: 'https://bondosirak.com/menu',
description: '체인점 등 공식 메뉴 페이지 URL',
})
@IsOptional()
@IsString()
@MaxLength(500)
externalMenuUrl?: string;

@ApiPropertyOptional({
type: [PartnershipInputDto],
description: '제휴 단과대학 + Instagram URL 목록 (식당당 단과대학별 1개)',
Expand Down
Loading
Loading