feat: bootstrap lunch picker miniapp with backend, docs, and branding assets

This commit is contained in:
mingking2
2026-04-15 14:03:08 +09:00
commit 7faf251fd3
85 changed files with 31332 additions and 0 deletions

BIN
.DS_Store:Zone.Identifier Normal file

Binary file not shown.

0
.codex Normal file
View File

23
.gitignore vendored Normal file
View 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
View 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`의 화면 문구

Binary file not shown.

View 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

Binary file not shown.

3
starter-babel.config.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = {
presets: ['babel-preset-granite'],
};

Binary file not shown.

22
starter-granite.config.ts Normal file
View 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: [],
}),
],
});

Binary file not shown.

3
starter-index.js Normal file
View File

@@ -0,0 +1,3 @@
const { register } = require('@granite-js/react-native');
register(require('./src/_app').default);

Binary file not shown.

23
starter-package.json Normal file
View 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"
}
}

Binary file not shown.

39
starter-pages-_404.tsx Normal file
View 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',
},
});

Binary file not shown.

Binary file not shown.

1
template/.watchmanconfig Normal file
View File

@@ -0,0 +1 @@
{}

Binary file not shown.

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

3
template/babel.config.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = {
presets: ['babel-preset-granite'],
};

Binary file not shown.

View 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

View 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

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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,
};

View 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

Binary file not shown.

View 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
- 개인화 추천(최근 선택 기반)
- 점심시간 알림
- 회사/사무실 즐겨찾기

View 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. 검수 체크리스트 기준 점검 후 배포

View 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
- [ ] 신고/문의 프로세스
- [ ] 장애 대응 담당자 지정

View 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
View 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)

View 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
View 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 ;

View 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: [],
}),
],
});

Binary file not shown.

3
template/index.js Normal file
View File

@@ -0,0 +1,3 @@
const { register } = require('@granite-js/react-native');
register(require('./src/_app').default);

Binary file not shown.

25187
template/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
template/package.json Normal file
View 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"
}
}

Binary file not shown.

View File

@@ -0,0 +1 @@
export const context = require.context('./src/pages', true, /\.[jt]sx?$/);

Binary file not shown.

Binary file not shown.

12
template/src/_app.tsx Normal file
View 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,
});

Binary file not shown.

View 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',
},
});

View 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,
},
});

View 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',
},
});

View 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',
},
});

View 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);
}

View 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,
};
}

View 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,
},
],
},
];

View 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;
}

View 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;
}

View 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',
},
});

Binary file not shown.

View 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,
},
});

Binary file not shown.

20
template/tsconfig.json Normal file
View 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"
]
}

Binary file not shown.