feat: bootstrap lunch picker miniapp with backend, docs, and branding assets
BIN
.DS_Store:Zone.Identifier
Normal file
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# macOS / Windows metadata
|
||||||
|
.DS_Store
|
||||||
|
*.Zone.Identifier
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
**/node_modules/
|
||||||
|
|
||||||
|
# build artifacts
|
||||||
|
*.ait
|
||||||
|
*.log
|
||||||
|
dist/
|
||||||
|
.granite/
|
||||||
|
.swc/
|
||||||
|
|
||||||
|
# env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# temp
|
||||||
|
tmp/
|
||||||
|
*.tmp
|
||||||
312
APPS_IN_TOSS_STARTER.md
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
# Apps in Toss Starter
|
||||||
|
|
||||||
|
새 프로젝트를 시작할 때 기준으로 쓰는 최소 설정이다.
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
- RN `0.84.0` 타깃 번들이 포함된 `.ait` 생성
|
||||||
|
- 불필요한 Granite 플러그인 없이 Apps in Toss 기준으로 안정적으로 시작
|
||||||
|
|
||||||
|
## 기본 원칙
|
||||||
|
|
||||||
|
- `build`는 반드시 `ait build`
|
||||||
|
- `granite.config.ts`에는 `target: '0.84.0'` 명시
|
||||||
|
- `appsInToss({...})` 중심의 최소 구성 유지
|
||||||
|
- `router()`, `hermes()` 같은 추가 Granite 플러그인은 꼭 필요한 경우에만 사용
|
||||||
|
|
||||||
|
## 시작 순서
|
||||||
|
|
||||||
|
### 1. 프로젝트 생성 후 의존성 설치
|
||||||
|
|
||||||
|
프로젝트 루트에서:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. `package.json` 설정
|
||||||
|
|
||||||
|
아래 형태로 맞춘다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-app",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "granite dev",
|
||||||
|
"build": "ait build",
|
||||||
|
"deploy": "ait deploy",
|
||||||
|
"check:build": "./check-apps-in-toss-build.sh"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@apps-in-toss/framework": "^2.4.1",
|
||||||
|
"@granite-js/native": "1.0.10",
|
||||||
|
"@granite-js/react-native": "1.0.10",
|
||||||
|
"brick-module": "0.5.1",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-native": "0.84.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.2.0",
|
||||||
|
"babel-preset-granite": "1.0.10",
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. `babel.config.js` 추가
|
||||||
|
|
||||||
|
루트에 아래 파일을 반드시 둔다.
|
||||||
|
|
||||||
|
```js
|
||||||
|
module.exports = {
|
||||||
|
presets: ['babel-preset-granite'],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
이 파일이 없으면 `react-native` 패키지 내부 구문을 Metro가 잘못 파싱해서
|
||||||
|
`Missing semicolon` 에러가 날 수 있다.
|
||||||
|
|
||||||
|
### 4. `index.js` 엔트리 설정
|
||||||
|
|
||||||
|
루트 `index.js`는 아래처럼 시작한다.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { register } = require('@granite-js/react-native');
|
||||||
|
|
||||||
|
register(require('./src/_app').default);
|
||||||
|
```
|
||||||
|
|
||||||
|
그냥 `require('./src/_app')`만 하면 Granite 개발 엔트리포인트인 `shared`가 등록되지 않아
|
||||||
|
`"shared" has not been registered` 에러가 날 수 있다.
|
||||||
|
|
||||||
|
### 5. `granite.config.ts` 설정
|
||||||
|
|
||||||
|
아래처럼 시작한다.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { appsInToss } from '@apps-in-toss/framework/plugins';
|
||||||
|
import { defineConfig } from '@granite-js/react-native/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
appName: 'my-app',
|
||||||
|
scheme: 'intoss',
|
||||||
|
plugins: [
|
||||||
|
appsInToss({
|
||||||
|
target: '0.84.0',
|
||||||
|
brand: {
|
||||||
|
displayName: '내 앱 이름',
|
||||||
|
primaryColor: '#0064FF',
|
||||||
|
icon: 'https://static.toss.im/appsintoss/your-icon.png',
|
||||||
|
},
|
||||||
|
navigationBar: {
|
||||||
|
withBackButton: true,
|
||||||
|
withHomeButton: true,
|
||||||
|
},
|
||||||
|
permissions: [],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. `src/pages/_404.tsx` 추가
|
||||||
|
|
||||||
|
파일 기반 라우터가 fallback 페이지를 요구하므로 아래 파일을 기본으로 넣는다.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from '@granite-js/native/react-native-safe-area-context';
|
||||||
|
|
||||||
|
export default function NotFoundPage() {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.safeArea}>
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>Page not found</Text>
|
||||||
|
<Text style={styles.description}>
|
||||||
|
요청한 화면을 찾을 수 없습니다.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F4F7FB',
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
color: '#4B5563',
|
||||||
|
fontSize: 15,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 권장하지 않는 시작 방식
|
||||||
|
|
||||||
|
아래는 이유 없으면 넣지 않는다.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
router()
|
||||||
|
hermes()
|
||||||
|
```
|
||||||
|
|
||||||
|
이 조합은 프로젝트에 따라 Apps in Toss 패키징 결과를 꼬이게 만들 수 있다.
|
||||||
|
|
||||||
|
## 개발 명령어
|
||||||
|
|
||||||
|
개발 서버:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
빌드:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
배포:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
검증:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check:build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 빌드 후 반드시 확인할 것
|
||||||
|
|
||||||
|
### 1. 빌드 로그 확인
|
||||||
|
|
||||||
|
아래 같은 로그가 보여야 한다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
[1/2] Built for RN 0.84.0
|
||||||
|
[2/2] Built for RN 0.72.6
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. `.ait` 안 번들 확인
|
||||||
|
|
||||||
|
아래 명령으로 확인:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 - <<'PY'
|
||||||
|
import zipfile
|
||||||
|
path='my-app.ait'
|
||||||
|
with zipfile.ZipFile(path) as z:
|
||||||
|
for name in z.namelist():
|
||||||
|
if name.startswith('bundle.') and name.endswith('.js'):
|
||||||
|
print(name)
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
정상 예시:
|
||||||
|
|
||||||
|
```text
|
||||||
|
bundle.ios.0_84_0.js
|
||||||
|
bundle.android.0_84_0.js
|
||||||
|
bundle.ios.0_72_6.js
|
||||||
|
bundle.android.0_72_6.js
|
||||||
|
```
|
||||||
|
|
||||||
|
중요한 건 최소한 아래 두 파일이 포함되는 것이다.
|
||||||
|
|
||||||
|
- `bundle.ios.0_84_0.js`
|
||||||
|
- `bundle.android.0_84_0.js`
|
||||||
|
|
||||||
|
## 문제 생겼을 때 체크리스트
|
||||||
|
|
||||||
|
### `지원하지 않는 번들이에요. 최신 SDK를 사용해주세요`가 뜨면
|
||||||
|
|
||||||
|
순서대로 확인:
|
||||||
|
|
||||||
|
1. `granite.config.ts`에 `target: '0.84.0'`가 있는지
|
||||||
|
2. `package.json`의 `build`가 `ait build`인지
|
||||||
|
3. `.ait` 내부에 `bundle.*.0_84_0.js`가 실제로 들어 있는지
|
||||||
|
4. 예전 `.ait` 파일을 업로드한 건 아닌지
|
||||||
|
5. 불필요한 Granite 플러그인을 추가하지 않았는지
|
||||||
|
|
||||||
|
## 새 프로젝트 시작용 빠른 체크
|
||||||
|
|
||||||
|
프로젝트 준비 후 아래만 실행하면 된다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
개발 실행 전에는 아래 파일들이 있는지 먼저 확인한다.
|
||||||
|
|
||||||
|
- `babel.config.js`
|
||||||
|
- `index.js`
|
||||||
|
- `src/pages/_404.tsx`
|
||||||
|
|
||||||
|
그 다음 `.ait` 확인:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 - <<'PY'
|
||||||
|
import zipfile
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
ait_files = sorted(pathlib.Path('.').glob('*.ait'))
|
||||||
|
if not ait_files:
|
||||||
|
raise SystemExit('No .ait file found')
|
||||||
|
|
||||||
|
path = str(ait_files[0])
|
||||||
|
print(path)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(path) as z:
|
||||||
|
for name in z.namelist():
|
||||||
|
if name.startswith('bundle.') and name.endswith('.js'):
|
||||||
|
print(name)
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
## 실전 기준 요약
|
||||||
|
|
||||||
|
- 새 프로젝트는 Apps in Toss 최소 설정으로 시작
|
||||||
|
- `build`는 `ait build`
|
||||||
|
- `target: '0.84.0'` 필수
|
||||||
|
- `.ait`에 `0_84_0` 번들이 있는지 꼭 확인
|
||||||
|
|
||||||
|
## 템플릿 폴더
|
||||||
|
|
||||||
|
`starter/template` 폴더에는 새 프로젝트 시작용 기본 파일 세트를 넣어뒀다.
|
||||||
|
|
||||||
|
- `package.json`
|
||||||
|
- `granite.config.ts`
|
||||||
|
- `babel.config.js`
|
||||||
|
- `index.js`
|
||||||
|
- `tsconfig.json`
|
||||||
|
- `require.context.ts`
|
||||||
|
- `.watchmanconfig`
|
||||||
|
- `src/_app.tsx`
|
||||||
|
- `src/pages/index.tsx`
|
||||||
|
- `src/pages/_404.tsx`
|
||||||
|
- `check-apps-in-toss-build.sh`
|
||||||
|
|
||||||
|
새 프로젝트를 만들 때는 이 폴더 내용을 복사한 다음 아래 값만 먼저 바꾸면 된다.
|
||||||
|
|
||||||
|
- `package.json`의 `name`
|
||||||
|
- `granite.config.ts`의 `appName`
|
||||||
|
- `granite.config.ts`의 `brand.displayName`
|
||||||
|
- `granite.config.ts`의 `brand.icon`
|
||||||
|
- `src/pages/index.tsx`의 화면 문구
|
||||||
BIN
APPS_IN_TOSS_STARTER.md:Zone.Identifier
Normal file
72
check-apps-in-toss-build.sh
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "[1/6] package.json scripts"
|
||||||
|
node -e "const p=require('./package.json'); console.log('dev =', p.scripts?.dev ?? ''); console.log('build =', p.scripts?.build ?? ''); console.log('deploy=', p.scripts?.deploy ?? '');"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "[2/6] required starter files"
|
||||||
|
for path in babel.config.js index.js src/pages/_404.tsx; do
|
||||||
|
if [[ -f "$path" ]]; then
|
||||||
|
echo "OK: $path"
|
||||||
|
else
|
||||||
|
echo "Missing: $path"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "[3/6] granite.config.ts target"
|
||||||
|
if rg -n "target:\s*['\"]0\.84\.0['\"]" granite.config.ts >/dev/null; then
|
||||||
|
rg -n "target:\s*['\"]0\.84\.0['\"]" granite.config.ts
|
||||||
|
else
|
||||||
|
echo "target: '0.84.0' not found in granite.config.ts"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "[4/6] babel preset"
|
||||||
|
node -e "const p=require('./babel.config.js'); const presets=Array.isArray(p.presets)?p.presets:[]; console.log('presets =', presets.join(', ')); if(!presets.includes('babel-preset-granite')) process.exit(1);"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "[5/6] build"
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "[6/6] .ait bundles"
|
||||||
|
python3 - <<'PY'
|
||||||
|
import pathlib
|
||||||
|
import zipfile
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ait_files = sorted(pathlib.Path('.').glob('*.ait'))
|
||||||
|
if not ait_files:
|
||||||
|
print('No .ait file found')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
path = ait_files[0]
|
||||||
|
print(path.name)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(path) as z:
|
||||||
|
bundles = [
|
||||||
|
name for name in z.namelist()
|
||||||
|
if name.startswith('bundle.') and name.endswith('.js')
|
||||||
|
]
|
||||||
|
|
||||||
|
for name in bundles:
|
||||||
|
print(name)
|
||||||
|
|
||||||
|
required = {
|
||||||
|
'bundle.ios.0_84_0.js',
|
||||||
|
'bundle.android.0_84_0.js',
|
||||||
|
}
|
||||||
|
|
||||||
|
missing = sorted(required.difference(bundles))
|
||||||
|
if missing:
|
||||||
|
print('\nMissing required 0.84 bundles:')
|
||||||
|
for name in missing:
|
||||||
|
print(name)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print('\nOK: RN 0.84.0 bundles found')
|
||||||
|
PY
|
||||||
BIN
check-apps-in-toss-build.sh:Zone.Identifier
Normal file
3
starter-babel.config.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: ['babel-preset-granite'],
|
||||||
|
};
|
||||||
BIN
starter-babel.config.js:Zone.Identifier
Normal file
22
starter-granite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { appsInToss } from '@apps-in-toss/framework/plugins';
|
||||||
|
import { defineConfig } from '@granite-js/react-native/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
appName: 'my-app',
|
||||||
|
scheme: 'intoss',
|
||||||
|
plugins: [
|
||||||
|
appsInToss({
|
||||||
|
target: '0.84.0',
|
||||||
|
brand: {
|
||||||
|
displayName: '내 앱 이름',
|
||||||
|
primaryColor: '#0064FF',
|
||||||
|
icon: 'https://static.toss.im/appsintoss/your-icon.png',
|
||||||
|
},
|
||||||
|
navigationBar: {
|
||||||
|
withBackButton: true,
|
||||||
|
withHomeButton: true,
|
||||||
|
},
|
||||||
|
permissions: [],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
BIN
starter-granite.config.ts:Zone.Identifier
Normal file
3
starter-index.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
const { register } = require('@granite-js/react-native');
|
||||||
|
|
||||||
|
register(require('./src/_app').default);
|
||||||
BIN
starter-index.js:Zone.Identifier
Normal file
23
starter-package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "my-app",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "granite dev",
|
||||||
|
"build": "ait build",
|
||||||
|
"deploy": "ait deploy",
|
||||||
|
"check:build": "./check-apps-in-toss-build.sh"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@apps-in-toss/framework": "^2.4.1",
|
||||||
|
"@granite-js/native": "1.0.10",
|
||||||
|
"@granite-js/react-native": "1.0.10",
|
||||||
|
"brick-module": "0.5.1",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-native": "0.84.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.2.0",
|
||||||
|
"babel-preset-granite": "1.0.10",
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
starter-package.json:Zone.Identifier
Normal file
39
starter-pages-_404.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from '@granite-js/native/react-native-safe-area-context';
|
||||||
|
|
||||||
|
export default function NotFoundPage() {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.safeArea}>
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>Page not found</Text>
|
||||||
|
<Text style={styles.description}>
|
||||||
|
요청한 화면을 찾을 수 없습니다.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F4F7FB',
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
color: '#4B5563',
|
||||||
|
fontSize: 15,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
BIN
starter-pages-_404.tsx:Zone.Identifier
Normal file
BIN
template/.DS_Store:Zone.Identifier
Normal file
1
template/.watchmanconfig
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
BIN
template/.watchmanconfig:Zone.Identifier
Normal file
165
template/assets/branding/generate-logos-v2.js
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { createCanvas } = require('@napi-rs/canvas');
|
||||||
|
|
||||||
|
const SIZE = 600;
|
||||||
|
const outDir = __dirname;
|
||||||
|
|
||||||
|
function roundedRect(ctx, x, y, w, h, r) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + r, y);
|
||||||
|
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||||
|
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||||
|
ctx.arcTo(x, y + h, x, y, r);
|
||||||
|
ctx.arcTo(x, y, x + w, y, r);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSteam(ctx, x, y, color) {
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 10;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, y + 32);
|
||||||
|
ctx.bezierCurveTo(x - 14, y + 6, x + 22, y - 6, x + 8, y - 34);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawChopsticks(ctx, color) {
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 12;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(380, 170);
|
||||||
|
ctx.lineTo(490, 280);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(415, 150);
|
||||||
|
ctx.lineTo(525, 260);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawLogo({
|
||||||
|
filename,
|
||||||
|
bgGradientFrom,
|
||||||
|
bgGradientTo,
|
||||||
|
cardColor,
|
||||||
|
cardShadow,
|
||||||
|
bowlTop,
|
||||||
|
bowlBottom,
|
||||||
|
accent,
|
||||||
|
textColor,
|
||||||
|
steamColor,
|
||||||
|
chopColor,
|
||||||
|
}) {
|
||||||
|
const canvas = createCanvas(SIZE, SIZE);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// background
|
||||||
|
const bg = ctx.createLinearGradient(0, 0, SIZE, SIZE);
|
||||||
|
bg.addColorStop(0, bgGradientFrom);
|
||||||
|
bg.addColorStop(1, bgGradientTo);
|
||||||
|
ctx.fillStyle = bg;
|
||||||
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
|
|
||||||
|
// icon card
|
||||||
|
ctx.save();
|
||||||
|
ctx.shadowColor = cardShadow;
|
||||||
|
ctx.shadowBlur = 28;
|
||||||
|
ctx.shadowOffsetY = 14;
|
||||||
|
roundedRect(ctx, 72, 72, 456, 456, 120);
|
||||||
|
ctx.fillStyle = cardColor;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// bowl gradient
|
||||||
|
const bowlGrad = ctx.createLinearGradient(200, 270, 400, 420);
|
||||||
|
bowlGrad.addColorStop(0, bowlTop);
|
||||||
|
bowlGrad.addColorStop(1, bowlBottom);
|
||||||
|
|
||||||
|
// bowl body
|
||||||
|
ctx.fillStyle = bowlGrad;
|
||||||
|
roundedRect(ctx, 170, 300, 260, 130, 54);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// bowl lip
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.86)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(300, 298, 144, 36, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// inner bowl
|
||||||
|
ctx.fillStyle = 'rgba(13,31,60,0.32)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(300, 300, 76, 18, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// noodles
|
||||||
|
ctx.strokeStyle = accent;
|
||||||
|
ctx.lineWidth = 9;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const y = 282 + i * 8;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(220, y);
|
||||||
|
ctx.bezierCurveTo(250, y - 14, 350, y - 14, 380, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// steam
|
||||||
|
drawSteam(ctx, 245, 215, steamColor);
|
||||||
|
drawSteam(ctx, 300, 190, steamColor);
|
||||||
|
drawSteam(ctx, 355, 215, steamColor);
|
||||||
|
|
||||||
|
// chopsticks
|
||||||
|
drawChopsticks(ctx, chopColor);
|
||||||
|
|
||||||
|
// JM monogram chip
|
||||||
|
roundedRect(ctx, 220, 448, 160, 56, 28);
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.22)';
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
ctx.font = 'bold 34px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText('점메추', 300, 476);
|
||||||
|
|
||||||
|
const output = path.join(outDir, filename);
|
||||||
|
fs.writeFileSync(output, canvas.toBuffer('image/png'));
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
const light = drawLogo({
|
||||||
|
filename: 'lunch-pick-logo-light-600-v2.png',
|
||||||
|
bgGradientFrom: '#F7FBFF',
|
||||||
|
bgGradientTo: '#E8F3FF',
|
||||||
|
cardColor: 'rgba(255,255,255,0.94)',
|
||||||
|
cardShadow: 'rgba(30,102,180,0.20)',
|
||||||
|
bowlTop: '#29B2F4',
|
||||||
|
bowlBottom: '#0A7FE8',
|
||||||
|
accent: '#0876D7',
|
||||||
|
textColor: '#0F3F7C',
|
||||||
|
steamColor: '#5AAAF6',
|
||||||
|
chopColor: '#1D4D87',
|
||||||
|
});
|
||||||
|
|
||||||
|
const dark = drawLogo({
|
||||||
|
filename: 'lunch-pick-logo-dark-600-v2.png',
|
||||||
|
bgGradientFrom: '#061226',
|
||||||
|
bgGradientTo: '#0D203F',
|
||||||
|
cardColor: 'rgba(17,39,72,0.95)',
|
||||||
|
cardShadow: 'rgba(0,0,0,0.45)',
|
||||||
|
bowlTop: '#56C8FF',
|
||||||
|
bowlBottom: '#1997F0',
|
||||||
|
accent: '#9ED9FF',
|
||||||
|
textColor: '#EAF4FF',
|
||||||
|
steamColor: '#BFE4FF',
|
||||||
|
chopColor: '#E6F3FF',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(light);
|
||||||
|
console.log(dark);
|
||||||
150
template/assets/branding/generate-logos-v3.js
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { createCanvas } = require('@napi-rs/canvas');
|
||||||
|
|
||||||
|
const SIZE = 600;
|
||||||
|
const outDir = __dirname;
|
||||||
|
|
||||||
|
function roundedRect(ctx, x, y, w, h, r) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + r, y);
|
||||||
|
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||||
|
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||||
|
ctx.arcTo(x, y + h, x, y, r);
|
||||||
|
ctx.arcTo(x, y, x + w, y, r);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSteam(ctx, x, y, color) {
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 10;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, y + 36);
|
||||||
|
ctx.bezierCurveTo(x - 14, y + 10, x + 20, y, x + 6, y - 30);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawRice(ctx, cx, cy, colorA, colorB) {
|
||||||
|
// Rice mound
|
||||||
|
const grad = ctx.createLinearGradient(cx - 100, cy - 30, cx + 100, cy + 20);
|
||||||
|
grad.addColorStop(0, colorA);
|
||||||
|
grad.addColorStop(1, colorB);
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cx, cy, 128, 56, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Rice grains highlights (subtle)
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.55)';
|
||||||
|
for (let i = 0; i < 14; i++) {
|
||||||
|
const x = cx - 95 + i * 14;
|
||||||
|
const y = cy - 6 + ((i % 2) * 6 - 3);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(x, y, 4, 2.6, -0.3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawLogo(theme) {
|
||||||
|
const canvas = createCanvas(SIZE, SIZE);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Background gradient
|
||||||
|
const bg = ctx.createLinearGradient(0, 0, SIZE, SIZE);
|
||||||
|
bg.addColorStop(0, theme.bgFrom);
|
||||||
|
bg.addColorStop(1, theme.bgTo);
|
||||||
|
ctx.fillStyle = bg;
|
||||||
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
|
|
||||||
|
// Main card
|
||||||
|
ctx.save();
|
||||||
|
ctx.shadowColor = theme.shadow;
|
||||||
|
ctx.shadowBlur = 28;
|
||||||
|
ctx.shadowOffsetY = 10;
|
||||||
|
roundedRect(ctx, 74, 74, 452, 452, 122);
|
||||||
|
ctx.fillStyle = theme.card;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Bowl body
|
||||||
|
const bowlGrad = ctx.createLinearGradient(180, 280, 420, 430);
|
||||||
|
bowlGrad.addColorStop(0, theme.bowlTop);
|
||||||
|
bowlGrad.addColorStop(1, theme.bowlBottom);
|
||||||
|
|
||||||
|
ctx.fillStyle = bowlGrad;
|
||||||
|
roundedRect(ctx, 168, 302, 264, 144, 64);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Bowl lip outer
|
||||||
|
ctx.fillStyle = theme.lipOuter;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(300, 300, 154, 44, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Bowl lip inner
|
||||||
|
ctx.fillStyle = theme.lipInner;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(300, 304, 104, 26, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Rice
|
||||||
|
drawRice(ctx, 300, 278, theme.riceA, theme.riceB);
|
||||||
|
|
||||||
|
// Chopsticks (to imply meal)
|
||||||
|
ctx.strokeStyle = theme.chop;
|
||||||
|
ctx.lineWidth = 12;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(380, 178);
|
||||||
|
ctx.lineTo(492, 290);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(416, 158);
|
||||||
|
ctx.lineTo(528, 270);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Steam
|
||||||
|
drawSteam(ctx, 240, 196, theme.steam);
|
||||||
|
drawSteam(ctx, 300, 172, theme.steam);
|
||||||
|
drawSteam(ctx, 360, 196, theme.steam);
|
||||||
|
|
||||||
|
const output = path.join(outDir, theme.filename);
|
||||||
|
fs.writeFileSync(output, canvas.toBuffer('image/png'));
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
const light = drawLogo({
|
||||||
|
filename: 'lunch-pick-logo-light-600-v3.png',
|
||||||
|
bgFrom: '#F6FAFF',
|
||||||
|
bgTo: '#EAF3FF',
|
||||||
|
card: 'rgba(255,255,255,0.96)',
|
||||||
|
shadow: 'rgba(16,78,147,0.18)',
|
||||||
|
bowlTop: '#31B4F4',
|
||||||
|
bowlBottom: '#0A85E8',
|
||||||
|
lipOuter: '#DDE9F8',
|
||||||
|
lipInner: '#8EA6C5',
|
||||||
|
riceA: '#FFFFFF',
|
||||||
|
riceB: '#EFF6FF',
|
||||||
|
chop: '#1E4F8A',
|
||||||
|
steam: '#5EA8EE',
|
||||||
|
});
|
||||||
|
|
||||||
|
const dark = drawLogo({
|
||||||
|
filename: 'lunch-pick-logo-dark-600-v3.png',
|
||||||
|
bgFrom: '#07142A',
|
||||||
|
bgTo: '#0E2243',
|
||||||
|
card: 'rgba(17,39,72,0.96)',
|
||||||
|
shadow: 'rgba(0,0,0,0.46)',
|
||||||
|
bowlTop: '#66CBFF',
|
||||||
|
bowlBottom: '#1A9EF4',
|
||||||
|
lipOuter: '#DDE6F2',
|
||||||
|
lipInner: '#8CA2BC',
|
||||||
|
riceA: '#FFFFFF',
|
||||||
|
riceB: '#F2F7FF',
|
||||||
|
chop: '#E4F0FF',
|
||||||
|
steam: '#B8DCFF',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(light);
|
||||||
|
console.log(dark);
|
||||||
166
template/assets/branding/generate-logos-v4.js
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { createCanvas } = require('@napi-rs/canvas');
|
||||||
|
|
||||||
|
const SIZE = 600;
|
||||||
|
const outDir = __dirname;
|
||||||
|
|
||||||
|
function roundedRect(ctx, x, y, w, h, r) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + r, y);
|
||||||
|
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||||
|
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||||
|
ctx.arcTo(x, y + h, x, y, r);
|
||||||
|
ctx.arcTo(x, y, x + w, y, r);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSteam(ctx, x, y, color) {
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 10;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, y + 34);
|
||||||
|
ctx.bezierCurveTo(x - 14, y + 8, x + 18, y - 2, x + 6, y - 30);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawRice(ctx, cx, cy, colorA, colorB) {
|
||||||
|
const grad = ctx.createLinearGradient(cx - 100, cy - 30, cx + 100, cy + 20);
|
||||||
|
grad.addColorStop(0, colorA);
|
||||||
|
grad.addColorStop(1, colorB);
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cx, cy, 128, 56, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.65)';
|
||||||
|
for (let i = 0; i < 14; i++) {
|
||||||
|
const x = cx - 95 + i * 14;
|
||||||
|
const y = cy - 8 + ((i % 2) * 6 - 3);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(x, y, 4.3, 2.7, -0.3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSideDish(ctx, x, y, colorOuter, colorInner) {
|
||||||
|
// small plate
|
||||||
|
ctx.fillStyle = colorOuter;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(x, y, 38, 16, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// food on plate
|
||||||
|
ctx.fillStyle = colorInner;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(x, y - 2, 24, 9, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawLogo(theme) {
|
||||||
|
const canvas = createCanvas(SIZE, SIZE);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
const bg = ctx.createLinearGradient(0, 0, SIZE, SIZE);
|
||||||
|
bg.addColorStop(0, theme.bgFrom);
|
||||||
|
bg.addColorStop(1, theme.bgTo);
|
||||||
|
ctx.fillStyle = bg;
|
||||||
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.shadowColor = theme.shadow;
|
||||||
|
ctx.shadowBlur = 28;
|
||||||
|
ctx.shadowOffsetY = 10;
|
||||||
|
roundedRect(ctx, 74, 74, 452, 452, 122);
|
||||||
|
ctx.fillStyle = theme.card;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// main bowl
|
||||||
|
const bowlGrad = ctx.createLinearGradient(180, 280, 420, 430);
|
||||||
|
bowlGrad.addColorStop(0, theme.bowlTop);
|
||||||
|
bowlGrad.addColorStop(1, theme.bowlBottom);
|
||||||
|
|
||||||
|
ctx.fillStyle = bowlGrad;
|
||||||
|
roundedRect(ctx, 168, 306, 264, 140, 62);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = theme.lipOuter;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(300, 305, 154, 44, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = theme.lipInner;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(300, 310, 102, 25, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
drawRice(ctx, 300, 283, theme.riceA, theme.riceB);
|
||||||
|
|
||||||
|
// side dishes (to emphasize food/meal)
|
||||||
|
drawSideDish(ctx, 192, 352, theme.sidePlate, theme.sideFoodA);
|
||||||
|
drawSideDish(ctx, 408, 352, theme.sidePlate, theme.sideFoodB);
|
||||||
|
|
||||||
|
// spoon silhouette (meal cue, no chopsticks)
|
||||||
|
ctx.strokeStyle = theme.spoon;
|
||||||
|
ctx.lineWidth = 10;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(445, 210);
|
||||||
|
ctx.lineTo(490, 300);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = theme.spoon;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(430, 196, 20, 14, -0.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
drawSteam(ctx, 245, 196, theme.steam);
|
||||||
|
drawSteam(ctx, 300, 172, theme.steam);
|
||||||
|
drawSteam(ctx, 355, 196, theme.steam);
|
||||||
|
|
||||||
|
const output = path.join(outDir, theme.filename);
|
||||||
|
fs.writeFileSync(output, canvas.toBuffer('image/png'));
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
const light = drawLogo({
|
||||||
|
filename: 'lunch-pick-logo-light-600-v4.png',
|
||||||
|
bgFrom: '#F6FAFF',
|
||||||
|
bgTo: '#EAF3FF',
|
||||||
|
card: 'rgba(255,255,255,0.97)',
|
||||||
|
shadow: 'rgba(16,78,147,0.18)',
|
||||||
|
bowlTop: '#31B4F4',
|
||||||
|
bowlBottom: '#0A85E8',
|
||||||
|
lipOuter: '#DDE9F8',
|
||||||
|
lipInner: '#8EA6C5',
|
||||||
|
riceA: '#FFFFFF',
|
||||||
|
riceB: '#EFF6FF',
|
||||||
|
sidePlate: '#D8E4F5',
|
||||||
|
sideFoodA: '#EF7C42',
|
||||||
|
sideFoodB: '#8BC34A',
|
||||||
|
spoon: '#1E4F8A',
|
||||||
|
steam: '#5EA8EE',
|
||||||
|
});
|
||||||
|
|
||||||
|
const dark = drawLogo({
|
||||||
|
filename: 'lunch-pick-logo-dark-600-v4.png',
|
||||||
|
bgFrom: '#07142A',
|
||||||
|
bgTo: '#0E2243',
|
||||||
|
card: 'rgba(17,39,72,0.96)',
|
||||||
|
shadow: 'rgba(0,0,0,0.46)',
|
||||||
|
bowlTop: '#66CBFF',
|
||||||
|
bowlBottom: '#1A9EF4',
|
||||||
|
lipOuter: '#DDE6F2',
|
||||||
|
lipInner: '#8CA2BC',
|
||||||
|
riceA: '#FFFFFF',
|
||||||
|
riceB: '#F2F7FF',
|
||||||
|
sidePlate: '#6E86A6',
|
||||||
|
sideFoodA: '#FF9F5A',
|
||||||
|
sideFoodB: '#A0D66A',
|
||||||
|
spoon: '#E6F1FF',
|
||||||
|
steam: '#B8DCFF',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(light);
|
||||||
|
console.log(dark);
|
||||||
126
template/assets/branding/generate-logos-v5.js
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { createCanvas } = require('@napi-rs/canvas');
|
||||||
|
|
||||||
|
const SIZE = 600;
|
||||||
|
const outDir = __dirname;
|
||||||
|
|
||||||
|
function roundedRect(ctx, x, y, w, h, r) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + r, y);
|
||||||
|
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||||
|
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||||
|
ctx.arcTo(x, y + h, x, y, r);
|
||||||
|
ctx.arcTo(x, y, x + w, y, r);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSteam(ctx, x, y, color) {
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 10;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, y + 34);
|
||||||
|
ctx.bezierCurveTo(x - 14, y + 8, x + 18, y - 2, x + 6, y - 30);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawRice(ctx, cx, cy, colorA, colorB) {
|
||||||
|
const grad = ctx.createLinearGradient(cx - 100, cy - 30, cx + 100, cy + 20);
|
||||||
|
grad.addColorStop(0, colorA);
|
||||||
|
grad.addColorStop(1, colorB);
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cx, cy, 128, 56, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.65)';
|
||||||
|
for (let i = 0; i < 14; i++) {
|
||||||
|
const x = cx - 95 + i * 14;
|
||||||
|
const y = cy - 8 + ((i % 2) * 6 - 3);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(x, y, 4.3, 2.7, -0.3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawLogo(theme) {
|
||||||
|
const canvas = createCanvas(SIZE, SIZE);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
const bg = ctx.createLinearGradient(0, 0, SIZE, SIZE);
|
||||||
|
bg.addColorStop(0, theme.bgFrom);
|
||||||
|
bg.addColorStop(1, theme.bgTo);
|
||||||
|
ctx.fillStyle = bg;
|
||||||
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.shadowColor = theme.shadow;
|
||||||
|
ctx.shadowBlur = 28;
|
||||||
|
ctx.shadowOffsetY = 10;
|
||||||
|
roundedRect(ctx, 74, 74, 452, 452, 122);
|
||||||
|
ctx.fillStyle = theme.card;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
const bowlGrad = ctx.createLinearGradient(180, 280, 420, 430);
|
||||||
|
bowlGrad.addColorStop(0, theme.bowlTop);
|
||||||
|
bowlGrad.addColorStop(1, theme.bowlBottom);
|
||||||
|
|
||||||
|
ctx.fillStyle = bowlGrad;
|
||||||
|
roundedRect(ctx, 168, 306, 264, 140, 62);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = theme.lipOuter;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(300, 305, 154, 44, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = theme.lipInner;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(300, 310, 102, 25, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
drawRice(ctx, 300, 283, theme.riceA, theme.riceB);
|
||||||
|
|
||||||
|
drawSteam(ctx, 245, 196, theme.steam);
|
||||||
|
drawSteam(ctx, 300, 172, theme.steam);
|
||||||
|
drawSteam(ctx, 355, 196, theme.steam);
|
||||||
|
|
||||||
|
const output = path.join(outDir, theme.filename);
|
||||||
|
fs.writeFileSync(output, canvas.toBuffer('image/png'));
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
const light = drawLogo({
|
||||||
|
filename: 'lunch-pick-logo-light-600-v5.png',
|
||||||
|
bgFrom: '#F6FAFF',
|
||||||
|
bgTo: '#EAF3FF',
|
||||||
|
card: 'rgba(255,255,255,0.97)',
|
||||||
|
shadow: 'rgba(16,78,147,0.18)',
|
||||||
|
bowlTop: '#31B4F4',
|
||||||
|
bowlBottom: '#0A85E8',
|
||||||
|
lipOuter: '#DDE9F8',
|
||||||
|
lipInner: '#8EA6C5',
|
||||||
|
riceA: '#FFFFFF',
|
||||||
|
riceB: '#EFF6FF',
|
||||||
|
steam: '#5EA8EE',
|
||||||
|
});
|
||||||
|
|
||||||
|
const dark = drawLogo({
|
||||||
|
filename: 'lunch-pick-logo-dark-600-v5.png',
|
||||||
|
bgFrom: '#07142A',
|
||||||
|
bgTo: '#0E2243',
|
||||||
|
card: 'rgba(17,39,72,0.96)',
|
||||||
|
shadow: 'rgba(0,0,0,0.46)',
|
||||||
|
bowlTop: '#66CBFF',
|
||||||
|
bowlBottom: '#1A9EF4',
|
||||||
|
lipOuter: '#DDE6F2',
|
||||||
|
lipInner: '#8CA2BC',
|
||||||
|
riceA: '#FFFFFF',
|
||||||
|
riceB: '#F2F7FF',
|
||||||
|
steam: '#B8DCFF',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(light);
|
||||||
|
console.log(dark);
|
||||||
136
template/assets/branding/generate-logos.js
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { PNG } = require('pngjs');
|
||||||
|
|
||||||
|
const SIZE = 600;
|
||||||
|
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
const clean = hex.replace('#', '');
|
||||||
|
return {
|
||||||
|
r: parseInt(clean.substring(0, 2), 16),
|
||||||
|
g: parseInt(clean.substring(2, 4), 16),
|
||||||
|
b: parseInt(clean.substring(4, 6), 16),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPixel(png, x, y, color) {
|
||||||
|
if (x < 0 || y < 0 || x >= png.width || y >= png.height) return;
|
||||||
|
const idx = (png.width * y + x) << 2;
|
||||||
|
png.data[idx] = color.r;
|
||||||
|
png.data[idx + 1] = color.g;
|
||||||
|
png.data[idx + 2] = color.b;
|
||||||
|
png.data[idx + 3] = 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillRect(png, x0, y0, x1, y1, color) {
|
||||||
|
const sx = Math.max(0, Math.floor(x0));
|
||||||
|
const sy = Math.max(0, Math.floor(y0));
|
||||||
|
const ex = Math.min(png.width - 1, Math.ceil(x1));
|
||||||
|
const ey = Math.min(png.height - 1, Math.ceil(y1));
|
||||||
|
for (let y = sy; y <= ey; y++) {
|
||||||
|
for (let x = sx; x <= ex; x++) setPixel(png, x, y, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillCircle(png, cx, cy, r, color) {
|
||||||
|
const rr = r * r;
|
||||||
|
const x0 = Math.floor(cx - r);
|
||||||
|
const x1 = Math.ceil(cx + r);
|
||||||
|
const y0 = Math.floor(cy - r);
|
||||||
|
const y1 = Math.ceil(cy + r);
|
||||||
|
for (let y = y0; y <= y1; y++) {
|
||||||
|
for (let x = x0; x <= x1; x++) {
|
||||||
|
const dx = x - cx;
|
||||||
|
const dy = y - cy;
|
||||||
|
if (dx * dx + dy * dy <= rr) setPixel(png, x, y, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillEllipse(png, cx, cy, rx, ry, color) {
|
||||||
|
const rx2 = rx * rx;
|
||||||
|
const ry2 = ry * ry;
|
||||||
|
const x0 = Math.floor(cx - rx);
|
||||||
|
const x1 = Math.ceil(cx + rx);
|
||||||
|
const y0 = Math.floor(cy - ry);
|
||||||
|
const y1 = Math.ceil(cy + ry);
|
||||||
|
for (let y = y0; y <= y1; y++) {
|
||||||
|
for (let x = x0; x <= x1; x++) {
|
||||||
|
const dx = x - cx;
|
||||||
|
const dy = y - cy;
|
||||||
|
if ((dx * dx) / rx2 + (dy * dy) / ry2 <= 1) setPixel(png, x, y, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawThickLine(png, x0, y0, x1, y1, thickness, color) {
|
||||||
|
const steps = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0));
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const t = i / (steps || 1);
|
||||||
|
const x = Math.round(x0 + (x1 - x0) * t);
|
||||||
|
const y = Math.round(y0 + (y1 - y0) * t);
|
||||||
|
fillCircle(png, x, y, thickness / 2, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawLogo(filename, theme) {
|
||||||
|
const png = new PNG({ width: SIZE, height: SIZE });
|
||||||
|
|
||||||
|
const bg = hexToRgb(theme.bg);
|
||||||
|
const card = hexToRgb(theme.card);
|
||||||
|
const bowl = hexToRgb(theme.bowl);
|
||||||
|
const accent = hexToRgb(theme.accent);
|
||||||
|
|
||||||
|
fillRect(png, 0, 0, SIZE - 1, SIZE - 1, bg);
|
||||||
|
|
||||||
|
// Card background shape
|
||||||
|
fillRect(png, 80, 80, 520, 520, card);
|
||||||
|
fillCircle(png, 80, 80, 70, card);
|
||||||
|
fillCircle(png, 520, 80, 70, card);
|
||||||
|
fillCircle(png, 80, 520, 70, card);
|
||||||
|
fillCircle(png, 520, 520, 70, card);
|
||||||
|
|
||||||
|
// Bowl
|
||||||
|
fillEllipse(png, 300, 350, 165, 90, bowl);
|
||||||
|
fillRect(png, 135, 350, 465, 430, bowl);
|
||||||
|
fillEllipse(png, 300, 430, 165, 45, bowl);
|
||||||
|
|
||||||
|
// Bowl top cut
|
||||||
|
fillEllipse(png, 300, 340, 175, 68, card);
|
||||||
|
fillEllipse(png, 300, 346, 175, 4, accent);
|
||||||
|
|
||||||
|
// Noodles
|
||||||
|
fillEllipse(png, 250, 372, 50, 12, accent);
|
||||||
|
fillEllipse(png, 300, 374, 55, 13, accent);
|
||||||
|
fillEllipse(png, 350, 372, 50, 12, accent);
|
||||||
|
|
||||||
|
// Steam
|
||||||
|
fillEllipse(png, 245, 255, 14, 32, accent);
|
||||||
|
fillEllipse(png, 300, 235, 16, 38, accent);
|
||||||
|
fillEllipse(png, 355, 255, 14, 32, accent);
|
||||||
|
|
||||||
|
// Chopsticks
|
||||||
|
drawThickLine(png, 360, 205, 485, 330, 11, accent);
|
||||||
|
drawThickLine(png, 390, 188, 515, 313, 11, accent);
|
||||||
|
|
||||||
|
const outPath = path.join(__dirname, filename);
|
||||||
|
fs.writeFileSync(outPath, PNG.sync.write(png));
|
||||||
|
return outPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const light = drawLogo('lunch-pick-logo-light-600.png', {
|
||||||
|
bg: '#F8FAFC',
|
||||||
|
card: '#E0F2FE',
|
||||||
|
bowl: '#0EA5E9',
|
||||||
|
accent: '#0C4A6E',
|
||||||
|
});
|
||||||
|
|
||||||
|
const dark = drawLogo('lunch-pick-logo-dark-600.png', {
|
||||||
|
bg: '#0B1220',
|
||||||
|
card: '#13233C',
|
||||||
|
bowl: '#38BDF8',
|
||||||
|
accent: '#E2E8F0',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(light);
|
||||||
|
console.log(dark);
|
||||||
198
template/assets/branding/generate-thumbnail-1932x828-v5.js
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { createCanvas, GlobalFonts } = require('@napi-rs/canvas');
|
||||||
|
|
||||||
|
const W = 1932;
|
||||||
|
const H = 828;
|
||||||
|
const outDir = __dirname;
|
||||||
|
|
||||||
|
const fontCandidates = [
|
||||||
|
'/usr/share/fonts/truetype/nanum/NanumGothic.ttf',
|
||||||
|
'/usr/share/fonts/truetype/nanum/NanumGothicBold.ttf',
|
||||||
|
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
|
||||||
|
'/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const fp of fontCandidates) {
|
||||||
|
if (fs.existsSync(fp)) {
|
||||||
|
GlobalFonts.registerFromPath(fp, path.basename(fp));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFamily(weight = 'regular') {
|
||||||
|
if (weight === 'bold' && fs.existsSync('/usr/share/fonts/truetype/nanum/NanumGothicBold.ttf')) {
|
||||||
|
return 'NanumGothicBold.ttf';
|
||||||
|
}
|
||||||
|
if (fs.existsSync('/usr/share/fonts/truetype/nanum/NanumGothic.ttf')) {
|
||||||
|
return 'NanumGothic.ttf';
|
||||||
|
}
|
||||||
|
if (weight === 'bold' && fs.existsSync('/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc')) {
|
||||||
|
return 'NotoSansCJK-Bold.ttc';
|
||||||
|
}
|
||||||
|
if (fs.existsSync('/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc')) {
|
||||||
|
return 'NotoSansCJK-Regular.ttc';
|
||||||
|
}
|
||||||
|
return 'sans-serif';
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundedRect(ctx, x, y, w, h, r) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + r, y);
|
||||||
|
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||||
|
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||||
|
ctx.arcTo(x, y + h, x, y, r);
|
||||||
|
ctx.arcTo(x, y, x + w, y, r);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSteam(ctx, x, y, color, width = 18) {
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = width;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, y + 56);
|
||||||
|
ctx.bezierCurveTo(x - 22, y + 16, x + 28, y - 6, x + 10, y - 54);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawV5Bowl(ctx, x, y, theme) {
|
||||||
|
const bowlGrad = ctx.createLinearGradient(x - 220, y + 80, x + 220, y + 300);
|
||||||
|
bowlGrad.addColorStop(0, theme.bowlTop);
|
||||||
|
bowlGrad.addColorStop(1, theme.bowlBottom);
|
||||||
|
ctx.fillStyle = bowlGrad;
|
||||||
|
roundedRect(ctx, x - 250, y + 120, 500, 260, 120);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = theme.lipOuter;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(x, y + 120, 280, 76, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = theme.lipInner;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(x, y + 130, 185, 44, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
const riceGrad = ctx.createLinearGradient(x - 150, y + 20, x + 150, y + 150);
|
||||||
|
riceGrad.addColorStop(0, theme.riceA);
|
||||||
|
riceGrad.addColorStop(1, theme.riceB);
|
||||||
|
ctx.fillStyle = riceGrad;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(x, y + 74, 230, 96, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.65)';
|
||||||
|
for (let i = 0; i < 18; i++) {
|
||||||
|
const gx = x - 130 + i * 15;
|
||||||
|
const gy = y + 54 + ((i % 2) * 7 - 3);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(gx, gy, 6, 3, -0.2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawSteam(ctx, x - 110, y - 120, theme.steam, 16);
|
||||||
|
drawSteam(ctx, x, y - 145, theme.steam, 18);
|
||||||
|
drawSteam(ctx, x + 110, y - 120, theme.steam, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawThumbnail({ filename, theme }) {
|
||||||
|
const canvas = createCanvas(W, H);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
const bg = ctx.createLinearGradient(0, 0, W, H);
|
||||||
|
bg.addColorStop(0, theme.bgFrom);
|
||||||
|
bg.addColorStop(1, theme.bgTo);
|
||||||
|
ctx.fillStyle = bg;
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
ctx.fillStyle = theme.blob1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(240, 140, 290, 190, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = theme.blob2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(1680, 710, 380, 210, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
roundedRect(ctx, 72, 62, W - 144, H - 124, 64);
|
||||||
|
ctx.fillStyle = theme.card;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
drawV5Bowl(ctx, 520, 300, theme);
|
||||||
|
|
||||||
|
ctx.fillStyle = theme.title;
|
||||||
|
ctx.font = `700 112px "${pickFamily('bold')}"`;
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
ctx.fillText('점메추', 910, 220);
|
||||||
|
|
||||||
|
ctx.fillStyle = theme.subtitle;
|
||||||
|
ctx.font = `700 54px "${pickFamily('bold')}"`;
|
||||||
|
ctx.fillText('직장인 점심 추천', 915, 364);
|
||||||
|
|
||||||
|
ctx.fillStyle = theme.desc;
|
||||||
|
ctx.font = `600 36px "${pickFamily('regular')}"`;
|
||||||
|
ctx.fillText('가까운 식당 찾기 · 카테고리별 탐색 · 리뷰/별점', 915, 448);
|
||||||
|
|
||||||
|
roundedRect(ctx, 915, 520, 540, 94, 46);
|
||||||
|
ctx.fillStyle = theme.badge;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = theme.badgeText;
|
||||||
|
ctx.font = `700 42px "${pickFamily('bold')}"`;
|
||||||
|
ctx.fillText('오늘 점심, 더 빠르게 결정', 965, 545);
|
||||||
|
|
||||||
|
const output = path.join(outDir, filename);
|
||||||
|
fs.writeFileSync(output, canvas.toBuffer('image/png'));
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
const light = drawThumbnail({
|
||||||
|
filename: 'thumbnail-1932x828-light-v5.png',
|
||||||
|
theme: {
|
||||||
|
bgFrom: '#F6FAFF',
|
||||||
|
bgTo: '#E5F1FF',
|
||||||
|
blob1: 'rgba(108,174,255,0.16)',
|
||||||
|
blob2: 'rgba(41,130,255,0.12)',
|
||||||
|
card: 'rgba(255,255,255,0.9)',
|
||||||
|
bowlTop: '#31B4F4',
|
||||||
|
bowlBottom: '#0A85E8',
|
||||||
|
lipOuter: '#DDE9F8',
|
||||||
|
lipInner: '#8EA6C5',
|
||||||
|
riceA: '#FFFFFF',
|
||||||
|
riceB: '#EFF6FF',
|
||||||
|
steam: '#5EA8EE',
|
||||||
|
title: '#114B95',
|
||||||
|
subtitle: '#1D5FB0',
|
||||||
|
desc: '#3A5A84',
|
||||||
|
badge: '#0B86E9',
|
||||||
|
badgeText: '#FFFFFF',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dark = drawThumbnail({
|
||||||
|
filename: 'thumbnail-1932x828-dark-v5.png',
|
||||||
|
theme: {
|
||||||
|
bgFrom: '#07142A',
|
||||||
|
bgTo: '#0F284C',
|
||||||
|
blob1: 'rgba(102,180,255,0.12)',
|
||||||
|
blob2: 'rgba(59,148,255,0.1)',
|
||||||
|
card: 'rgba(18,42,78,0.84)',
|
||||||
|
bowlTop: '#66CBFF',
|
||||||
|
bowlBottom: '#1A9EF4',
|
||||||
|
lipOuter: '#DDE6F2',
|
||||||
|
lipInner: '#8CA2BC',
|
||||||
|
riceA: '#FFFFFF',
|
||||||
|
riceB: '#F2F7FF',
|
||||||
|
steam: '#B8DCFF',
|
||||||
|
title: '#EAF4FF',
|
||||||
|
subtitle: '#B8D9FF',
|
||||||
|
desc: '#9EC2E8',
|
||||||
|
badge: '#2A9DF8',
|
||||||
|
badgeText: '#FFFFFF',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(light);
|
||||||
|
console.log(dark);
|
||||||
180
template/assets/branding/generate-thumbnail-1932x828.js
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { createCanvas } = require('@napi-rs/canvas');
|
||||||
|
|
||||||
|
const W = 1932;
|
||||||
|
const H = 828;
|
||||||
|
const outDir = __dirname;
|
||||||
|
|
||||||
|
function roundedRect(ctx, x, y, w, h, r) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + r, y);
|
||||||
|
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||||
|
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||||
|
ctx.arcTo(x, y + h, x, y, r);
|
||||||
|
ctx.arcTo(x, y, x + w, y, r);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSteam(ctx, x, y, color, width = 18) {
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = width;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, y + 56);
|
||||||
|
ctx.bezierCurveTo(x - 22, y + 16, x + 28, y - 6, x + 10, y - 54);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawHeroBowl(ctx, x, y, theme) {
|
||||||
|
// bowl body
|
||||||
|
const grad = ctx.createLinearGradient(x - 220, y + 80, x + 220, y + 300);
|
||||||
|
grad.addColorStop(0, theme.bowlTop);
|
||||||
|
grad.addColorStop(1, theme.bowlBottom);
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
roundedRect(ctx, x - 250, y + 120, 500, 260, 120);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// lip
|
||||||
|
ctx.fillStyle = theme.lipOuter;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(x, y + 120, 280, 76, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = theme.lipInner;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(x, y + 130, 185, 44, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// rice
|
||||||
|
const riceGrad = ctx.createLinearGradient(x - 150, y + 20, x + 150, y + 150);
|
||||||
|
riceGrad.addColorStop(0, theme.riceA);
|
||||||
|
riceGrad.addColorStop(1, theme.riceB);
|
||||||
|
ctx.fillStyle = riceGrad;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(x, y + 74, 230, 96, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// grains
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.65)';
|
||||||
|
for (let i = 0; i < 18; i++) {
|
||||||
|
const gx = x - 130 + i * 15;
|
||||||
|
const gy = y + 54 + ((i % 2) * 7 - 3);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(gx, gy, 6, 3, -0.2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// steam
|
||||||
|
drawSteam(ctx, x - 110, y - 120, theme.steam, 16);
|
||||||
|
drawSteam(ctx, x, y - 145, theme.steam, 18);
|
||||||
|
drawSteam(ctx, x + 110, y - 120, theme.steam, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawThumbnail({ filename, theme }) {
|
||||||
|
const canvas = createCanvas(W, H);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// background
|
||||||
|
const bg = ctx.createLinearGradient(0, 0, W, H);
|
||||||
|
bg.addColorStop(0, theme.bgFrom);
|
||||||
|
bg.addColorStop(1, theme.bgTo);
|
||||||
|
ctx.fillStyle = bg;
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// soft circles
|
||||||
|
ctx.fillStyle = theme.blob1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(260, 120, 280, 180, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = theme.blob2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(1700, 720, 360, 220, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// glass card
|
||||||
|
roundedRect(ctx, 72, 62, W - 144, H - 124, 64);
|
||||||
|
ctx.fillStyle = theme.card;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// left visual
|
||||||
|
drawHeroBowl(ctx, 520, 300, theme);
|
||||||
|
|
||||||
|
// right text area
|
||||||
|
ctx.fillStyle = theme.title;
|
||||||
|
ctx.font = 'bold 112px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
ctx.fillText('점메추', 910, 220);
|
||||||
|
|
||||||
|
ctx.fillStyle = theme.subtitle;
|
||||||
|
ctx.font = '600 52px sans-serif';
|
||||||
|
ctx.fillText('직장인 점심 추천', 915, 360);
|
||||||
|
|
||||||
|
ctx.fillStyle = theme.desc;
|
||||||
|
ctx.font = '500 34px sans-serif';
|
||||||
|
ctx.fillText('가까운 식당 찾기 · 카테고리별 탐색 · 리뷰/별점', 915, 446);
|
||||||
|
|
||||||
|
// badge
|
||||||
|
roundedRect(ctx, 915, 518, 520, 92, 44);
|
||||||
|
ctx.fillStyle = theme.badge;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = theme.badgeText;
|
||||||
|
ctx.font = '700 40px sans-serif';
|
||||||
|
ctx.fillText('오늘 점심, 더 빠르게 결정', 962, 542);
|
||||||
|
|
||||||
|
const output = path.join(outDir, filename);
|
||||||
|
fs.writeFileSync(output, canvas.toBuffer('image/png'));
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
const light = drawThumbnail({
|
||||||
|
filename: 'thumbnail-1932x828-light.png',
|
||||||
|
theme: {
|
||||||
|
bgFrom: '#F6FAFF',
|
||||||
|
bgTo: '#E5F1FF',
|
||||||
|
blob1: 'rgba(108,174,255,0.16)',
|
||||||
|
blob2: 'rgba(41,130,255,0.12)',
|
||||||
|
card: 'rgba(255,255,255,0.9)',
|
||||||
|
bowlTop: '#31B4F4',
|
||||||
|
bowlBottom: '#0A85E8',
|
||||||
|
lipOuter: '#DDE9F8',
|
||||||
|
lipInner: '#8EA6C5',
|
||||||
|
riceA: '#FFFFFF',
|
||||||
|
riceB: '#EFF6FF',
|
||||||
|
steam: '#5EA8EE',
|
||||||
|
title: '#114B95',
|
||||||
|
subtitle: '#1D5FB0',
|
||||||
|
desc: '#3A5A84',
|
||||||
|
badge: '#0B86E9',
|
||||||
|
badgeText: '#FFFFFF',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dark = drawThumbnail({
|
||||||
|
filename: 'thumbnail-1932x828-dark.png',
|
||||||
|
theme: {
|
||||||
|
bgFrom: '#07142A',
|
||||||
|
bgTo: '#0F284C',
|
||||||
|
blob1: 'rgba(102,180,255,0.12)',
|
||||||
|
blob2: 'rgba(59,148,255,0.1)',
|
||||||
|
card: 'rgba(18,42,78,0.84)',
|
||||||
|
bowlTop: '#66CBFF',
|
||||||
|
bowlBottom: '#1A9EF4',
|
||||||
|
lipOuter: '#DDE6F2',
|
||||||
|
lipInner: '#8CA2BC',
|
||||||
|
riceA: '#FFFFFF',
|
||||||
|
riceB: '#F2F7FF',
|
||||||
|
steam: '#B8DCFF',
|
||||||
|
title: '#EAF4FF',
|
||||||
|
subtitle: '#B8D9FF',
|
||||||
|
desc: '#9EC2E8',
|
||||||
|
badge: '#2A9DF8',
|
||||||
|
badgeText: '#FFFFFF',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(light);
|
||||||
|
console.log(dark);
|
||||||
BIN
template/assets/branding/lunch-pick-logo-dark-600-v2.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
template/assets/branding/lunch-pick-logo-dark-600-v3.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
template/assets/branding/lunch-pick-logo-dark-600-v4.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
template/assets/branding/lunch-pick-logo-dark-600-v5.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
template/assets/branding/lunch-pick-logo-dark-600.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
template/assets/branding/lunch-pick-logo-light-600-v2.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
template/assets/branding/lunch-pick-logo-light-600-v3.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
template/assets/branding/lunch-pick-logo-light-600-v4.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
template/assets/branding/lunch-pick-logo-light-600-v5.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
template/assets/branding/lunch-pick-logo-light-600.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
template/assets/branding/thumbnail-1932x828-dark-v5.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
template/assets/branding/thumbnail-1932x828-dark.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
template/assets/branding/thumbnail-1932x828-light-v5.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
template/assets/branding/thumbnail-1932x828-light.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
3
template/babel.config.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: ['babel-preset-granite'],
|
||||||
|
};
|
||||||
BIN
template/babel.config.js:Zone.Identifier
Normal file
15
template/backend/.env.example
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Backend port
|
||||||
|
PORT=4000
|
||||||
|
|
||||||
|
# MySQL mode is enabled when MYSQL_HOST or MYSQL_URL is set.
|
||||||
|
# If neither is set, backend runs in memory mode.
|
||||||
|
|
||||||
|
# Option A: full URL
|
||||||
|
# MYSQL_URL=mysql://user:password@127.0.0.1:3306/lunch_picker
|
||||||
|
|
||||||
|
# Option B: split fields
|
||||||
|
MYSQL_HOST=127.0.0.1
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_USER=root
|
||||||
|
MYSQL_PASSWORD=secret
|
||||||
|
MYSQL_DATABASE=lunch_picker
|
||||||
63
template/backend/README.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Lunch Picker Backend
|
||||||
|
|
||||||
|
Express API server for Lunch Picker miniapp.
|
||||||
|
|
||||||
|
## Modes
|
||||||
|
|
||||||
|
- Memory mode: run without MySQL env vars (quick local demo)
|
||||||
|
- MySQL mode: set `MYSQL_HOST` (or `MYSQL_URL`) and run with real DB
|
||||||
|
|
||||||
|
## 1) Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2) Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Server: `http://localhost:4000`
|
||||||
|
|
||||||
|
## 3) MySQL setup
|
||||||
|
|
||||||
|
1. Create DB (example)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
create database lunch_picker character set utf8mb4 collate utf8mb4_0900_ai_ci;
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Apply schema
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mysql -u root -p lunch_picker < ../docs/db/schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Set `.env` values (`MYSQL_HOST`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE`)
|
||||||
|
|
||||||
|
When MySQL mode starts, seed data is inserted automatically only when `restaurant` table is empty.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
- `GET /v1/health`
|
||||||
|
- `GET /v1/restaurants/nearby`
|
||||||
|
- `GET /v1/restaurants/:restaurantId`
|
||||||
|
- `GET /v1/restaurants/:restaurantId/reviews`
|
||||||
|
- `POST /v1/restaurants/:restaurantId/reviews`
|
||||||
|
- `DELETE /v1/reviews/:reviewId`
|
||||||
|
- `POST /v1/restaurants/:restaurantId/like`
|
||||||
|
- `GET /v1/users/me/reviews`
|
||||||
|
|
||||||
|
## Auth Simulation
|
||||||
|
|
||||||
|
Set `x-user-id` header to simulate user identity.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "x-user-id: demo-user-1" "http://localhost:4000/v1/restaurants/nearby?lat=37.501&lng=127.037&radiusMeters=1000&sort=distance"
|
||||||
|
```
|
||||||
1018
template/backend/package-lock.json
generated
Normal file
15
template/backend/package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "lunch-picker-backend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.6.1",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"mysql2": "^3.15.3",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
174
template/backend/server.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const { createStore } = require('./storage');
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT || 4000);
|
||||||
|
const CATEGORY_SET = new Set(['KOREAN', 'CHINESE', 'JAPANESE', 'WESTERN', 'OTHER']);
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { mode, store } = await createStore();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.get('/v1/health', (_req, res) => {
|
||||||
|
res.json({ status: 'ok', mode, time: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/v1/restaurants/nearby', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userExternalId = getUserId(req);
|
||||||
|
const lat = Number(req.query.lat);
|
||||||
|
const lng = Number(req.query.lng);
|
||||||
|
const radiusMeters = Number(req.query.radiusMeters || 1000);
|
||||||
|
const category = typeof req.query.category === 'string' ? req.query.category : undefined;
|
||||||
|
const sort = normalizeSort(req.query.sort);
|
||||||
|
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
|
||||||
|
return res.status(400).json({ code: 'INVALID_INPUT', message: 'lat/lng required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category && !CATEGORY_SET.has(category)) {
|
||||||
|
return res.status(400).json({ code: 'INVALID_INPUT', message: 'invalid category' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await store.getNearby({
|
||||||
|
userExternalId,
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
|
radiusMeters,
|
||||||
|
category,
|
||||||
|
sort,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ items, nextCursor: null });
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(res, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/v1/restaurants/:restaurantId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userExternalId = getUserId(req);
|
||||||
|
const restaurant = await store.getRestaurantById(
|
||||||
|
req.params.restaurantId,
|
||||||
|
userExternalId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!restaurant) {
|
||||||
|
return res.status(404).json({ code: 'NOT_FOUND', message: 'restaurant not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(restaurant);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(res, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/v1/restaurants/:restaurantId/reviews', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userExternalId = getUserId(req);
|
||||||
|
const items = await store.getReviews(req.params.restaurantId, userExternalId);
|
||||||
|
return res.json({ items, nextCursor: null });
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(res, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/v1/restaurants/:restaurantId/reviews', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userExternalId = getUserId(req);
|
||||||
|
const restaurantId = req.params.restaurantId;
|
||||||
|
const rating = Number(req.body?.rating);
|
||||||
|
const content = String(req.body?.content || '').trim();
|
||||||
|
|
||||||
|
if (!Number.isInteger(rating) || rating < 1 || rating > 5 || !content) {
|
||||||
|
return res.status(400).json({
|
||||||
|
code: 'INVALID_INPUT',
|
||||||
|
message: 'rating must be 1~5 and content required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await store.upsertReview(restaurantId, userExternalId, rating, content);
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({ code: 'NOT_FOUND', message: 'restaurant not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(res, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/v1/reviews/:reviewId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userExternalId = getUserId(req);
|
||||||
|
const result = await store.deleteReview(req.params.reviewId, userExternalId);
|
||||||
|
|
||||||
|
if (!result.deleted && result.reason === 'NOT_FOUND') {
|
||||||
|
return res.status(404).json({ code: 'NOT_FOUND', message: 'review not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.deleted && result.reason === 'FORBIDDEN') {
|
||||||
|
return res.status(403).json({ code: 'FORBIDDEN', message: 'not your review' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(res, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/v1/restaurants/:restaurantId/like', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userExternalId = getUserId(req);
|
||||||
|
const result = await store.toggleLike(req.params.restaurantId, userExternalId);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({ code: 'NOT_FOUND', message: 'restaurant not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(res, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/v1/users/me/reviews', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userExternalId = getUserId(req);
|
||||||
|
const items = await store.getMyReviews(userExternalId);
|
||||||
|
return res.json({ items, nextCursor: null });
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(res, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`[lunch-picker-backend] mode=${mode} listening on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error('[lunch-picker-backend] failed to start', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
function getUserId(req) {
|
||||||
|
const value = req.header('x-user-id');
|
||||||
|
return value && value.trim() ? value.trim() : 'demo-user-1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSort(input) {
|
||||||
|
if (typeof input !== 'string') return 'distance';
|
||||||
|
if (input === 'rating' || input === 'likes') return input;
|
||||||
|
return 'distance';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleError(res, error) {
|
||||||
|
console.error('[api-error]', error);
|
||||||
|
return res.status(500).json({ code: 'INTERNAL_ERROR', message: 'unexpected error' });
|
||||||
|
}
|
||||||
733
template/backend/storage.js
Normal file
@@ -0,0 +1,733 @@
|
|||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
|
||||||
|
const CATEGORY_SET = new Set(['KOREAN', 'CHINESE', 'JAPANESE', 'WESTERN', 'OTHER']);
|
||||||
|
|
||||||
|
const seedRestaurants = [
|
||||||
|
{
|
||||||
|
id: 'a1f5d3a5-1c39-4c7d-a0fb-2f8f8f5f7a11',
|
||||||
|
name: '강남스시마루',
|
||||||
|
category: 'JAPANESE',
|
||||||
|
address: '서울 강남구 테헤란로 123',
|
||||||
|
phone: '02-1234-5678',
|
||||||
|
lat: 37.5009,
|
||||||
|
lng: 127.0368,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2f9b0c77-dcf7-44c5-8fcb-aefb4a16c222',
|
||||||
|
name: '북경반점 선릉점',
|
||||||
|
category: 'CHINESE',
|
||||||
|
address: '서울 강남구 선릉로 88',
|
||||||
|
phone: '02-7890-1234',
|
||||||
|
lat: 37.5043,
|
||||||
|
lng: 127.0496,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3be86df0-8b69-4f1b-9f0f-c08726f78333',
|
||||||
|
name: '파스타하우스 역삼',
|
||||||
|
category: 'WESTERN',
|
||||||
|
address: '서울 강남구 역삼로 201',
|
||||||
|
phone: '02-2222-3333',
|
||||||
|
lat: 37.4982,
|
||||||
|
lng: 127.0397,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4d645f7f-0f08-4cb2-b218-0dd07a838444',
|
||||||
|
name: '한그릇집 역삼점',
|
||||||
|
category: 'KOREAN',
|
||||||
|
address: '서울 강남구 논현로 401',
|
||||||
|
phone: '02-5566-7788',
|
||||||
|
lat: 37.5012,
|
||||||
|
lng: 127.0301,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const seedUsers = [
|
||||||
|
{ id: '77a341ce-68b4-4fde-9f23-b64fa87cb111', providerUserId: 'demo-user-1', nickName: '나' },
|
||||||
|
{ id: 'a8d1cd2d-f4b7-4f4f-b6b6-1be63e2f8222', providerUserId: 'user-a', nickName: '점심러버' },
|
||||||
|
{ id: 'f1f4ea2a-07e9-4cb8-bd8d-a16792b96333', providerUserId: 'user-b', nickName: '직장인A' },
|
||||||
|
{ id: '4f659ac3-35f1-4907-91c7-11fe2e87a444', providerUserId: 'user-c', nickName: '직장인C' },
|
||||||
|
{ id: '555f6868-c2b5-4025-b0cb-f2f65f1bc555', providerUserId: 'user-d', nickName: '직장인D' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const seedReviews = [
|
||||||
|
{
|
||||||
|
id: 'r-101',
|
||||||
|
restaurantId: 'a1f5d3a5-1c39-4c7d-a0fb-2f8f8f5f7a11',
|
||||||
|
providerUserId: 'user-a',
|
||||||
|
userNickname: '점심러버',
|
||||||
|
rating: 5,
|
||||||
|
content: '초밥 신선하고 점심 세트 가성비 좋아요.',
|
||||||
|
createdAt: '2026-04-10T03:10:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'r-102',
|
||||||
|
restaurantId: 'a1f5d3a5-1c39-4c7d-a0fb-2f8f8f5f7a11',
|
||||||
|
providerUserId: 'demo-user-1',
|
||||||
|
userNickname: '나',
|
||||||
|
rating: 4,
|
||||||
|
content: '웨이팅만 짧으면 자주 올 듯.',
|
||||||
|
createdAt: '2026-04-12T02:20:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'r-201',
|
||||||
|
restaurantId: '2f9b0c77-dcf7-44c5-8fcb-aefb4a16c222',
|
||||||
|
providerUserId: 'user-b',
|
||||||
|
userNickname: '직장인A',
|
||||||
|
rating: 4,
|
||||||
|
content: '짬뽕 국물이 진해서 해장 점심으로 좋아요.',
|
||||||
|
createdAt: '2026-04-08T04:10:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const seedLikes = [
|
||||||
|
{ restaurantId: '2f9b0c77-dcf7-44c5-8fcb-aefb4a16c222', providerUserId: 'demo-user-1' },
|
||||||
|
{ restaurantId: 'a1f5d3a5-1c39-4c7d-a0fb-2f8f8f5f7a11', providerUserId: 'user-a' },
|
||||||
|
{ restaurantId: '3be86df0-8b69-4f1b-9f0f-c08726f78333', providerUserId: 'user-c' },
|
||||||
|
{ restaurantId: '3be86df0-8b69-4f1b-9f0f-c08726f78333', providerUserId: 'user-d' },
|
||||||
|
];
|
||||||
|
|
||||||
|
class MemoryStore {
|
||||||
|
constructor() {
|
||||||
|
this.restaurants = [...seedRestaurants];
|
||||||
|
this.users = seedUsers.map((u) => ({ ...u }));
|
||||||
|
this.reviews = seedReviews.map((r) => ({ ...r }));
|
||||||
|
this.likes = seedLikes.map((l) => ({ ...l }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNearby({ userExternalId, lat, lng, radiusMeters, category, sort }) {
|
||||||
|
let items = this.restaurants
|
||||||
|
.map((restaurant) => this.#toRestaurantListItem(restaurant, userExternalId, lat, lng))
|
||||||
|
.filter((item) => item.distanceMeters <= radiusMeters);
|
||||||
|
|
||||||
|
if (category && CATEGORY_SET.has(category)) {
|
||||||
|
items = items.filter((item) => item.category === category);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortItems(items, sort);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRestaurantById(restaurantId, userExternalId) {
|
||||||
|
const restaurant = this.restaurants.find((item) => item.id === restaurantId);
|
||||||
|
if (!restaurant) return null;
|
||||||
|
|
||||||
|
return this.#toRestaurantListItem(
|
||||||
|
restaurant,
|
||||||
|
userExternalId,
|
||||||
|
restaurant.lat,
|
||||||
|
restaurant.lng,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getReviews(restaurantId, userExternalId) {
|
||||||
|
return this.reviews
|
||||||
|
.filter((review) => review.restaurantId === restaurantId)
|
||||||
|
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
|
||||||
|
.map((review) => ({
|
||||||
|
id: review.id,
|
||||||
|
restaurantId: review.restaurantId,
|
||||||
|
userExternalId: review.providerUserId,
|
||||||
|
nickName: review.userNickname,
|
||||||
|
rating: review.rating,
|
||||||
|
content: review.content,
|
||||||
|
createdAt: review.createdAt,
|
||||||
|
updatedAt: review.createdAt,
|
||||||
|
mine: review.providerUserId === userExternalId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertReview(restaurantId, userExternalId, rating, content) {
|
||||||
|
const restaurant = this.restaurants.find((item) => item.id === restaurantId);
|
||||||
|
if (!restaurant) return null;
|
||||||
|
|
||||||
|
const user = this.#getOrCreateUser(userExternalId);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const existing = this.reviews.find(
|
||||||
|
(review) => review.restaurantId === restaurantId && review.providerUserId === userExternalId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.rating = rating;
|
||||||
|
existing.content = content;
|
||||||
|
existing.createdAt = now;
|
||||||
|
existing.userNickname = user.nickName;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: existing.id,
|
||||||
|
restaurantId,
|
||||||
|
userExternalId,
|
||||||
|
nickName: user.nickName,
|
||||||
|
rating,
|
||||||
|
content,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
mine: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = {
|
||||||
|
id: uuidv4(),
|
||||||
|
restaurantId,
|
||||||
|
providerUserId: userExternalId,
|
||||||
|
userNickname: user.nickName,
|
||||||
|
rating,
|
||||||
|
content,
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
this.reviews.push(created);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: created.id,
|
||||||
|
restaurantId,
|
||||||
|
userExternalId,
|
||||||
|
nickName: user.nickName,
|
||||||
|
rating,
|
||||||
|
content,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
mine: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteReview(reviewId, userExternalId) {
|
||||||
|
const index = this.reviews.findIndex((item) => item.id === reviewId);
|
||||||
|
if (index < 0) return { deleted: false, reason: 'NOT_FOUND' };
|
||||||
|
|
||||||
|
if (this.reviews[index].providerUserId !== userExternalId) {
|
||||||
|
return { deleted: false, reason: 'FORBIDDEN' };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reviews.splice(index, 1);
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleLike(restaurantId, userExternalId) {
|
||||||
|
const restaurant = this.restaurants.find((item) => item.id === restaurantId);
|
||||||
|
if (!restaurant) return null;
|
||||||
|
|
||||||
|
const index = this.likes.findIndex(
|
||||||
|
(item) => item.restaurantId === restaurantId && item.providerUserId === userExternalId,
|
||||||
|
);
|
||||||
|
|
||||||
|
let liked = false;
|
||||||
|
if (index >= 0) {
|
||||||
|
this.likes.splice(index, 1);
|
||||||
|
} else {
|
||||||
|
this.likes.push({ restaurantId, providerUserId: userExternalId });
|
||||||
|
liked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const likeCount = this.likes.filter((item) => item.restaurantId === restaurantId).length;
|
||||||
|
return { liked, likeCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyReviews(userExternalId) {
|
||||||
|
return this.reviews
|
||||||
|
.filter((item) => item.providerUserId === userExternalId)
|
||||||
|
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
restaurantId: item.restaurantId,
|
||||||
|
userExternalId,
|
||||||
|
nickName: item.userNickname,
|
||||||
|
rating: item.rating,
|
||||||
|
content: item.content,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
updatedAt: item.createdAt,
|
||||||
|
mine: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#getOrCreateUser(userExternalId) {
|
||||||
|
const found = this.users.find((u) => u.providerUserId === userExternalId);
|
||||||
|
if (found) return found;
|
||||||
|
|
||||||
|
const created = {
|
||||||
|
id: uuidv4(),
|
||||||
|
providerUserId: userExternalId,
|
||||||
|
nickName: userExternalId === 'demo-user-1' ? '나' : '사용자',
|
||||||
|
};
|
||||||
|
this.users.push(created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
#toRestaurantListItem(restaurant, userExternalId, lat, lng) {
|
||||||
|
const restaurantReviews = this.reviews.filter((item) => item.restaurantId === restaurant.id);
|
||||||
|
const reviewCount = restaurantReviews.length;
|
||||||
|
const ratingSum = restaurantReviews.reduce((sum, item) => sum + item.rating, 0);
|
||||||
|
const averageRating = reviewCount === 0 ? 0 : roundTo1(ratingSum / reviewCount);
|
||||||
|
const likeCount = this.likes.filter((item) => item.restaurantId === restaurant.id).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: restaurant.id,
|
||||||
|
name: restaurant.name,
|
||||||
|
category: restaurant.category,
|
||||||
|
distanceMeters: Math.round(haversineMeters(lat, lng, restaurant.lat, restaurant.lng)),
|
||||||
|
averageRating,
|
||||||
|
reviewCount,
|
||||||
|
likeCount,
|
||||||
|
likedByMe: this.likes.some(
|
||||||
|
(item) => item.restaurantId === restaurant.id && item.providerUserId === userExternalId,
|
||||||
|
),
|
||||||
|
address: restaurant.address,
|
||||||
|
phone: restaurant.phone,
|
||||||
|
lat: restaurant.lat,
|
||||||
|
lng: restaurant.lng,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MySQLStore {
|
||||||
|
constructor(pool) {
|
||||||
|
this.pool = pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
async bootstrap() {
|
||||||
|
const [rows] = await this.pool.query('select count(*) as cnt from restaurant');
|
||||||
|
if ((rows[0]?.cnt ?? 0) > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const user of seedUsers) {
|
||||||
|
await this.pool.query(
|
||||||
|
`insert into app_user (id, provider, provider_user_id, nick_name)
|
||||||
|
values (?, 'toss', ?, ?)
|
||||||
|
on duplicate key update nick_name = values(nick_name)`,
|
||||||
|
[user.id, user.providerUserId, user.nickName],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const restaurant of seedRestaurants) {
|
||||||
|
await this.pool.query(
|
||||||
|
`insert into restaurant (id, external_place_id, name, category, address, phone, lat, lng, is_active)
|
||||||
|
values (?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||||
|
on duplicate key update
|
||||||
|
name = values(name),
|
||||||
|
category = values(category),
|
||||||
|
address = values(address),
|
||||||
|
phone = values(phone),
|
||||||
|
lat = values(lat),
|
||||||
|
lng = values(lng),
|
||||||
|
is_active = 1`,
|
||||||
|
[
|
||||||
|
restaurant.id,
|
||||||
|
`seed-${restaurant.id}`,
|
||||||
|
restaurant.name,
|
||||||
|
restaurant.category,
|
||||||
|
restaurant.address,
|
||||||
|
restaurant.phone,
|
||||||
|
restaurant.lat,
|
||||||
|
restaurant.lng,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const review of seedReviews) {
|
||||||
|
const user = seedUsers.find((u) => u.providerUserId === review.providerUserId);
|
||||||
|
if (!user) continue;
|
||||||
|
|
||||||
|
await this.pool.query(
|
||||||
|
`insert into restaurant_review (id, restaurant_id, user_id, rating, content, created_at, updated_at, deleted_at)
|
||||||
|
values (?, ?, ?, ?, ?, ?, ?, null)
|
||||||
|
on duplicate key update
|
||||||
|
rating = values(rating),
|
||||||
|
content = values(content),
|
||||||
|
updated_at = values(updated_at),
|
||||||
|
deleted_at = null`,
|
||||||
|
[
|
||||||
|
review.id,
|
||||||
|
review.restaurantId,
|
||||||
|
user.id,
|
||||||
|
review.rating,
|
||||||
|
review.content,
|
||||||
|
review.createdAt,
|
||||||
|
review.createdAt,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await this.#refreshStat(review.restaurantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const like of seedLikes) {
|
||||||
|
const user = seedUsers.find((u) => u.providerUserId === like.providerUserId);
|
||||||
|
if (!user) continue;
|
||||||
|
await this.pool.query(
|
||||||
|
`insert ignore into restaurant_like (restaurant_id, user_id) values (?, ?)`,
|
||||||
|
[like.restaurantId, user.id],
|
||||||
|
);
|
||||||
|
await this.#refreshStat(like.restaurantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNearby({ userExternalId, lat, lng, radiusMeters, category, sort }) {
|
||||||
|
const user = await this.#getOrCreateUser(userExternalId);
|
||||||
|
const params = [user.id];
|
||||||
|
let whereClause = 'where r.is_active = 1';
|
||||||
|
|
||||||
|
if (category && CATEGORY_SET.has(category)) {
|
||||||
|
whereClause += ' and r.category = ?';
|
||||||
|
params.push(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await this.pool.query(
|
||||||
|
`select
|
||||||
|
r.id,
|
||||||
|
r.name,
|
||||||
|
r.category,
|
||||||
|
r.address,
|
||||||
|
r.phone,
|
||||||
|
r.lat,
|
||||||
|
r.lng,
|
||||||
|
coalesce(s.review_count, 0) as reviewCount,
|
||||||
|
coalesce(s.like_count, 0) as likeCount,
|
||||||
|
coalesce(s.average_rating, 0.00) as averageRating,
|
||||||
|
case when ul.user_id is null then 0 else 1 end as likedByMe
|
||||||
|
from restaurant r
|
||||||
|
left join restaurant_stat s on s.restaurant_id = r.id
|
||||||
|
left join restaurant_like ul on ul.restaurant_id = r.id and ul.user_id = ?
|
||||||
|
${whereClause}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
let items = rows
|
||||||
|
.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
category: row.category,
|
||||||
|
address: row.address,
|
||||||
|
phone: row.phone,
|
||||||
|
lat: Number(row.lat),
|
||||||
|
lng: Number(row.lng),
|
||||||
|
distanceMeters: Math.round(haversineMeters(lat, lng, Number(row.lat), Number(row.lng))),
|
||||||
|
averageRating: Number(row.averageRating),
|
||||||
|
reviewCount: Number(row.reviewCount),
|
||||||
|
likeCount: Number(row.likeCount),
|
||||||
|
likedByMe: Number(row.likedByMe) === 1,
|
||||||
|
}))
|
||||||
|
.filter((item) => item.distanceMeters <= radiusMeters);
|
||||||
|
|
||||||
|
return sortItems(items, sort);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRestaurantById(restaurantId, userExternalId) {
|
||||||
|
const user = await this.#getOrCreateUser(userExternalId);
|
||||||
|
const [rows] = await this.pool.query(
|
||||||
|
`select
|
||||||
|
r.id,
|
||||||
|
r.name,
|
||||||
|
r.category,
|
||||||
|
r.address,
|
||||||
|
r.phone,
|
||||||
|
r.lat,
|
||||||
|
r.lng,
|
||||||
|
coalesce(s.review_count, 0) as reviewCount,
|
||||||
|
coalesce(s.like_count, 0) as likeCount,
|
||||||
|
coalesce(s.average_rating, 0.00) as averageRating,
|
||||||
|
case when ul.user_id is null then 0 else 1 end as likedByMe
|
||||||
|
from restaurant r
|
||||||
|
left join restaurant_stat s on s.restaurant_id = r.id
|
||||||
|
left join restaurant_like ul on ul.restaurant_id = r.id and ul.user_id = ?
|
||||||
|
where r.id = ? and r.is_active = 1
|
||||||
|
limit 1`,
|
||||||
|
[user.id, restaurantId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
const row = rows[0];
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
category: row.category,
|
||||||
|
address: row.address,
|
||||||
|
phone: row.phone,
|
||||||
|
lat: Number(row.lat),
|
||||||
|
lng: Number(row.lng),
|
||||||
|
distanceMeters: 0,
|
||||||
|
averageRating: Number(row.averageRating),
|
||||||
|
reviewCount: Number(row.reviewCount),
|
||||||
|
likeCount: Number(row.likeCount),
|
||||||
|
likedByMe: Number(row.likedByMe) === 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getReviews(restaurantId, userExternalId) {
|
||||||
|
const user = await this.#getOrCreateUser(userExternalId);
|
||||||
|
const [rows] = await this.pool.query(
|
||||||
|
`select
|
||||||
|
rr.id,
|
||||||
|
rr.restaurant_id as restaurantId,
|
||||||
|
rr.rating,
|
||||||
|
rr.content,
|
||||||
|
rr.created_at as createdAt,
|
||||||
|
rr.updated_at as updatedAt,
|
||||||
|
au.provider_user_id as userExternalId,
|
||||||
|
au.nick_name as nickName,
|
||||||
|
case when rr.user_id = ? then 1 else 0 end as mine
|
||||||
|
from restaurant_review rr
|
||||||
|
inner join app_user au on au.id = rr.user_id
|
||||||
|
where rr.restaurant_id = ? and rr.deleted_at is null
|
||||||
|
order by rr.created_at desc`,
|
||||||
|
[user.id, restaurantId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
restaurantId: row.restaurantId,
|
||||||
|
userExternalId: row.userExternalId,
|
||||||
|
nickName: row.nickName,
|
||||||
|
rating: Number(row.rating),
|
||||||
|
content: row.content,
|
||||||
|
createdAt: toIso(row.createdAt),
|
||||||
|
updatedAt: toIso(row.updatedAt),
|
||||||
|
mine: Number(row.mine) === 1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertReview(restaurantId, userExternalId, rating, content) {
|
||||||
|
const user = await this.#getOrCreateUser(userExternalId);
|
||||||
|
const [restaurantRows] = await this.pool.query(
|
||||||
|
`select id from restaurant where id = ? and is_active = 1 limit 1`,
|
||||||
|
[restaurantId],
|
||||||
|
);
|
||||||
|
if (restaurantRows.length === 0) return null;
|
||||||
|
|
||||||
|
const reviewId = uuidv4();
|
||||||
|
await this.pool.query(
|
||||||
|
`insert into restaurant_review
|
||||||
|
(id, restaurant_id, user_id, rating, content, created_at, updated_at, deleted_at)
|
||||||
|
values
|
||||||
|
(?, ?, ?, ?, ?, current_timestamp, current_timestamp, null)
|
||||||
|
on duplicate key update
|
||||||
|
rating = values(rating),
|
||||||
|
content = values(content),
|
||||||
|
deleted_at = null,
|
||||||
|
updated_at = current_timestamp`,
|
||||||
|
[reviewId, restaurantId, user.id, rating, content],
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.#refreshStat(restaurantId);
|
||||||
|
|
||||||
|
const [rows] = await this.pool.query(
|
||||||
|
`select rr.id, rr.rating, rr.content, rr.created_at as createdAt, rr.updated_at as updatedAt
|
||||||
|
from restaurant_review rr
|
||||||
|
where rr.restaurant_id = ? and rr.user_id = ? and rr.deleted_at is null
|
||||||
|
limit 1`,
|
||||||
|
[restaurantId, user.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = rows[0];
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
restaurantId,
|
||||||
|
userExternalId,
|
||||||
|
nickName: user.nickName,
|
||||||
|
rating: Number(row.rating),
|
||||||
|
content: row.content,
|
||||||
|
createdAt: toIso(row.createdAt),
|
||||||
|
updatedAt: toIso(row.updatedAt),
|
||||||
|
mine: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteReview(reviewId, userExternalId) {
|
||||||
|
const user = await this.#getOrCreateUser(userExternalId);
|
||||||
|
const [targetRows] = await this.pool.query(
|
||||||
|
`select restaurant_id as restaurantId, user_id as userId
|
||||||
|
from restaurant_review
|
||||||
|
where id = ? and deleted_at is null
|
||||||
|
limit 1`,
|
||||||
|
[reviewId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (targetRows.length === 0) {
|
||||||
|
return { deleted: false, reason: 'NOT_FOUND' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = targetRows[0];
|
||||||
|
if (target.userId !== user.id) {
|
||||||
|
return { deleted: false, reason: 'FORBIDDEN' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.pool.query(`delete from restaurant_review where id = ?`, [reviewId]);
|
||||||
|
await this.#refreshStat(target.restaurantId);
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleLike(restaurantId, userExternalId) {
|
||||||
|
const user = await this.#getOrCreateUser(userExternalId);
|
||||||
|
|
||||||
|
const [restaurantRows] = await this.pool.query(
|
||||||
|
`select id from restaurant where id = ? and is_active = 1 limit 1`,
|
||||||
|
[restaurantId],
|
||||||
|
);
|
||||||
|
if (restaurantRows.length === 0) return null;
|
||||||
|
|
||||||
|
const [insertResult] = await this.pool.query(
|
||||||
|
`insert ignore into restaurant_like (restaurant_id, user_id) values (?, ?)`,
|
||||||
|
[restaurantId, user.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
let liked = true;
|
||||||
|
if ((insertResult.affectedRows ?? 0) === 0) {
|
||||||
|
await this.pool.query(
|
||||||
|
`delete from restaurant_like where restaurant_id = ? and user_id = ?`,
|
||||||
|
[restaurantId, user.id],
|
||||||
|
);
|
||||||
|
liked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.#refreshStat(restaurantId);
|
||||||
|
|
||||||
|
const [rows] = await this.pool.query(
|
||||||
|
`select count(*) as cnt from restaurant_like where restaurant_id = ?`,
|
||||||
|
[restaurantId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
liked,
|
||||||
|
likeCount: Number(rows[0]?.cnt ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyReviews(userExternalId) {
|
||||||
|
const user = await this.#getOrCreateUser(userExternalId);
|
||||||
|
const [rows] = await this.pool.query(
|
||||||
|
`select
|
||||||
|
rr.id,
|
||||||
|
rr.restaurant_id as restaurantId,
|
||||||
|
rr.rating,
|
||||||
|
rr.content,
|
||||||
|
rr.created_at as createdAt,
|
||||||
|
rr.updated_at as updatedAt,
|
||||||
|
au.nick_name as nickName
|
||||||
|
from restaurant_review rr
|
||||||
|
inner join app_user au on au.id = rr.user_id
|
||||||
|
where rr.user_id = ? and rr.deleted_at is null
|
||||||
|
order by rr.created_at desc`,
|
||||||
|
[user.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
restaurantId: row.restaurantId,
|
||||||
|
userExternalId,
|
||||||
|
nickName: row.nickName,
|
||||||
|
rating: Number(row.rating),
|
||||||
|
content: row.content,
|
||||||
|
createdAt: toIso(row.createdAt),
|
||||||
|
updatedAt: toIso(row.updatedAt),
|
||||||
|
mine: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async #getOrCreateUser(userExternalId) {
|
||||||
|
const [rows] = await this.pool.query(
|
||||||
|
`select id, nick_name as nickName
|
||||||
|
from app_user
|
||||||
|
where provider = 'toss' and provider_user_id = ?
|
||||||
|
limit 1`,
|
||||||
|
[userExternalId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
return { id: rows[0].id, nickName: rows[0].nickName };
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdId = uuidv4();
|
||||||
|
const nickName = userExternalId === 'demo-user-1' ? '나' : '사용자';
|
||||||
|
await this.pool.query(
|
||||||
|
`insert into app_user (id, provider, provider_user_id, nick_name)
|
||||||
|
values (?, 'toss', ?, ?)`,
|
||||||
|
[createdId, userExternalId, nickName],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { id: createdId, nickName };
|
||||||
|
}
|
||||||
|
|
||||||
|
async #refreshStat(restaurantId) {
|
||||||
|
await this.pool.query(`call refresh_restaurant_stat(?)`, [restaurantId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createStore() {
|
||||||
|
const mysqlEnabled = Boolean(process.env.MYSQL_HOST || process.env.MYSQL_URL);
|
||||||
|
if (!mysqlEnabled) {
|
||||||
|
return {
|
||||||
|
mode: 'memory',
|
||||||
|
store: new MemoryStore(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = process.env.MYSQL_URL
|
||||||
|
? mysql.createPool(process.env.MYSQL_URL)
|
||||||
|
: mysql.createPool({
|
||||||
|
host: process.env.MYSQL_HOST,
|
||||||
|
port: Number(process.env.MYSQL_PORT || 3306),
|
||||||
|
user: process.env.MYSQL_USER,
|
||||||
|
password: process.env.MYSQL_PASSWORD,
|
||||||
|
database: process.env.MYSQL_DATABASE,
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = new MySQLStore(pool);
|
||||||
|
await store.bootstrap();
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: 'mysql',
|
||||||
|
store,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortItems(items, sort) {
|
||||||
|
const copied = [...items];
|
||||||
|
|
||||||
|
if (sort === 'rating') {
|
||||||
|
return copied.sort((a, b) => {
|
||||||
|
if (b.averageRating === a.averageRating) {
|
||||||
|
return b.reviewCount - a.reviewCount;
|
||||||
|
}
|
||||||
|
return b.averageRating - a.averageRating;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sort === 'likes') {
|
||||||
|
return copied.sort((a, b) => {
|
||||||
|
if (b.likeCount === a.likeCount) {
|
||||||
|
return a.distanceMeters - b.distanceMeters;
|
||||||
|
}
|
||||||
|
return b.likeCount - a.likeCount;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return copied.sort((a, b) => a.distanceMeters - b.distanceMeters);
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundTo1(value) {
|
||||||
|
return Math.round(value * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function haversineMeters(lat1, lng1, lat2, lng2) {
|
||||||
|
const toRad = (value) => (value * Math.PI) / 180;
|
||||||
|
const earthRadius = 6371000;
|
||||||
|
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLng = toRad(lng2 - lng1);
|
||||||
|
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
|
||||||
|
Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||||
|
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
return earthRadius * c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIso(dateLike) {
|
||||||
|
return new Date(dateLike).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createStore,
|
||||||
|
};
|
||||||
72
template/check-apps-in-toss-build.sh
Executable file
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "[1/6] package.json scripts"
|
||||||
|
node -e "const p=require('./package.json'); console.log('dev =', p.scripts?.dev ?? ''); console.log('build =', p.scripts?.build ?? ''); console.log('deploy=', p.scripts?.deploy ?? '');"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "[2/6] required starter files"
|
||||||
|
for path in babel.config.js index.js src/pages/_404.tsx; do
|
||||||
|
if [[ -f "$path" ]]; then
|
||||||
|
echo "OK: $path"
|
||||||
|
else
|
||||||
|
echo "Missing: $path"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "[3/6] granite.config.ts target"
|
||||||
|
if rg -n "target:\s*['\"]0\.84\.0['\"]" granite.config.ts >/dev/null; then
|
||||||
|
rg -n "target:\s*['\"]0\.84\.0['\"]" granite.config.ts
|
||||||
|
else
|
||||||
|
echo "target: '0.84.0' not found in granite.config.ts"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "[4/6] babel preset"
|
||||||
|
node -e "const p=require('./babel.config.js'); const presets=Array.isArray(p.presets)?p.presets:[]; console.log('presets =', presets.join(', ')); if(!presets.includes('babel-preset-granite')) process.exit(1);"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "[5/6] build"
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "[6/6] .ait bundles"
|
||||||
|
python3 - <<'PY'
|
||||||
|
import pathlib
|
||||||
|
import zipfile
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ait_files = sorted(pathlib.Path('.').glob('*.ait'))
|
||||||
|
if not ait_files:
|
||||||
|
print('No .ait file found')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
path = ait_files[0]
|
||||||
|
print(path.name)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(path) as z:
|
||||||
|
bundles = [
|
||||||
|
name for name in z.namelist()
|
||||||
|
if name.startswith('bundle.') and name.endswith('.js')
|
||||||
|
]
|
||||||
|
|
||||||
|
for name in bundles:
|
||||||
|
print(name)
|
||||||
|
|
||||||
|
required = {
|
||||||
|
'bundle.ios.0_84_0.js',
|
||||||
|
'bundle.android.0_84_0.js',
|
||||||
|
}
|
||||||
|
|
||||||
|
missing = sorted(required.difference(bundles))
|
||||||
|
if missing:
|
||||||
|
print('\nMissing required 0.84 bundles:')
|
||||||
|
for name in missing:
|
||||||
|
print(name)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print('\nOK: RN 0.84.0 bundles found')
|
||||||
|
PY
|
||||||
BIN
template/check-apps-in-toss-build.sh:Zone.Identifier
Normal file
120
template/docs/01_MVP_PRODUCT_SPEC.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# 직장인 점심 추천 미니앱 MVP 기획/기능 명세
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
- 사용자 현재 위치 기반으로 근처 식당을 빠르게 탐색한다.
|
||||||
|
- 점심 시간 의사결정을 줄이기 위해 카테고리 필터(한식/중식/일식/양식/기타)와 정렬(거리/평점/좋아요)을 제공한다.
|
||||||
|
- 사용자 생성 데이터(리뷰/별점/좋아요)를 누적해 추천 품질을 개선한다.
|
||||||
|
|
||||||
|
## 2. MVP 범위
|
||||||
|
### 2.1 포함
|
||||||
|
- 위치 권한 요청 및 현재 좌표 획득
|
||||||
|
- 반경 N미터 내 식당 목록 조회
|
||||||
|
- 카테고리 필터
|
||||||
|
- 식당 상세(기본 정보, 평균 평점, 좋아요 수, 최근 리뷰)
|
||||||
|
- 리뷰 작성/수정/삭제
|
||||||
|
- 별점(1~5) 작성
|
||||||
|
- 좋아요 토글
|
||||||
|
- 사용자별 내 리뷰 목록
|
||||||
|
|
||||||
|
### 2.2 제외(2차)
|
||||||
|
- 결제/예약
|
||||||
|
- 친구 초대/소셜 그래프
|
||||||
|
- AI 취향 학습 개인화 추천
|
||||||
|
- 관리자 운영 콘솔 웹
|
||||||
|
|
||||||
|
## 3. 사용자 시나리오
|
||||||
|
1. 사용자가 미니앱 실행
|
||||||
|
2. 위치 권한 동의
|
||||||
|
3. 현재 위치 기준 주변 식당 로딩
|
||||||
|
4. 카테고리(예: 일식) 선택
|
||||||
|
5. 정렬 기준(거리순/평점순) 선택
|
||||||
|
6. 식당 상세 진입
|
||||||
|
7. 리뷰와 평점 확인 후 본인 리뷰 남김
|
||||||
|
8. 마음에 드는 식당 좋아요
|
||||||
|
|
||||||
|
## 4. 화면 구조(MVP)
|
||||||
|
### 4.1 홈/목록 화면
|
||||||
|
- 상단: 현재 위치(동 이름 또는 좌표 기반 주소), 반경 선택(500m/1km/2km)
|
||||||
|
- 필터: 카테고리 탭
|
||||||
|
- 정렬: 거리순, 평점순, 좋아요순
|
||||||
|
- 리스트 카드: 식당명, 카테고리, 거리, 평균평점, 좋아요수, 리뷰수
|
||||||
|
|
||||||
|
### 4.2 식당 상세 화면
|
||||||
|
- 기본 정보: 상호명, 주소, 영업시간, 전화번호
|
||||||
|
- 통계: 평균평점, 총 리뷰수, 좋아요수
|
||||||
|
- 액션: 좋아요 버튼, 리뷰 작성 버튼
|
||||||
|
- 리뷰 리스트: 최신순 기본, 본인 리뷰 강조
|
||||||
|
|
||||||
|
### 4.3 리뷰 작성/수정 화면
|
||||||
|
- 평점(1~5)
|
||||||
|
- 텍스트(최대 500자)
|
||||||
|
- 저장/취소
|
||||||
|
|
||||||
|
### 4.4 내 활동 화면
|
||||||
|
- 내가 작성한 리뷰 목록
|
||||||
|
- 내가 좋아요한 식당 목록(선택)
|
||||||
|
|
||||||
|
## 5. 추천/정렬 규칙(MVP)
|
||||||
|
- 기본 정렬: 거리 오름차순
|
||||||
|
- 평점 정렬: 평균 평점 내림차순, 동점 시 리뷰수 내림차순
|
||||||
|
- 좋아요 정렬: 좋아요수 내림차순, 동점 시 거리 오름차순
|
||||||
|
|
||||||
|
## 6. 카테고리 정책
|
||||||
|
- 내부 표준 카테고리
|
||||||
|
- KOREAN
|
||||||
|
- CHINESE
|
||||||
|
- JAPANESE
|
||||||
|
- WESTERN
|
||||||
|
- OTHER
|
||||||
|
- 외부 장소 API 카테고리 값을 내부 표준 카테고리로 매핑
|
||||||
|
- 매핑 실패 시 OTHER
|
||||||
|
|
||||||
|
## 7. 권한/개인정보 최소 수집
|
||||||
|
- 필수: 위치 권한
|
||||||
|
- 선택: 알림(2차)
|
||||||
|
- 사용자 식별자: 토스 식별자 또는 내부 user_id
|
||||||
|
- 저장 데이터
|
||||||
|
- 리뷰 텍스트, 평점
|
||||||
|
- 좋아요 이력
|
||||||
|
- 위치는 조회 시점 중심 좌표만 사용(정밀 로그 장기 저장 금지 권장)
|
||||||
|
|
||||||
|
## 8. 비기능 요구사항
|
||||||
|
- 최초 화면 진입 10초 이내
|
||||||
|
- 목록 API p95 800ms 이하 목표
|
||||||
|
- 앱 크래시 없이 복구 가능한 오류 처리
|
||||||
|
- 네트워크 장애 시 재시도/안내 메시지 제공
|
||||||
|
|
||||||
|
## 9. 운영 정책
|
||||||
|
- 욕설/비방 리뷰 신고 플로우(간단 신고 버튼)
|
||||||
|
- 중복 리뷰 정책
|
||||||
|
- 식당당 사용자 1개 리뷰 원칙
|
||||||
|
- 작성 시 upsert(기존 리뷰 수정)
|
||||||
|
- 좋아요 정책
|
||||||
|
- 식당당 사용자 1회
|
||||||
|
- 토글 허용
|
||||||
|
|
||||||
|
## 10. 성공 지표(MVP)
|
||||||
|
- DAU
|
||||||
|
- 위치 권한 허용률
|
||||||
|
- 리뷰 작성 전환율
|
||||||
|
- 식당 상세 진입률
|
||||||
|
- 재방문율(7일)
|
||||||
|
|
||||||
|
## 11. 리스크와 대응
|
||||||
|
- 위치 권한 거부율 높음
|
||||||
|
- 대안: 수동 지역 선택(구/동) 제공
|
||||||
|
- 카테고리 오분류
|
||||||
|
- 대안: 사용자 제보/운영 보정
|
||||||
|
- 초기 리뷰 부족
|
||||||
|
- 대안: 기본 외부 평점(가능 시) + 내부 리뷰 분리 표기
|
||||||
|
|
||||||
|
## 12. 릴리즈 단위
|
||||||
|
### v0.1 (MVP)
|
||||||
|
- 위치 기반 목록/상세
|
||||||
|
- 카테고리 필터
|
||||||
|
- 리뷰/평점/좋아요
|
||||||
|
|
||||||
|
### v0.2
|
||||||
|
- 개인화 추천(최근 선택 기반)
|
||||||
|
- 점심시간 알림
|
||||||
|
- 회사/사무실 즐겨찾기
|
||||||
148
template/docs/02_IMPLEMENTATION_PLAN.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# 직장인 점심 추천 미니앱 구현 계획
|
||||||
|
|
||||||
|
## 1. 기술 스택 제안
|
||||||
|
- MiniApp Frontend: Apps in Toss + Granite RN
|
||||||
|
- Backend API: Node.js (NestJS/Express) or Java Spring Boot
|
||||||
|
- DB: PostgreSQL
|
||||||
|
- Cache: Redis (선택)
|
||||||
|
- 지도/장소 데이터: Kakao/Google/Naver Place API 중 1개 선택
|
||||||
|
|
||||||
|
## 2. 프론트엔드 디렉터리 구조 제안
|
||||||
|
```
|
||||||
|
template/src/
|
||||||
|
_app.tsx
|
||||||
|
pages/
|
||||||
|
index.tsx # 홈/목록
|
||||||
|
restaurants/[id].tsx # 식당 상세
|
||||||
|
reviews/edit.tsx # 리뷰 작성/수정
|
||||||
|
me/reviews.tsx # 내 리뷰
|
||||||
|
_404.tsx
|
||||||
|
features/
|
||||||
|
location/
|
||||||
|
useCurrentLocation.ts
|
||||||
|
restaurants/
|
||||||
|
api.ts
|
||||||
|
models.ts
|
||||||
|
hooks.ts
|
||||||
|
reviews/
|
||||||
|
api.ts
|
||||||
|
hooks.ts
|
||||||
|
likes/
|
||||||
|
api.ts
|
||||||
|
components/
|
||||||
|
RestaurantCard.tsx
|
||||||
|
CategoryFilter.tsx
|
||||||
|
SortSelector.tsx
|
||||||
|
RatingStars.tsx
|
||||||
|
EmptyState.tsx
|
||||||
|
lib/
|
||||||
|
httpClient.ts
|
||||||
|
queryClient.ts
|
||||||
|
error.ts
|
||||||
|
constants/
|
||||||
|
category.ts
|
||||||
|
sort.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 백엔드 모듈 구조 제안
|
||||||
|
- auth
|
||||||
|
- 토스 사용자 식별자 검증/매핑
|
||||||
|
- restaurants
|
||||||
|
- 주변 조회, 상세 조회
|
||||||
|
- reviews
|
||||||
|
- 리뷰 upsert/delete/list
|
||||||
|
- likes
|
||||||
|
- 좋아요 토글
|
||||||
|
- users
|
||||||
|
- 내 리뷰 조회
|
||||||
|
- integrations
|
||||||
|
- 장소 API 클라이언트, 카테고리 매핑
|
||||||
|
|
||||||
|
## 4. 핵심 유스케이스별 처리
|
||||||
|
### 4.1 주변 식당 조회
|
||||||
|
1. FE가 현재 좌표(lat,lng) 획득
|
||||||
|
2. GET /v1/restaurants/nearby 호출
|
||||||
|
3. BE는 내부 캐시 우선 조회
|
||||||
|
4. 캐시 미스 시 장소 API 호출 + 표준 카테고리 매핑 + DB upsert
|
||||||
|
5. 통계(join restaurant_stat) 결합 후 반환
|
||||||
|
|
||||||
|
### 4.2 리뷰 작성/수정
|
||||||
|
1. FE가 rating/content 전송
|
||||||
|
2. BE가 (restaurant_id, user_id) unique 기준 upsert
|
||||||
|
3. refresh_restaurant_stat(restaurant_id) 호출
|
||||||
|
4. 최신 리뷰/평점 반환
|
||||||
|
|
||||||
|
### 4.3 좋아요 토글
|
||||||
|
1. row 존재 여부 확인
|
||||||
|
2. 있으면 delete, 없으면 insert
|
||||||
|
3. refresh_restaurant_stat 호출
|
||||||
|
4. liked 상태와 like_count 반환
|
||||||
|
|
||||||
|
## 5. API 에러 규약
|
||||||
|
- 공통 포맷
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "INVALID_INPUT",
|
||||||
|
"message": "rating must be between 1 and 5",
|
||||||
|
"traceId": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 주요 코드
|
||||||
|
- UNAUTHORIZED
|
||||||
|
- FORBIDDEN
|
||||||
|
- NOT_FOUND
|
||||||
|
- INVALID_INPUT
|
||||||
|
- RATE_LIMITED
|
||||||
|
- INTERNAL_ERROR
|
||||||
|
|
||||||
|
## 6. 보안/악용 방지
|
||||||
|
- 리뷰/좋아요 API 인증 필수
|
||||||
|
- 리뷰 작성 rate limit (예: 사용자당 분당 5회)
|
||||||
|
- 욕설 필터(간단 키워드 + 신고 플래그)
|
||||||
|
- SQL injection/XSS 대비(파라미터 바인딩, escaping)
|
||||||
|
|
||||||
|
## 7. QA 체크리스트
|
||||||
|
- 위치 권한 허용/거부 둘 다 정상 동작
|
||||||
|
- 권한 거부 시 수동 지역 선택 제공
|
||||||
|
- 0개 결과(empty state) UI
|
||||||
|
- 느린 네트워크에서 skeleton/loading 표시
|
||||||
|
- 리뷰 저장 후 목록/평점 즉시 반영
|
||||||
|
- 좋아요 토글 연타 시 데이터 일관성 확인
|
||||||
|
- 앱 백그라운드 전환 후 복귀 시 상태 복원
|
||||||
|
|
||||||
|
## 8. 출시 전 운영 준비
|
||||||
|
- 개인정보처리방침/이용약관 페이지
|
||||||
|
- 고객 문의 채널(이메일/채팅 URL)
|
||||||
|
- 모니터링 대시보드
|
||||||
|
- API 성공률
|
||||||
|
- p95 latency
|
||||||
|
- 에러율
|
||||||
|
- 장애 대응 runbook
|
||||||
|
|
||||||
|
## 9. 개발 일정(예시, 2주)
|
||||||
|
### Day 1-2
|
||||||
|
- 프로젝트 구조 확장
|
||||||
|
- 공통 HTTP 클라이언트/에러 처리
|
||||||
|
|
||||||
|
### Day 3-5
|
||||||
|
- 식당 목록/상세 API + 화면
|
||||||
|
- 위치 권한/좌표 처리
|
||||||
|
|
||||||
|
### Day 6-8
|
||||||
|
- 리뷰 CRUD + 좋아요 토글
|
||||||
|
- 통계 반영
|
||||||
|
|
||||||
|
### Day 9-10
|
||||||
|
- 내 활동 화면
|
||||||
|
- QA/버그 수정
|
||||||
|
|
||||||
|
### Day 11-12
|
||||||
|
- 성능/로그 점검
|
||||||
|
- 검수 대응 문서 정리
|
||||||
|
|
||||||
|
## 10. 바로 시작할 개발 순서
|
||||||
|
1. `granite.config.ts`의 appName/brand 값 실서비스 값으로 변경
|
||||||
|
2. 홈 목록 화면에 위치 권한 + nearby API 연결
|
||||||
|
3. 식당 상세와 리뷰 작성 페이지 연결
|
||||||
|
4. 리뷰/좋아요 API 붙이고 optimistic update 적용
|
||||||
|
5. 검수 체크리스트 기준 점검 후 배포
|
||||||
50
template/docs/03_RELEASE_CHECKLIST.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# 토스 미니앱 배포 체크리스트 (점심 추천 서비스)
|
||||||
|
|
||||||
|
## 1. 앱 기본 정보
|
||||||
|
- [ ] `appName`이 콘솔 등록 값과 정확히 일치
|
||||||
|
- [ ] 앱 이름/아이콘/브랜드 컬러 반영
|
||||||
|
- [ ] 고객 문의 이메일/채널 기입
|
||||||
|
- [ ] 이용 연령, 카테고리 설정 완료
|
||||||
|
|
||||||
|
## 2. 권한/정책
|
||||||
|
- [ ] 위치 권한 요청 사유를 사용자에게 명확히 설명
|
||||||
|
- [ ] 권한 거부 시 대체 경로(수동 지역 선택) 제공
|
||||||
|
- [ ] 외부 링크 유도/자사앱 설치 유도 없음
|
||||||
|
- [ ] 민감 정보 과수집 없음
|
||||||
|
|
||||||
|
## 3. UX/기능
|
||||||
|
- [ ] 첫 화면 10초 이내 진입
|
||||||
|
- [ ] Safe Area 미침범
|
||||||
|
- [ ] 뒤로가기/닫기 동작 정상
|
||||||
|
- [ ] 빈 결과/오류 상태 UI 제공
|
||||||
|
- [ ] 리뷰 작성/수정/삭제 정상
|
||||||
|
- [ ] 좋아요 토글 정상 및 중복 방지
|
||||||
|
|
||||||
|
## 4. 데이터 품질
|
||||||
|
- [ ] 카테고리 매핑 정확도 점검(일식/중식/양식)
|
||||||
|
- [ ] 거리 계산 오차 허용 범위 점검
|
||||||
|
- [ ] 평균 평점/좋아요 수 집계 검증
|
||||||
|
|
||||||
|
## 5. 성능/안정성
|
||||||
|
- [ ] 목록 API p95 지연 측정
|
||||||
|
- [ ] 동일 요청 과다 호출 방지(debounce/throttle)
|
||||||
|
- [ ] 앱 백그라운드 복귀 시 상태 복원
|
||||||
|
- [ ] 장애 로그/추적 ID 수집
|
||||||
|
|
||||||
|
## 6. 보안
|
||||||
|
- [ ] 인증 없는 쓰기 API 차단
|
||||||
|
- [ ] 입력 검증(rating 범위, content 길이)
|
||||||
|
- [ ] SQL injection/XSS 방어
|
||||||
|
- [ ] rate limit 적용
|
||||||
|
|
||||||
|
## 7. 배포 산출물
|
||||||
|
- [ ] `npm run build` 성공
|
||||||
|
- [ ] `.ait` 산출물 생성 확인
|
||||||
|
- [ ] RN 0.84.0 번들 포함 확인
|
||||||
|
- [ ] 콘솔 업로드 및 테스트 QR 검증
|
||||||
|
|
||||||
|
## 8. 운영 준비
|
||||||
|
- [ ] 개인정보처리방침 URL
|
||||||
|
- [ ] 이용약관 URL
|
||||||
|
- [ ] 신고/문의 프로세스
|
||||||
|
- [ ] 장애 대응 담당자 지정
|
||||||
53
template/docs/04_LOCAL_DEV.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Local Development Guide
|
||||||
|
|
||||||
|
## 1) Backend 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mingking2/pick-lunch/template/backend
|
||||||
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
기본 주소: `http://localhost:4000`
|
||||||
|
|
||||||
|
## 2) MySQL 연결 (선택)
|
||||||
|
|
||||||
|
MySQL 없이도 메모리 모드로 동작합니다.
|
||||||
|
MySQL을 쓰려면:
|
||||||
|
|
||||||
|
1. DB 생성
|
||||||
|
```sql
|
||||||
|
create database lunch_picker character set utf8mb4 collate utf8mb4_0900_ai_ci;
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 스키마 반영
|
||||||
|
```bash
|
||||||
|
mysql -u root -p lunch_picker < /home/mingking2/pick-lunch/template/docs/db/schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
3. `backend/.env`에 `MYSQL_HOST`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE` 설정
|
||||||
|
|
||||||
|
## 3) MiniApp API 연결
|
||||||
|
|
||||||
|
파일: `src/features/restaurants/service.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const API_BASE_URL = 'http://localhost:4000';
|
||||||
|
```
|
||||||
|
|
||||||
|
주의: 실제 토스 인앱 테스트/배포에서는 `localhost` 대신
|
||||||
|
외부 접근 가능한 HTTPS 백엔드 도메인을 사용해야 합니다.
|
||||||
|
|
||||||
|
## 4) 프론트 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mingking2/pick-lunch/template
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5) 프론트 빌드
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
8
template/docs/README.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Lunch MiniApp Docs
|
||||||
|
|
||||||
|
- `01_MVP_PRODUCT_SPEC.md`: 제품/기능 명세
|
||||||
|
- `02_IMPLEMENTATION_PLAN.md`: 구현 구조/일정/QA 계획
|
||||||
|
- `03_RELEASE_CHECKLIST.md`: 배포 전 점검표
|
||||||
|
- `04_LOCAL_DEV.md`: 로컬 실행/연동 가이드
|
||||||
|
- `api/openapi.yaml`: API 계약(OpenAPI 3.0)
|
||||||
|
- `db/schema.sql`: DB 스키마(MySQL 8.0)
|
||||||
356
template/docs/api/openapi.yaml
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Lunch MiniApp API
|
||||||
|
version: 0.1.0
|
||||||
|
description: |
|
||||||
|
직장인 점심 추천 미니앱 MVP API
|
||||||
|
servers:
|
||||||
|
- url: https://api.example.com
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- name: Health
|
||||||
|
- name: Restaurants
|
||||||
|
- name: Reviews
|
||||||
|
- name: Likes
|
||||||
|
- name: Users
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/v1/health:
|
||||||
|
get:
|
||||||
|
tags: [Health]
|
||||||
|
summary: 헬스 체크
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: ok
|
||||||
|
|
||||||
|
/v1/restaurants/nearby:
|
||||||
|
get:
|
||||||
|
tags: [Restaurants]
|
||||||
|
summary: 주변 식당 목록 조회
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: lat
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
format: double
|
||||||
|
- in: query
|
||||||
|
name: lng
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
format: double
|
||||||
|
- in: query
|
||||||
|
name: radiusMeters
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 1000
|
||||||
|
enum: [500, 1000, 2000]
|
||||||
|
- in: query
|
||||||
|
name: category
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RestaurantCategory'
|
||||||
|
- in: query
|
||||||
|
name: sort
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [distance, rating, likes]
|
||||||
|
default: distance
|
||||||
|
- in: query
|
||||||
|
name: cursor
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- in: query
|
||||||
|
name: size
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 50
|
||||||
|
default: 20
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 조회 성공
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/RestaurantListItem'
|
||||||
|
nextCursor:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
|
||||||
|
/v1/restaurants/{restaurantId}:
|
||||||
|
get:
|
||||||
|
tags: [Restaurants]
|
||||||
|
summary: 식당 상세 조회
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: restaurantId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 조회 성공
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RestaurantDetail'
|
||||||
|
'404':
|
||||||
|
description: 식당 없음
|
||||||
|
|
||||||
|
/v1/restaurants/{restaurantId}/reviews:
|
||||||
|
get:
|
||||||
|
tags: [Reviews]
|
||||||
|
summary: 식당 리뷰 목록 조회
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: restaurantId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
- in: query
|
||||||
|
name: cursor
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- in: query
|
||||||
|
name: size
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 20
|
||||||
|
minimum: 1
|
||||||
|
maximum: 50
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 조회 성공
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ReviewItem'
|
||||||
|
nextCursor:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
|
||||||
|
post:
|
||||||
|
tags: [Reviews]
|
||||||
|
summary: 리뷰 작성 또는 수정(upsert)
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: restaurantId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ReviewUpsertRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 저장 성공
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ReviewItem'
|
||||||
|
'400':
|
||||||
|
description: 유효성 오류
|
||||||
|
'401':
|
||||||
|
description: 인증 필요
|
||||||
|
|
||||||
|
/v1/reviews/{reviewId}:
|
||||||
|
delete:
|
||||||
|
tags: [Reviews]
|
||||||
|
summary: 리뷰 삭제
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: reviewId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: 삭제 성공
|
||||||
|
'401':
|
||||||
|
description: 인증 필요
|
||||||
|
'403':
|
||||||
|
description: 본인 리뷰만 삭제 가능
|
||||||
|
|
||||||
|
/v1/restaurants/{restaurantId}/like:
|
||||||
|
post:
|
||||||
|
tags: [Likes]
|
||||||
|
summary: 좋아요 토글
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: restaurantId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 토글 성공
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
liked:
|
||||||
|
type: boolean
|
||||||
|
likeCount:
|
||||||
|
type: integer
|
||||||
|
|
||||||
|
/v1/users/me/reviews:
|
||||||
|
get:
|
||||||
|
tags: [Users]
|
||||||
|
summary: 내 리뷰 목록
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: cursor
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- in: query
|
||||||
|
name: size
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 20
|
||||||
|
minimum: 1
|
||||||
|
maximum: 50
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 조회 성공
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ReviewItem'
|
||||||
|
nextCursor:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
RestaurantCategory:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- KOREAN
|
||||||
|
- CHINESE
|
||||||
|
- JAPANESE
|
||||||
|
- WESTERN
|
||||||
|
- OTHER
|
||||||
|
|
||||||
|
RestaurantListItem:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
category:
|
||||||
|
$ref: '#/components/schemas/RestaurantCategory'
|
||||||
|
distanceMeters:
|
||||||
|
type: integer
|
||||||
|
averageRating:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
reviewCount:
|
||||||
|
type: integer
|
||||||
|
likeCount:
|
||||||
|
type: integer
|
||||||
|
likedByMe:
|
||||||
|
type: boolean
|
||||||
|
required: [id, name, category, distanceMeters, averageRating, reviewCount, likeCount, likedByMe]
|
||||||
|
|
||||||
|
RestaurantDetail:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/RestaurantListItem'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
address:
|
||||||
|
type: string
|
||||||
|
phone:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
openingHours:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
lat:
|
||||||
|
type: number
|
||||||
|
format: double
|
||||||
|
lng:
|
||||||
|
type: number
|
||||||
|
format: double
|
||||||
|
|
||||||
|
ReviewUpsertRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
rating:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 5
|
||||||
|
content:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 500
|
||||||
|
required: [rating, content]
|
||||||
|
|
||||||
|
ReviewItem:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
restaurantId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
userId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
nickName:
|
||||||
|
type: string
|
||||||
|
rating:
|
||||||
|
type: integer
|
||||||
|
content:
|
||||||
|
type: string
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
updatedAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
mine:
|
||||||
|
type: boolean
|
||||||
|
required: [id, restaurantId, userId, nickName, rating, content, createdAt, updatedAt, mine]
|
||||||
127
template/docs/db/schema.sql
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
-- Lunch MiniApp MVP MySQL Schema
|
||||||
|
-- Assumption: MySQL 8.0+
|
||||||
|
-- Charset/Collation: utf8mb4
|
||||||
|
|
||||||
|
set names utf8mb4;
|
||||||
|
|
||||||
|
-- 1) users
|
||||||
|
create table if not exists app_user (
|
||||||
|
id char(36) not null,
|
||||||
|
provider varchar(32) not null default 'toss',
|
||||||
|
provider_user_id varchar(128) not null,
|
||||||
|
nick_name varchar(50) not null,
|
||||||
|
created_at timestamp not null default current_timestamp,
|
||||||
|
updated_at timestamp not null default current_timestamp on update current_timestamp,
|
||||||
|
primary key (id),
|
||||||
|
unique key uk_app_user_provider (provider, provider_user_id)
|
||||||
|
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- 2) restaurants
|
||||||
|
create table if not exists restaurant (
|
||||||
|
id char(36) not null,
|
||||||
|
external_place_id varchar(128) null,
|
||||||
|
name varchar(120) not null,
|
||||||
|
category varchar(20) not null,
|
||||||
|
address varchar(255) not null,
|
||||||
|
phone varchar(30) null,
|
||||||
|
opening_hours text null,
|
||||||
|
lat double not null,
|
||||||
|
lng double not null,
|
||||||
|
is_active tinyint(1) not null default 1,
|
||||||
|
created_at timestamp not null default current_timestamp,
|
||||||
|
updated_at timestamp not null default current_timestamp on update current_timestamp,
|
||||||
|
primary key (id),
|
||||||
|
unique key uk_restaurant_external_place_id (external_place_id),
|
||||||
|
key idx_restaurant_lat_lng (lat, lng),
|
||||||
|
key idx_restaurant_category (category),
|
||||||
|
constraint chk_restaurant_category check (category in ('KOREAN','CHINESE','JAPANESE','WESTERN','OTHER'))
|
||||||
|
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- 3) reviews (1 user : 1 restaurant)
|
||||||
|
create table if not exists restaurant_review (
|
||||||
|
id char(36) not null,
|
||||||
|
restaurant_id char(36) not null,
|
||||||
|
user_id char(36) not null,
|
||||||
|
rating tinyint not null,
|
||||||
|
content varchar(500) not null,
|
||||||
|
deleted_at timestamp null,
|
||||||
|
created_at timestamp not null default current_timestamp,
|
||||||
|
updated_at timestamp not null default current_timestamp on update current_timestamp,
|
||||||
|
primary key (id),
|
||||||
|
unique key uk_review_restaurant_user (restaurant_id, user_id),
|
||||||
|
key idx_review_restaurant_created_at (restaurant_id, created_at),
|
||||||
|
key idx_review_user_created_at (user_id, created_at),
|
||||||
|
constraint fk_review_restaurant foreign key (restaurant_id) references restaurant(id) on delete cascade,
|
||||||
|
constraint fk_review_user foreign key (user_id) references app_user(id) on delete cascade,
|
||||||
|
constraint chk_review_rating check (rating between 1 and 5)
|
||||||
|
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- 4) likes (1 user : 1 restaurant)
|
||||||
|
create table if not exists restaurant_like (
|
||||||
|
restaurant_id char(36) not null,
|
||||||
|
user_id char(36) not null,
|
||||||
|
created_at timestamp not null default current_timestamp,
|
||||||
|
primary key (restaurant_id, user_id),
|
||||||
|
key idx_like_restaurant (restaurant_id),
|
||||||
|
key idx_like_user (user_id),
|
||||||
|
constraint fk_like_restaurant foreign key (restaurant_id) references restaurant(id) on delete cascade,
|
||||||
|
constraint fk_like_user foreign key (user_id) references app_user(id) on delete cascade
|
||||||
|
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- 5) denormalized stats
|
||||||
|
create table if not exists restaurant_stat (
|
||||||
|
restaurant_id char(36) not null,
|
||||||
|
review_count int not null default 0,
|
||||||
|
like_count int not null default 0,
|
||||||
|
rating_sum int not null default 0,
|
||||||
|
average_rating decimal(3,2) not null default 0.00,
|
||||||
|
updated_at timestamp not null default current_timestamp on update current_timestamp,
|
||||||
|
primary key (restaurant_id),
|
||||||
|
constraint fk_stat_restaurant foreign key (restaurant_id) references restaurant(id) on delete cascade
|
||||||
|
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- optional seed behavior:
|
||||||
|
-- insert into restaurant_stat (restaurant_id)
|
||||||
|
-- select r.id from restaurant r
|
||||||
|
-- on duplicate key update restaurant_id = values(restaurant_id);
|
||||||
|
|
||||||
|
-- 6) stat refresh procedure (call from app service after write)
|
||||||
|
drop procedure if exists refresh_restaurant_stat;
|
||||||
|
delimiter $$
|
||||||
|
create procedure refresh_restaurant_stat(in p_restaurant_id char(36))
|
||||||
|
begin
|
||||||
|
declare v_review_count int default 0;
|
||||||
|
declare v_like_count int default 0;
|
||||||
|
declare v_rating_sum int default 0;
|
||||||
|
declare v_average decimal(3,2) default 0.00;
|
||||||
|
|
||||||
|
select count(*), ifnull(sum(r.rating), 0)
|
||||||
|
into v_review_count, v_rating_sum
|
||||||
|
from restaurant_review r
|
||||||
|
where r.restaurant_id = p_restaurant_id
|
||||||
|
and r.deleted_at is null;
|
||||||
|
|
||||||
|
select count(*)
|
||||||
|
into v_like_count
|
||||||
|
from restaurant_like l
|
||||||
|
where l.restaurant_id = p_restaurant_id;
|
||||||
|
|
||||||
|
if v_review_count = 0 then
|
||||||
|
set v_average = 0.00;
|
||||||
|
else
|
||||||
|
set v_average = round(v_rating_sum / v_review_count, 2);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into restaurant_stat (
|
||||||
|
restaurant_id, review_count, like_count, rating_sum, average_rating, updated_at
|
||||||
|
) values (
|
||||||
|
p_restaurant_id, v_review_count, v_like_count, v_rating_sum, v_average, current_timestamp
|
||||||
|
)
|
||||||
|
on duplicate key update
|
||||||
|
review_count = values(review_count),
|
||||||
|
like_count = values(like_count),
|
||||||
|
rating_sum = values(rating_sum),
|
||||||
|
average_rating = values(average_rating),
|
||||||
|
updated_at = current_timestamp;
|
||||||
|
end $$
|
||||||
|
delimiter ;
|
||||||
27
template/granite.config.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { appsInToss } from '@apps-in-toss/framework/plugins';
|
||||||
|
import { defineConfig } from '@granite-js/react-native/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
appName: 'lunch-picker',
|
||||||
|
scheme: 'intoss',
|
||||||
|
metro: {
|
||||||
|
resolver: {
|
||||||
|
useWatchman: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
appsInToss({
|
||||||
|
target: '0.84.0',
|
||||||
|
brand: {
|
||||||
|
displayName: '점심픽',
|
||||||
|
primaryColor: '#0EA5E9',
|
||||||
|
icon: 'https://static.toss.im/appsintoss/your-icon.png',
|
||||||
|
},
|
||||||
|
navigationBar: {
|
||||||
|
withBackButton: true,
|
||||||
|
withHomeButton: true,
|
||||||
|
},
|
||||||
|
permissions: [],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
BIN
template/granite.config.ts:Zone.Identifier
Normal file
3
template/index.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
const { register } = require('@granite-js/react-native');
|
||||||
|
|
||||||
|
register(require('./src/_app').default);
|
||||||
BIN
template/index.js:Zone.Identifier
Normal file
25187
template/package-lock.json
generated
Normal file
25
template/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "lunch-picker",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "granite dev",
|
||||||
|
"build": "ait build",
|
||||||
|
"deploy": "ait deploy",
|
||||||
|
"check:build": "./check-apps-in-toss-build.sh"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@apps-in-toss/framework": "^2.4.1",
|
||||||
|
"@granite-js/native": "1.0.10",
|
||||||
|
"@granite-js/react-native": "1.0.10",
|
||||||
|
"brick-module": "0.5.1",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-native": "0.84.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@napi-rs/canvas": "^0.1.97",
|
||||||
|
"@types/react": "19.2.0",
|
||||||
|
"babel-preset-granite": "1.0.10",
|
||||||
|
"pngjs": "^7.0.0",
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
template/package.json:Zone.Identifier
Normal file
1
template/require.context.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const context = require.context('./src/pages', true, /\.[jt]sx?$/);
|
||||||
BIN
template/require.context.ts:Zone.Identifier
Normal file
BIN
template/src/.DS_Store:Zone.Identifier
Normal file
12
template/src/_app.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { PropsWithChildren } from 'react';
|
||||||
|
import { Granite, type InitialProps } from '@granite-js/react-native';
|
||||||
|
import { context } from '../require.context';
|
||||||
|
|
||||||
|
function AppContainer({ children }: PropsWithChildren<InitialProps>) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Granite.registerApp(AppContainer, {
|
||||||
|
appName: 'lunch-picker',
|
||||||
|
context,
|
||||||
|
});
|
||||||
BIN
template/src/_app.tsx:Zone.Identifier
Normal file
79
template/src/components/CategoryFilter.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import {
|
||||||
|
CATEGORY_LABELS,
|
||||||
|
CATEGORY_OPTIONS,
|
||||||
|
} from '../constants/restaurant';
|
||||||
|
import type { CategoryFilter } from '../features/restaurants/types';
|
||||||
|
|
||||||
|
interface CategoryFilterProps {
|
||||||
|
selected: CategoryFilter;
|
||||||
|
onSelect: (category: CategoryFilter) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
||||||
|
return (
|
||||||
|
<View style={styles.wrapper}>
|
||||||
|
<Text style={styles.title}>카테고리</Text>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<View style={styles.row}>
|
||||||
|
{CATEGORY_OPTIONS.map((category) => {
|
||||||
|
const isSelected = selected === category;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={category}
|
||||||
|
onPress={() => onSelect(category)}
|
||||||
|
style={[styles.button, isSelected ? styles.buttonSelected : null]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.buttonLabel,
|
||||||
|
isSelected ? styles.buttonLabelSelected : null,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{CATEGORY_LABELS[category]}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrapper: {
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: '#1F2937',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
paddingRight: 8,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
borderColor: '#D1D5DB',
|
||||||
|
borderRadius: 999,
|
||||||
|
borderWidth: 1,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
buttonSelected: {
|
||||||
|
backgroundColor: '#0EA5E9',
|
||||||
|
borderColor: '#0EA5E9',
|
||||||
|
},
|
||||||
|
buttonLabel: {
|
||||||
|
color: '#374151',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
buttonLabelSelected: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
});
|
||||||
51
template/src/components/RatingStars.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
interface RatingStarsProps {
|
||||||
|
rating: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RatingStars({ rating, size = 13 }: RatingStarsProps) {
|
||||||
|
const rounded = Math.round(rating);
|
||||||
|
const stars = Array.from({ length: 5 }, (_, index) => index < rounded);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.row}>
|
||||||
|
{stars.map((filled, index) => (
|
||||||
|
<Text
|
||||||
|
key={index}
|
||||||
|
style={[
|
||||||
|
styles.star,
|
||||||
|
{ fontSize: size },
|
||||||
|
filled ? styles.starOn : styles.starOff,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{'*'}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
<Text style={styles.score}>{rating.toFixed(1)}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
star: {
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
starOn: {
|
||||||
|
color: '#F59E0B',
|
||||||
|
},
|
||||||
|
starOff: {
|
||||||
|
color: '#D1D5DB',
|
||||||
|
},
|
||||||
|
score: {
|
||||||
|
color: '#4B5563',
|
||||||
|
fontSize: 12,
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
114
template/src/components/RestaurantCard.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { CATEGORY_LABELS } from '../constants/restaurant';
|
||||||
|
import type { RestaurantItem } from '../features/restaurants/types';
|
||||||
|
import { RatingStars } from './RatingStars';
|
||||||
|
|
||||||
|
interface RestaurantCardProps {
|
||||||
|
item: RestaurantItem;
|
||||||
|
selected: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
onToggleLike: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RestaurantCard({
|
||||||
|
item,
|
||||||
|
selected,
|
||||||
|
onPress,
|
||||||
|
onToggleLike,
|
||||||
|
}: RestaurantCardProps) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
style={[styles.card, selected ? styles.cardSelected : null]}
|
||||||
|
>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<Text style={styles.name}>{item.name}</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={onToggleLike}
|
||||||
|
style={[styles.likeButton, item.likedByMe ? styles.likeButtonOn : null]}
|
||||||
|
>
|
||||||
|
<Text style={item.likedByMe ? styles.likeTextOn : styles.likeTextOff}>
|
||||||
|
좋아요 {item.likeCount}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.meta}>
|
||||||
|
{CATEGORY_LABELS[item.category]} · {item.distanceMeters}m
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.address}>{item.address}</Text>
|
||||||
|
|
||||||
|
<View style={styles.footerRow}>
|
||||||
|
<RatingStars rating={item.averageRating} />
|
||||||
|
<Text style={styles.reviewCount}>리뷰 {item.reviewCount}</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderColor: '#E5E7EB',
|
||||||
|
borderRadius: 14,
|
||||||
|
borderWidth: 1,
|
||||||
|
marginBottom: 10,
|
||||||
|
padding: 14,
|
||||||
|
},
|
||||||
|
cardSelected: {
|
||||||
|
borderColor: '#2563EB',
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
color: '#111827',
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
likeButton: {
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
},
|
||||||
|
likeButtonOn: {
|
||||||
|
backgroundColor: '#FEE2E2',
|
||||||
|
},
|
||||||
|
likeTextOn: {
|
||||||
|
color: '#B91C1C',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
likeTextOff: {
|
||||||
|
color: '#4B5563',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
color: '#2563EB',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
color: '#4B5563',
|
||||||
|
fontSize: 13,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
footerRow: {
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
reviewCount: {
|
||||||
|
color: '#4B5563',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
67
template/src/components/SortSelector.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { SORT_LABELS, SORT_OPTIONS } from '../constants/restaurant';
|
||||||
|
import type { SortOption } from '../features/restaurants/types';
|
||||||
|
|
||||||
|
interface SortSelectorProps {
|
||||||
|
selected: SortOption;
|
||||||
|
onSelect: (sort: SortOption) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SortSelector({ selected, onSelect }: SortSelectorProps) {
|
||||||
|
return (
|
||||||
|
<View style={styles.wrapper}>
|
||||||
|
<Text style={styles.title}>정렬</Text>
|
||||||
|
<View style={styles.row}>
|
||||||
|
{SORT_OPTIONS.map((sort) => {
|
||||||
|
const active = sort === selected;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={sort}
|
||||||
|
onPress={() => onSelect(sort)}
|
||||||
|
style={[styles.button, active ? styles.buttonActive : null]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[styles.label, active ? styles.labelActive : null]}
|
||||||
|
>
|
||||||
|
{SORT_LABELS[sort]}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrapper: {
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: '#1F2937',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
buttonActive: {
|
||||||
|
backgroundColor: '#DBEAFE',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: '#4B5563',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
labelActive: {
|
||||||
|
color: '#1D4ED8',
|
||||||
|
},
|
||||||
|
});
|
||||||
43
template/src/constants/restaurant.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type {
|
||||||
|
CategoryFilter,
|
||||||
|
RestaurantCategory,
|
||||||
|
SortOption,
|
||||||
|
} from '../features/restaurants/types';
|
||||||
|
|
||||||
|
export const CATEGORY_OPTIONS: CategoryFilter[] = [
|
||||||
|
'ALL',
|
||||||
|
'KOREAN',
|
||||||
|
'CHINESE',
|
||||||
|
'JAPANESE',
|
||||||
|
'WESTERN',
|
||||||
|
'OTHER',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CATEGORY_LABELS: Record<CategoryFilter, string> = {
|
||||||
|
ALL: '전체',
|
||||||
|
KOREAN: '한식',
|
||||||
|
CHINESE: '중식',
|
||||||
|
JAPANESE: '일식',
|
||||||
|
WESTERN: '양식',
|
||||||
|
OTHER: '기타',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SORT_OPTIONS: SortOption[] = ['distance', 'rating', 'likes'];
|
||||||
|
|
||||||
|
export const SORT_LABELS: Record<SortOption, string> = {
|
||||||
|
distance: '거리순',
|
||||||
|
rating: '평점순',
|
||||||
|
likes: '좋아요순',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RADIUS_OPTIONS = [500, 1000, 2000] as const;
|
||||||
|
|
||||||
|
export const RADIUS_LABELS: Record<(typeof RADIUS_OPTIONS)[number], string> = {
|
||||||
|
500: '500m',
|
||||||
|
1000: '1km',
|
||||||
|
2000: '2km',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isCategory(value: string): value is RestaurantCategory {
|
||||||
|
return ['KOREAN', 'CHINESE', 'JAPANESE', 'WESTERN', 'OTHER'].includes(value);
|
||||||
|
}
|
||||||
65
template/src/features/location/useCurrentLocation.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
export interface CurrentLocation {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
source: 'gps' | 'fallback';
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEOUL_CITY_HALL = {
|
||||||
|
lat: 37.5665,
|
||||||
|
lng: 126.978,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCurrentLocation() {
|
||||||
|
const [location, setLocation] = useState<CurrentLocation | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const requestCurrentLocation = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const geolocation = (globalThis as any)?.navigator?.geolocation;
|
||||||
|
if (!geolocation?.getCurrentPosition) {
|
||||||
|
setLocation({ ...SEOUL_CITY_HALL, source: 'fallback' });
|
||||||
|
setError('위치 권한을 사용할 수 없어 기본 위치(서울시청)로 표시했어요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = await new Promise<any>((resolve, reject) => {
|
||||||
|
geolocation.getCurrentPosition(resolve, reject, {
|
||||||
|
enableHighAccuracy: false,
|
||||||
|
timeout: 8000,
|
||||||
|
maximumAge: 60000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const latitude = Number(position?.coords?.latitude);
|
||||||
|
const longitude = Number(position?.coords?.longitude);
|
||||||
|
|
||||||
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||||
|
throw new Error('Invalid location');
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocation({
|
||||||
|
lat: latitude,
|
||||||
|
lng: longitude,
|
||||||
|
source: 'gps',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setLocation({ ...SEOUL_CITY_HALL, source: 'fallback' });
|
||||||
|
setError('현재 위치를 가져오지 못해 기본 위치(서울시청)로 표시했어요.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
location,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
requestCurrentLocation,
|
||||||
|
};
|
||||||
|
}
|
||||||
95
template/src/features/restaurants/mockData.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import type { RestaurantItem } from './types';
|
||||||
|
|
||||||
|
export const MOCK_RESTAURANTS: Omit<RestaurantItem, 'distanceMeters'>[] = [
|
||||||
|
{
|
||||||
|
id: 'a1f5d3a5-1c39-4c7d-a0fb-2f8f8f5f7a11',
|
||||||
|
name: '강남스시마루',
|
||||||
|
category: 'JAPANESE',
|
||||||
|
address: '서울 강남구 테헤란로 123',
|
||||||
|
phone: '02-1234-5678',
|
||||||
|
lat: 37.5009,
|
||||||
|
lng: 127.0368,
|
||||||
|
averageRating: 4.6,
|
||||||
|
reviewCount: 12,
|
||||||
|
likeCount: 48,
|
||||||
|
likedByMe: false,
|
||||||
|
reviews: [
|
||||||
|
{
|
||||||
|
id: 'r-101',
|
||||||
|
userNickname: '점심러버',
|
||||||
|
rating: 5,
|
||||||
|
content: '초밥 신선하고 점심 세트 가성비 좋아요.',
|
||||||
|
createdAt: '2026-04-10T03:10:00Z',
|
||||||
|
mine: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'r-102',
|
||||||
|
userNickname: '나',
|
||||||
|
rating: 4,
|
||||||
|
content: '웨이팅만 짧으면 자주 올 듯.',
|
||||||
|
createdAt: '2026-04-12T02:20:00Z',
|
||||||
|
mine: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2f9b0c77-dcf7-44c5-8fcb-aefb4a16c222',
|
||||||
|
name: '북경반점 선릉점',
|
||||||
|
category: 'CHINESE',
|
||||||
|
address: '서울 강남구 선릉로 88',
|
||||||
|
phone: '02-7890-1234',
|
||||||
|
lat: 37.5043,
|
||||||
|
lng: 127.0496,
|
||||||
|
averageRating: 4.2,
|
||||||
|
reviewCount: 35,
|
||||||
|
likeCount: 31,
|
||||||
|
likedByMe: true,
|
||||||
|
reviews: [
|
||||||
|
{
|
||||||
|
id: 'r-201',
|
||||||
|
userNickname: '직장인A',
|
||||||
|
rating: 4,
|
||||||
|
content: '짬뽕 국물이 진해서 해장 점심으로 좋아요.',
|
||||||
|
createdAt: '2026-04-08T04:10:00Z',
|
||||||
|
mine: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3be86df0-8b69-4f1b-9f0f-c08726f78333',
|
||||||
|
name: '파스타하우스 역삼',
|
||||||
|
category: 'WESTERN',
|
||||||
|
address: '서울 강남구 역삼로 201',
|
||||||
|
phone: '02-2222-3333',
|
||||||
|
lat: 37.4982,
|
||||||
|
lng: 127.0397,
|
||||||
|
averageRating: 4.4,
|
||||||
|
reviewCount: 18,
|
||||||
|
likeCount: 56,
|
||||||
|
likedByMe: false,
|
||||||
|
reviews: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4d645f7f-0f08-4cb2-b218-0dd07a838444',
|
||||||
|
name: '한그릇집 역삼점',
|
||||||
|
category: 'KOREAN',
|
||||||
|
address: '서울 강남구 논현로 401',
|
||||||
|
phone: '02-5566-7788',
|
||||||
|
lat: 37.5012,
|
||||||
|
lng: 127.0301,
|
||||||
|
averageRating: 4.1,
|
||||||
|
reviewCount: 9,
|
||||||
|
likeCount: 22,
|
||||||
|
likedByMe: false,
|
||||||
|
reviews: [
|
||||||
|
{
|
||||||
|
id: 'r-401',
|
||||||
|
userNickname: '나',
|
||||||
|
rating: 4,
|
||||||
|
content: '국밥이 빨리 나와서 바쁜 점심에 좋아요.',
|
||||||
|
createdAt: '2026-04-14T02:00:00Z',
|
||||||
|
mine: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
320
template/src/features/restaurants/service.ts
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
import { isCategory } from '../../constants/restaurant';
|
||||||
|
import { MOCK_RESTAURANTS } from './mockData';
|
||||||
|
import type {
|
||||||
|
CategoryFilter,
|
||||||
|
NearbyQuery,
|
||||||
|
RestaurantItem,
|
||||||
|
ReviewItem,
|
||||||
|
ReviewUpsertInput,
|
||||||
|
SortOption,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
interface NearbyApiItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
distanceMeters: number;
|
||||||
|
averageRating: number;
|
||||||
|
reviewCount: number;
|
||||||
|
likeCount: number;
|
||||||
|
likedByMe: boolean;
|
||||||
|
address?: string;
|
||||||
|
phone?: string;
|
||||||
|
lat?: number;
|
||||||
|
lng?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NearbyApiResponse {
|
||||||
|
items?: NearbyApiItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LikeApiResponse {
|
||||||
|
liked: boolean;
|
||||||
|
likeCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewApiResponse {
|
||||||
|
id: string;
|
||||||
|
userNickname?: string;
|
||||||
|
nickName?: string;
|
||||||
|
rating: number;
|
||||||
|
content: string;
|
||||||
|
createdAt?: string;
|
||||||
|
mine?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE_URL = 'http://localhost:4000';
|
||||||
|
|
||||||
|
const mockState: Omit<RestaurantItem, 'distanceMeters'>[] = MOCK_RESTAURANTS.map(
|
||||||
|
(restaurant) => ({
|
||||||
|
...restaurant,
|
||||||
|
reviews: [...restaurant.reviews],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function fetchNearbyRestaurants(
|
||||||
|
query: NearbyQuery,
|
||||||
|
): Promise<RestaurantItem[]> {
|
||||||
|
if (API_BASE_URL) {
|
||||||
|
try {
|
||||||
|
const url = new URL('/v1/restaurants/nearby', API_BASE_URL);
|
||||||
|
url.searchParams.set('lat', String(query.lat));
|
||||||
|
url.searchParams.set('lng', String(query.lng));
|
||||||
|
url.searchParams.set('radiusMeters', String(query.radiusMeters));
|
||||||
|
if (query.category !== 'ALL') {
|
||||||
|
url.searchParams.set('category', query.category);
|
||||||
|
}
|
||||||
|
url.searchParams.set('sort', query.sort);
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
headers: {
|
||||||
|
'x-user-id': 'demo-user-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as NearbyApiResponse;
|
||||||
|
return (data.items ?? []).map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
category: isCategory(item.category) ? item.category : 'OTHER',
|
||||||
|
address: item.address ?? '주소 정보 없음',
|
||||||
|
phone: item.phone,
|
||||||
|
lat: item.lat ?? query.lat,
|
||||||
|
lng: item.lng ?? query.lng,
|
||||||
|
distanceMeters: item.distanceMeters,
|
||||||
|
averageRating: item.averageRating,
|
||||||
|
reviewCount: item.reviewCount,
|
||||||
|
likeCount: item.likeCount,
|
||||||
|
likedByMe: item.likedByMe,
|
||||||
|
reviews: [],
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return buildFromMock(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildFromMock(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleRestaurantLike(
|
||||||
|
restaurantId: string,
|
||||||
|
query: NearbyQuery,
|
||||||
|
): Promise<RestaurantItem[]> {
|
||||||
|
if (API_BASE_URL) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE_URL}/v1/restaurants/${restaurantId}/like`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-user-id': 'demo-user-1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as LikeApiResponse;
|
||||||
|
return (await fetchNearbyRestaurants(query)).map((restaurant) =>
|
||||||
|
restaurant.id === restaurantId
|
||||||
|
? {
|
||||||
|
...restaurant,
|
||||||
|
likedByMe: data.liked,
|
||||||
|
likeCount: data.likeCount,
|
||||||
|
}
|
||||||
|
: restaurant,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return toggleLikeFromMock(restaurantId, query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return toggleLikeFromMock(restaurantId, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertRestaurantReview(
|
||||||
|
restaurantId: string,
|
||||||
|
review: ReviewUpsertInput,
|
||||||
|
query: NearbyQuery,
|
||||||
|
): Promise<RestaurantItem[]> {
|
||||||
|
if (API_BASE_URL) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE_URL}/v1/restaurants/${restaurantId}/reviews`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-user-id': 'demo-user-1',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(review),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as ReviewApiResponse;
|
||||||
|
|
||||||
|
return (await fetchNearbyRestaurants(query)).map((restaurant) => {
|
||||||
|
if (restaurant.id !== restaurantId) {
|
||||||
|
return restaurant;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextReview: ReviewItem = {
|
||||||
|
id: data.id,
|
||||||
|
userNickname: data.userNickname ?? data.nickName ?? '나',
|
||||||
|
rating: data.rating,
|
||||||
|
content: data.content,
|
||||||
|
createdAt: data.createdAt ?? new Date().toISOString(),
|
||||||
|
mine: data.mine ?? true,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...restaurant,
|
||||||
|
reviews: [nextReview, ...restaurant.reviews.filter((item) => !item.mine)],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return upsertReviewFromMock(restaurantId, review, query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return upsertReviewFromMock(restaurantId, review, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLikeFromMock(
|
||||||
|
restaurantId: string,
|
||||||
|
query: NearbyQuery,
|
||||||
|
): RestaurantItem[] {
|
||||||
|
const index = mockState.findIndex((restaurant) => restaurant.id === restaurantId);
|
||||||
|
if (index >= 0) {
|
||||||
|
const target = mockState[index]!;
|
||||||
|
const nextLiked = !target.likedByMe;
|
||||||
|
mockState[index] = {
|
||||||
|
...target,
|
||||||
|
likedByMe: nextLiked,
|
||||||
|
likeCount: nextLiked ? target.likeCount + 1 : Math.max(0, target.likeCount - 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildFromMock(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertReviewFromMock(
|
||||||
|
restaurantId: string,
|
||||||
|
review: ReviewUpsertInput,
|
||||||
|
query: NearbyQuery,
|
||||||
|
): RestaurantItem[] {
|
||||||
|
const index = mockState.findIndex((restaurant) => restaurant.id === restaurantId);
|
||||||
|
if (index >= 0) {
|
||||||
|
const target = mockState[index]!;
|
||||||
|
const nextReview: ReviewItem = {
|
||||||
|
id: `review-${Date.now()}`,
|
||||||
|
userNickname: '나',
|
||||||
|
rating: review.rating,
|
||||||
|
content: review.content.trim(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
mine: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const others = target.reviews.filter((item) => !item.mine);
|
||||||
|
const reviews = [nextReview, ...others];
|
||||||
|
const ratingSum = reviews.reduce((sum, item) => sum + item.rating, 0);
|
||||||
|
const averageRating = reviews.length === 0 ? 0 : ratingSum / reviews.length;
|
||||||
|
|
||||||
|
mockState[index] = {
|
||||||
|
...target,
|
||||||
|
reviews,
|
||||||
|
reviewCount: reviews.length,
|
||||||
|
averageRating: Number(averageRating.toFixed(1)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildFromMock(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFromMock(query: NearbyQuery): RestaurantItem[] {
|
||||||
|
const withDistance: RestaurantItem[] = mockState.map((restaurant) => ({
|
||||||
|
...restaurant,
|
||||||
|
distanceMeters: Math.round(
|
||||||
|
haversineMeters(query.lat, query.lng, restaurant.lat, restaurant.lng),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const filteredByRadius = withDistance.filter(
|
||||||
|
(restaurant) => restaurant.distanceMeters <= query.radiusMeters,
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredByCategory = applyCategoryFilter(filteredByRadius, query.category);
|
||||||
|
return sortRestaurants(filteredByCategory, query.sort);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCategoryFilter(
|
||||||
|
restaurants: RestaurantItem[],
|
||||||
|
category: CategoryFilter,
|
||||||
|
): RestaurantItem[] {
|
||||||
|
if (category === 'ALL') {
|
||||||
|
return restaurants;
|
||||||
|
}
|
||||||
|
|
||||||
|
return restaurants.filter((restaurant) => restaurant.category === category);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortRestaurants(
|
||||||
|
restaurants: RestaurantItem[],
|
||||||
|
sort: SortOption,
|
||||||
|
): RestaurantItem[] {
|
||||||
|
const items = [...restaurants];
|
||||||
|
|
||||||
|
if (sort === 'distance') {
|
||||||
|
return items.sort((a, b) => a.distanceMeters - b.distanceMeters);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sort === 'rating') {
|
||||||
|
return items.sort((a, b) => {
|
||||||
|
if (b.averageRating === a.averageRating) {
|
||||||
|
return b.reviewCount - a.reviewCount;
|
||||||
|
}
|
||||||
|
return b.averageRating - a.averageRating;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.sort((a, b) => {
|
||||||
|
if (b.likeCount === a.likeCount) {
|
||||||
|
return a.distanceMeters - b.distanceMeters;
|
||||||
|
}
|
||||||
|
return b.likeCount - a.likeCount;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function haversineMeters(
|
||||||
|
lat1: number,
|
||||||
|
lng1: number,
|
||||||
|
lat2: number,
|
||||||
|
lng2: number,
|
||||||
|
): number {
|
||||||
|
const toRad = (value: number) => (value * Math.PI) / 180;
|
||||||
|
const earthRadius = 6371000;
|
||||||
|
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLng = toRad(lng2 - lng1);
|
||||||
|
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(toRad(lat1)) *
|
||||||
|
Math.cos(toRad(lat2)) *
|
||||||
|
Math.sin(dLng / 2) *
|
||||||
|
Math.sin(dLng / 2);
|
||||||
|
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
return earthRadius * c;
|
||||||
|
}
|
||||||
48
template/src/features/restaurants/types.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
export type RestaurantCategory =
|
||||||
|
| 'KOREAN'
|
||||||
|
| 'CHINESE'
|
||||||
|
| 'JAPANESE'
|
||||||
|
| 'WESTERN'
|
||||||
|
| 'OTHER';
|
||||||
|
|
||||||
|
export type CategoryFilter = RestaurantCategory | 'ALL';
|
||||||
|
|
||||||
|
export type SortOption = 'distance' | 'rating' | 'likes';
|
||||||
|
|
||||||
|
export interface ReviewItem {
|
||||||
|
id: string;
|
||||||
|
userNickname: string;
|
||||||
|
rating: number;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
mine: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewUpsertInput {
|
||||||
|
rating: number;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestaurantItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: RestaurantCategory;
|
||||||
|
address: string;
|
||||||
|
phone?: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
distanceMeters: number;
|
||||||
|
averageRating: number;
|
||||||
|
reviewCount: number;
|
||||||
|
likeCount: number;
|
||||||
|
likedByMe: boolean;
|
||||||
|
reviews: ReviewItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NearbyQuery {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
radiusMeters: number;
|
||||||
|
category: CategoryFilter;
|
||||||
|
sort: SortOption;
|
||||||
|
}
|
||||||
39
template/src/pages/_404.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from '@granite-js/native/react-native-safe-area-context';
|
||||||
|
|
||||||
|
export default function NotFoundPage() {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.safeArea}>
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>Page not found</Text>
|
||||||
|
<Text style={styles.description}>
|
||||||
|
요청한 화면을 찾을 수 없습니다.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F4F7FB',
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
color: '#4B5563',
|
||||||
|
fontSize: 15,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
BIN
template/src/pages/_404.tsx:Zone.Identifier
Normal file
562
template/src/pages/index.tsx
Normal file
@@ -0,0 +1,562 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
FlatList,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from '@granite-js/native/react-native-safe-area-context';
|
||||||
|
import { CategoryFilter } from '../components/CategoryFilter';
|
||||||
|
import { RatingStars } from '../components/RatingStars';
|
||||||
|
import { RestaurantCard } from '../components/RestaurantCard';
|
||||||
|
import { SortSelector } from '../components/SortSelector';
|
||||||
|
import {
|
||||||
|
RADIUS_LABELS,
|
||||||
|
RADIUS_OPTIONS,
|
||||||
|
SORT_LABELS,
|
||||||
|
} from '../constants/restaurant';
|
||||||
|
import { useCurrentLocation } from '../features/location/useCurrentLocation';
|
||||||
|
import {
|
||||||
|
fetchNearbyRestaurants,
|
||||||
|
toggleRestaurantLike,
|
||||||
|
upsertRestaurantReview,
|
||||||
|
} from '../features/restaurants/service';
|
||||||
|
import type {
|
||||||
|
CategoryFilter as CategoryFilterType,
|
||||||
|
NearbyQuery,
|
||||||
|
RestaurantItem,
|
||||||
|
SortOption,
|
||||||
|
} from '../features/restaurants/types';
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const {
|
||||||
|
location,
|
||||||
|
error: locationError,
|
||||||
|
isLoading: isLocating,
|
||||||
|
requestCurrentLocation,
|
||||||
|
} = useCurrentLocation();
|
||||||
|
|
||||||
|
const [restaurants, setRestaurants] = useState<RestaurantItem[]>([]);
|
||||||
|
const [selectedCategory, setSelectedCategory] =
|
||||||
|
useState<CategoryFilterType>('ALL');
|
||||||
|
const [selectedSort, setSelectedSort] = useState<SortOption>('distance');
|
||||||
|
const [selectedRadius, setSelectedRadius] = useState<(typeof RADIUS_OPTIONS)[number]>(
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
const [selectedRestaurantId, setSelectedRestaurantId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [isFetching, setIsFetching] = useState(false);
|
||||||
|
const [isMutating, setIsMutating] = useState(false);
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [draftRating, setDraftRating] = useState(5);
|
||||||
|
const [draftContent, setDraftContent] = useState('');
|
||||||
|
|
||||||
|
const query = useMemo<NearbyQuery | null>(() => {
|
||||||
|
if (!location) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
lat: location.lat,
|
||||||
|
lng: location.lng,
|
||||||
|
radiusMeters: selectedRadius,
|
||||||
|
category: selectedCategory,
|
||||||
|
sort: selectedSort,
|
||||||
|
};
|
||||||
|
}, [location, selectedRadius, selectedCategory, selectedSort]);
|
||||||
|
|
||||||
|
const loadRestaurants = useCallback(async () => {
|
||||||
|
if (!query) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsFetching(true);
|
||||||
|
setFetchError(null);
|
||||||
|
try {
|
||||||
|
const list = await fetchNearbyRestaurants(query);
|
||||||
|
setRestaurants(list);
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
setSelectedRestaurantId(null);
|
||||||
|
} else if (!list.find((item) => item.id === selectedRestaurantId)) {
|
||||||
|
setSelectedRestaurantId(list[0]?.id ?? null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setFetchError('식당 목록을 불러오지 못했어요. 잠시 후 다시 시도해 주세요.');
|
||||||
|
} finally {
|
||||||
|
setIsFetching(false);
|
||||||
|
}
|
||||||
|
}, [query, selectedRestaurantId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
requestCurrentLocation();
|
||||||
|
}, [requestCurrentLocation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRestaurants();
|
||||||
|
}, [loadRestaurants]);
|
||||||
|
|
||||||
|
const selectedRestaurant = useMemo(
|
||||||
|
() => restaurants.find((restaurant) => restaurant.id === selectedRestaurantId) ?? null,
|
||||||
|
[restaurants, selectedRestaurantId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onToggleLike = useCallback(
|
||||||
|
async (restaurantId: string) => {
|
||||||
|
if (!query || isMutating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsMutating(true);
|
||||||
|
setFetchError(null);
|
||||||
|
try {
|
||||||
|
const next = await toggleRestaurantLike(restaurantId, query);
|
||||||
|
setRestaurants(next);
|
||||||
|
} catch {
|
||||||
|
setFetchError('좋아요 처리 중 오류가 발생했어요.');
|
||||||
|
} finally {
|
||||||
|
setIsMutating(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[query, isMutating],
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitReview = useCallback(async () => {
|
||||||
|
if (!query || !selectedRestaurant || !draftContent.trim() || isMutating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsMutating(true);
|
||||||
|
setFetchError(null);
|
||||||
|
try {
|
||||||
|
const next = await upsertRestaurantReview(
|
||||||
|
selectedRestaurant.id,
|
||||||
|
{
|
||||||
|
rating: draftRating,
|
||||||
|
content: draftContent,
|
||||||
|
},
|
||||||
|
query,
|
||||||
|
);
|
||||||
|
|
||||||
|
setRestaurants(next);
|
||||||
|
setDraftContent('');
|
||||||
|
setDraftRating(5);
|
||||||
|
} catch {
|
||||||
|
setFetchError('리뷰 저장 중 오류가 발생했어요. 잠시 후 다시 시도해 주세요.');
|
||||||
|
} finally {
|
||||||
|
setIsMutating(false);
|
||||||
|
}
|
||||||
|
}, [query, selectedRestaurant, draftContent, draftRating, isMutating]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.safeArea}>
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>직장인 점심 추천</Text>
|
||||||
|
<Text style={styles.subtitle}>위치 기반으로 근처 식당을 빠르게 고르세요.</Text>
|
||||||
|
|
||||||
|
<View style={styles.locationBox}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.locationLabel}>현재 위치</Text>
|
||||||
|
<Text style={styles.locationValue}>
|
||||||
|
{location
|
||||||
|
? `${location.lat.toFixed(4)}, ${location.lng.toFixed(4)} (${location.source})`
|
||||||
|
: '위치 확인 중'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
style={styles.refreshButton}
|
||||||
|
onPress={requestCurrentLocation}
|
||||||
|
disabled={isLocating}
|
||||||
|
>
|
||||||
|
<Text style={styles.refreshButtonText}>
|
||||||
|
{isLocating ? '확인 중...' : '위치 새로고침'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{locationError ? <Text style={styles.warning}>{locationError}</Text> : null}
|
||||||
|
|
||||||
|
<View style={styles.radiusRow}>
|
||||||
|
<Text style={styles.radiusTitle}>반경</Text>
|
||||||
|
<View style={styles.radiusButtons}>
|
||||||
|
{RADIUS_OPTIONS.map((radius) => (
|
||||||
|
<Pressable
|
||||||
|
key={radius}
|
||||||
|
onPress={() => setSelectedRadius(radius)}
|
||||||
|
style={[
|
||||||
|
styles.radiusButton,
|
||||||
|
selectedRadius === radius ? styles.radiusButtonActive : null,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={
|
||||||
|
selectedRadius === radius
|
||||||
|
? styles.radiusButtonLabelActive
|
||||||
|
: styles.radiusButtonLabel
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{RADIUS_LABELS[radius]}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<CategoryFilter selected={selectedCategory} onSelect={setSelectedCategory} />
|
||||||
|
<SortSelector selected={selectedSort} onSelect={setSelectedSort} />
|
||||||
|
|
||||||
|
<Text style={styles.sectionTitle}>
|
||||||
|
근처 식당 ({restaurants.length}) · {SORT_LABELS[selectedSort]}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isFetching ? (
|
||||||
|
<View style={styles.centerBox}>
|
||||||
|
<ActivityIndicator size="small" color="#2563EB" />
|
||||||
|
<Text style={styles.loadingText}>식당 목록을 불러오는 중...</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{fetchError ? <Text style={styles.error}>{fetchError}</Text> : null}
|
||||||
|
|
||||||
|
{!isFetching && restaurants.length === 0 ? (
|
||||||
|
<View style={styles.centerBox}>
|
||||||
|
<Text style={styles.emptyTitle}>조건에 맞는 식당이 없어요</Text>
|
||||||
|
<Text style={styles.emptyDescription}>
|
||||||
|
반경을 넓히거나 카테고리를 바꿔보세요.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={restaurants}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
style={styles.list}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<RestaurantCard
|
||||||
|
item={item}
|
||||||
|
selected={item.id === selectedRestaurantId}
|
||||||
|
onPress={() => setSelectedRestaurantId(item.id)}
|
||||||
|
onToggleLike={() => onToggleLike(item.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedRestaurant ? (
|
||||||
|
<View style={styles.detailBox}>
|
||||||
|
<Text style={styles.detailTitle}>{selectedRestaurant.name}</Text>
|
||||||
|
<RatingStars rating={selectedRestaurant.averageRating} size={14} />
|
||||||
|
<Text style={styles.detailMeta}>
|
||||||
|
리뷰 {selectedRestaurant.reviewCount} · 좋아요 {selectedRestaurant.likeCount}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={styles.detailSection}>리뷰 작성</Text>
|
||||||
|
<View style={styles.ratingRow}>
|
||||||
|
{[1, 2, 3, 4, 5].map((rating) => (
|
||||||
|
<Pressable
|
||||||
|
key={rating}
|
||||||
|
onPress={() => setDraftRating(rating)}
|
||||||
|
style={[
|
||||||
|
styles.ratingButton,
|
||||||
|
draftRating === rating ? styles.ratingButtonActive : null,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={
|
||||||
|
draftRating === rating
|
||||||
|
? styles.ratingButtonTextActive
|
||||||
|
: styles.ratingButtonText
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{rating}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.reviewInput}
|
||||||
|
value={draftContent}
|
||||||
|
onChangeText={setDraftContent}
|
||||||
|
placeholder="점심 후기 한 줄을 남겨주세요 (최대 500자)"
|
||||||
|
maxLength={500}
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
onPress={submitReview}
|
||||||
|
style={[
|
||||||
|
styles.submitButton,
|
||||||
|
!draftContent.trim() || isMutating ? styles.submitButtonDisabled : null,
|
||||||
|
]}
|
||||||
|
disabled={!draftContent.trim() || isMutating}
|
||||||
|
>
|
||||||
|
<Text style={styles.submitButtonText}>
|
||||||
|
{isMutating ? '처리 중...' : '리뷰 저장'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Text style={styles.detailSection}>최근 리뷰</Text>
|
||||||
|
{selectedRestaurant.reviews.length === 0 ? (
|
||||||
|
<Text style={styles.reviewEmpty}>아직 리뷰가 없어요.</Text>
|
||||||
|
) : (
|
||||||
|
selectedRestaurant.reviews.slice(0, 3).map((review) => (
|
||||||
|
<View key={review.id} style={styles.reviewCard}>
|
||||||
|
<View style={styles.reviewHeader}>
|
||||||
|
<Text style={styles.reviewUser}>
|
||||||
|
{review.userNickname}
|
||||||
|
{review.mine ? ' (내 리뷰)' : ''}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.reviewRating}>{review.rating}점</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.reviewContent}>{review.content}</Text>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
backgroundColor: '#F8FAFC',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 14,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: '#0F172A',
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
color: '#475569',
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
locationBox: {
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderColor: '#E2E8F0',
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginTop: 14,
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
locationLabel: {
|
||||||
|
color: '#0F172A',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
locationValue: {
|
||||||
|
color: '#334155',
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
refreshButton: {
|
||||||
|
backgroundColor: '#DBEAFE',
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
refreshButtonText: {
|
||||||
|
color: '#1D4ED8',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
color: '#92400E',
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
radiusRow: {
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginTop: 14,
|
||||||
|
},
|
||||||
|
radiusTitle: {
|
||||||
|
color: '#1F2937',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
radiusButtons: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
radiusButton: {
|
||||||
|
backgroundColor: '#E5E7EB',
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
},
|
||||||
|
radiusButtonActive: {
|
||||||
|
backgroundColor: '#BFDBFE',
|
||||||
|
},
|
||||||
|
radiusButtonLabel: {
|
||||||
|
color: '#374151',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
radiusButtonLabelActive: {
|
||||||
|
color: '#1D4ED8',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginBottom: 8,
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
centerBox: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginVertical: 20,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
color: '#64748B',
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: '#B91C1C',
|
||||||
|
fontSize: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
color: '#334155',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
emptyDescription: {
|
||||||
|
color: '#64748B',
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
detailBox: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderColor: '#D1D5DB',
|
||||||
|
borderRadius: 14,
|
||||||
|
borderWidth: 1,
|
||||||
|
marginBottom: 12,
|
||||||
|
marginTop: 8,
|
||||||
|
padding: 14,
|
||||||
|
},
|
||||||
|
detailTitle: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
detailMeta: {
|
||||||
|
color: '#4B5563',
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
detailSection: {
|
||||||
|
color: '#1F2937',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
ratingRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
ratingButton: {
|
||||||
|
backgroundColor: '#E5E7EB',
|
||||||
|
borderRadius: 8,
|
||||||
|
minWidth: 32,
|
||||||
|
paddingVertical: 6,
|
||||||
|
},
|
||||||
|
ratingButtonActive: {
|
||||||
|
backgroundColor: '#0EA5E9',
|
||||||
|
},
|
||||||
|
ratingButtonText: {
|
||||||
|
color: '#334155',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
ratingButtonTextActive: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
reviewInput: {
|
||||||
|
backgroundColor: '#F8FAFC',
|
||||||
|
borderColor: '#CBD5E1',
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
fontSize: 13,
|
||||||
|
marginTop: 8,
|
||||||
|
minHeight: 70,
|
||||||
|
padding: 10,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
},
|
||||||
|
submitButton: {
|
||||||
|
backgroundColor: '#2563EB',
|
||||||
|
borderRadius: 10,
|
||||||
|
marginTop: 8,
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
submitButtonDisabled: {
|
||||||
|
backgroundColor: '#93C5FD',
|
||||||
|
},
|
||||||
|
submitButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '700',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
reviewEmpty: {
|
||||||
|
color: '#64748B',
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
reviewCard: {
|
||||||
|
backgroundColor: '#F8FAFC',
|
||||||
|
borderColor: '#E5E7EB',
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
marginTop: 8,
|
||||||
|
padding: 10,
|
||||||
|
},
|
||||||
|
reviewHeader: {
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
reviewUser: {
|
||||||
|
color: '#1F2937',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
reviewRating: {
|
||||||
|
color: '#2563EB',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
reviewContent: {
|
||||||
|
color: '#374151',
|
||||||
|
fontSize: 13,
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
});
|
||||||
BIN
template/src/pages/index.tsx:Zone.Identifier
Normal file
20
template/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||