diff --git a/.github/workflows/frontend-deploy-main.yml b/.github/workflows/frontend-deploy-main.yml
new file mode 100644
index 0000000..d3989d9
--- /dev/null
+++ b/.github/workflows/frontend-deploy-main.yml
@@ -0,0 +1,35 @@
+name: CI/CD Frontend StudySpot (main)
+
+on:
+ push:
+ branches:
+ - main
+
+jobs:
+ build-and-deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+
+ - name: Docker Hub login
+ uses: docker/login-action@v2
+ with:
+ username: ${{ secrets.DOCKER_USERNAME }}
+ password: ${{ secrets.DOCKER_PASSWORD }}
+
+ - name: Build and push Docker image
+ run: |
+ docker build -t ${{ secrets.DOCKER_USERNAME }}/studyspot-frontend:latest .
+ docker push ${{ secrets.DOCKER_USERNAME }}/studyspot-frontend:latest
+
+ - name: Deploy to server via SSH
+ uses: appleboy/ssh-action@v0.1.7
+ with:
+ host: ${{ secrets.SERVER_IP }}
+ username: ${{ secrets.SERVER_USER }}
+ key: ${{ secrets.SERVER_SSH_KEY }}
+ script: |
+ cd ~/studyspot
+ docker compose pull frontend
+ docker compose up -d frontend
\ No newline at end of file
diff --git a/.github/workflows/frontend-deploy-test.yml b/.github/workflows/frontend-deploy-test.yml
new file mode 100644
index 0000000..472e89d
--- /dev/null
+++ b/.github/workflows/frontend-deploy-test.yml
@@ -0,0 +1,35 @@
+name: CI/CD Frontend StudySpot (test)
+
+on:
+ push:
+ branches:
+ - test
+
+jobs:
+ build-and-deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+
+ - name: Docker Hub login
+ uses: docker/login-action@v2
+ with:
+ username: ${{ secrets.DOCKER_USERNAME }}
+ password: ${{ secrets.DOCKER_PASSWORD }}
+
+ - name: Build and push Docker image
+ run: |
+ docker build -t ${{ secrets.DOCKER_USERNAME }}/studyspot-frontend:test .
+ docker push ${{ secrets.DOCKER_USERNAME }}/studyspot-frontend:test
+
+ - name: Deploy to server via SSH
+ uses: appleboy/ssh-action@v0.1.7
+ with:
+ host: ${{ secrets.SERVER_IP }}
+ username: ${{ secrets.SERVER_USER }}
+ key: ${{ secrets.SERVER_SSH_KEY }}
+ script: |
+ cd ~/studyspot
+ docker compose pull frontend-test
+ docker compose up -d frontend-test
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 4d29575..67a2c0c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,23 +1,216 @@
-# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+# Created by https://www.toptal.com/developers/gitignore/api/node,macos,windows,react
+# Edit at https://www.toptal.com/developers/gitignore?templates=node,macos,windows,react
-# dependencies
-/node_modules
-/.pnp
-.pnp.js
+### macOS ###
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
-# testing
-/coverage
+# Icon must end with two \r
+Icon
-# production
-/build
-# misc
-.DS_Store
-.env.local
-.env.development.local
-.env.test.local
-.env.production.local
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+### macOS Patch ###
+# iCloud generated files
+*.icloud
+
+### Node ###
+# Logs
+logs
+*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+lerna-debug.log*
+.pnpm-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# Snowpack dependency directory (https://snowpack.dev/)
+web_modules/
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional stylelint cache
+.stylelintcache
+
+# Microbundle cache
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variable files
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+.parcel-cache
+
+# Next.js build output
+.next
+out
+
+# Nuxt.js build / generate output
+.nuxt
+dist
+
+# Gatsby files
+.cache/
+# Comment in the public line in if your project uses Gatsby and not Next.js
+# https://nextjs.org/blog/next-9-1#public-directory-support
+# public
+
+# vuepress build output
+.vuepress/dist
+
+# vuepress v2.x temp and cache directory
+.temp
+
+# Docusaurus cache and generated files
+.docusaurus
+
+# Serverless directories
+.serverless/
+
+# FuseBox cache
+.fusebox/
+
+# DynamoDB Local files
+.dynamodb/
+
+# TernJS port file
+.tern-port
+
+# Stores VSCode versions used for testing VSCode extensions
+.vscode-test
+
+# yarn v2
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.*
+
+### Node Patch ###
+# Serverless Webpack directories
+.webpack/
+
+# Optional stylelint cache
+
+# SvelteKit build / generate output
+.svelte-kit
+
+### react ###
+.DS_*
+**/*.backup.*
+**/*.back.*
+
+node_modules
+
+*.sublime*
+
+psd
+thumb
+sketch
+
+### Windows ###
+# Windows thumbnail cache files
+Thumbs.db
+Thumbs.db:encryptable
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+# End of https://www.toptal.com/developers/gitignore/api/node,macos,windows,react
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..3884a34
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,31 @@
+# 1단계: React 앱 빌드
+FROM node:20-alpine AS builder
+
+WORKDIR /app
+COPY package.json package-lock.json ./
+
+# lock 파일 불일치 문제 우회
+RUN npm install --legacy-peer-deps
+
+COPY . .
+RUN npm run build
+
+# 2단계: Nginx로 정적 파일 제공
+FROM nginx:alpine
+
+# React 빌드 결과물을 복사
+COPY --from=builder /app/build /usr/share/nginx/html
+
+# SPA 라우팅 지원
+RUN echo 'server { \
+ listen 80; \
+ server_name localhost; \
+ root /usr/share/nginx/html; \
+ index index.html; \
+ location / { \
+ try_files $uri /index.html; \
+ } \
+}' > /etc/nginx/conf.d/default.conf
+
+EXPOSE 80
+CMD ["nginx", "-g", "daemon off;"]
\ No newline at end of file
diff --git a/db.json b/db.json
new file mode 100644
index 0000000..36927bf
--- /dev/null
+++ b/db.json
@@ -0,0 +1,60 @@
+{
+
+ "tags": [
+ { "id": 1, "category": "☕️ 공간종류", "items": ["카페", "스터디카페", "독서실"] },
+ { "id": 2, "category": "💡 조명 밝기", "items": ["어두움", "중간", "밝음"] },
+ { "id": 3, "category": "👂 소음정도", "items": ["조용함", "적당한소음", "시끌벅적"] }
+ ],
+
+
+ "cafes": [
+ {
+ "id": 1,
+ "name": "카페 블루보틀",
+ "location": "서울 강남",
+ "tags": ["카페", "밝음", "조용함"],
+ "thumbnail": "https://via.placeholder.com/80",
+ "rating": 4.7,
+ "hours": "09:00~22:00"
+ },
+ {
+ "id": 2,
+ "name": "카페 루시드",
+ "location": "서울 홍대",
+ "tags": ["독서실", "밝음", "시간제한 없음"],
+ "thumbnail": "https://via.placeholder.com/80",
+ "rating": 4.5,
+ "hours": "08:00~23:00"
+ },
+ {
+
+ "id" : 3,
+ "name" : "성수동 스타벅스",
+ "averageStarRating" : 4.5,
+ "isWork" : true,
+ "startingTime" : "10:00",
+ "closingTime" : "22:00",
+ "category" : "카페",
+ "tags" : ["콘센트", "애견동반"],
+ "imageUrl": "https:...",
+ "address" : "서울 마포구 신수동"
+
+ }
+
+ ],
+ "categories": [
+ { "id": 1, "name": "카페" },
+ { "id": 2, "name": "스터디카페" },
+ { "id": 3, "name": "독서실" }
+ ],
+ "reviews": [
+ {
+ "id": 1,
+ "cafeId": 1,
+ "rating": 5,
+ "comment": "좋아요!",
+ "author": "홍길동",
+ "createdAt": "2025-11-13T10:00:00Z"
+ }
+ ]
+}
diff --git a/package-lock.json b/package-lock.json
index e40dc2d..bba1d56 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,10 @@
"name": "sprint-frontend",
"version": "0.1.0",
"dependencies": {
+ "@emotion/react": "^11.14.0",
+ "@emotion/styled": "^11.14.1",
+ "@mui/icons-material": "^7.3.5",
+ "@mui/material": "^7.3.5",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
@@ -16,6 +20,8 @@
"@types/node": "^16.18.126",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
+ "axios": "^1.13.2",
+ "lucide-react": "^0.554.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.5",
@@ -2377,6 +2383,167 @@
"postcss-selector-parser": "^6.0.10"
}
},
+ "node_modules/@emotion/babel-plugin": {
+ "version": "11.13.5",
+ "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
+ "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.16.7",
+ "@babel/runtime": "^7.18.3",
+ "@emotion/hash": "^0.9.2",
+ "@emotion/memoize": "^0.9.0",
+ "@emotion/serialize": "^1.3.3",
+ "babel-plugin-macros": "^3.1.0",
+ "convert-source-map": "^1.5.0",
+ "escape-string-regexp": "^4.0.0",
+ "find-root": "^1.1.0",
+ "source-map": "^0.5.7",
+ "stylis": "4.2.0"
+ }
+ },
+ "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
+ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/babel-plugin/node_modules/source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@emotion/cache": {
+ "version": "11.14.0",
+ "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz",
+ "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==",
+ "license": "MIT",
+ "dependencies": {
+ "@emotion/memoize": "^0.9.0",
+ "@emotion/sheet": "^1.4.0",
+ "@emotion/utils": "^1.4.2",
+ "@emotion/weak-memoize": "^0.4.0",
+ "stylis": "4.2.0"
+ }
+ },
+ "node_modules/@emotion/hash": {
+ "version": "0.9.2",
+ "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
+ "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/is-prop-valid": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
+ "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==",
+ "license": "MIT",
+ "dependencies": {
+ "@emotion/memoize": "^0.9.0"
+ }
+ },
+ "node_modules/@emotion/memoize": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
+ "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/react": {
+ "version": "11.14.0",
+ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
+ "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.18.3",
+ "@emotion/babel-plugin": "^11.13.5",
+ "@emotion/cache": "^11.14.0",
+ "@emotion/serialize": "^1.3.3",
+ "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0",
+ "@emotion/utils": "^1.4.2",
+ "@emotion/weak-memoize": "^0.4.0",
+ "hoist-non-react-statics": "^3.3.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@emotion/serialize": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz",
+ "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==",
+ "license": "MIT",
+ "dependencies": {
+ "@emotion/hash": "^0.9.2",
+ "@emotion/memoize": "^0.9.0",
+ "@emotion/unitless": "^0.10.0",
+ "@emotion/utils": "^1.4.2",
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/@emotion/sheet": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz",
+ "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/styled": {
+ "version": "11.14.1",
+ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
+ "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.18.3",
+ "@emotion/babel-plugin": "^11.13.5",
+ "@emotion/is-prop-valid": "^1.3.0",
+ "@emotion/serialize": "^1.3.3",
+ "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0",
+ "@emotion/utils": "^1.4.2"
+ },
+ "peerDependencies": {
+ "@emotion/react": "^11.0.0-rc.0",
+ "react": ">=16.8.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@emotion/unitless": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz",
+ "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/use-insertion-effect-with-fallbacks": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz",
+ "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@emotion/utils": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
+ "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/weak-memoize": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz",
+ "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
+ "license": "MIT"
+ },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
@@ -2959,6 +3126,251 @@
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
"license": "MIT"
},
+ "node_modules/@mui/core-downloads-tracker": {
+ "version": "7.3.5",
+ "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.5.tgz",
+ "integrity": "sha512-kOLwlcDPnVz2QMhiBv0OQ8le8hTCqKM9cRXlfVPL91l3RGeOsxrIhNRsUt3Xb8wb+pTVUolW+JXKym93vRKxCw==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ }
+ },
+ "node_modules/@mui/icons-material": {
+ "version": "7.3.5",
+ "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.5.tgz",
+ "integrity": "sha512-LciL1GLMZ+VlzyHAALSVAR22t8IST4LCXmljcUSx2NOutgO2XnxdIp8ilFbeNf9wpo0iUFbAuoQcB7h+HHIf3A==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.4"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ },
+ "peerDependencies": {
+ "@mui/material": "^7.3.5",
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@mui/material": {
+ "version": "7.3.5",
+ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz",
+ "integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.4",
+ "@mui/core-downloads-tracker": "^7.3.5",
+ "@mui/system": "^7.3.5",
+ "@mui/types": "^7.4.8",
+ "@mui/utils": "^7.3.5",
+ "@popperjs/core": "^2.11.8",
+ "@types/react-transition-group": "^4.4.12",
+ "clsx": "^2.1.1",
+ "csstype": "^3.1.3",
+ "prop-types": "^15.8.1",
+ "react-is": "^19.2.0",
+ "react-transition-group": "^4.4.5"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ },
+ "peerDependencies": {
+ "@emotion/react": "^11.5.0",
+ "@emotion/styled": "^11.3.0",
+ "@mui/material-pigment-css": "^7.3.5",
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/react": {
+ "optional": true
+ },
+ "@emotion/styled": {
+ "optional": true
+ },
+ "@mui/material-pigment-css": {
+ "optional": true
+ },
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@mui/material/node_modules/react-is": {
+ "version": "19.2.0",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz",
+ "integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==",
+ "license": "MIT"
+ },
+ "node_modules/@mui/private-theming": {
+ "version": "7.3.5",
+ "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.5.tgz",
+ "integrity": "sha512-cTx584W2qrLonwhZLbEN7P5pAUu0nZblg8cLBlTrZQ4sIiw8Fbvg7GvuphQaSHxPxrCpa7FDwJKtXdbl2TSmrA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.4",
+ "@mui/utils": "^7.3.5",
+ "prop-types": "^15.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ },
+ "peerDependencies": {
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@mui/styled-engine": {
+ "version": "7.3.5",
+ "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.5.tgz",
+ "integrity": "sha512-zbsZ0uYYPndFCCPp2+V3RLcAN6+fv4C8pdwRx6OS3BwDkRCN8WBehqks7hWyF3vj1kdQLIWrpdv/5Y0jHRxYXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.4",
+ "@emotion/cache": "^11.14.0",
+ "@emotion/serialize": "^1.3.3",
+ "@emotion/sheet": "^1.4.0",
+ "csstype": "^3.1.3",
+ "prop-types": "^15.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ },
+ "peerDependencies": {
+ "@emotion/react": "^11.4.1",
+ "@emotion/styled": "^11.3.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/react": {
+ "optional": true
+ },
+ "@emotion/styled": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@mui/system": {
+ "version": "7.3.5",
+ "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.5.tgz",
+ "integrity": "sha512-yPaf5+gY3v80HNkJcPi6WT+r9ebeM4eJzrREXPxMt7pNTV/1eahyODO4fbH3Qvd8irNxDFYn5RQ3idHW55rA6g==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.4",
+ "@mui/private-theming": "^7.3.5",
+ "@mui/styled-engine": "^7.3.5",
+ "@mui/types": "^7.4.8",
+ "@mui/utils": "^7.3.5",
+ "clsx": "^2.1.1",
+ "csstype": "^3.1.3",
+ "prop-types": "^15.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ },
+ "peerDependencies": {
+ "@emotion/react": "^11.5.0",
+ "@emotion/styled": "^11.3.0",
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/react": {
+ "optional": true
+ },
+ "@emotion/styled": {
+ "optional": true
+ },
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@mui/types": {
+ "version": "7.4.8",
+ "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.8.tgz",
+ "integrity": "sha512-ZNXLBjkPV6ftLCmmRCafak3XmSn8YV0tKE/ZOhzKys7TZXUiE0mZxlH8zKDo6j6TTUaDnuij68gIG+0Ucm7Xhw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.4"
+ },
+ "peerDependencies": {
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@mui/utils": {
+ "version": "7.3.5",
+ "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.5.tgz",
+ "integrity": "sha512-jisvFsEC3sgjUjcPnR4mYfhzjCDIudttSGSbe1o/IXFNu0kZuR+7vqQI0jg8qtcVZBHWrwTfvAZj9MNMumcq1g==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.4",
+ "@mui/types": "^7.4.8",
+ "@types/prop-types": "^15.7.15",
+ "clsx": "^2.1.1",
+ "prop-types": "^15.8.1",
+ "react-is": "^19.2.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ },
+ "peerDependencies": {
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@mui/utils/node_modules/react-is": {
+ "version": "19.2.0",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz",
+ "integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==",
+ "license": "MIT"
+ },
"node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
"version": "5.1.1-v1",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
@@ -3083,6 +3495,16 @@
}
}
},
+ "node_modules/@popperjs/core": {
+ "version": "2.11.8",
+ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
+ "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/popperjs"
+ }
+ },
"node_modules/@rollup/plugin-babel": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
@@ -3801,6 +4223,12 @@
"integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==",
"license": "MIT"
},
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "license": "MIT"
+ },
"node_modules/@types/q": {
"version": "1.5.8",
"resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.8.tgz",
@@ -3837,6 +4265,15 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/react-transition-group": {
+ "version": "4.4.12",
+ "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
+ "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*"
+ }
+ },
"node_modules/@types/resolve": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
@@ -4936,6 +5373,33 @@
"node": ">=4"
}
},
+ "node_modules/axios": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
+ "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.6",
+ "form-data": "^4.0.4",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
+ "node_modules/axios/node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -5684,6 +6148,15 @@
"wrap-ansi": "^7.0.0"
}
},
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -6746,6 +7219,16 @@
"utila": "~0.4"
}
},
+ "node_modules/dom-helpers": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.8.7",
+ "csstype": "^3.0.2"
+ }
+ },
"node_modules/dom-serializer": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
@@ -8190,6 +8673,12 @@
"url": "https://github.com/avajs/find-cache-dir?sponsor=1"
}
},
+ "node_modules/find-root": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
+ "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
+ "license": "MIT"
+ },
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
@@ -8891,6 +9380,21 @@
"he": "bin/he"
}
},
+ "node_modules/hoist-non-react-statics": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
+ "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "react-is": "^16.7.0"
+ }
+ },
+ "node_modules/hoist-non-react-statics/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
"node_modules/hoopy": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz",
@@ -11283,6 +11787,15 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lucide-react": {
+ "version": "0.554.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.554.0.tgz",
+ "integrity": "sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -13701,6 +14214,12 @@
"node": ">= 0.10"
}
},
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "license": "MIT"
+ },
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
@@ -14217,6 +14736,22 @@
"node": ">= 14.6"
}
},
+ "node_modules/react-transition-group": {
+ "version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+ "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@babel/runtime": "^7.5.5",
+ "dom-helpers": "^5.0.1",
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.6.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0",
+ "react-dom": ">=16.6.0"
+ }
+ },
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -15794,6 +16329,12 @@
"postcss": "^8.2.15"
}
},
+ "node_modules/stylis": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
+ "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
+ "license": "MIT"
+ },
"node_modules/sucrase": {
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
diff --git a/package.json b/package.json
index e05fc9d..2c2705e 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,10 @@
"version": "0.1.0",
"private": true,
"dependencies": {
+ "@emotion/react": "^11.14.0",
+ "@emotion/styled": "^11.14.1",
+ "@mui/icons-material": "^7.3.5",
+ "@mui/material": "^7.3.5",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
@@ -11,6 +15,8 @@
"@types/node": "^16.18.126",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
+ "axios": "^1.13.2",
+ "lucide-react": "^0.554.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.5",
diff --git a/public/index.html b/public/index.html
index b4e7fe1..dd857aa 100644
--- a/public/index.html
+++ b/public/index.html
@@ -15,6 +15,11 @@
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
+
+
+
+
React App
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..3b8d0b8
--- /dev/null
+++ b/server.js
@@ -0,0 +1,105 @@
+const express = require("express");
+const cors = require("cors");
+
+const app = express();
+app.use(cors());
+app.use(express.json());
+
+const PORT = 4000;
+
+// Mock 데이터
+const TAG_KEYWORDS = {
+ 카페: ["카페", "커피", "디저트", "케이크", "쿠키", "노트북"],
+ 스터디카페: ["스터디카페", "공부", "집중"],
+ 독서실: ["독서실", "책", "혼공", "조용한"],
+ "조명 어두움": ["조명 어두움", "어두움", "어두운", "컴컴"],
+ "조명 중간": ["조명 중간", "적당한 조명", "중간"],
+ "조명 밝음": ["조명 밝음", "밝은 조명", "햇빛"],
+ 조용함: ["조용함", "조용한", "차분", "집중", "혼자", "시험", "조용", "집중"],
+ 적당한소음: ["적당한소음", "보통"],
+ 시끌벅적: ["시끌벅적", "북적", "떠들썩"],
+};
+
+const CAFES = [
+ {
+ id: 1,
+ name: "카페 블루보틀",
+ location: "서울 강남",
+ tags: ["카페", "밝음", "조용함"],
+ thumbnail: "https://via.placeholder.com/80",
+ rating: 4.7,
+ hours: "09:00~22:00",
+ },
+ {
+ id: 2,
+ name: "카페 루시드",
+ location: "서울 홍대",
+ tags: ["독서실", "밝음", "시간제한 없음"],
+ thumbnail: "https://via.placeholder.com/80",
+ rating: 4.5,
+ hours: "08:00~23:00",
+ },
+ {
+ id: 3,
+ name: "스터디카페 집중존",
+ location: "서울 신촌",
+ tags: ["스터디카페", "조용함", "적당한소음"],
+ thumbnail: "https://via.placeholder.com/80",
+ rating: 4.3,
+ hours: "09:00~23:00",
+ },
+];
+
+// ----------------------
+// 1. AI 추천 태그 API
+// ----------------------
+app.get("/api/ai-tags", (req, res) => {
+ const { query } = req.query;
+
+ if (!query) return res.json([]);
+
+ const lower = query.toLowerCase();
+ const matchedTags = [];
+
+ Object.entries(TAG_KEYWORDS).forEach(([tag, keywords]) => {
+ for (const kw of keywords) {
+ if (lower.includes(kw.toLowerCase())) {
+ matchedTags.push(tag);
+ break;
+ }
+ }
+ });
+
+ res.json([...new Set(matchedTags)]);
+});
+
+// 추가: 태그 기반 카페 검색
+app.get("/api/ai-search", (req, res) => {
+ const query = req.query.query?.trim();
+ if (!query) return res.status(400).json({ message: "검색어가 없습니다." });
+
+ const results = mockData.cafes.filter((cafe) =>
+ cafe.tags.some((tag) => tag.includes(query))
+ );
+
+ res.json({ cafes: results });
+});
+// ----------------------
+// 2. 카페 검색 API
+// ----------------------
+app.get("/api/search-cafes", (req, res) => {
+ const { tags } = req.query; // tags는 "카페,조용함" 같은 CSV 문자열
+ if (!tags) return res.json([]);
+
+ const tagArray = tags.split(",");
+ const filtered = CAFES.filter((cafe) =>
+ tagArray.every((t) => cafe.tags.includes(t))
+ );
+
+ res.json(filtered);
+});
+
+// ----------------------
+app.listen(PORT, () => {
+ console.log(`Server running on http://localhost:${PORT}`);
+});
diff --git a/src/App.tsx b/src/App.tsx
index 027e7c4..075946d 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -3,6 +3,8 @@ import AiSearchPage from "./pages/search/AiSearchPage";
import TagSearchPage from "./pages/search/TagSearchPage";
import Home from "./pages/Home"; // 홈 페이지
import CafeDetailPage from "./pages/cafe/CafeDetailPage";
+import StudySpotRec from "./pages/recommend/StudySpotRec";
+import CafeReviewPage from "./pages/cafe/CafeReviewPage";
function App() {
return (
@@ -15,7 +17,11 @@ function App() {
} />
{/* }/> */}
} />
-
+ } />
+ {/* /*안드로이드 WebView가 타이머 클릭 시 가로채기 위해 반드시 필요함*/ }
+ timer} />
+ } />
+ } />
);
diff --git a/src/api/cafeApi.ts b/src/api/cafeApi.ts
index 5410207..a6d4546 100644
--- a/src/api/cafeApi.ts
+++ b/src/api/cafeApi.ts
@@ -1,8 +1,7 @@
// src/api/cafeApi.ts
import axios from "axios";
-const BASE_URL = "https://"; // Postman mock URL
-
+const BASE_URL = "https://studyspot.kr/api";
// ----------------------
// 타입 정의
// ----------------------
@@ -39,7 +38,9 @@ export interface NewReview {
// ----------------------
export const getRecommendedCafes = async (): Promise => {
try {
- const res = await axios.get<{ cafes: Cafe[] }>(`${BASE_URL}/cafes/recommended`);
+ const res = await axios.get<{ cafes: Cafe[] }>(
+ `${BASE_URL}/cafes/recommended`
+ );
return res.data.cafes;
} catch (err) {
console.error("[ERROR] 추천 카페 불러오기 실패:", err);
@@ -52,7 +53,9 @@ export const getRecommendedCafes = async (): Promise => {
// ----------------------
export const getCategories = async (): Promise => {
try {
- const res = await axios.get<{ categories: Category[] }>(`${BASE_URL}/categories`);
+ const res = await axios.get<{ categories: Category[] }>(
+ `${BASE_URL}/categories`
+ );
return res.data.categories;
} catch (err) {
console.error("[ERROR] 카테고리 불러오기 실패:", err);
@@ -67,26 +70,27 @@ const TAG_KEYWORDS: { [tag: string]: string[] } = {
카페: ["카페", "커피", "디저트", "케이크", "쿠키", "노트북"],
스터디카페: ["스터디카페", "공부", "집중"],
독서실: ["독서실", "책", "혼공", "조용한"],
- "어두움": ["조명 어두움", "어두움", "어두운", "컴컴"],
- "중간": ["조명 중간", "적당한 조명", "중간"],
- "밝음": ["조 명 밝음", "밝은 조명", "햇빛", "밝음", "밝은"],
+ 어두움: ["조명 어두움", "어두움", "어두운", "컴컴"],
+ 중간: ["조명 중간", "적당한 조명", "중간"],
+ 밝음: ["조 명 밝음", "밝은 조명", "햇빛", "밝음", "밝은"],
조용함: ["조용함", "조용한", "차분", "집중", "혼자", "시험", "조용", "집중"],
- 적당한소음: ["적당한소음", "보통"],
- 시끌벅적: ["시끌벅적", "북적", "떠들썩"],
+ "적당한 소음": ["적당한소음", "보통"],
+ "시끌벅적": ["시끌벅적", "북적", "떠들썩"],
"콘센트 있음": ["콘센트", "있음", "전원", "노트북", "코딩"],
"시간제한 있음": ["2시간 이하", "시간제한 있음", "빈자리"],
"시간제한 없음": ["시간제한 없음", "오래있기좋음"],
"주차 가능": ["주차 가능", "주차여유", "주차", "차", "차량"],
- 유료주차: ["유료주차", "주차비"],
- 주차불가: ["주차불가", "주차없음"],
- 지하철근처: ["지하철", "역근ㄴ처", "역세권", "역", "뚜벅이"],
+ "유료 주차": ["유료주차", "주차비"],
+ "주차 불가": ["주차불가", "주차없음"],
+ "지하철 근처": ["지하철", "역근ㄴ처", "역세권", "역", "뚜벅이"],
"버스정류장 근처": ["버스정류장", "버스근처"],
- 접근성좋음: ["접근성좋음", "편리", "오가기편함"],
- 번화가: ["번화가", "상권좋음", "역근처"],
- 조용한골목: ["조용한골목", "한적"],
- 공원근처: ["공원근처", "산책", "공원"],
- 캠퍼스근처: ["캠퍼스근처", "학교근처", "학교"],
+ "접근성 좋음": ["접근성좋음", "편리", "오가기편함"],
+ "번화가": ["번화가", "상권좋음", "역근처"],
+ "조용한 골목": ["조용한골목", "한적"],
+ "공원 근처": ["공원근처", "산책", "공원"],
+ "캠퍼스 근처": ["캠퍼스근처", "학교근처", "학교"],
"애견동반 가능": ["애견동반", "반려견", "강아지", "애카", "동물"],
+ "애견동반 불가능": ["애견동반불가", "반려견불가", "강아지불가"],
};
export const getAiTags = async (query: string): Promise => {
@@ -138,7 +142,9 @@ export const getCafeDetail = async (cafeId: string): Promise => {
// ----------------------
export const getCafeReviews = async (cafeId: string): Promise => {
try {
- const res = await axios.get<{ reviews: CafeReview[] }>(`${BASE_URL}/cafes/${cafeId}/reviews`);
+ const res = await axios.get<{ reviews: CafeReview[] }>(
+ `${BASE_URL}/cafes/${cafeId}/reviews`
+ );
return res.data.reviews;
} catch (err) {
console.error("[ERROR] 리뷰 불러오기 실패:", err);
@@ -151,7 +157,10 @@ export const postCafeReview = async (
review: NewReview
): Promise => {
try {
- const res = await axios.post<{ review: CafeReview }>(`${BASE_URL}/cafes/${cafeId}/reviews`, review);
+ const res = await axios.post<{ review: CafeReview }>(
+ `${BASE_URL}/cafes/${cafeId}/reviews`,
+ review
+ );
return res.data.review;
} catch (err) {
console.error("[ERROR] 리뷰 등록 실패:", err);
@@ -162,13 +171,30 @@ export const postCafeReview = async (
// ----------------------
// 카테고리별 태그 가져오기
// ----------------------
-export const getTags = async (category: string): Promise => {
+// export const getTags = async (category: string): Promise => {
+// try {
+// const res = await axios.get<{
+// tags: { id: number; category: string; items: string[] }[];
+// }>(`${BASE_URL}/tags`);
+// const found = res.data.tags.find((t) => t.category === category);
+// return found ? found.items : [];
+// } catch (err) {
+// console.error("[ERROR] 태그 불러오기 실패:", err);
+// return [];
+// }
+// };
+// src/api/cafeApi.ts
+export const getTags = async (searchPhrase: string): Promise => {
try {
- const res = await axios.get<{ tags: { id: number; category: string; items: string[] }[] }>(`${BASE_URL}/tags`);
- const found = res.data.tags.find((t) => t.category === category);
- return found ? found.items : [];
+ const res = await axios.get<{ data: string[] }>(
+ `https://studyspot.kr/api/tags?searchPhrase=${encodeURIComponent(searchPhrase)}`
+ );
+ return res.data.data;
} catch (err) {
console.error("[ERROR] 태그 불러오기 실패:", err);
- return [];
+ return []; // 실패 시 빈 배열 반환
}
};
+
+
+
diff --git a/src/api/reviewApi.ts b/src/api/reviewApi.ts
new file mode 100644
index 0000000..b9c7d24
--- /dev/null
+++ b/src/api/reviewApi.ts
@@ -0,0 +1,8 @@
+import axios from "axios";
+
+const BASE_URL = "https://studyspot.kr/api";
+
+export const getBestReviews = async (cafeId: number) => {
+ const response = await axios.get(`${BASE_URL}/reviews/best/${cafeId}`);
+ return response.data.data;
+};
diff --git a/src/components/BottomNav.css b/src/components/BottomNav.css
index cef7a28..9069d24 100644
--- a/src/components/BottomNav.css
+++ b/src/components/BottomNav.css
@@ -1,10 +1,10 @@
.bottom-nav {
display: flex;
justify-content: space-around;
- align-items: center;
+ align-items: center;
border-top: 1px solid #ddd;
padding: 12px 0;
- position: fixed;
+ position: fixed;
bottom: 0;
left: 0;
right: 0;
@@ -13,50 +13,43 @@
margin: 0 auto;
font-family: "Pretendard", sans-serif;
box-sizing: border-box;
+ z-index: 100;
}
-/* 아이폰 기준 보기 좋게 폰트크기와 터치 영역 조정 */
-.bottom-nav div {
- flex: 1;
- text-align: center;
- font-size: 0.95rem;
- padding: 8px 0;
+.nav-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ color: #888;
+ font-size: 0.9rem;
transition: color 0.2s ease;
-
}
-.bottom-nav div.active {
- color: black;
+.nav-item svg {
+ font-size: 28px;
+ margin-bottom: 4px;
+}
+
+.nav-item.active {
+ color: #000;
font-weight: 600;
- display: flex;
- flex-direction: column;
- justify-content: center;
}
-.cursor-pointer {
- cursor: pointer;
- display: flex;
- flex-direction: column;
- justify-content: center;
-
+.nav-label {
+ font-size: 0.85rem;
}
-/* 모바일 반응형 (아이폰 16 기준) */
+/* 모바일 반응형 */
@media (max-width: 430px) {
.bottom-nav {
padding: 10px 0;
}
- .bottom-nav div {
- font-size: 0.85rem;
- padding: 7px 0;
+ .nav-item svg {
+ font-size: 24px;
+ }
+ .nav-label {
+ font-size: 0.8rem;
}
-}
-
-
-.material-symbols-outlined {
- font-variation-settings:
- 'FILL' 1,
- 'wght' 100,
- 'GRAD' -25,
- 'opsz' 24
}
diff --git a/src/components/BottomNav.tsx b/src/components/BottomNav.tsx
index 3457f7a..747c768 100644
--- a/src/components/BottomNav.tsx
+++ b/src/components/BottomNav.tsx
@@ -1,36 +1,32 @@
import React from "react";
import { useNavigate, useLocation } from "react-router-dom";
import "./BottomNav.css";
+import HomeIcon from "@mui/icons-material/Home";
+import AlarmIcon from "@mui/icons-material/Alarm";
+import SearchIcon from "@mui/icons-material/Search";
const BottomNav: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
+ const navItems = [
+ { path: "/timer", label: "타이머", icon: },
+ { path: "/", label: "홈", icon: },
+ { path: "/ai-search", label: "검색", icon: },
+ ];
+
return (
-
navigate("/timer")}
- >
- alarm
- 타이머
-
-
navigate("/")}
- >
- home
- 홈
-
-
navigate("/search")}
- >
- search
- 검색
-
+ {navItems.map((item) => (
+
navigate(item.path)}
+ >
+ {item.icon}
+ {item.label}
+
+ ))}
);
};
diff --git a/src/components/cafe/CafeCard.css b/src/components/card/CafeCard.css
similarity index 76%
rename from src/components/cafe/CafeCard.css
rename to src/components/card/CafeCard.css
index 0039b73..6ac1bc8 100644
--- a/src/components/cafe/CafeCard.css
+++ b/src/components/card/CafeCard.css
@@ -1,16 +1,34 @@
+:root {
+ --bg: #FFF7ED; /* 따뜻한 크림 베이스 */
+ --highlight: #FFE4D6;
+ --primary: #E4AFAF; /* 말린장미 포인트 */
+ --border: #E9D5CA;
+ --text: #5A4A42;
+}
/* ============================
CafeCard 전체 컨테이너
============================ */
.cafe-card {
width: 100%;
- padding: 10px 0;
+ padding: 10px 10px;
border-bottom: 1px solid #eee;
+ background-color: white;
+ border-radius: 12px;
+ border: 1px solid #E4AFAF;
+ margin-top: 10px;
+ /* margin-bottom: 10px; */
}
+
+/* 카페 이용 목적 마우스 오버 */
+
.cafe-card:hover {
- opacity: 0.95;
+ transform: scale(1.02);
+ box-shadow: 0 3px 8px rgba(0, 0, 0, 0.12);
+ background-color: var(--highlight);
+ cursor: pointer;
+ transition: all 0.3s ease;
}
-
/* 카드 내부 가로 배치 */
.cafe-card-div {
display: flex;
@@ -26,6 +44,7 @@
border-radius: 12px;
overflow: hidden;
flex-shrink: 0;
+ border: 1px solid #ddd;
}
.cafe-thumbnail {
diff --git a/src/components/cafe/CafeCard.tsx b/src/components/card/CafeCard.tsx
similarity index 71%
rename from src/components/cafe/CafeCard.tsx
rename to src/components/card/CafeCard.tsx
index 0f3f8cd..4721f23 100644
--- a/src/components/cafe/CafeCard.tsx
+++ b/src/components/card/CafeCard.tsx
@@ -1,13 +1,13 @@
-import React from "react";
+import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import "./CafeCard.css";
export interface CafeCardData {
id: number;
name: string;
- category?: string;
- purpose?: string[];
- imageList?: { imageUrl: string; index: number }[];
+ category: string;
+ purpose: string[];
+ imageList: { imageUrl: string; index: number }[];
location?: string[];
rating?: number;
startingTime?: string;
@@ -15,6 +15,8 @@ export interface CafeCardData {
tags?: string[];
}
+const IMAGE_BASE_URL = "https://tmp.studyspot.kr";
+
interface CafeCardProps {
cafe: CafeCardData;
}
@@ -23,6 +25,14 @@ const CafeCard: React.FC = ({ cafe }) => {
const navigate = useNavigate();
const fallbackImage = "/images/default_cafe.png";
+ // 이미지 src 상태
+ const [imgSrc, setImgSrc] = useState(
+ cafe.imageList?.[0]?.imageUrl
+ ? `${IMAGE_BASE_URL}${cafe.imageList[0].imageUrl}`
+ : fallbackImage
+ );
+
+ // 영업 상태 계산
const checkOpenStatus = (start?: string, end?: string) => {
if (!start || !end) return "NO_INFO";
@@ -53,20 +63,24 @@ const CafeCard: React.FC = ({ cafe }) => {

(e.currentTarget.src = fallbackImage)}
+ onError={() => {
+ if (imgSrc !== fallbackImage) setImgSrc(fallbackImage);
+ }}
/>
{cafe.name}
-
⭐ {cafe.rating}
+
⭐ {cafe.rating ?? "N/A"}
-
📍 {cafe.location?.join(", ")}
+
+ 📍 {cafe.location?.length ? cafe.location.join(", ") : "정보없음"}
+
{openStatus === "OPEN"
@@ -77,7 +91,7 @@ const CafeCard: React.FC = ({ cafe }) => {
- {cafe.purpose?.map((tag) => (
+ {(cafe.purpose?.length ? cafe.purpose : cafe.tags)?.map((tag) => (
{tag}
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
new file mode 100644
index 0000000..e78f327
--- /dev/null
+++ b/src/components/ui/card.tsx
@@ -0,0 +1,14 @@
+import * as React from "react";
+
+interface CardProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function Card({ children, className }: CardProps) {
+ return
{children}
;
+}
+
+export function CardContent({ children, className }: CardProps) {
+ return
{children}
;
+}
diff --git a/src/components/utils/normalizeCafe.ts b/src/components/utils/normalizeCafe.ts
new file mode 100644
index 0000000..eec1700
--- /dev/null
+++ b/src/components/utils/normalizeCafe.ts
@@ -0,0 +1,66 @@
+// 서버에서 받은 데이터(c: any)를 CafeCardData 형태로 변환
+export const normalizeCafe = (c: any) => {
+ return {
+ id: c.id,
+ name: c.name,
+
+ // 카테고리 (없으면 기본값)
+ category: c.category || "기타",
+
+ // 목적(purpose는 추천카페 API에는 없음 → 기본 [])
+ purpose: c.purpose || [],
+
+ // 이미지 처리
+ // 추천카페 API → imageUrl
+ // 상세페이지 API → imageList: [{ imageUrl, sequence }]
+ imageList: c.imageList
+ ? c.imageList.map((img: any, idx: number) => ({
+ imageUrl: img.imageUrl,
+ index: img.sequence ?? idx,
+ }))
+ : c.imageUrl
+ ? [{ imageUrl: c.imageUrl, index: 0 }]
+ : [],
+
+ // 위치 처리 (추천카페는 address, 상세페이지는 location [lat, lng])
+ location: Array.isArray(c.location)
+ ? c.location.map((l: any) => String(l))
+ : c.address
+ ? [c.address]
+ : [],
+
+ // 평점
+ rating: c.averageStarRating ?? c.rating ?? 0,
+
+ startingTime: c.startingTime,
+ closingTime: c.closingTime,
+
+ // 태그
+ // 추천카페 → tags: ["콘센트 많음", "주차 가능"]
+ // 상세페이지 → tags: { powerOutletLevel: "...", ... }
+ tags: Array.isArray(c.tags)
+ ? c.tags // 추천카페
+ : c.tags
+ ? Object.values(c.tags) // 상세페이지
+ : [],
+
+ // 상세페이지용 값들 추가 (추천카페에는 없지만 있어도 문제X)
+ phoneNumber: c.phoneNumber || "",
+ limitTime: c.limitTime ?? null,
+ menuList: c.menuList || [],
+ };
+};
+
+
+export interface CafeCardData {
+ id: number;
+ name: string;
+ category: string;
+ purpose: string[];
+ imageList: { imageUrl: string; index: number }[];
+ location?: string[];
+ rating?: number;
+ startingTime?: string;
+ closingTime?: string;
+ tags?: string[];
+}
\ No newline at end of file
diff --git a/src/config/api.ts b/src/config/api.ts
new file mode 100644
index 0000000..9360f93
--- /dev/null
+++ b/src/config/api.ts
@@ -0,0 +1,8 @@
+// API 설정 파일
+// 환경 변수에서 API Base URL 가져오기
+
+export const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || "https://studyspot.kr/api";
+
+// 네이버 맵 API 클라이언트 ID
+export const NAVER_MAP_CLIENT_ID = process.env.REACT_APP_NAVER_MAP_CLIENT_ID || "d8111nt3ma";
+
diff --git a/src/index.tsx b/src/index.tsx
index 03f0956..bc4abdd 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -8,22 +8,10 @@ const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
-
-
-
-
-
+
+
);
-// If you want to start measuring performance in your app, pass a function
-// to log results (for example: reportWebVitals(console.log))
-// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
diff --git a/src/pages/CafeDetail.tsx b/src/pages/CafeDetail.tsx
new file mode 100644
index 0000000..f0fd5cb
--- /dev/null
+++ b/src/pages/CafeDetail.tsx
@@ -0,0 +1,117 @@
+import React, { useEffect, useState } from "react";
+import { useParams } from "react-router-dom";
+import { getCafeDetail, getCafeReviews, postCafeReview } from "../api/cafeApi";
+import "../styles/CafeDetail.css";
+
+interface Cafe {
+ id: number;
+ name: string;
+ location: string;
+ tags: string[];
+ thumbnail?: string;
+}
+
+interface Review {
+ id: number;
+ rating: number;
+ comment: string;
+ author: string;
+ createdAt?: string;
+}
+
+const CafeDetail: React.FC = () => {
+ const { cafeId } = useParams<{ cafeId: string }>();
+ const [cafe, setCafe] = useState
(null);
+ const [reviews, setReviews] = useState([]);
+ const [newRating, setNewRating] = useState(0);
+ const [newComment, setNewComment] = useState("");
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ if (!cafeId) return;
+
+ const fetchData = async () => {
+ try {
+ const [cafeData, reviewData] = await Promise.all([
+ getCafeDetail(cafeId!), // string 그대로 전달, !로 undefined 제외
+ getCafeReviews(cafeId!),
+ ]);
+
+ setCafe(cafeData);
+ setReviews(
+ reviewData.map((r: any) => ({ ...r, author: r.author ? r.author : "익명" }))
+ );
+ } catch (err) {
+ console.error("카페 상세정보 불러오기 실패:", err);
+ } finally {
+ setLoading(false);
+ }
+};
+
+
+ fetchData();
+ }, [cafeId]);
+
+ const handleReviewSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!cafeId) return;
+
+ try {
+ await postCafeReview(cafeId!, { rating: newRating, comment: newComment });
+
+ const updatedReviews = await getCafeReviews(cafeId!);
+ setReviews(
+ updatedReviews.map((r: any) => ({ ...r, author: r.author ? r.author : "익명" }))
+ );
+
+ setNewRating(0);
+ setNewComment("");
+ } catch (err) {
+ console.error("리뷰 등록 실패:", err);
+ }
+};
+
+
+ if (loading) return 로딩 중...
;
+ if (!cafe) return 카페 정보를 불러오지 못했습니다.
;
+
+ return (
+
+
{cafe.name}
+ {cafe.thumbnail &&

}
+
{cafe.location}
+
{cafe.tags.map((tag, i) => #{tag})}
+
+
리뷰
+
+ {reviews.map((review) => (
+ -
+ ⭐ {review.rating} - {review.comment} - {review.author}
+
+ ))}
+
+
+
+
+ );
+};
+
+export default CafeDetail;
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index e883743..78c7e8b 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -2,44 +2,54 @@ import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import "../styles/Home.css";
import BottomNav from "../components/BottomNav";
-import CafeCard, { CafeCardData } from "../components/cafe/CafeCard";
+import CafeCard, { CafeCardData } from "../components/card/CafeCard";
import { normalizeCafe } from "../components/utils/normalizeCafe";
+import { API_BASE_URL } from "../config/api";
-interface Category { id: number; name: string; imgUrl?: string; }
-interface Purpose { id: number; name: string; imgUrl?: string; }
+interface Category {
+ id: number;
+ name: string;
+ emoji?: string;
+}
+interface Purpose {
+ id: number;
+ name: string;
+ emoji?: string;
+}
const Home: React.FC = () => {
const navigate = useNavigate();
const [categories, setCategories] = useState([
- { id: 1, name: "스터디카페", imgUrl: "https://placehold.co/80x80" },
- { id: 2, name: "대형카페", imgUrl: "https://placehold.co/80x80" },
- { id: 3, name: "조용한 카페", imgUrl: "https://placehold.co/80x80" },
+ { id: 1, name: "카페", emoji: "☕️" },
+ { id: 2, name: "스터디카페", emoji: "✏️" },
+ { id: 3, name: "독서실", emoji: "🪑" },
]);
const [purposes, setPurposes] = useState([
- { id: 1, name: "책", imgUrl: "https://placehold.co/80x80" },
- { id: 2, name: "노트북", imgUrl: "https://placehold.co/80x80" },
- { id: 3, name: "데이트", imgUrl: "https://placehold.co/80x80" },
- { id: 4, name: "휴식", imgUrl: "https://placehold.co/80x80" },
- { id: 5, name: "포토스팟", imgUrl: "https://placehold.co/80x80" },
- { id: 6, name: "단체", imgUrl: "https://placehold.co/80x80" },
+ { id: 1, name: "책", emoji: "📖" },
+ { id: 2, name: "노트북", emoji: "💻" },
+ { id: 3, name: "데이트", emoji: "💘" },
+ { id: 4, name: "휴식", emoji: "😌" },
+ { id: 5, name: "포토스팟", emoji: "📸" },
+ { id: 6, name: "단체", emoji: "👥" },
]);
-const [recommendedCafes, setRecommendedCafes] = useState([]);
+ const [recommendedCafes, setRecommendedCafes] = useState([]);
// -------------------------------
// 추천 카페 API fetch
// -------------------------------
+
useEffect(() => {
const fetchRecommendedCafes = async () => {
try {
- const res = await fetch(
- "https://c765212b-1c21-4d14-98d9-56dd58cc5d3d.mock.pstmn.io/cafes/recommended"
- );
+ const res = await fetch("https://studyspot.kr/api/cafes/recommended");
const json = await res.json();
+ console.log("카페추천데이터:", json.data); // 확인용
if (json.status === "success") {
- const cafesFromServer = json.data.map(normalizeCafe);
+ const cafesFromServer =
+ json.data.recommendationCafes.map(normalizeCafe);
setRecommendedCafes(cafesFromServer);
}
} catch (err) {
@@ -63,27 +73,41 @@ const [recommendedCafes, setRecommendedCafes] = useState([]);
readOnly
/>
-
- {/* 공부 장소 추천 */}
+ {/* 공부 장소 추천 */}
공부 장소 추천
{categories.map((cat) => (
-
-

+
+ navigate("/studyspot-rec", {
+ state: { filterTag: cat.name },
+ })
+ }
+ >
+ {cat.emoji}
{cat.name}
))}
- {/* 카페 이용 목적 */}
카페 이용 목적
{purposes.map((p) => (
-
-

+
+ navigate("/studyspot-rec", {
+ state: { filterTag: p.name },
+ })
+ }
+ >
+ {p.emoji}
{p.name}
))}
diff --git a/src/pages/cafe/CafeDetail.css b/src/pages/cafe/CafeDetail.css
index c131ea5..0c356bd 100644
--- a/src/pages/cafe/CafeDetail.css
+++ b/src/pages/cafe/CafeDetail.css
@@ -1,8 +1,8 @@
/* CafeDetail.css */
.cafe-detail-page {
- max-width: 420px;
- margin: 0 auto;
+ max-width: 420px;
+ margin: 0 auto;
padding: 16px;
font-family: "Noto Sans KR", sans-serif;
padding-bottom: 100px;
@@ -40,7 +40,7 @@
position: absolute;
top: 50%;
transform: translateY(-50%);
- background-color: rgba(0,0,0,0.4);
+ background-color: rgba(0, 0, 0, 0.4);
color: white;
border: none;
border-radius: 50%;
@@ -149,6 +149,13 @@
}
/* 평가 & 리뷰 */
+.best-review-header {
+ font-size: 18px;
+ font-weight: 600;
+ margin: 20px 0 8px;
+ border-bottom: 2px solid #eee;
+ padding-bottom: 4px;
+}
.reviews-section {
margin-top: 2rem;
}
@@ -159,12 +166,100 @@
gap: 0.5rem;
margin-bottom: 1rem;
}
-
+.see-all-reviews{
+ font-size: 15px;
+}
+.see-all-reviews:hover{
+cursor: pointer;}
.avg-rating {
font-size: 2rem;
font-weight: bold;
}
+/* 베스트 리뷰 블록 */
+.best-review-block {
+ margin-top: 20px;
+ padding: 16px;
+ background-color: white;
+ border-radius: 12px;
+}
+
+/* 베스트 리뷰 헤더: 제목 + 버튼 가로 정렬 */
+.best-review-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 12px;
+}
+
+/* 베스트 리뷰 제목 */
+.best-review-header h3 {
+ font-size: 18px;
+ font-weight: 600;
+ margin: 0;
+}
+
+/* 전체 리뷰 페이지 버튼 */
+.view-all-btn {
+ padding: 6px 12px;
+ background-color: #4a90e2;
+ color: white;
+ font-size: 14px;
+ font-weight: 500;
+ border: none;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: 0.2s;
+}
+
+.view-all-btn:hover {
+ background-color: #357ab8;
+}
+
+/* 베스트 리뷰 카드 */
+.review-card.best {
+ border: 1px solid #ddd; /* 베스트 리뷰 강조 */
+ background-color: white;
+ border-radius: 12px;
+ padding: 12px;
+ display: flex;
+ gap: 12px;
+ align-items: flex-start;
+ margin-bottom: 10px;
+}
+
+.review-card.best img {
+ width: 80px;
+ height: 80px;
+ object-fit: cover;
+ border-radius: 100px;
+ flex-shrink: 0; /* 이미지 고정 크기 */
+}
+/* 리뷰 내용 (텍스트) */
+.review-card.best .review-content {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ flex: 1; /* 남는 공간 채우기 */
+}
+.review-content {
+ /* border: 1px solid blue; */
+ height: 100%;
+}
+/* 리뷰 작성자 및 별점 */
+.review-card.best .review-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-weight: 600;
+ font-size: 14px;
+}
+/* 리뷰 내용 텍스트 */
+.review-card.best p {
+ font-size: 13px;
+ color: #333;
+ margin: 0;
+}
/* 별점 */
.stars {
color: #ccc;
diff --git a/src/pages/cafe/CafeDetailPage.tsx b/src/pages/cafe/CafeDetailPage.tsx
index 6485b25..3827c3f 100644
--- a/src/pages/cafe/CafeDetailPage.tsx
+++ b/src/pages/cafe/CafeDetailPage.tsx
@@ -1,90 +1,64 @@
-// src/pages/search/cafe/CafeDetailPage.tsx
-
-import { CafeBase } from "./cafecard/CafeBase"; // CafeBase 인터페이스 가져오기
+import { CafeBase } from "./cafecard/CafeBase";
import { useEffect, useState, useRef } from "react";
import BottomNav from "../../components/BottomNav";
import { useParams, useNavigate } from "react-router-dom";
import axios from "axios";
+import { API_BASE_URL } from "../../config/api";
import "./CafeDetail.css";
-// ----------------------
-// TypeScript 타입 정의
-// ----------------------
interface Menu {
name: string;
price: number;
description: string;
imageUrl: string;
}
-
interface ImageData {
imageUrl: string;
index: number;
}
-
-interface Tags {
- petFriendly: boolean;
- power_outlet_level: string;
-}
-
-// 운영 정보 포함 CafeDetail 타입
export interface CafeDetail extends CafeBase {
- purpose: string[]; // 목적 태그
- limitTime: number; // 시간 제한
- phoneNumber: string; // 전화번호
- tags: {
- petFriendly: boolean; // 반려견 동반 가능 여부
- power_outlet_level: string; // 콘센트 수준
- };
- menuList: Menu[]; // 메뉴 리스트
- imageList: ImageData[]; // 이미지 리스트
- location: number[]; // [lng, lat]
+ purpose: string[];
+ limitTime: number;
+ phoneNumber: string;
+ tags: { petFriendly: boolean; power_outlet_level: string };
+ menuList: Menu[];
+ imageList: ImageData[];
+ location: number[];
}
-// 리뷰 타입
interface Review {
starRating: number;
- name: string;
+ name?: string | null;
content: string;
imageUrl?: string | null;
+ createdAt?: string | number;
}
-interface CafeReviews {
- averageStarRating?: number; // 평균 별점
- reviewCount?: number; // 리뷰 개수
- reviews?: Review[];
-}
+const BASE_IMAGE_URL = "https://tmp.studyspot.kr";
+const getFullImageUrl = (url?: string | null) =>
+ url ? (url.startsWith("http") ? url : `${BASE_IMAGE_URL}${url}`) : "";
-// ----------------------
-// 글로벌 window 타입 선언 필요 (naver 지도)
-// ----------------------
declare global {
interface Window {
- naver: any; // naver.maps 타입 정의 가능, 일단 any로
+ naver: any;
}
}
-// ----------------------
-// 컴포넌트 시작
-// ----------------------
export default function CafeDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const mapRef = useRef
(null);
+ const mapInstanceRef = useRef(null); // 지도 인스턴스 저장
- // 상태 관리
const [cafe, setCafe] = useState(null);
- const [reviews, setReviews] = useState(null);
const [currentImage, setCurrentImage] = useState(0);
+ const [bestReview, setBestReview] = useState([]);
- // ----------------------
- // 카페 상세 정보 불러오기
- // ----------------------
useEffect(() => {
const fetchCafe = async () => {
try {
const res = await axios.get<{ data: CafeDetail }>(
- `https://c765212b-1c21-4d14-98d9-56dd58cc5d3d.mock.pstmn.io/cafes/details/${id}`
+ `https://studyspot.kr/api/cafes/details/${id}`
);
setCafe(res.data.data);
} catch (err) {
@@ -94,36 +68,33 @@ export default function CafeDetailPage() {
fetchCafe();
}, [id]);
- // ----------------------
- // 리뷰 불러오기
- // ----------------------
+ // 베스트 리뷰만 불러오기
useEffect(() => {
- const fetchReviews = async () => {
+ const fetchBestReview = async () => {
try {
- const res = await axios.get<{ data: CafeReviews }>(
- `https://c765212b-1c21-4d14-98d9-56dd58cc5d3d.mock.pstmn.io/reviews/best/${id}`
+ const res = await axios.get<{ data: { reviews: Review[] } }>(
+ `https://studyspot.kr/api/reviews/best/${id}`
);
- setReviews(res.data.data);
+ setBestReview(res.data.data?.reviews ?? []);
} catch (err) {
- console.error("❌ 리뷰 정보를 불러올 수 없습니다:", err);
+ console.error("❌ 베스트 리뷰를 불러올 수 없습니다:", err);
}
};
- fetchReviews();
+ fetchBestReview();
}, [id]);
- // ----------------------
- // 네이버 지도 초기화
- // ----------------------
+ // 네이버 지도
useEffect(() => {
if (!cafe || !mapRef.current || !window.naver?.maps) return;
-
const map = new window.naver.maps.Map(mapRef.current, {
center: new window.naver.maps.LatLng(cafe.location[1], cafe.location[0]),
zoom: 15,
});
-
new window.naver.maps.Marker({
- position: new window.naver.maps.LatLng(cafe.location[1], cafe.location[0]),
+ position: new window.naver.maps.LatLng(
+ cafe.location[1],
+ cafe.location[0]
+ ),
map,
});
}, [cafe]);
@@ -132,15 +103,13 @@ export default function CafeDetailPage() {
return (
- {/* 뒤로가기 */}
-