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 }) => {
{cafe.name} (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.name}} +

{cafe.location}

+
{cafe.tags.map((tag, i) => #{tag})}
+ +

리뷰

+
    + {reviews.map((review) => ( +
  • + ⭐ {review.rating} - {review.comment} - {review.author} +
  • + ))} +
+ +
+

리뷰 남기기

+ +