diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml new file mode 100644 index 000000000..01f013197 --- /dev/null +++ b/.github/workflows/frontend-ci.yml @@ -0,0 +1,35 @@ +name: πŸ” Celuveat Frontend CI πŸ” + +on: + push: + branches: + - develop-frontend + pull_request: + branches: + - develop-frontend + +jobs: + frontend-test: + runs-on: ubuntu-latest + env: + working-directory: ./frontend + + name: πŸ”ν…ŒμŠ€νŠΈ λ”± λŒ€λΌ πŸ’’πŸ‘Š + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: '18.16.1' + + - name: πŸ” yarn install + run: yarn install + working-directory: ${{ env.working-directory }} + + - name: πŸ” eslint ν…ŒμŠ€νŠΈ + run: yarn lint + working-directory: ${{ env.working-directory }} + + - name: πŸ” React ν”„λ‘œμ νŠΈ λΉŒλ“œ + run: yarn build + working-directory: ${{ env.working-directory }} diff --git a/.github/workflows/frontend-dev-cd.yml b/.github/workflows/frontend-dev-cd.yml new file mode 100644 index 000000000..f40b08e1f --- /dev/null +++ b/.github/workflows/frontend-dev-cd.yml @@ -0,0 +1,23 @@ +name: πŸ” Celuveat frontend DEV CD πŸ” + +on: + push: + branches: + - develop-frontend + paths: + - 'frontend/**' + +jobs: + deploy-frontend: + runs-on: [self-hosted, dev] + + steps: + - name: πŸ” .env 파일 μ„ΈνŒ… + run: | + touch ~/frontend-env/.env + echo GOOGLE_MAP_API_KEY=${{ secrets.GOOGLE_MAP_API_KEY }} > ~/frontend-env/.env + echo BASE_URL=${{ secrets.DEV_BASE_URL }} >> ~/frontend-env/.env + - name: πŸ” Run Frontend Deploy Script + run: | + cd ~ + sudo sh deploy-frontend.sh diff --git a/.github/workflows/frontend-prod-cd.yml b/.github/workflows/frontend-prod-cd.yml new file mode 100644 index 000000000..9d154bbdd --- /dev/null +++ b/.github/workflows/frontend-prod-cd.yml @@ -0,0 +1,24 @@ +name: πŸ” Celuveat frontend PROD CD πŸ” + +on: + push: + branches: + - main + paths: + - 'frontend/**' + +jobs: + deploy-frontend: + runs-on: [self-hosted, prod] + + steps: + - name: πŸ” .env 파일 μ„ΈνŒ… + run: | + touch ~/frontend-env/.env + echo GOOGLE_MAP_API_KEY=${{ secrets.GOOGLE_MAP_API_KEY }} > ~/frontend-env/.env + echo BASE_URL=${{ secrets.PROD_BASE_URL }} >> ~/frontend-env/.env + + - name: πŸ” Run Frontend Deploy Script + run: | + cd ~ + sudo sh deploy-frontend.sh diff --git a/README.md b/README.md deleted file mode 100644 index 41a26b62a..000000000 --- a/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Celuveat μ†Œκ°œ -μ…€λŸ½ 기반 맛집 탐색 μ„œλΉ„μŠ€ - -
-
- -# νŒ€μ› πŸ‘¨β€πŸ‘¨β€πŸ‘§β€πŸ‘§πŸ‘©β€πŸ‘¦β€πŸ‘¦ -| Backend | Backend | Backend | Backend | Frontend | Frontend | Frontend | -| :--------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------: | -| λ§λž‘ | 둜이슀 | 도기 | μ˜€λ„ | ν‘Έλ§ŒλŠ₯ | 제레미 | 도담 | -| [λ§λž‘](https://github.com/shin-mallang) | [둜이슀](https://github.com/taeyeonroyce) | [도기](https://github.com/kdkdhoho) | [μ˜€λ„](https://github.com/odo27) | [ν‘Έλ§ŒλŠ₯](https://github.com/turtle601) | [제레미](https://github.com/shackstack) | [도담](https://github.com/d0dam) | - - diff --git a/frontend/.babelrc b/frontend/.babelrc new file mode 100644 index 000000000..8f9ef2ccb --- /dev/null +++ b/frontend/.babelrc @@ -0,0 +1,35 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "targets": { "browsers": ["last 2 versions", ">= 5% in KR"] }, + "useBuiltIns": "usage", // 폴리필 μ‚¬μš© 방식 지정 + "corejs": { + "version": 3 // 폴리필 버전 지정 + } + } + ], + [ + "@babel/react", + { + "runtime": "automatic" + } + ], + "@babel/preset-typescript" + ], + "plugins": [ + [ + "babel-plugin-root-import", + { + "rootPathPrefix": "~", + "rootPathSuffix": "src" + } + ] + ], + "env": { + "development": { + "plugins": ["babel-plugin-styled-components"] + } + } +} diff --git a/frontend/.eslintrc b/frontend/.eslintrc new file mode 100644 index 000000000..8374c0efe --- /dev/null +++ b/frontend/.eslintrc @@ -0,0 +1,42 @@ +{ + "env": { + "browser": true, + "es2021": true, + "node": true, + "jest": true + }, + "extends": [ + "eslint:recommended", + "plugin:react/recommended", + "plugin:@typescript-eslint/recommended", + "airbnb", + "airbnb/hooks", + "airbnb-typescript", + "prettier" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaFeatures": { + "jsx": true + }, + "ecmaVersion": "latest", + "sourceType": "module", + "project": "./tsconfig.json" + }, + "plugins": ["@typescript-eslint", "react"], + "settings": { + "react": { + "version": "detect" + } + }, + "ignorePatterns": ["build", "dist", "public", "webpack.**.js", "mocks", "fileTransformer.js"], + "rules": { + "no-console": "warn", + "react/react-in-jsx-scope": "off", + "@typescript-eslint/no-use-before-define": "off", + "react/require-default-props": "off", + "react/jsx-props-no-spreading": "off", + "import/extensions": "off", + "react-hooks/exhaustive-deps": "off" + } +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 000000000..143a01960 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,3 @@ +/node_modules +/dist +/.env \ No newline at end of file diff --git a/frontend/.nvmrc b/frontend/.nvmrc new file mode 100644 index 000000000..8d2a45160 --- /dev/null +++ b/frontend/.nvmrc @@ -0,0 +1 @@ +18.16.1 \ No newline at end of file diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 000000000..1d611d3b2 --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,11 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "arrowParens": "avoid", + "proseWrap": "never", + "endOfLine": "auto" +} diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts new file mode 100644 index 000000000..58bd4fac3 --- /dev/null +++ b/frontend/.storybook/main.ts @@ -0,0 +1,31 @@ +import type { StorybookConfig } from '@storybook/react-webpack5'; +const config: StorybookConfig = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], + addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'], + framework: { + name: '@storybook/react-webpack5', + options: {}, + }, + docs: { + autodocs: 'tag', + }, + webpackFinal: async config => { + const imageRule = config.module?.rules?.find(rule => { + const test = (rule as { test: RegExp }).test; + + if (!test) return false; + + return test.test('.svg'); + }) as { [key: string]: any }; + + imageRule.exclude = /\.svg$/; + + config.module?.rules?.push({ + test: /\.svg$/, + use: ['@svgr/webpack'], + }); + + return config; + }, +}; +export default config; diff --git a/frontend/.storybook/preview.ts b/frontend/.storybook/preview.ts new file mode 100644 index 000000000..6555dd400 --- /dev/null +++ b/frontend/.storybook/preview.ts @@ -0,0 +1,19 @@ +import type { Preview } from '@storybook/react'; +import { withThemeFromJSXProvider } from '@storybook/addon-styling'; +import GlobalStyles from '../src/styles/GlobalStyles'; + +const preview: Preview = { + parameters: { + actions: { argTypesRegex: '^on[A-Z].*' }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + }, +}; + +export const decorators = [withThemeFromJSXProvider({ GlobalStyles })]; + +export default preview; diff --git a/frontend/.stylelintrc b/frontend/.stylelintrc new file mode 100644 index 000000000..68a06c7a9 --- /dev/null +++ b/frontend/.stylelintrc @@ -0,0 +1,101 @@ +{ + "extends": ["stylelint-config-standard"], + "plugins": ["stylelint-order"], + "customSyntax": "postcss-styled-syntax", + "rules": { + "declaration-empty-line-before": [ + "always", + { + "ignore": ["first-nested", "after-comment", "after-declaration", "inside-single-line-block"] + } + ], + "order/order": ["custom-properties", "declarations"], + "declaration-property-unit-allowed-list": { + "/^border/": ["px", "%"], + "/^width|^height/": ["px", "%", "vh", "vw"], + "/^margin|^padding|^gap/": ["rem"] + }, + "order/properties-order": [ + { + "groupName": "display", + "emptyLineBefore": "always", + "noEmptyLineBetween": true, + "properties": [ + "display", + "flex", + "flex-direction", + "flex-grow", + "flex-shrink", + "flex-basis", + "flex-flow", + "justify-content", + "align-items", + "align-content", + "gap" + ] + }, + + { + "groupName": "positioning", + "emptyLineBefore": "always", + "noEmptyLineBetween": true, + "properties": ["position", "top", "right", "bottom", "left", "z-index"] + }, + { + "groupName": "float", + "emptyLineBefore": "always", + "noEmptyLineBetween": true, + "properties": ["float"] + }, + { + "groupName": "width / height", + "emptyLineBefore": "always", + "noEmptyLineBetween": true, + "properties": ["width", "min-width", "max-width", "height", "min-height", "max-height"] + }, + { + "groupName": "padding / margin", + "emptyLineBefore": "always", + "noEmptyLineBetween": true, + "properties": ["padding", "margin"] + }, + { + "groupName": "border / background", + "emptyLineBefore": "always", + "noEmptyLineBetween": true, + "properties": [ + "border", + "border-radius", + "background", + "background-color", + "background-image", + "background-repeat", + "background-position", + "background-size" + ] + }, + { + "groupName": "colors and typography", + "emptyLineBefore": "always", + "noEmptyLineBetween": true, + "properties": [ + "color", + "font", + "font-family", + "font-size", + "font-weight", + "line-height", + "text-align", + "text-transform", + "text-decoration" + ] + }, + { + "groupName": "other", + "emptyLineBefore": "always", + "noEmptyLineBetween": true, + "properties": ["cursor", "opacity", "transition", "transform"] + } + ] + } +} diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json new file mode 100644 index 000000000..0f385ba75 --- /dev/null +++ b/frontend/.vscode/settings.json @@ -0,0 +1,15 @@ +{ + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true, + "source.fixAll.stylelint": true + }, + "typescript.validate.enable": true, + + "editor.defaultFormatter": "esbenp.prettier-vscode", + + "stylelint.enable": true, + + "stylelint.config": null, + "stylelint.validate": ["css", "scss", "typescript", "typescriptreact"] +} diff --git a/frontend/.webpack/webpack.common.js b/frontend/.webpack/webpack.common.js new file mode 100644 index 000000000..6c55af818 --- /dev/null +++ b/frontend/.webpack/webpack.common.js @@ -0,0 +1,79 @@ +const path = require('path'); + +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); + +const webpack = require('webpack'); +const dotenv = require('dotenv'); +dotenv.config(); + +const InterpolateHtmlPlugin = require('interpolate-html-plugin'); + +module.exports = { + entry: ['./src/index.tsx'], + output: { + path: path.resolve(__dirname, '../dist/'), + publicPath: '/', + clean: true, + }, + resolve: { + extensions: ['.tsx', '.ts', '.jsx', '.js', '.json'], + }, + module: { + rules: [ + { + test: /\.(ts|tsx)$/, + use: [ + 'babel-loader', + { + loader: 'ts-loader', + options: { + transpileOnly: true, + }, + }, + ], + exclude: /node_modules/, + }, + { + test: /\.(jpg|jpeg|gif|png|ico)?$/, + type: 'asset', + generator: { + filename: 'images/[name].[ext]', + }, + }, + { + test: /\.(woff|woff2|eot|ttf|otf)?$/, + type: 'asset', + generator: { + filename: 'fonts/[name].[ext]', + }, + }, + { + test: /\.svg$/, + use: ['@svgr/webpack'], + }, + ], + }, + + devServer: { + static: { + directory: path.resolve(__dirname, '../public'), + }, + hot: true, + open: true, + historyApiFallback: true, + allowedHosts: 'all', + }, + + plugins: [ + new HtmlWebpackPlugin({ + template: './public/index.html', + filename: 'index.html', + }), + new ForkTsCheckerWebpackPlugin(), + new webpack.DefinePlugin({ + 'process.env': JSON.stringify(process.env), + }), + new InterpolateHtmlPlugin({ KAKAO_MAP_API_KEY: process.env.KAKAO_MAP_API_KEY }), + ], +}; diff --git a/frontend/.webpack/webpack.config.js b/frontend/.webpack/webpack.config.js new file mode 100644 index 000000000..100fd5b4b --- /dev/null +++ b/frontend/.webpack/webpack.config.js @@ -0,0 +1,16 @@ +const { merge } = require('webpack-merge'); + +const commonConfig = require('./webpack.common.js'); +const productionConfig = require('./webpack.prod.js'); +const developmentConfig = require('./webpack.dev.js'); + +module.exports = (env, args) => { + switch (args.mode) { + case 'development': + return merge(commonConfig, developmentConfig); + case 'production': + return merge(commonConfig, productionConfig); + default: + throw new Error('No matching configuration was found!'); + } +}; diff --git a/frontend/.webpack/webpack.dev.js b/frontend/.webpack/webpack.dev.js new file mode 100644 index 000000000..9420d660b --- /dev/null +++ b/frontend/.webpack/webpack.dev.js @@ -0,0 +1,18 @@ +const path = require('path'); + +const CopyWebpackPlugin = require('copy-webpack-plugin'); + +module.exports = { + mode: 'development', + devtool: 'inline-source-map', + plugins: [ + new CopyWebpackPlugin({ + patterns: [ + { + from: 'public/mockServiceWorker.js', + to: 'mockServiceWorker.js', + }, + ], + }), + ], +}; diff --git a/frontend/.webpack/webpack.prod.js b/frontend/.webpack/webpack.prod.js new file mode 100644 index 000000000..88199ffa6 --- /dev/null +++ b/frontend/.webpack/webpack.prod.js @@ -0,0 +1,13 @@ +const TerserPlugin = require('terser-webpack-plugin'); + +module.exports = { + mode: 'production', + devtool: 'source-map', + optimization: { + minimize: true, + minimizer: [new TerserPlugin()], + }, + performance: { + hints: false, + }, +}; diff --git a/frontend/fileTransformer.js b/frontend/fileTransformer.js new file mode 100644 index 000000000..6ac5c63ea --- /dev/null +++ b/frontend/fileTransformer.js @@ -0,0 +1,10 @@ +// fileTransformer.js +const path = require('path'); + +module.exports = { + process(sourceText, sourcePath, options) { + return { + code: `module.exports = ${JSON.stringify(path.basename(sourcePath))};`, + }; + }, +}; diff --git a/frontend/jest.config.js b/frontend/jest.config.js new file mode 100644 index 000000000..c9f02b6e3 --- /dev/null +++ b/frontend/jest.config.js @@ -0,0 +1,14 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'jsdom', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect'], + transform: { + '^.+\\.[tj]sx?$': 'babel-jest', + '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': + '/fileTransformer.js', + }, + moduleNameMapper: { + '^\\~/(.*)$': '/src/$1', + }, +}; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 000000000..6584e885d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,101 @@ +{ + "name": "frontend", + "version": "1.0.0", + "main": "index.tsx", + "license": "MIT", + "scripts": { + "start": "webpack serve --open --mode development --port 3000", + "start:prod": "webpack serve --open --mode production --port 3000", + "build": "webpack --mode production", + "lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"", + "lint:css": "stylelint './src/**/*.{tsx,ts,jsx,js}'", + "lint:css:fix": "stylelint './src/**/*.{tsx,ts,jsx,js}' --fix", + "prettier": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build", + "test": "jest" + }, + "dependencies": { + "@googlemaps/react-wrapper": "^1.1.35", + "@storybook/react": "^7.0.25", + "@storybook/react-webpack5": "^7.0.25", + "@tanstack/react-query": "^4.32.0", + "@tanstack/react-query-devtools": "^4.32.0", + "@testing-library/jest-dom": "^5.16.5", + "@types/react": "^18.2.14", + "@types/react-dom": "^18.2.6", + "@types/styled-components": "^5.1.26", + "axios": "^1.4.0", + "babel-plugin-root-import": "^6.6.0", + "copy-webpack-plugin": "^11.0.0", + "dotenv": "^16.3.1", + "fork-ts-checker-webpack-plugin": "^8.0.0", + "html-webpack-plugin": "^5.5.3", + "interpolate-html-plugin": "^4.0.0", + "msw": "^1.2.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.14.2", + "storybook": "^7.0.25", + "styled-components": "^6.0.2", + "ts-loader": "^9.4.4", + "typescript": "^5.1.6", + "webpack": "^5.88.1", + "webpack-cli": "^5.1.4", + "webpack-dev-server": "^4.15.1", + "webpack-merge": "^5.9.0", + "zustand": "^4.3.9" + }, + "devDependencies": { + "@babel/core": "^7.22.5", + "@babel/preset-env": "^7.22.5", + "@babel/preset-react": "^7.22.5", + "@babel/preset-typescript": "^7.22.5", + "@storybook/addon-essentials": "^7.0.25", + "@storybook/addon-interactions": "^7.0.25", + "@storybook/addon-links": "^7.0.25", + "@storybook/addon-styling": "^1.3.2", + "@storybook/blocks": "^7.0.25", + "@storybook/testing-library": "^0.0.14-next.2", + "@stylelint/postcss-css-in-js": "^0.38.0", + "@svgr/webpack": "^8.0.1", + "@tanstack/eslint-plugin-query": "^4.29.25", + "@testing-library/react": "^14.0.0", + "@types/google.maps": "^3.53.5", + "@types/jest": "^29.5.3", + "@typescript-eslint/eslint-plugin": "^5.61.0", + "@typescript-eslint/parser": "^5.61.0", + "babel-jest": "^29.6.1", + "babel-loader": "^9.1.2", + "babel-plugin-styled-components": "^2.1.4", + "clean-webpack-plugin": "^4.0.0", + "core-js": "3", + "eslint": "^8.44.0", + "eslint-config-airbnb": "^19.0.4", + "eslint-config-airbnb-typescript": "^17.0.0", + "eslint-config-prettier": "^8.8.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.32.2", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-storybook": "^0.6.12", + "jest": "^29.6.1", + "jest-environment-jsdom": "^29.6.1", + "postcss-styled-syntax": "^0.4.0", + "prettier": "^2.8.8", + "stylelint": "^15.10.1", + "stylelint-config-prettier": "^9.0.5", + "stylelint-config-standard": "^34.0.0", + "stylelint-order": "^6.0.3", + "terser-webpack-plugin": "^5.3.9", + "ts-jest": "^29.1.1" + }, + "eslintConfig": { + "extends": [ + "plugin:storybook/recommended" + ] + }, + "msw": { + "workerDirectory": "public" + } +} diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 000000000..615b5e221 --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,16 @@ + + + + + + celuveat + + + +
+ + + diff --git a/frontend/public/mockServiceWorker.js b/frontend/public/mockServiceWorker.js new file mode 100644 index 000000000..8ee70b3e4 --- /dev/null +++ b/frontend/public/mockServiceWorker.js @@ -0,0 +1,303 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker (1.2.2). + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70' +const activeClientIds = new Set() + +self.addEventListener('install', function () { + self.skipWaiting() +}) + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +self.addEventListener('message', async function (event) { + const clientId = event.source.id + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: INTEGRITY_CHECKSUM, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +self.addEventListener('fetch', function (event) { + const { request } = event + const accept = request.headers.get('accept') || '' + + // Bypass server-sent events. + if (accept.includes('text/event-stream')) { + return + } + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + // Generate unique request ID. + const requestId = Math.random().toString(16).slice(2) + + event.respondWith( + handleRequest(event, requestId).catch((error) => { + if (error.name === 'NetworkError') { + console.warn( + '[MSW] Successfully emulated a network error for the "%s %s" request.', + request.method, + request.url, + ) + return + } + + // At this point, any exception indicates an issue with the original request/response. + console.error( + `\ +[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, + request.method, + request.url, + `${error.name}: ${error.message}`, + ) + }), + ) +}) + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + ;(async function () { + const clonedResponse = response.clone() + sendToClient(client, { + type: 'RESPONSE', + payload: { + requestId, + type: clonedResponse.type, + ok: clonedResponse.ok, + status: clonedResponse.status, + statusText: clonedResponse.statusText, + body: + clonedResponse.body === null ? null : await clonedResponse.text(), + headers: Object.fromEntries(clonedResponse.headers.entries()), + redirected: clonedResponse.redirected, + }, + }) + })() + } + + return response +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +async function getResponse(event, client, requestId) { + const { request } = event + const clonedRequest = request.clone() + + function passthrough() { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const headers = Object.fromEntries(clonedRequest.headers.entries()) + + // Remove MSW-specific request headers so the bypassed requests + // comply with the server's CORS preflight check. + // Operate with the headers as an object because request "Headers" + // are immutable. + delete headers['x-msw-bypass'] + + return fetch(clonedRequest, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Bypass requests with the explicit bypass header. + // Such requests can be issued by "ctx.fetch()". + if (request.headers.get('x-msw-bypass') === 'true') { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const clientMessage = await sendToClient(client, { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + mode: request.mode, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.text(), + bodyUsed: request.bodyUsed, + keepalive: request.keepalive, + }, + }) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'MOCK_NOT_FOUND': { + return passthrough() + } + + case 'NETWORK_ERROR': { + const { name, message } = clientMessage.data + const networkError = new Error(message) + networkError.name = name + + // Rejecting a "respondWith" promise emulates a network error. + throw networkError + } + } + + return passthrough() +} + +function sendToClient(client, message) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [channel.port2]) + }) +} + +function sleep(timeMs) { + return new Promise((resolve) => { + setTimeout(resolve, timeMs) + }) +} + +async function respondWithMock(response) { + await sleep(response.delay) + return new Response(response.body, response) +} diff --git a/frontend/src/@types/api.types.ts b/frontend/src/@types/api.types.ts new file mode 100644 index 000000000..e773b993a --- /dev/null +++ b/frontend/src/@types/api.types.ts @@ -0,0 +1,21 @@ +export interface RestaurantListData { + content: RestaurantData[]; + currentElementsCount: number; + currentPage: number; + pageSize: number; + totalElementsCount: number; + totalPage: number; +} + +export interface RestaurantData { + id: number; + name: string; + category: string; + roadAddress: string; + lat: number; + lng: number; + phoneNumber: string; + naverMapUrl: string; + celebs: { id: number; name: string; youtubeChannelName: string; profileImageUrl: string }[]; + images: { id: number; name: string; author: string; sns: string }[]; +} diff --git a/frontend/src/@types/celeb.types.ts b/frontend/src/@types/celeb.types.ts new file mode 100644 index 000000000..49fe204e8 --- /dev/null +++ b/frontend/src/@types/celeb.types.ts @@ -0,0 +1,7 @@ +import type { RestaurantData } from './api.types'; + +type Celebs = RestaurantData['celebs']; + +export type Celeb = Celebs[number]; + +export type CelebsSearchBarOptionType = Omit; diff --git a/frontend/src/@types/global.d.ts b/frontend/src/@types/global.d.ts new file mode 100644 index 000000000..e5edb4a74 --- /dev/null +++ b/frontend/src/@types/global.d.ts @@ -0,0 +1,15 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +declare module '*.png'; +declare module '*.woff2'; +declare module '*.svg' { + import * as React from 'react'; + + const ReactComponent: React.FunctionComponent>; + + export default ReactComponent; +} + +interface Window { + kakao: any; +} diff --git a/frontend/src/@types/image.type.ts b/frontend/src/@types/image.type.ts new file mode 100644 index 000000000..aadbc4cb2 --- /dev/null +++ b/frontend/src/@types/image.type.ts @@ -0,0 +1,5 @@ +import type { RestaurantData } from './api.types'; + +type RestaurantImages = RestaurantData['images']; + +export type RestaurantImage = RestaurantImages[number]; diff --git a/frontend/src/@types/map.types.ts b/frontend/src/@types/map.types.ts new file mode 100644 index 000000000..43654e612 --- /dev/null +++ b/frontend/src/@types/map.types.ts @@ -0,0 +1,11 @@ +export interface Coordinate { + lng: number; + lat: number; +} + +export interface CoordinateBoundary { + lowLatitude: string; + highLatitude: string; + lowLongitude: string; + highLongitude: string; +} diff --git a/frontend/src/@types/oauth.types.ts b/frontend/src/@types/oauth.types.ts new file mode 100644 index 000000000..3dbe5eb87 --- /dev/null +++ b/frontend/src/@types/oauth.types.ts @@ -0,0 +1 @@ +export type Oauth = 'google' | 'kakao' | 'naver'; diff --git a/frontend/src/@types/restaurant.types.ts b/frontend/src/@types/restaurant.types.ts new file mode 100644 index 000000000..0e88480db --- /dev/null +++ b/frontend/src/@types/restaurant.types.ts @@ -0,0 +1,18 @@ +import type { RestaurantData } from './api.types'; + +export type Restaurant = Omit; +export type RestaurantModalInfo = Omit; + +export type RestaurantCategory = + | '전체' + | '일식당' + | 'ν•œμ‹' + | '초λ°₯,λ‘€' + | 'μƒμ„ νšŒ' + | '양식' + | '윑λ₯˜,κ³ κΈ°μš”λ¦¬' + | 'μ΄μžμΉ΄μ•Ό' + | '돼지고기ꡬ이' + | 'μš”λ¦¬μ£Όμ ' + | '와인' + | '기타'; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 000000000..f5eb41aa3 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,20 @@ +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import OauthRedirectPage from '~/pages/OauthRedirectPage'; +import MainPage from '~/pages/MainPage'; + +export const { BASE_URL } = process.env; + +function App() { + return ( + + + } /> + } /> + } /> + } /> + + + ); +} + +export default App; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 000000000..c51d4e5a3 --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,32 @@ +import axios from 'axios'; +import { Celeb } from '../@types/celeb.types'; + +import type { RestaurantListData } from '~/@types/api.types'; +import { CoordinateBoundary } from '~/@types/map.types'; +import { RestaurantCategory } from '~/@types/restaurant.types'; +import getQueryString from '~/utils/getQueryString'; + +export interface GetRestaurantsQueryParams { + boundary: CoordinateBoundary; + celebId: number; + category: RestaurantCategory; + page: number; +} + +export const apiClient = axios.create({ + baseURL: `${process.env.BASE_URL}/api`, + headers: { + 'Content-type': 'application/json', + }, +}); + +export const getRestaurants = async (queryParams: GetRestaurantsQueryParams) => { + const queryString = getQueryString(queryParams); + const response = await apiClient.get(`/restaurants?${queryString}`); + return response.data; +}; + +export const getCelebs = async () => { + const response = await apiClient.get('/celebs'); + return response.data; +}; diff --git a/frontend/src/api/oauth.ts b/frontend/src/api/oauth.ts new file mode 100644 index 000000000..7184580fd --- /dev/null +++ b/frontend/src/api/oauth.ts @@ -0,0 +1,12 @@ +import { BASE_URL } from '../constants/api'; + +import type { Oauth } from '~/@types/oauth.types'; +import { apiClient } from '~/api'; + +const getAccessToken = async (type: Oauth, code: string) => { + const response = await apiClient.get(`${BASE_URL}/api/oauth/login/${type}?code=${code}`); + // 톡신 μ—λŸ¬ λ˜μ—ˆμ„ λ•Œ 둜직 μΆ”κ°€ + return response.data; +}; + +export default getAccessToken; diff --git a/frontend/src/assets/all.png b/frontend/src/assets/all.png new file mode 100644 index 000000000..b608aef60 Binary files /dev/null and b/frontend/src/assets/all.png differ diff --git a/frontend/src/assets/fonts/SUIT-Bold.woff2 b/frontend/src/assets/fonts/SUIT-Bold.woff2 new file mode 100644 index 000000000..5bf0106e9 Binary files /dev/null and b/frontend/src/assets/fonts/SUIT-Bold.woff2 differ diff --git a/frontend/src/assets/fonts/SUIT-Medium.woff2 b/frontend/src/assets/fonts/SUIT-Medium.woff2 new file mode 100644 index 000000000..35e8bee6b Binary files /dev/null and b/frontend/src/assets/fonts/SUIT-Medium.woff2 differ diff --git a/frontend/src/assets/fonts/SUIT-Regular.woff2 b/frontend/src/assets/fonts/SUIT-Regular.woff2 new file mode 100644 index 000000000..0261b163c Binary files /dev/null and b/frontend/src/assets/fonts/SUIT-Regular.woff2 differ diff --git a/frontend/src/assets/icons/celeb.svg b/frontend/src/assets/icons/celeb.svg new file mode 100644 index 000000000..1e6f7bedb --- /dev/null +++ b/frontend/src/assets/icons/celeb.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/assets/icons/celuveat_cel.svg b/frontend/src/assets/icons/celuveat_cel.svg new file mode 100644 index 000000000..aac79088c --- /dev/null +++ b/frontend/src/assets/icons/celuveat_cel.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/icons/celuveat_eat.svg b/frontend/src/assets/icons/celuveat_eat.svg new file mode 100644 index 000000000..ee2807b64 --- /dev/null +++ b/frontend/src/assets/icons/celuveat_eat.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/icons/celuveat_luv.svg b/frontend/src/assets/icons/celuveat_luv.svg new file mode 100644 index 000000000..2a5823727 --- /dev/null +++ b/frontend/src/assets/icons/celuveat_luv.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/icons/dot.svg b/frontend/src/assets/icons/dot.svg new file mode 100644 index 000000000..6aecd1bdc --- /dev/null +++ b/frontend/src/assets/icons/dot.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/icons/etc/menu.svg b/frontend/src/assets/icons/etc/menu.svg new file mode 100644 index 000000000..f2063d991 --- /dev/null +++ b/frontend/src/assets/icons/etc/menu.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/icons/etc/user.svg b/frontend/src/assets/icons/etc/user.svg new file mode 100644 index 000000000..23456087a --- /dev/null +++ b/frontend/src/assets/icons/etc/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/exit.svg b/frontend/src/assets/icons/exit.svg new file mode 100644 index 000000000..9adf7143c --- /dev/null +++ b/frontend/src/assets/icons/exit.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/icons/fastFood.svg b/frontend/src/assets/icons/fastFood.svg new file mode 100644 index 000000000..690ddf707 --- /dev/null +++ b/frontend/src/assets/icons/fastFood.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/github.svg b/frontend/src/assets/icons/github.svg new file mode 100644 index 000000000..c13ec2cae --- /dev/null +++ b/frontend/src/assets/icons/github.svg @@ -0,0 +1,20 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/instagram.svg b/frontend/src/assets/icons/instagram.svg new file mode 100644 index 000000000..4513c0c49 --- /dev/null +++ b/frontend/src/assets/icons/instagram.svg @@ -0,0 +1,41 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/left-bracket.svg b/frontend/src/assets/icons/left-bracket.svg new file mode 100644 index 000000000..a408c1827 --- /dev/null +++ b/frontend/src/assets/icons/left-bracket.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/icons/love.svg b/frontend/src/assets/icons/love.svg new file mode 100644 index 000000000..75a0db3be --- /dev/null +++ b/frontend/src/assets/icons/love.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/icons/minus.svg b/frontend/src/assets/icons/minus.svg new file mode 100644 index 000000000..1cea0df66 --- /dev/null +++ b/frontend/src/assets/icons/minus.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/icons/my-location.svg b/frontend/src/assets/icons/my-location.svg new file mode 100644 index 000000000..4b2109903 --- /dev/null +++ b/frontend/src/assets/icons/my-location.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/oauth/google.svg b/frontend/src/assets/icons/oauth/google.svg new file mode 100644 index 000000000..138004410 --- /dev/null +++ b/frontend/src/assets/icons/oauth/google.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/oauth/kakao.svg b/frontend/src/assets/icons/oauth/kakao.svg new file mode 100644 index 000000000..1915a9b39 --- /dev/null +++ b/frontend/src/assets/icons/oauth/kakao.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/frontend/src/assets/icons/oauth/naver.svg b/frontend/src/assets/icons/oauth/naver.svg new file mode 100644 index 000000000..a9c147926 --- /dev/null +++ b/frontend/src/assets/icons/oauth/naver.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/frontend/src/assets/icons/plus.svg b/frontend/src/assets/icons/plus.svg new file mode 100644 index 000000000..d7331a426 --- /dev/null +++ b/frontend/src/assets/icons/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/icons/restaurantCategory/all.svg b/frontend/src/assets/icons/restaurantCategory/all.svg new file mode 100644 index 000000000..f066ec43f --- /dev/null +++ b/frontend/src/assets/icons/restaurantCategory/all.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/restaurantCategory/ijakaya.svg b/frontend/src/assets/icons/restaurantCategory/ijakaya.svg new file mode 100644 index 000000000..6ee54a623 --- /dev/null +++ b/frontend/src/assets/icons/restaurantCategory/ijakaya.svg @@ -0,0 +1,36 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/restaurantCategory/japanese.svg b/frontend/src/assets/icons/restaurantCategory/japanese.svg new file mode 100644 index 000000000..d7663a0d2 --- /dev/null +++ b/frontend/src/assets/icons/restaurantCategory/japanese.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/restaurantCategory/korean.svg b/frontend/src/assets/icons/restaurantCategory/korean.svg new file mode 100644 index 000000000..a82872bb6 --- /dev/null +++ b/frontend/src/assets/icons/restaurantCategory/korean.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/restaurantCategory/meat.svg b/frontend/src/assets/icons/restaurantCategory/meat.svg new file mode 100644 index 000000000..01a95aaf5 --- /dev/null +++ b/frontend/src/assets/icons/restaurantCategory/meat.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/restaurantCategory/pasta.svg b/frontend/src/assets/icons/restaurantCategory/pasta.svg new file mode 100644 index 000000000..3ef154cc5 --- /dev/null +++ b/frontend/src/assets/icons/restaurantCategory/pasta.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/restaurantCategory/pig.svg b/frontend/src/assets/icons/restaurantCategory/pig.svg new file mode 100644 index 000000000..a519c9a42 --- /dev/null +++ b/frontend/src/assets/icons/restaurantCategory/pig.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/restaurantCategory/pub.svg b/frontend/src/assets/icons/restaurantCategory/pub.svg new file mode 100644 index 000000000..65fcacba6 --- /dev/null +++ b/frontend/src/assets/icons/restaurantCategory/pub.svg @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/restaurantCategory/sashimi.svg b/frontend/src/assets/icons/restaurantCategory/sashimi.svg new file mode 100644 index 000000000..0ab6f457a --- /dev/null +++ b/frontend/src/assets/icons/restaurantCategory/sashimi.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/frontend/src/assets/icons/restaurantCategory/sushi.svg b/frontend/src/assets/icons/restaurantCategory/sushi.svg new file mode 100644 index 000000000..d87eaa6fd --- /dev/null +++ b/frontend/src/assets/icons/restaurantCategory/sushi.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/restaurantCategory/wine.svg b/frontend/src/assets/icons/restaurantCategory/wine.svg new file mode 100644 index 000000000..661adca9e --- /dev/null +++ b/frontend/src/assets/icons/restaurantCategory/wine.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/right-bracket.svg b/frontend/src/assets/icons/right-bracket.svg new file mode 100644 index 000000000..bfe2f257b --- /dev/null +++ b/frontend/src/assets/icons/right-bracket.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/icons/search.svg b/frontend/src/assets/icons/search.svg new file mode 100644 index 000000000..9c20b22c6 --- /dev/null +++ b/frontend/src/assets/icons/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/icons/star.svg b/frontend/src/assets/icons/star.svg new file mode 100644 index 000000000..3f6adc318 --- /dev/null +++ b/frontend/src/assets/icons/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/logo.png b/frontend/src/assets/logo.png new file mode 100644 index 000000000..a13cf2584 Binary files /dev/null and b/frontend/src/assets/logo.png differ diff --git a/frontend/src/components/@common/BottomSheet/BottomSheet.tsx b/frontend/src/components/@common/BottomSheet/BottomSheet.tsx new file mode 100644 index 000000000..3ad7d4cc6 --- /dev/null +++ b/frontend/src/components/@common/BottomSheet/BottomSheet.tsx @@ -0,0 +1,80 @@ +import styled, { css } from 'styled-components'; +import { useRef } from 'react'; +import { shallow } from 'zustand/shallow'; +import BottomSheetHeader from './BottomSheetHeader'; +import { BORDER_RADIUS } from '~/styles/common'; +import useOnClickOutside from '~/hooks/useOnClickOutside'; +import useBottomSheetStatus from '~/hooks/store/useBottomSheetStatus'; + +interface BottomSheetProps { + children: React.ReactNode; + title: string; + isLoading: boolean; +} + +function BottomSheet({ children, title = '', isLoading }: BottomSheetProps) { + const { isOpen, close } = useBottomSheetStatus(state => ({ isOpen: state.isOpen, close: state.close }), shallow); + const ref = useRef(); + + useOnClickOutside(ref, close); + + return ( + + {title} + {children} + + ); +} + +export default BottomSheet; + +const Wrapper = styled.div<{ isOpen: boolean }>` + display: flex; + flex-direction: column; + align-items: center; + + position: fixed; + bottom: 0; + z-index: 10; + + width: 100%; + height: 74px; + + background-color: var(--white); + + border-top-left-radius: ${BORDER_RADIUS.lg}; + border-top-right-radius: ${BORDER_RADIUS.lg}; + + transition: height 0.8s ease-in-out; + + ${({ isOpen }) => + isOpen && + css` + position: absolute; + + height: calc(36vh + 74px); + `} + + &::before { + display: block; + + position: absolute; + top: 8px; + left: 50%; + + width: 40px; + height: 4px; + + border-radius: ${BORDER_RADIUS.sm}; + background-color: var(--gray-2); + + transform: translateX(-20px); + content: ''; + } +`; + +const StyledContent = styled.div` + width: 100%; + + background-color: var(--white); +`; diff --git a/frontend/src/components/@common/BottomSheet/BottomSheetHeader.tsx b/frontend/src/components/@common/BottomSheet/BottomSheetHeader.tsx new file mode 100644 index 000000000..1ef07c7a8 --- /dev/null +++ b/frontend/src/components/@common/BottomSheet/BottomSheetHeader.tsx @@ -0,0 +1,59 @@ +import { useEffect, useRef } from 'react'; +import styled, { css } from 'styled-components'; +import { shallow } from 'zustand/shallow'; +import { BORDER_RADIUS, FONT_SIZE, paintSkeleton } from '~/styles/common'; +import useBottomSheetStatus from '~/hooks/store/useBottomSheetStatus'; +import useTouchMoveDirection from '~/hooks/useTouchMoveDirection'; + +interface BottomSheetHeaderProps { + isLoading: boolean; + children: string; +} + +function BottomSheetHeader({ children, isLoading }: BottomSheetHeaderProps) { + const { open, close } = useBottomSheetStatus(state => ({ open: state.open, close: state.close }), shallow); + const ref = useRef(); + const { movingDirection } = useTouchMoveDirection(ref); + + useEffect(() => { + if (movingDirection.Y === 'up') open(); + if (movingDirection.Y === 'down') close(); + }, [movingDirection]); + + return ( + + {!isLoading && children} + + ); +} + +export default BottomSheetHeader; + +const Wrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + + width: 100%; + min-width: 120px; + min-height: 74px; + + font-size: ${FONT_SIZE.md}; + font-weight: 900; + text-align: center; + padding-top: 3.2rem; + padding-bottom: 2.4rem; +`; + +const StyledBottomSheetTitle = styled.div<{ isLoading: boolean }>` + ${({ isLoading }) => + isLoading && + css` + ${paintSkeleton} + width: 200px; + height: 16px; + align-self: center; + + border-radius: ${BORDER_RADIUS.xs}; + `} +`; diff --git a/frontend/src/components/@common/BottomSheet/index.tsx b/frontend/src/components/@common/BottomSheet/index.tsx new file mode 100644 index 000000000..0402fcad4 --- /dev/null +++ b/frontend/src/components/@common/BottomSheet/index.tsx @@ -0,0 +1,3 @@ +import BottomSheet from './BottomSheet'; + +export default BottomSheet; diff --git a/frontend/src/components/@common/Button/TextButton.stories.tsx b/frontend/src/components/@common/Button/TextButton.stories.tsx new file mode 100644 index 000000000..a69583acf --- /dev/null +++ b/frontend/src/components/@common/Button/TextButton.stories.tsx @@ -0,0 +1,59 @@ +/* eslint-disable no-alert */ +import type { Meta, StoryObj } from '@storybook/react'; +import { styled } from 'styled-components'; +import TextButton from './TextButton'; + +function TextButtons() { + return ( + +
+

Dark Button

+
+
disabled
+ alert('clicked')} disabled /> +
default
+ alert('clicked')} /> +
+
+
+

light Button

+
+
disabled
+ alert('clicked')} disabled /> +
default
+ alert('clicked')} /> +
+
+
+ ); +} + +const meta: Meta = { + title: 'TextButton', + component: TextButtons, +}; + +export default meta; + +type Story = StoryObj; + +export const Buttons: Story = {}; + +const StyledTextButtons = styled.div` + display: flex; + gap: 0 12.4rem; + + & > section > div { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + + & > * { + display: flex; + justify-content: center; + align-items: center; + + margin: 0.8rem; + } + } +`; diff --git a/frontend/src/components/@common/Button/TextButton.tsx b/frontend/src/components/@common/Button/TextButton.tsx new file mode 100644 index 000000000..aae68a84f --- /dev/null +++ b/frontend/src/components/@common/Button/TextButton.tsx @@ -0,0 +1,41 @@ +import { styled } from 'styled-components'; +import { BORDER_RADIUS, FONT_SIZE } from '~/styles/common'; + +interface TextButtonProps extends React.HTMLAttributes { + type: 'button' | 'submit'; + text: string; + onClick: React.MouseEventHandler; + colorType: 'dark' | 'light'; + disabled?: boolean; +} + +function TextButton({ text, colorType, disabled = false, ...props }: TextButtonProps) { + return ( + + {text} + + ); +} + +export default TextButton; + +const StyledButton = styled.button<{ colorType: 'dark' | 'light' }>` + padding: 1.2rem 2.4rem; + + border: none; + border-radius: ${BORDER_RADIUS.sm}; + background-color: ${({ colorType }) => (colorType === 'dark' ? 'var(--primary-5)' : 'var(--primary-1)')}; + + color: ${({ colorType }) => (colorType === 'dark' ? 'var(--white)' : 'var(--primary-5)')}; + font-size: ${FONT_SIZE.md}; + + &:disabled { + background-color: ${({ colorType }) => (colorType === 'dark' ? 'var(--primary-3)' : 'var(--gray-1)')}; + + color: var(--white); + } + + &:not(:disabled):hover { + background-color: ${({ colorType }) => (colorType === 'dark' ? 'var(--primary-6);' : 'var(--primary-2)')}; + } +`; diff --git a/frontend/src/components/@common/Button/index.tsx b/frontend/src/components/@common/Button/index.tsx new file mode 100644 index 000000000..a3fbdbdde --- /dev/null +++ b/frontend/src/components/@common/Button/index.tsx @@ -0,0 +1,3 @@ +import TextButton from './TextButton'; + +export default TextButton; diff --git a/frontend/src/components/@common/Footer/Footer.stories.tsx b/frontend/src/components/@common/Footer/Footer.stories.tsx new file mode 100644 index 000000000..ac026906c --- /dev/null +++ b/frontend/src/components/@common/Footer/Footer.stories.tsx @@ -0,0 +1,15 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Footer from './Footer'; + +const meta: Meta = { + title: 'Footer', + component: Footer, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/frontend/src/components/@common/Footer/Footer.tsx b/frontend/src/components/@common/Footer/Footer.tsx new file mode 100644 index 000000000..33fe559ba --- /dev/null +++ b/frontend/src/components/@common/Footer/Footer.tsx @@ -0,0 +1,66 @@ +import styled from 'styled-components'; +import Instagram from '~/assets/icons/instagram.svg'; +import Github from '~/assets/icons/github.svg'; +import { FONT_SIZE } from '~/styles/common'; + +function Footer() { + return ( + + +

μ…€λŸ½λ“€μ΄ λ‹€λ…€κ°„ 맛집을 μ°Ύμ•„λ³΄μ„Έμš”!

+

μ…€λŸ½ 기반 맛집 탐색 ν”Œλž«νΌ, μ…€λŸ½μž‡μž…λ‹ˆλ‹€.

+
+ CONTACT: celuveat@gmail.com + +
COPYRIGHT Β© 2023 CELUVEAT ALL RIGHTS RESERVED
+ + + + +
+
+ ); +} + +export default Footer; + +const StyledFooter = styled.footer` + display: flex; + flex-direction: column; + + position: relative; + + width: 100%; + + padding: 2.4rem; + + background-color: var(--gray-1); + + font-size: ${FONT_SIZE.sm}; +`; + +const StyledIntro = styled.div` + margin-bottom: 1.2rem; + + & > p { + padding: 0.2rem 0; + } +`; + +const StyledContact = styled.div` + margin-bottom: 1.2rem; +`; + +const StyledLastLine = styled.div` + display: flex; + justify-content: space-between; +`; + +const StyledSNSLinkButtonList = styled.div` + display: flex; + gap: 0 1.6rem; + + & > * { + cursor: pointer; + } +`; diff --git a/frontend/src/components/@common/Footer/index.tsx b/frontend/src/components/@common/Footer/index.tsx new file mode 100644 index 000000000..ced11e525 --- /dev/null +++ b/frontend/src/components/@common/Footer/index.tsx @@ -0,0 +1,3 @@ +import Footer from './Footer'; + +export default Footer; diff --git a/frontend/src/components/@common/Header/Header.stories.tsx b/frontend/src/components/@common/Header/Header.stories.tsx new file mode 100644 index 000000000..3f959ac4b --- /dev/null +++ b/frontend/src/components/@common/Header/Header.stories.tsx @@ -0,0 +1,15 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Header from './Header'; + +const meta: Meta = { + title: 'Header', + component: Header, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/frontend/src/components/@common/Header/Header.tsx b/frontend/src/components/@common/Header/Header.tsx new file mode 100644 index 000000000..6e9431c64 --- /dev/null +++ b/frontend/src/components/@common/Header/Header.tsx @@ -0,0 +1,68 @@ +import React, { useMemo } from 'react'; +import { styled } from 'styled-components'; +import Logo from '~/assets/logo.png'; +import { Modal, ModalContent } from '~/components/@common/Modal'; +import InfoDropDown from '~/components/InfoDropDown'; +import LoginModalContent from '~/components/LoginModalContent'; +import { OPTION_FOR_NOT_USER, OPTION_FOR_USER } from '~/constants/options'; +import useTokenStore from '~/hooks/store/useTokenState'; +import useBooleanState from '~/hooks/useBooleanState'; +import { isEmptyString } from '~/utils/compare'; +import useMediaQuery from '~/hooks/useMediaQuery'; + +function Header() { + const { isMobile } = useMediaQuery(); + const { value: isModalOpen, setTrue: openModal, setFalse: closeModal } = useBooleanState(false); + + const token = useTokenStore(state => state.token); + const clearToken = useTokenStore(state => state.clearToken); + + const options = useMemo(() => (isEmptyString(token) ? OPTION_FOR_NOT_USER : OPTION_FOR_USER), [token]); + + const handleInfoDropDown = (event: React.MouseEvent) => { + const currentOption = event.currentTarget.dataset.name; + + if (currentOption === '둜그인') openModal(); + if (currentOption === 'λ‘œκ·Έμ•„μ›ƒ') { + clearToken(); + } + }; + + return ( + <> + + + + + + + + + + + ); +} + +export default Header; + +const StyledHeader = styled.header<{ isMobile: boolean }>` + display: flex; + justify-content: space-between; + align-items: center; + + position: ${({ isMobile }) => (isMobile ? 'fixed' : 'sticky')}; + top: 0; + z-index: 20; + + width: 100%; + height: 80px; + + padding: 1.2rem 2.4rem; + + background-color: var(--white); + border-bottom: 1px solid var(--gray-1); +`; + +const StyledLogo = styled.img` + width: 136px; +`; diff --git a/frontend/src/components/@common/Header/index.tsx b/frontend/src/components/@common/Header/index.tsx new file mode 100644 index 000000000..a9ce1058c --- /dev/null +++ b/frontend/src/components/@common/Header/index.tsx @@ -0,0 +1,3 @@ +import Header from './Header'; + +export default Header; diff --git a/frontend/src/components/@common/ImageCarousel/ImageCarousel.stories.tsx b/frontend/src/components/@common/ImageCarousel/ImageCarousel.stories.tsx new file mode 100644 index 000000000..0c905dd92 --- /dev/null +++ b/frontend/src/components/@common/ImageCarousel/ImageCarousel.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import ImageCarousel from './ImageCarousel'; + +const meta: Meta = { + title: 'ImageCarousel', + component: ImageCarousel, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + images: [ + { id: 1, name: 'https://picsum.photos/315/300', author: '@d0dam', sns: 'youtube' }, + { id: 2, name: 'https://picsum.photos/315/300', author: '@d0dam', sns: 'youtube' }, + { id: 3, name: 'https://picsum.photos/315/300', author: '@d0dam', sns: 'youtube' }, + { id: 4, name: 'https://picsum.photos/315/300', author: '@d0dam', sns: 'youtube' }, + ], + }, +}; + +export const OneImage: Story = { + args: { + images: [{ id: 1, name: 'https://picsum.photos/315/300', author: '@d0dam', sns: 'youtube' }], + }, +}; diff --git a/frontend/src/components/@common/ImageCarousel/ImageCarousel.tsx b/frontend/src/components/@common/ImageCarousel/ImageCarousel.tsx new file mode 100644 index 000000000..312fbc60f --- /dev/null +++ b/frontend/src/components/@common/ImageCarousel/ImageCarousel.tsx @@ -0,0 +1,159 @@ +import { useEffect, useRef, useState } from 'react'; +import styled, { css } from 'styled-components'; +import { RestaurantImage } from '~/@types/image.type'; +import LeftBracket from '~/assets/icons/left-bracket.svg'; +import RightBracket from '~/assets/icons/right-bracket.svg'; +import { BORDER_RADIUS } from '~/styles/common'; +import WaterMarkImage from '../WaterMarkImage'; +import useTouchMoveDirection from '~/hooks/useTouchMoveDirection'; +import useMediaQuery from '~/hooks/useMediaQuery'; + +interface ImageCarouselProps { + images: RestaurantImage[]; + type: 'list' | 'map'; +} + +function ImageCarousel({ images, type }: ImageCarouselProps) { + const { isMobile } = useMediaQuery(); + const ref = useRef(); + const { movingDirection } = useTouchMoveDirection(ref); + const [currentIndex, setCurrentIndex] = useState(0); + + const goToPrevious = () => setCurrentIndex(prevIndex => prevIndex - 1); + const goToNext = () => setCurrentIndex(prevIndex => prevIndex + 1); + + useEffect(() => { + if (movingDirection.X === 'left' && currentIndex !== images.length - 1) goToNext(); + if (movingDirection.X === 'right' && currentIndex !== 0) goToPrevious(); + }, [movingDirection.X]); + + return ( + + + {images.map(({ id, name, author }) => ( + + ))} + + {!isMobile && currentIndex !== 0 && ( + + + + )} + {!isMobile && currentIndex !== images.length - 1 && ( + + + + )} + {images.length > 1 && ( + + {Array.from({ length: images.length }, () => ( + + ))} + + )} + + ); +} + +export default ImageCarousel; + +const StyledCarouselContainer = styled.div<{ type: 'list' | 'map' }>` + position: relative; + + width: 100%; + overflow: hidden; + + border-radius: ${({ type }) => + type === 'list' ? `${BORDER_RADIUS.md};` : `${BORDER_RADIUS.md} ${BORDER_RADIUS.md} 0 0;`} + button { + visibility: hidden; + + display: flex; + justify-content: center; + align-items: center; + + position: absolute; + top: 50%; + + width: 32px; + height: 32px; + + border: none; + border-radius: 50%; + background-color: var(--white); + + cursor: pointer; + opacity: 0; + transition: transform 0.15s ease-in-out, opacity 0.2s ease-in-out; + transform: translateY(-50%); + box-shadow: var(--shadow); + outline: none; + + &:hover { + transform: translateY(-50%) scale(1.04); + } + } + + &:hover { + button { + visibility: visible; + + opacity: 0.85; + + &:hover { + opacity: 1; + } + } + } +`; + +const StyledLeftButton = styled.button` + left: 12px; +`; + +const StyledRightButton = styled.button` + right: 12px; +`; + +const StyledCarouselSlide = styled.div<{ currentIndex: number }>` + display: flex; + + width: 100%; + + transition: transform 0.3s ease-in-out; + transform: ${({ currentIndex }) => `translateX(-${currentIndex * 100}%)`}; + flex-wrap: nowrap; + + aspect-ratio: 1.05 / 1; +`; + +const StyledDots = styled.div<{ currentIndex: number }>` + display: flex; + justify-content: center; + align-items: center; + gap: 0 0.5rem; + + position: absolute; + bottom: 12px; + + width: 100%; + + ${({ currentIndex }) => css` + & > span:nth-child(${currentIndex + 1}) { + opacity: 1; + transition: transform 0.2s ease-in-out, opacity 0.2s ease-in-out; + transform: scale(1.1); + } + `} +`; + +const StyledDot = styled.span` + width: 6px; + height: 6px; + + border-radius: 50%; + background-color: var(--white); + + opacity: 0.2; + transition: transform 0.2s ease-in-out, opacity 0.2s ease-in-out; +`; diff --git a/frontend/src/components/@common/ImageCarousel/index.tsx b/frontend/src/components/@common/ImageCarousel/index.tsx new file mode 100644 index 000000000..d557e0a41 --- /dev/null +++ b/frontend/src/components/@common/ImageCarousel/index.tsx @@ -0,0 +1,3 @@ +import ImageCarousel from './ImageCarousel'; + +export default ImageCarousel; diff --git a/frontend/src/components/@common/InfoButton/InfoButton.stories.tsx b/frontend/src/components/@common/InfoButton/InfoButton.stories.tsx new file mode 100644 index 000000000..4be75506c --- /dev/null +++ b/frontend/src/components/@common/InfoButton/InfoButton.stories.tsx @@ -0,0 +1,15 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import InfoButton from './InfoButton'; + +const meta: Meta = { + title: 'InfoButton', + component: InfoButton, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/frontend/src/components/@common/InfoButton/InfoButton.tsx b/frontend/src/components/@common/InfoButton/InfoButton.tsx new file mode 100644 index 000000000..10f5f0ad2 --- /dev/null +++ b/frontend/src/components/@common/InfoButton/InfoButton.tsx @@ -0,0 +1,47 @@ +import styled, { css } from 'styled-components'; + +import Menu from '~/assets/icons/etc/menu.svg'; +import User from '~/assets/icons/etc/user.svg'; + +interface InfoButtonProps { + isShow?: boolean; +} + +function InfoButton({ isShow = false }: InfoButtonProps) { + return ( + + + + + ); +} + +export default InfoButton; + +const StyledInfoButton = styled.button` + display: flex; + justify-content: space-between; + align-items: center; + + width: 77px; + + padding: 0.5rem 0.5rem 0.5rem 1.2rem; + + border: 1px solid #ddd; + border-radius: 21px; + background: transparent; + + cursor: pointer; + + ${({ isShow }) => + isShow && + css` + box-shadow: var(--shadow); + `} + + &:hover { + box-shadow: var(--shadow); + + transition: box-shadow 0.2s ease-in-out; + } +`; diff --git a/frontend/src/components/@common/InfoButton/index.tsx b/frontend/src/components/@common/InfoButton/index.tsx new file mode 100644 index 000000000..dac566eef --- /dev/null +++ b/frontend/src/components/@common/InfoButton/index.tsx @@ -0,0 +1,3 @@ +import InfoButton from '~/components/@common/InfoButton/InfoButton'; + +export default InfoButton; diff --git a/frontend/src/components/@common/Label/Label.stories.tsx b/frontend/src/components/@common/Label/Label.stories.tsx new file mode 100644 index 000000000..6b5502a38 --- /dev/null +++ b/frontend/src/components/@common/Label/Label.stories.tsx @@ -0,0 +1,15 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Label from './Label'; + +const meta: Meta = { + title: 'Label', + component: Label, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { text: 'μœ λ£Œκ΄‘κ³ ' }, +}; diff --git a/frontend/src/components/@common/Label/Label.tsx b/frontend/src/components/@common/Label/Label.tsx new file mode 100644 index 000000000..609a1f67f --- /dev/null +++ b/frontend/src/components/@common/Label/Label.tsx @@ -0,0 +1,27 @@ +import { styled } from 'styled-components'; +import { BORDER_RADIUS, FONT_SIZE } from '~/styles/common'; + +interface LabelProps { + text: string; +} + +function Label({ text }: LabelProps) { + return {text}; +} + +export default Label; + +const StyledDiv = styled.div` + display: flex; + justify-content: center; + align-items: center; + + width: 38px; + height: 14px; + + border-radius: ${BORDER_RADIUS.xs}; + background-color: var(--primary-3-transparent-25); + + color: var(--primary-6); + font-size: ${FONT_SIZE.xs}; +`; diff --git a/frontend/src/components/@common/Label/index.tsx b/frontend/src/components/@common/Label/index.tsx new file mode 100644 index 000000000..2b0c401bd --- /dev/null +++ b/frontend/src/components/@common/Label/index.tsx @@ -0,0 +1,3 @@ +import Label from './Label'; + +export default Label; diff --git a/frontend/src/components/@common/LoadingDots/LoadingDots.stories.tsx b/frontend/src/components/@common/LoadingDots/LoadingDots.stories.tsx new file mode 100644 index 000000000..1496eaabb --- /dev/null +++ b/frontend/src/components/@common/LoadingDots/LoadingDots.stories.tsx @@ -0,0 +1,15 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import LoadingDots from './LoadingDots'; + +const meta: Meta = { + title: 'Loading/LoadingDots', + component: LoadingDots, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { size: 30 }, +}; diff --git a/frontend/src/components/@common/LoadingDots/LoadingDots.tsx b/frontend/src/components/@common/LoadingDots/LoadingDots.tsx new file mode 100644 index 000000000..e87ce43d2 --- /dev/null +++ b/frontend/src/components/@common/LoadingDots/LoadingDots.tsx @@ -0,0 +1,54 @@ +import styled, { keyframes } from 'styled-components'; +import Dot from '~/assets/icons/dot.svg'; + +function LoadingDots() { + return ( + + + + + + + + + + + + ); +} + +export default LoadingDots; + +const StyledLoadingDots = styled.div` + display: flex; + gap: 0 1.4rem; + + & > div:nth-child(2) { + animation-delay: 0.14s; + } + + & > div:nth-child(3) { + animation-delay: 0.28s; + } +`; + +const pulseAnimation = keyframes` + 0% { + transform: scale(0); + } + 90%, 100% { + transform: scale(10); + } +`; + +const StyledLoadingDot = styled.div` + display: flex; + justify-content: center; + align-items: center; + + width: 1.2px; + height: 1.2px; + + animation: ${pulseAnimation} 0.4s ease-in-out infinite alternate; + animation-timing-function: cubic-bezier(0, 0, 1, 1); +`; diff --git a/frontend/src/components/@common/LoadingDots/index.tsx b/frontend/src/components/@common/LoadingDots/index.tsx new file mode 100644 index 000000000..c819498e7 --- /dev/null +++ b/frontend/src/components/@common/LoadingDots/index.tsx @@ -0,0 +1,3 @@ +import LoadingDots from './LoadingDots'; + +export default LoadingDots; diff --git a/frontend/src/components/@common/LoadingIndicator/LoadingIndicator.stories.tsx b/frontend/src/components/@common/LoadingIndicator/LoadingIndicator.stories.tsx new file mode 100644 index 000000000..0df4eb8c4 --- /dev/null +++ b/frontend/src/components/@common/LoadingIndicator/LoadingIndicator.stories.tsx @@ -0,0 +1,15 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import LoadingIndicator from './LoadingIndicator'; + +const meta: Meta = { + title: 'Loading/LoadingIndicator', + component: LoadingIndicator, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { size: 30 }, +}; diff --git a/frontend/src/components/@common/LoadingIndicator/LoadingIndicator.tsx b/frontend/src/components/@common/LoadingIndicator/LoadingIndicator.tsx new file mode 100644 index 000000000..4a0613210 --- /dev/null +++ b/frontend/src/components/@common/LoadingIndicator/LoadingIndicator.tsx @@ -0,0 +1,76 @@ +import styled, { keyframes } from 'styled-components'; +import Cel from '~/assets/icons/celuveat_cel.svg'; +import Luv from '~/assets/icons/celuveat_luv.svg'; +import Eat from '~/assets/icons/celuveat_eat.svg'; + +interface LoadingIndicatorProps { + size: number; +} + +function LoadingIndicator({ size }: LoadingIndicatorProps) { + return ( + + + + + + + + + + + + ); +} + +export default LoadingIndicator; + +const StyledLoadingAnimation = styled.div` + display: flex; + gap: 0.6rem; + + & > div:nth-child(2) { + animation-delay: 0.2s; + } + + & > div:nth-child(3) { + animation-delay: 0.4s; + } +`; + +const bounceAnimation = keyframes` + 0% { + top: 0px; + } + 25% { + top: 10px; + } + 30% { + transform: rotate(10deg); + } + 50% { + top: 0px; + } + 75% { + top: 10px; + } + 80% { + transform: rotate(-10deg); + } + 100% { + top: 0px; + } +`; + +const StyledBouncing = styled.div<{ size: number }>` + position: relative; + top: 0; + left: ${({ size }) => `${size}px`}; + + width: ${({ size }) => `${size}px`}; + height: ${({ size }) => `${size}px`}; + + border-radius: 50%; + background: none; + animation: ${bounceAnimation} 1.6s ease-in-out infinite; +`; diff --git a/frontend/src/components/@common/LoadingIndicator/index.tsx b/frontend/src/components/@common/LoadingIndicator/index.tsx new file mode 100644 index 000000000..334b5c42c --- /dev/null +++ b/frontend/src/components/@common/LoadingIndicator/index.tsx @@ -0,0 +1,3 @@ +import LoadingIndicator from './LoadingIndicator'; + +export default LoadingIndicator; diff --git a/frontend/src/components/@common/LoginButton/LoginButton.stories.tsx b/frontend/src/components/@common/LoginButton/LoginButton.stories.tsx new file mode 100644 index 000000000..52cd8c2e0 --- /dev/null +++ b/frontend/src/components/@common/LoginButton/LoginButton.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import LoginButton from './LoginButton'; + +const meta: Meta = { + title: 'Oauth/LoginButton', + component: LoginButton, + decorators: [ + Story => ( + + + } /> + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Google: Story = { + args: { type: 'google' }, +}; + +export const KaKao: Story = { + args: { type: 'kakao' }, +}; + +export const Naver: Story = { + args: { type: 'naver' }, +}; diff --git a/frontend/src/components/@common/LoginButton/LoginButton.tsx b/frontend/src/components/@common/LoginButton/LoginButton.tsx new file mode 100644 index 000000000..4fd1b0e19 --- /dev/null +++ b/frontend/src/components/@common/LoginButton/LoginButton.tsx @@ -0,0 +1,74 @@ +import { Link } from 'react-router-dom'; +import styled, { css } from 'styled-components'; +import React from 'react'; +import { OAUTH_BUTTON_MESSAGE, OAUTH_LINK } from '~/constants/api'; + +import KaKao from '~/assets/icons/oauth/kakao.svg'; +import Naver from '~/assets/icons/oauth/naver.svg'; +import Google from '~/assets/icons/oauth/google.svg'; +import { Oauth } from '~/@types/oauth.types'; + +interface LoginButtonProps { + type: Oauth; +} + +const LoginIcon: Record = { + naver: , + kakao: , + google: , +}; + +function LoginButton({ type }: LoginButtonProps) { + return ( + +
{LoginIcon[type]}
+ {OAUTH_BUTTON_MESSAGE[type]} +
+ ); +} + +export default LoginButton; + +const StyledLoginButtonWrapper = styled(Link)` + display: flex; + + width: 100%; + height: fit-content; + + padding: 2.3rem 1.3rem; + + border-radius: 12px; + + font-size: 1.4rem; + font-weight: 600; + text-decoration: none; + + ${({ type }) => + type === 'naver' && + css` + background: #03c759; + + color: #fff; + `} + + ${({ type }) => + type === 'kakao' && + css` + background: #fee500; + `} + + ${({ type }) => + type === 'google' && + css` + border: 1px solid var(--gray-3); + `} + + cursor: pointer; + transition: box-shadow 0.2s cubic-bezier(0.2, 0, 0, 1), transform 0.1s cubic-bezier(0.2, 0, 0, 1); +`; + +const StyledLoginButtonText = styled.span` + margin: 0 auto; + + color: inherit; +`; diff --git a/frontend/src/components/@common/LoginButton/index.tsx b/frontend/src/components/@common/LoginButton/index.tsx new file mode 100644 index 000000000..9ee6be126 --- /dev/null +++ b/frontend/src/components/@common/LoginButton/index.tsx @@ -0,0 +1,3 @@ +import LoginButton from '~/components/@common/LoginButton/LoginButton'; + +export default LoginButton; diff --git a/frontend/src/components/@common/Map/Map.stories.tsx b/frontend/src/components/@common/Map/Map.stories.tsx new file mode 100644 index 000000000..074c20e12 --- /dev/null +++ b/frontend/src/components/@common/Map/Map.stories.tsx @@ -0,0 +1,29 @@ +import { Wrapper } from '@googlemaps/react-wrapper'; +import type { Meta, StoryObj } from '@storybook/react'; +import Map from './MapContent'; + +const meta: Meta = { + title: 'Map', + component: Map, + decorators: [ + Story => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + center: { lat: 37.5057482, lng: 127.050727 }, + zoom: 12, + style: { width: '600px', height: '600px' }, + onIdle: () => {}, + onClick: () => {}, + }, +}; diff --git a/frontend/src/components/@common/Map/Map.tsx b/frontend/src/components/@common/Map/Map.tsx new file mode 100644 index 000000000..f3bcfc6bc --- /dev/null +++ b/frontend/src/components/@common/Map/Map.tsx @@ -0,0 +1,203 @@ +import { useState } from 'react'; +import { Wrapper, Status } from '@googlemaps/react-wrapper'; +import { styled } from 'styled-components'; +import MapContent from './MapContent'; +import OverlayMyLocation from './OverlayMyLocation'; +import LoadingDots from '../LoadingDots'; +import { mapUIBase } from '~/styles/common'; +import MyLocation from '~/assets/icons/my-location.svg'; +import LeftBracket from '~/assets/icons/left-bracket.svg'; +import RightBracket from '~/assets/icons/right-bracket.svg'; +import Minus from '~/assets/icons/minus.svg'; +import Plus from '~/assets/icons/plus.svg'; +import getQuadrant from '~/utils/getQuadrant'; +import OverlayMarker from './OverlayMarker'; + +import type { Coordinate, CoordinateBoundary } from '~/@types/map.types'; +import type { RestaurantData } from '~/@types/api.types'; +import useMediaQuery from '~/hooks/useMediaQuery'; + +interface MapProps { + data: RestaurantData[]; + hoveredId: number | null; + setBoundary: React.Dispatch>; + setCurrentPage: React.Dispatch>; + toggleMapExpand: () => void; + loadingData: boolean; +} + +const render = (status: Status) => { + if (status === Status.FAILURE) + return
지도λ₯Ό 뢈러올 수 μ—†μŠ΅λ‹ˆλ‹€. νŽ˜μ΄μ§€λ₯Ό μƒˆλ‘œκ³ μΉ¨ ν•˜κ±°λ‚˜ λ„€νŠΈμ›Œν¬ 연결을 λ‹€μ‹œ ν•œ 번 ν™•μΈν•΄μ£Όμ„Έμš”.
; + return ( + + + + ); +}; + +const StyledMapLoadingContainer = styled.section` + display: flex; + justify-content: center; + align-items: center; + + height: 100%; + + background-color: var(--gray-2); +`; + +const JamsilCampus = { lat: 37.515271, lng: 127.1029949 }; + +function Map({ data, setBoundary, toggleMapExpand, loadingData, hoveredId, setCurrentPage }: MapProps) { + const { isMobile } = useMediaQuery(); + const [center, setCenter] = useState({ lat: 37.5057482, lng: 127.050727 }); + const [clicks, setClicks] = useState([]); + const [zoom, setZoom] = useState(16); + const [myPosition, setMyPosition] = useState(null); + const [isMapExpanded, setIsMapExpanded] = useState(false); + const [loading, setLoading] = useState(false); + const [currentCenter, setCurrentCenter] = useState(JamsilCampus); + + const onClick = (e: google.maps.MapMouseEvent) => { + setClicks([...clicks, e.latLng!]); + }; + + const onIdle = (m: google.maps.Map) => { + setZoom(m.getZoom()!); + setCurrentCenter({ lat: m.getCenter().lat(), lng: m.getCenter().lng() }); + + const lowLatitude = String(m.getBounds().getSouthWest().lat()); + const highLatitude = String(m.getBounds().getNorthEast().lat()); + const lowLongitude = String(m.getBounds().getSouthWest().lng()); + const highLongitude = String(m.getBounds().getNorthEast().lng()); + const coordinateBoundary = { lowLatitude, highLatitude, lowLongitude, highLongitude }; + + setBoundary(coordinateBoundary); + setCurrentPage(0); + window.scrollTo(0, 0); + }; + + const clickMyLocationButton = () => { + setLoading(true); + navigator.geolocation.getCurrentPosition((position: GeolocationPosition) => { + setMyPosition({ lat: position.coords.latitude, lng: position.coords.longitude }); + setLoading(false); + setCenter({ lat: position.coords.latitude, lng: position.coords.longitude }); + }); + }; + + const clickZoom = + (number: number): React.MouseEventHandler => + () => { + setZoom(prev => prev + number); + }; + + const clickMapExpand = () => { + setIsMapExpanded(prev => !prev); + toggleMapExpand(); + }; + + return ( + + + {data?.map(({ celebs, ...restaurant }) => { + const { lat, lng } = restaurant; + return ( + + ); + })} + {myPosition && } + {(loadingData || loading) && ( + + + + )} + + + + + +
+ + + {!isMobile && ( + + {isMapExpanded ? : } + + )} + + + ); +} + +export default Map; + +const LoadingUI = styled.div` + ${mapUIBase} + position: absolute; + top: 24px; + right: calc(50% - 41px); + + width: 82px; + height: 40px; +`; + +const StyledMyPositionButtonUI = styled.button` + ${mapUIBase} + position: absolute; + top: 129px; + right: 24px; + + width: 40px; + height: 40px; +`; + +const StyledZoomUI = styled.div` + ${mapUIBase} + flex-direction: column; + + position: absolute; + top: 24px; + right: 24px; + + & > button { + ${mapUIBase} + width: 40px; + height: 40px; + box-shadow: none; + } + + & > div { + width: 100%; + height: 1px; + + background-color: var(--black); + + opacity: 0.1; + } +`; + +const StyledMapExpandButton = styled.button` + ${mapUIBase} + position: absolute; + top: 24px; + left: 24px; + + width: 40px; + height: 40px; +`; diff --git a/frontend/src/components/@common/Map/MapContent.tsx b/frontend/src/components/@common/Map/MapContent.tsx new file mode 100644 index 000000000..962be92b4 --- /dev/null +++ b/frontend/src/components/@common/Map/MapContent.tsx @@ -0,0 +1,34 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +/* eslint-disable react-hooks/exhaustive-deps */ + +import React from 'react'; +import useMap from './hooks/useMap'; +import type { Coordinate } from '~/@types/map.types'; + +interface MapContentProps extends google.maps.MapOptions { + zoom: number; + center: Coordinate; + children: React.ReactNode; + style: { [key: string]: string }; + onIdle?: (map: google.maps.Map) => void; + onClick?: (e: google.maps.MapMouseEvent) => void; +} + +function MapContent({ style, zoom, center, children, onClick, onIdle }: MapContentProps) { + const { ref, map } = useMap({ center, zoom, onClick, onIdle }); + + return ( + <> +
+ {React.Children.map(children, child => { + if (React.isValidElement(child)) { + // @ts-ignore + return React.cloneElement(child, { map }); + } + return null; + })} + + ); +} + +export default MapContent; diff --git a/frontend/src/components/@common/Map/Overlay/Overlay.tsx b/frontend/src/components/@common/Map/Overlay/Overlay.tsx new file mode 100644 index 000000000..712752ce7 --- /dev/null +++ b/frontend/src/components/@common/Map/Overlay/Overlay.tsx @@ -0,0 +1,41 @@ +import { useEffect, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import createOverlay from './domain/createOverlay'; + +interface OverlayProps { + map: google.maps.Map; + children: React.ReactNode; + position: google.maps.LatLng | google.maps.LatLngLiteral; + pane?: keyof google.maps.MapPanes; + zIndex?: number; +} + +function Overlay({ position, pane = 'floatPane', map, zIndex, children }: OverlayProps) { + const container = useMemo(() => { + const div = document.createElement('div'); + div.style.position = 'absolute'; + + return div; + }, []); + + const overlay = useMemo(() => createOverlay(container, pane, position), [container, pane, position]); + + useEffect(() => { + overlay?.setMap(map); + return () => overlay?.setMap(null); + }, [map, overlay]); + + useEffect(() => { + container.style.zIndex = `${zIndex}`; + container.onmouseover = function () { + container.style.zIndex = '18'; + }; + container.onmouseout = function () { + container.style.zIndex = `${zIndex}`; + }; + }, [zIndex, container]); + + return createPortal(children, container); +} + +export default Overlay; diff --git a/frontend/src/components/@common/Map/Overlay/domain/createOverlay.ts b/frontend/src/components/@common/Map/Overlay/domain/createOverlay.ts new file mode 100644 index 000000000..c2c58eab1 --- /dev/null +++ b/frontend/src/components/@common/Map/Overlay/domain/createOverlay.ts @@ -0,0 +1,50 @@ +/* eslint-disable @typescript-eslint/no-shadow */ + +function createOverlay( + container: HTMLElement, + pane: keyof google.maps.MapPanes, + position: google.maps.LatLng | google.maps.LatLngLiteral, +) { + class Overlay extends google.maps.OverlayView { + container: HTMLElement; + + pane: keyof google.maps.MapPanes; + + position: google.maps.LatLng | google.maps.LatLngLiteral; + + constructor( + container: HTMLElement, + pane: keyof google.maps.MapPanes, + position: google.maps.LatLng | google.maps.LatLngLiteral, + ) { + super(); + this.container = container; + this.pane = pane; + this.position = position; + } + + onAdd() { + const pane = this.getPanes()?.[this.pane]; + pane?.appendChild(this.container); + } + + draw() { + const projection = this.getProjection(); + const point = projection.fromLatLngToDivPixel(this.position); + if (point === null) { + return; + } + this.container.style.transform = `translate(${point.x}px, ${point.y}px)`; + } + + onRemove() { + if (this.container.parentNode !== null) { + this.container.parentNode.removeChild(this.container); + } + } + } + + return new Overlay(container, pane, position); +} + +export default createOverlay; diff --git a/frontend/src/components/@common/Map/Overlay/index.tsx b/frontend/src/components/@common/Map/Overlay/index.tsx new file mode 100644 index 000000000..f543d7c3a --- /dev/null +++ b/frontend/src/components/@common/Map/Overlay/index.tsx @@ -0,0 +1,3 @@ +import Overlay from './Overlay'; + +export default Overlay; diff --git a/frontend/src/components/@common/Map/OverlayMarker.tsx b/frontend/src/components/@common/Map/OverlayMarker.tsx new file mode 100644 index 000000000..2d658ccd2 --- /dev/null +++ b/frontend/src/components/@common/Map/OverlayMarker.tsx @@ -0,0 +1,110 @@ +import styled, { css, keyframes } from 'styled-components'; +import { MouseEvent, useRef, useState } from 'react'; +import ProfileImage from '../ProfileImage'; +import Overlay from './Overlay/Overlay'; +import RestaurantCard from '~/components/RestaurantCard'; +import useOnClickOutside from '~/hooks/useOnClickOutside'; + +import type { Quadrant } from '~/utils/getQuadrant'; +import type { Restaurant } from '~/@types/restaurant.types'; +import type { Celeb } from '~/@types/celeb.types'; + +interface OverlayMarkerProps { + celeb: Celeb; + map?: google.maps.Map; + restaurant: Restaurant; + quadrant: Quadrant; + isRestaurantHovered: boolean; +} + +function OverlayMarker({ celeb, restaurant, map, quadrant, isRestaurantHovered }: OverlayMarkerProps) { + const { lat, lng } = restaurant; + const [isClicked, setIsClicked] = useState(false); + const ref = useRef(); + useOnClickOutside(ref, () => setIsClicked(false)); + + const clickMarker = () => { + setIsClicked(true); + }; + + const clickModal = (e: MouseEvent) => { + e.stopPropagation(); + }; + + return ( + map && ( + +
+ + + + {isClicked && ( + + + + )} +
+
+ ) + ); +} + +export default OverlayMarker; + +const scaleUp = keyframes` + 0% { + transform: scale(1); + } + 100% { + transform: scale(1.5); + } +`; + +const StyledMarker = styled.div<{ isClicked: boolean; isRestaurantHovered: boolean }>` + display: flex; + justify-content: center; + align-items: center; + + width: 36px; + height: 36px; + + border: ${({ isClicked, isRestaurantHovered }) => + isClicked || isRestaurantHovered ? '3px solid var(--orange-2)' : '3px solid transparent'}; + border-radius: 50%; + + transition: transform 0.2s ease-in-out; + transform: ${({ isClicked }) => (isClicked ? 'scale(1.5)' : 'scale(1)')}; + + &:hover { + transform: scale(1.5); + } + + ${({ isRestaurantHovered }) => + isRestaurantHovered && + css` + animation: ${scaleUp} 0.2s ease-in-out forwards; + `} +`; + +const fadeInAnimation = keyframes` + from { + opacity: 0; + } + to { + opacity: 1; + } +`; + +const StyledModal = styled.div<{ quadrant: Quadrant }>` + position: absolute; + top: ${({ quadrant }) => (quadrant === 1 || quadrant === 2 ? '40px' : '-280px')}; + right: ${({ quadrant }) => (quadrant === 1 || quadrant === 4 ? '45px' : '-210px')}; + + width: 200px; + + border-radius: 12px; + background-color: #fff; + + animation: ${fadeInAnimation} 100ms ease-in; + box-shadow: 0 4px 6px rgb(0 0 0 / 20%); +`; diff --git a/frontend/src/components/@common/Map/OverlayMyLocation.tsx b/frontend/src/components/@common/Map/OverlayMyLocation.tsx new file mode 100644 index 000000000..3fef3c57e --- /dev/null +++ b/frontend/src/components/@common/Map/OverlayMyLocation.tsx @@ -0,0 +1,56 @@ +import styled from 'styled-components'; +import Overlay from './Overlay/Overlay'; +import type { Coordinate } from '~/@types/map.types'; + +interface OverlayMyLocationProps { + position: Coordinate; + map?: google.maps.Map; +} + +function OverlayMyLocation({ position, map }: OverlayMyLocationProps) { + return ( + map && ( + + + + + + + ) + ); +} + +export default OverlayMyLocation; + +const StyledMyLocationRound = styled.div` + position: absolute; + top: 6px; + left: 6px; + z-index: 10; + + width: 12px; + height: 12px; + + border: 1px solid var(--white); + border-radius: 50%; + background-color: var(--primary-5); +`; + +const StyledMyLocationBorder = styled.div` + position: absolute; + + width: 24px; + height: 24px; + + border-radius: 50%; + background-color: var(--primary-5); + + opacity: 0.5; +`; + +const StyledMyLocation = styled.div` + display: relative; + + width: 24px; + height: 24px; +`; diff --git a/frontend/src/components/@common/Map/hooks/useMap.ts b/frontend/src/components/@common/Map/hooks/useMap.ts new file mode 100644 index 000000000..338bf124a --- /dev/null +++ b/frontend/src/components/@common/Map/hooks/useMap.ts @@ -0,0 +1,198 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { useEffect, useRef, useState } from 'react'; + +interface UseDrawMapProps { + zoom: number; + center: google.maps.LatLngLiteral; + onIdle?: (map: google.maps.Map) => void; + onClick?: (e: google.maps.MapMouseEvent) => void; +} + +const styles = [ + { + featureType: 'landscape.man_made', + elementType: 'geometry.fill', + stylers: [ + { + color: '#f9f4f2', + }, + ], + }, + { + featureType: 'landscape.natural.landcover', + elementType: 'geometry.fill', + stylers: [ + { + color: '#d3eddb', + }, + ], + }, + { + featureType: 'landscape.natural.terrain', + elementType: 'geometry.fill', + stylers: [ + { + color: '#dbf0e0', + }, + ], + }, + { + featureType: 'poi.business', + elementType: 'labels.icon', + stylers: [ + { + visibility: 'off', + }, + ], + }, + { + featureType: 'poi.park', + elementType: 'geometry.fill', + stylers: [ + { + color: '#dbf3cc', + }, + ], + }, + { + featureType: 'poi.school', + elementType: 'geometry.fill', + stylers: [ + { + color: '#f2f2f2', + }, + { + lightness: '0', + }, + ], + }, + { + featureType: 'road', + elementType: 'geometry.fill', + stylers: [ + { + color: '#ffffff', + }, + ], + }, + { + featureType: 'road', + elementType: 'geometry.stroke', + stylers: [ + { + color: '#cfc8c4', + }, + ], + }, + { + featureType: 'road', + elementType: 'labels.icon', + stylers: [ + { + visibility: 'off', + }, + ], + }, + { + featureType: 'road.highway.controlled_access', + elementType: 'geometry.fill', + stylers: [ + { + color: '#ffffff', + }, + ], + }, + { + featureType: 'road.highway.controlled_access', + elementType: 'geometry.stroke', + stylers: [ + { + color: '#cfc8c4', + }, + ], + }, + { + featureType: 'road.arterial', + elementType: 'geometry.fill', + stylers: [ + { + color: '#ffffff', + }, + ], + }, + { + featureType: 'road.arterial', + elementType: 'geometry.stroke', + stylers: [ + { + color: '#cfc8c4', + }, + ], + }, + { + featureType: 'road.local', + elementType: 'labels.text', + stylers: [ + { + visibility: 'off', + }, + ], + }, + { + featureType: 'transit.line', + elementType: 'geometry.stroke', + stylers: [ + { + visibility: 'off', + }, + ], + }, + { + featureType: 'water', + elementType: 'geometry.fill', + stylers: [ + { + color: '#b3e6f4', + }, + ], + }, +]; + +const useMap = ({ center, zoom, onClick, onIdle }: UseDrawMapProps) => { + const ref = useRef(null); + const [map, setMap] = useState(null); + + useEffect(() => { + if (ref.current && !map) { + const newMap = new window.google.maps.Map(ref.current, { + center, + zoom, + disableDefaultUI: true, + gestureHandling: 'greedy', + styles, + }); + setMap(newMap); + } + }, [ref]); + + useEffect(() => { + if (map) map.panTo(center); + }, [center]); + + useEffect(() => { + if (map) map.setZoom(zoom); + }, [zoom]); + + useEffect(() => { + if (map) { + ['click', 'idle'].forEach(eventName => google.maps.event.clearListeners(map, eventName)); + + if (onClick) map.addListener('click', onClick); + if (onIdle) map.addListener('idle', () => onIdle(map)); + } + }, [map, onClick, onIdle]); + + return { ref, map }; +}; + +export default useMap; diff --git a/frontend/src/components/@common/Map/index.tsx b/frontend/src/components/@common/Map/index.tsx new file mode 100644 index 000000000..11e3dba1a --- /dev/null +++ b/frontend/src/components/@common/Map/index.tsx @@ -0,0 +1,3 @@ +import Map from './Map'; + +export default Map; diff --git a/frontend/src/components/@common/Modal/Modal.tsx b/frontend/src/components/@common/Modal/Modal.tsx new file mode 100644 index 000000000..b5eb84500 --- /dev/null +++ b/frontend/src/components/@common/Modal/Modal.tsx @@ -0,0 +1,11 @@ +import { createPortal } from 'react-dom'; + +interface ModalProps { + children: React.ReactNode; +} + +function Modal({ children }: ModalProps) { + return createPortal(children, document.querySelector('#modal')); +} + +export default Modal; diff --git a/frontend/src/components/@common/Modal/ModalContent.stories.tsx b/frontend/src/components/@common/Modal/ModalContent.stories.tsx new file mode 100644 index 000000000..fc7ce1800 --- /dev/null +++ b/frontend/src/components/@common/Modal/ModalContent.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import ModalContent from './ModalContent'; + +const meta: Meta = { + title: 'ModalContent', + component: ModalContent, +}; + +export default meta; + +type Story = StoryObj; + +export const LoginModal: Story = { + args: { + isShow: true, + title: '둜그인 λ˜λŠ” νšŒμ›κ°€μž…', + closeModal: () => {}, + children: 'λͺ¨λ‹¬ λ‚΄μš©', + }, +}; diff --git a/frontend/src/components/@common/Modal/ModalContent.tsx b/frontend/src/components/@common/Modal/ModalContent.tsx new file mode 100644 index 000000000..fbab17a16 --- /dev/null +++ b/frontend/src/components/@common/Modal/ModalContent.tsx @@ -0,0 +1,106 @@ +import styled, { css } from 'styled-components'; +import Exit from '~/assets/icons/exit.svg'; + +interface ModalContentProps { + isShow?: boolean; + title: string; + closeModal: () => void; + children: React.ReactNode; +} + +function ModalContent({ isShow = false, title, closeModal, children }: ModalContentProps) { + return ( + + + + + + {title} + + {children} + + + ); +} + +export default ModalContent; + +const StyledModalContentWrapper = styled.div<{ isShow: boolean }>` + display: flex; + justify-content: center; + align-items: center; + + position: fixed; + top: 0; + left: 0; + z-index: 999; + + width: 100%; + height: 100%; + + opacity: 0; + visibility: hidden; + + ${({ isShow }) => + isShow && + css` + visibility: visible; + + opacity: 1; + transition: opacity ease 0.25s; + `} +`; + +const StyledModalOverlay = styled.div` + position: absolute; + top: 0; + left: 0; + z-index: 1; + + width: 100%; + height: 100%; + + background: rgb(0 0 0 / 50%); +`; + +const StyledModalContent = styled.div<{ isShow: boolean }>` + display: flex; + flex-direction: column; + + position: relative; + z-index: 10; + + width: 33%; + min-width: 500px; + max-width: 600px; + min-height: 100px; + + padding: 2rem; + + border-radius: 5px; + background: #fff; + + transition: transform ease 0.3s 0.1s; + transform: translateY(80px); + + overflow-y: auto; + + ${({ isShow }) => + isShow && + css` + transform: translateY(0); + `} +`; + +const StyledModalHeader = styled.h5` + display: flex; + align-items: center; +`; + +const StyledModalTitleText = styled.span` + margin: 0 auto; +`; + +const StyledModalBody = styled.div` + margin-top: 2.4rem; +`; diff --git a/frontend/src/components/@common/Modal/index.tsx b/frontend/src/components/@common/Modal/index.tsx new file mode 100644 index 000000000..4caa2d0b2 --- /dev/null +++ b/frontend/src/components/@common/Modal/index.tsx @@ -0,0 +1,4 @@ +import Modal from '~/components/@common/Modal/Modal'; +import ModalContent from '~/components/@common/Modal/ModalContent'; + +export { Modal, ModalContent }; diff --git a/frontend/src/components/@common/NavButton/NavButton.stories.tsx b/frontend/src/components/@common/NavButton/NavButton.stories.tsx new file mode 100644 index 000000000..61e0e1c9e --- /dev/null +++ b/frontend/src/components/@common/NavButton/NavButton.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import FastFoodIcon from '~/assets/icons/fastFood.svg'; +import NavItem from '~/components/@common/NavButton/NavButton'; + +const meta: Meta = { + title: 'NavItem', + component: NavItem, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + label: 'μΊ ν•‘μž₯', + icon: , + isShow: true, + }, +}; diff --git a/frontend/src/components/@common/NavButton/NavButton.tsx b/frontend/src/components/@common/NavButton/NavButton.tsx new file mode 100644 index 000000000..0a1109038 --- /dev/null +++ b/frontend/src/components/@common/NavButton/NavButton.tsx @@ -0,0 +1,94 @@ +import styled, { css } from 'styled-components'; + +import { FONT_SIZE } from '~/styles/common'; + +interface NavItemProps { + label: string; + icon: React.ReactNode; + isShow?: boolean; +} + +function NavItem({ icon, label, isShow = false }: NavItemProps) { + return ( + +
{icon}
+
+ {label} +
+
+ ); +} + +export default NavItem; + +const StyledNavItem = styled.div<{ isShow: boolean }>` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 0.8rem 0; + + width: 80px; + + border: none; + background: none; + + font-size: ${FONT_SIZE.sm}; + + & > *:first-child > svg { + fill: ${({ isShow }) => (isShow ? 'var(--black)' : '#717171')}; + } + + &:hover > *:first-child > svg { + fill: var(--black); + } + + & > * > * { + color: ${({ isShow }) => (isShow ? 'var(--black)' : '#717171')}; + } + + &:hover > * > * { + color: var(--black); + } + + ${({ isShow }) => + isShow + ? css` + & > div:last-child { + position: relative; + + &::after { + position: absolute; + top: calc(100% + 12px); + z-index: -1; + + height: 2px; + + background-color: var(--black); + white-space: nowrap; + inset-inline: 0; + content: ''; + } + } + ` + : css` + &:hover { + & > div:last-child { + position: relative; + + &::after { + position: absolute; + top: calc(100% + 12px); + z-index: -1; + + height: 2px; + + background: var(--gray-2); + white-space: nowrap; + inset-inline: 0; + content: ''; + } + } + } + `}; +`; diff --git a/frontend/src/components/@common/NavButton/index.tsx b/frontend/src/components/@common/NavButton/index.tsx new file mode 100644 index 000000000..c5ad4b809 --- /dev/null +++ b/frontend/src/components/@common/NavButton/index.tsx @@ -0,0 +1,3 @@ +import NavButton from '~/components/@common/NavButton/NavButton'; + +export default NavButton; diff --git a/frontend/src/components/@common/PageNationBar/PageNationBar.stories.tsx b/frontend/src/components/@common/PageNationBar/PageNationBar.stories.tsx new file mode 100644 index 000000000..a534bc5a4 --- /dev/null +++ b/frontend/src/components/@common/PageNationBar/PageNationBar.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import PageNationBar from './PageNationBar'; + +const meta: Meta = { + title: 'PageNationBar', + component: PageNationBar, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + totalPage: 18, + currentPage: 15, + clickPageButton: () => {}, + }, +}; diff --git a/frontend/src/components/@common/PageNationBar/PageNationBar.tsx b/frontend/src/components/@common/PageNationBar/PageNationBar.tsx new file mode 100644 index 000000000..97971fb8a --- /dev/null +++ b/frontend/src/components/@common/PageNationBar/PageNationBar.tsx @@ -0,0 +1,124 @@ +import { useMemo } from 'react'; +import styled, { css } from 'styled-components'; +import LeftBracket from '~/assets/icons/left-bracket.svg'; +import RightBracket from '~/assets/icons/right-bracket.svg'; +import { FONT_SIZE } from '~/styles/common'; + +interface PageNationBarProps { + totalPage: number; + currentPage: number; + clickPageButton: React.MouseEventHandler; +} + +function PageNationBar({ totalPage, currentPage, clickPageButton }: PageNationBarProps) { + const makePageNumber = useMemo(() => { + const isNeedPrevDots = currentPage - 2 > 1; + const isNeedNextDots = currentPage + 2 < totalPage; + + if (totalPage < 7) { + return Array.from({ length: totalPage }, (_, index) => index + 1); + } + if (isNeedPrevDots && isNeedNextDots) { + return [1, '...', currentPage - 1, currentPage, currentPage + 1, '...', totalPage]; + } + if (isNeedPrevDots && !isNeedNextDots) { + return [1, '...', totalPage - 3, totalPage - 2, totalPage - 1, totalPage]; + } + if (!isNeedPrevDots && isNeedNextDots) { + return [1, 2, 3, 4, '...', totalPage]; + } + + return Array.from({ length: totalPage }, (_, index) => index + 1); + }, [totalPage, currentPage]); + + return ( + + + + + {makePageNumber.map(value => ( + + {value} + + ))} + + + + + ); +} + +export default PageNationBar; + +const StyledPageNationBar = styled.div` + display: flex; + justify-content: center; + align-items: center; + gap: 0 1.6rem; + margin-top: 3.6rem; + + & > button { + display: flex; + justify-content: center; + align-items: center; + + width: 32px; + height: 32px; + + border: none; + border-radius: 50%; + } + + @media screen and (width <= 420px) { + gap: 0 0.8rem; + margin-top: 3.6rem; + + & > button { + width: 24px; + height: 24px; + + border: none; + border-radius: 50%; + + font-size: ${FONT_SIZE.sm}; + } + } +`; + +const StyledBracketButton = styled.button<{ disabled: boolean }>` + background: none; + + ${({ disabled }) => disabled && `opacity: 0.2;`} +`; + +const StyledPageButton = styled.button<{ isCurrentPage: boolean; value: number | string }>` + background: none; + + ${({ isCurrentPage }) => + isCurrentPage + ? css` + background-color: var(--black); + + color: var(--white); + ` + : css` + &:hover { + background: #f7f7f7; + } + `} + + ${({ value }) => + value === '...' && + css` + &:hover { + background: var(--white); + } + + cursor: default; + `} +`; diff --git a/frontend/src/components/@common/PageNationBar/index.tsx b/frontend/src/components/@common/PageNationBar/index.tsx new file mode 100644 index 000000000..8207e7f9e --- /dev/null +++ b/frontend/src/components/@common/PageNationBar/index.tsx @@ -0,0 +1,3 @@ +import PageNationBar from './PageNationBar'; + +export default PageNationBar; diff --git a/frontend/src/components/@common/ProfileImage/ProfileImage.stories.tsx b/frontend/src/components/@common/ProfileImage/ProfileImage.stories.tsx new file mode 100644 index 000000000..58f63f8a1 --- /dev/null +++ b/frontend/src/components/@common/ProfileImage/ProfileImage.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import ProfileImage from './ProfileImage'; + +const meta: Meta = { + title: 'ProfileImage', + component: ProfileImage, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + name: 'λˆ„κ΅°κ°€', + imageUrl: 'https://avatars.githubusercontent.com/u/51052049?v=4', + size: '64px', + }, +}; diff --git a/frontend/src/components/@common/ProfileImage/ProfileImage.tsx b/frontend/src/components/@common/ProfileImage/ProfileImage.tsx new file mode 100644 index 000000000..e2efe8345 --- /dev/null +++ b/frontend/src/components/@common/ProfileImage/ProfileImage.tsx @@ -0,0 +1,22 @@ +import { styled } from 'styled-components'; + +interface ProfileImageProps extends React.HTMLAttributes { + name: string; + imageUrl: string; + border?: boolean; + size?: string; +} + +function ProfileImage({ name = 'μ…€λŸ½', imageUrl, size, border = false, ...props }: ProfileImageProps) { + return ; +} + +export default ProfileImage; + +const StyledProfile = styled.img<{ size: string; border: boolean }>` + width: ${({ size }) => size || 'auto'}; + height: ${({ size }) => size || 'auto'}; + + border-radius: 50%; + background: none; +`; diff --git a/frontend/src/components/@common/ProfileImage/ProfileImageSkeleton.tsx b/frontend/src/components/@common/ProfileImage/ProfileImageSkeleton.tsx new file mode 100644 index 000000000..9ca92eab1 --- /dev/null +++ b/frontend/src/components/@common/ProfileImage/ProfileImageSkeleton.tsx @@ -0,0 +1,21 @@ +import { styled } from 'styled-components'; +import { paintSkeleton } from '~/styles/common'; + +interface ProfileImageSkeletonProps { + size: number; +} + +function ProfileImageSkeleton({ size }: ProfileImageSkeletonProps) { + return ; +} + +export default ProfileImageSkeleton; + +const StyledProfileImageSkeleton = styled.div<{ size: number }>` + ${paintSkeleton} + width: ${({ size }) => (size ? `${size}px` : '100%')}; + height: ${({ size }) => (size ? `${size}px` : 'auto')}; + + border-radius: 50%; + background: none; +`; diff --git a/frontend/src/components/@common/ProfileImage/index.tsx b/frontend/src/components/@common/ProfileImage/index.tsx new file mode 100644 index 000000000..957eb55ce --- /dev/null +++ b/frontend/src/components/@common/ProfileImage/index.tsx @@ -0,0 +1,3 @@ +import ProfileImage from './ProfileImage'; + +export default ProfileImage; diff --git a/frontend/src/components/@common/ProfileImageList/ProfileImageList.stories.tsx b/frontend/src/components/@common/ProfileImageList/ProfileImageList.stories.tsx new file mode 100644 index 000000000..2d4992598 --- /dev/null +++ b/frontend/src/components/@common/ProfileImageList/ProfileImageList.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import ProfileImageList from './ProfileImageList'; + +const meta: Meta = { + title: 'ProfileImageList', + component: ProfileImageList, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + celebs: [ + { + name: 'λˆ„κ΅°κ°€', + profileImageUrl: 'https://avatars.githubusercontent.com/u/51052049?v=4', + id: 1, + youtubeChannelName: '@d0dam', + }, + { + name: 'λˆ„κ΅°κ°€', + profileImageUrl: 'https://avatars.githubusercontent.com/u/51052049?v=4', + id: 2, + youtubeChannelName: '@d0dam', + }, + { + name: 'λˆ„κ΅°κ°€', + profileImageUrl: 'https://avatars.githubusercontent.com/u/51052049?v=4', + id: 3, + youtubeChannelName: '@d0dam', + }, + { + name: 'λˆ„κ΅°κ°€', + profileImageUrl: 'https://avatars.githubusercontent.com/u/51052049?v=4', + id: 4, + youtubeChannelName: '@d0dam', + }, + ], + size: '42px', + }, +}; diff --git a/frontend/src/components/@common/ProfileImageList/ProfileImageList.tsx b/frontend/src/components/@common/ProfileImageList/ProfileImageList.tsx new file mode 100644 index 000000000..1ee2c0ec1 --- /dev/null +++ b/frontend/src/components/@common/ProfileImageList/ProfileImageList.tsx @@ -0,0 +1,49 @@ +import { styled } from 'styled-components'; +import ProfileImage from '../ProfileImage/ProfileImage'; +import useBooleanState from '~/hooks/useBooleanState'; + +import type { Celeb } from '~/@types/celeb.types'; + +interface ProfileImageListProps { + celebs: Celeb[]; + size: string; +} + +function ProfileImageList({ celebs, size }: ProfileImageListProps) { + const { value: hover, setTrue, setFalse } = useBooleanState(false); + + return ( + + {celebs.map((celeb, index) => ( + + + + ))} + + ); +} + +export default ProfileImageList; + +const StyledProfileImageList = styled.div<{ size: string }>` + position: relative; + + width: ${({ size }) => `${size}`}; + height: ${({ size }) => `${size}`}; +`; + +const StyledProfileImageWrapper = styled.div<{ index: number; hover: boolean }>` + position: absolute; + z-index: ${({ index }) => 100 - index}; + + transition: 0.4s ease-in-out; + + ${({ hover, index }) => + hover + ? ` + transform: translateX(${index * -110}%); + ` + : ` + transform: translateX(${index * -20}%); + `}; +`; diff --git a/frontend/src/components/@common/ProfileImageList/index.tsx b/frontend/src/components/@common/ProfileImageList/index.tsx new file mode 100644 index 000000000..11aaa2980 --- /dev/null +++ b/frontend/src/components/@common/ProfileImageList/index.tsx @@ -0,0 +1,3 @@ +import ProfileImageList from './ProfileImageList'; + +export default ProfileImageList; diff --git a/frontend/src/components/@common/Skeleton/Skeleton.stories.tsx b/frontend/src/components/@common/Skeleton/Skeleton.stories.tsx new file mode 100644 index 000000000..79a64e3c1 --- /dev/null +++ b/frontend/src/components/@common/Skeleton/Skeleton.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Skeleton from './Skeleton'; + +const meta: Meta = { + title: 'Skeleton', + component: Skeleton, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + width: '256px', + height: '256px', + }, +}; diff --git a/frontend/src/components/@common/Skeleton/Skeleton.tsx b/frontend/src/components/@common/Skeleton/Skeleton.tsx new file mode 100644 index 000000000..18b6d8421 --- /dev/null +++ b/frontend/src/components/@common/Skeleton/Skeleton.tsx @@ -0,0 +1,22 @@ +import { css, styled } from 'styled-components'; +import { paintSkeleton } from '~/styles/common'; + +interface SkeletonProps { + width: string; + height: string; +} + +function Skeleton({ width, height }: SkeletonProps) { + return ; +} + +export default Skeleton; + +const StyledSkeleton = styled.div<{ width: string; height: string }>` + ${paintSkeleton} + + ${({ width, height }) => css` + width: ${width}; + height: ${height}; + `} +`; diff --git a/frontend/src/components/@common/Skeleton/index.tsx b/frontend/src/components/@common/Skeleton/index.tsx new file mode 100644 index 000000000..63e5e4d7d --- /dev/null +++ b/frontend/src/components/@common/Skeleton/index.tsx @@ -0,0 +1,3 @@ +import Skeleton from './Skeleton'; + +export default Skeleton; diff --git a/frontend/src/components/@common/WaterMarkImage/WaterMarkImage.stories.tsx b/frontend/src/components/@common/WaterMarkImage/WaterMarkImage.stories.tsx new file mode 100644 index 000000000..3026f3220 --- /dev/null +++ b/frontend/src/components/@common/WaterMarkImage/WaterMarkImage.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import WaterMarkImage from './WaterMarkImage'; + +const meta: Meta = { + title: 'WaterMarkImage', + component: WaterMarkImage, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + waterMark: '@d0dam', + imageUrl: 'https://picsum.photos/315/300', + }, +}; diff --git a/frontend/src/components/@common/WaterMarkImage/WaterMarkImage.tsx b/frontend/src/components/@common/WaterMarkImage/WaterMarkImage.tsx new file mode 100644 index 000000000..b84551b84 --- /dev/null +++ b/frontend/src/components/@common/WaterMarkImage/WaterMarkImage.tsx @@ -0,0 +1,51 @@ +import styled from 'styled-components'; +import { BORDER_RADIUS, FONT_SIZE, paintSkeleton } from '~/styles/common'; + +interface WaterMarkImageProps { + waterMark: string; + imageUrl: string; +} + +function WaterMarkImage({ waterMark, imageUrl }: WaterMarkImageProps) { + return ( + + + + + ); +} + +export default WaterMarkImage; + +const StyledWaterMarkImage = styled.div` + position: relative; + + width: 100%; + aspect-ratio: 1.05 / 1; + + height: auto; +`; + +const StyledImage = styled.img` + ${paintSkeleton} + display: block; + + aspect-ratio: 1.05 / 1; + object-fit: cover; + + width: 100%; +`; + +const StyledWaterMark = styled.div` + position: absolute; + top: 12px; + left: 12px; + + padding: 0.4rem 0.8rem; + + border-radius: ${BORDER_RADIUS.xs}; + background-color: var(--white); + + color: var(--black); + font-size: ${FONT_SIZE.sm}; +`; diff --git a/frontend/src/components/@common/WaterMarkImage/index.tsx b/frontend/src/components/@common/WaterMarkImage/index.tsx new file mode 100644 index 000000000..b45cee9d3 --- /dev/null +++ b/frontend/src/components/@common/WaterMarkImage/index.tsx @@ -0,0 +1,3 @@ +import WaterMarkImage from './WaterMarkImage'; + +export default WaterMarkImage; diff --git a/frontend/src/components/CategoryNavbar/CategoryNavbar.stories.tsx b/frontend/src/components/CategoryNavbar/CategoryNavbar.stories.tsx new file mode 100644 index 000000000..72d44cfde --- /dev/null +++ b/frontend/src/components/CategoryNavbar/CategoryNavbar.stories.tsx @@ -0,0 +1,67 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import CategoryNavbar from './CategoryNavbar'; +import FastFood from '~/assets/icons/fastFood.svg'; +import { RestaurantCategory } from '~/@types/restaurant.types'; + +interface Category { + label: RestaurantCategory; + icon: React.ReactNode; +} + +const categories: Category[] = [ + { + label: '일식당', + icon: , + }, + { + label: 'ν•œμ‹', + icon: , + }, + { + label: '초λ°₯,λ‘€', + icon: , + }, + { + label: 'μƒμ„ νšŒ', + icon: , + }, + { + label: '양식', + icon: , + }, + { + label: '윑λ₯˜,κ³ κΈ°μš”λ¦¬', + icon: , + }, + { + label: 'μ΄μžμΉ΄μ•Ό', + icon: , + }, + { + label: '돼지고기ꡬ이', + icon: , + }, + { + label: 'μš”λ¦¬μ£Όμ ', + icon: , + }, + { + label: '와인', + icon: , + }, +]; + +const meta: Meta = { + title: 'Selector/CategoryNavbar', + component: CategoryNavbar, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + categories, + }, +}; diff --git a/frontend/src/components/CategoryNavbar/CategoryNavbar.tsx b/frontend/src/components/CategoryNavbar/CategoryNavbar.tsx new file mode 100644 index 000000000..06a767329 --- /dev/null +++ b/frontend/src/components/CategoryNavbar/CategoryNavbar.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react'; +import styled from 'styled-components'; +import NavItem from '~/components/@common/NavButton/NavButton'; +import { isEqual } from '~/utils/compare'; + +import type { RestaurantCategory } from '~/@types/restaurant.types'; + +interface Category { + label: RestaurantCategory; + icon: React.ReactNode; +} + +interface CategoryProps { + categories: Category[]; + externalOnClick?: (e?: React.MouseEvent) => void; +} + +function CategoryNavbar({ categories, externalOnClick }: CategoryProps) { + const [selected, setSelected] = useState('전체'); + + const clickCategory = (value: RestaurantCategory) => (event?: React.MouseEvent) => { + setSelected(value); + + if (externalOnClick) externalOnClick(event); + }; + + return ( + + {categories.map(({ icon, label }) => ( + + + + ))} + + ); +} + +export default CategoryNavbar; + +const StyledCategoryNavbarWrapper = styled.ul` + display: flex; + align-items: center; + + background: transparent; + + overflow-x: scroll; +`; + +const StyledNavItemButton = styled.button` + border: none; + background: transparent; + + cursor: pointer; + outline: none; +`; diff --git a/frontend/src/components/CategoryNavbar/index.tsx b/frontend/src/components/CategoryNavbar/index.tsx new file mode 100644 index 000000000..4b1e7d84c --- /dev/null +++ b/frontend/src/components/CategoryNavbar/index.tsx @@ -0,0 +1,3 @@ +import CategoryNavbar from '~/components/CategoryNavbar/CategoryNavbar'; + +export default CategoryNavbar; diff --git a/frontend/src/components/CelebBanner/CelebBanner.stories.tsx b/frontend/src/components/CelebBanner/CelebBanner.stories.tsx new file mode 100644 index 000000000..60bb7e75d --- /dev/null +++ b/frontend/src/components/CelebBanner/CelebBanner.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import CelebBanner from './CelebBanner'; + +const meta: Meta = { + title: 'CelebBanner', + component: CelebBanner, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + name: '도담', + youtubeChannelName: '@d0dam', + subscriberCount: 650_000, + restaurantCount: 123, + youtubeChannelUrl: 'https://www.youtube.com/watch?v=D3lU6KokgVs', + profileImageUrl: 'https://avatars.githubusercontent.com/u/51052049?v=4', + backgroundImageUrl: null, + }, +}; diff --git a/frontend/src/components/CelebBanner/CelebBanner.tsx b/frontend/src/components/CelebBanner/CelebBanner.tsx new file mode 100644 index 000000000..9a78383cf --- /dev/null +++ b/frontend/src/components/CelebBanner/CelebBanner.tsx @@ -0,0 +1,89 @@ +import { styled, css } from 'styled-components'; +import TextButton from '../@common/Button'; +import ProfileImage from '../@common/ProfileImage'; +import { FONT_SIZE } from '~/styles/common'; + +interface CelebBannerProps { + name: string; + youtubeChannelName: string; + subscriberCount: number; + restaurantCount: number; + youtubeChannelUrl: string; + profileImageUrl: string; + backgroundImageUrl: string | null; +} + +function CelebBanner({ + name, + youtubeChannelName, + subscriberCount, + restaurantCount, + youtubeChannelUrl, + profileImageUrl, + backgroundImageUrl, +}: CelebBannerProps) { + const onClick = () => window.open(youtubeChannelUrl, '_blank'); + + return ( + + + + {name} + + {youtubeChannelName} κ΅¬λ…μž {subscriberCount / 10_000}만λͺ… βˆ™ μŒμ‹μ  {restaurantCount}개 + + + + + ); +} + +export default CelebBanner; + +const StyledContainer = styled.section<{ background: string }>` + display: flex; + justify-content: space-around; + align-items: center; + + width: 100%; + + padding: 6.4rem 0; + + ${({ background }) => + background + ? css` + background-image: url(${background}); + ` + : css` + background-color: var(--primary-4); + `}; + + & > button { + width: 260px; + } +`; + +const StyledCelebInfo = styled.div` + display: grid; + grid-template-columns: auto 1fr; + + gap: 2.4rem; + + & > *:first-child { + grid-area: 1 / 1 / span 2 / span 1; + } +`; + +const StyledName = styled.h2` + grid-area: 1 / 2 / span 1 / span 1; + align-self: end; + + color: var(--white); +`; + +const StyledDetail = styled.div` + grid-area: 2 / 2 / span 1 / span 1; + + color: var(--white); + font-size: ${FONT_SIZE.sm}; +`; diff --git a/frontend/src/components/CelebBanner/index.tsx b/frontend/src/components/CelebBanner/index.tsx new file mode 100644 index 000000000..a8433e6fa --- /dev/null +++ b/frontend/src/components/CelebBanner/index.tsx @@ -0,0 +1,3 @@ +import CelebBanner from './CelebBanner'; + +export default CelebBanner; diff --git a/frontend/src/components/CelebDropDown/CelebDropDown.stories.tsx b/frontend/src/components/CelebDropDown/CelebDropDown.stories.tsx new file mode 100644 index 000000000..26f4dabcd --- /dev/null +++ b/frontend/src/components/CelebDropDown/CelebDropDown.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import CelebDropDown from './CelebDropDown'; + +const meta: Meta = { + title: 'Selector/CelebDropDown', + component: CelebDropDown, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + celebs: [ + { + id: 1, + name: '히λ°₯', + youtubeChannelName: '@heebab', + profileImageUrl: + 'https://yt3.googleusercontent.com/sL5ugPfl9vvwRwhf6l5APY__BZBw8qWiwgHs-uVsMPFoD5-a4opTJIcRSyrY8aY5LEESOMWJ=s176-c-k-c0x00ffffff-no-rj', + }, + { + id: 2, + name: 'μ •μ°¬μ„±', + youtubeChannelName: '@Korean_zzombi', + profileImageUrl: + 'https://yt3.googleusercontent.com/sL5ugPfl9vvwRwhf6l5APY__BZBw8qWiwgHs-uVsMPFoD5-a4opTJIcRSyrY8aY5LEESOMWJ=s176-c-k-c0x00ffffff-no-rj', + }, + { + id: 3, + name: 'μ •μ°¬', + youtubeChannelName: '@Korean_zzombi', + profileImageUrl: + 'https://yt3.googleusercontent.com/sL5ugPfl9vvwRwhf6l5APY__BZBw8qWiwgHs-uVsMPFoD5-a4opTJIcRSyrY8aY5LEESOMWJ=s176-c-k-c0x00ffffff-no-rj', + }, + { + id: 4, + name: 'μ •μ„±', + youtubeChannelName: '@Korean_zzombi', + profileImageUrl: + 'https://yt3.googleusercontent.com/sL5ugPfl9vvwRwhf6l5APY__BZBw8qWiwgHs-uVsMPFoD5-a4opTJIcRSyrY8aY5LEESOMWJ=s176-c-k-c0x00ffffff-no-rj', + }, + { + id: 5, + name: 'μ •μ°¬μ„±1', + youtubeChannelName: '@Korean_zzombi', + profileImageUrl: + 'https://yt3.googleusercontent.com/sL5ugPfl9vvwRwhf6l5APY__BZBw8qWiwgHs-uVsMPFoD5-a4opTJIcRSyrY8aY5LEESOMWJ=s176-c-k-c0x00ffffff-no-rj', + }, + ], + }, +}; diff --git a/frontend/src/components/CelebDropDown/CelebDropDown.tsx b/frontend/src/components/CelebDropDown/CelebDropDown.tsx new file mode 100644 index 000000000..5abb8439a --- /dev/null +++ b/frontend/src/components/CelebDropDown/CelebDropDown.tsx @@ -0,0 +1,121 @@ +import styled from 'styled-components'; +import { MouseEvent, useState } from 'react'; + +import CelebIcon from '~/assets/icons/celeb.svg'; +import SearchIcon from '~/assets/icons/search.svg'; +import { isEqual } from '~/utils/compare'; +import ProfileImage from '~/components/@common/ProfileImage'; +import NavItem from '~/components/@common/NavButton/NavButton'; +import useBooleanState from '~/hooks/useBooleanState'; + +import type { Celeb } from '~/@types/celeb.types'; + +interface DropDownProps { + celebs: Celeb[]; + isOpen?: boolean; + externalOnClick?: (e?: MouseEvent) => void; +} + +function CelebDropDown({ celebs, externalOnClick, isOpen = false }: DropDownProps) { + const [selected, setSelected] = useState('전체'); + const { value: isShow, toggle: onToggleDropDown, setFalse: onCloseDropDown } = useBooleanState(isOpen); + + const onSelection = (celeb: Celeb['name']) => (event?: MouseEvent) => { + setSelected(celeb); + + if (externalOnClick) externalOnClick(event); + }; + + return ( + + + } isShow={isShow} /> + + + {isShow && ( + + + {celebs.map(({ id, name, profileImageUrl }) => ( + +
+ + {name} +
+ {isEqual(selected, name) && } +
+ ))} +
+
+ )} +
+ ); +} + +export default CelebDropDown; + +const StyledNavItemWrapper = styled.button` + border: none; + background: transparent; + + cursor: pointer; + outline: none; +`; + +const StyledCelebDropDown = styled.div` + position: relative; +`; + +const StyledDropDownWrapper = styled.ul` + display: flex; + flex-direction: column; + align-content: center; + + position: absolute; + top: calc(100% + 16px); + left: 18px; + + width: 216px; + height: 176px; + + padding: 1.8rem 0; + + border-radius: 10px; + background: white; + + box-shadow: var(--shadow); +`; + +const StyledSelectContainer = styled.div` + width: 100%; + height: 150px; + + background: transparent; + + overflow-y: auto; +`; + +const StyledDropDownOption = styled.li` + display: flex; + justify-content: space-between; + align-items: center; + + height: 44px; + + margin: 0 1.8rem; + + cursor: pointer; + + & + & { + border-bottom: 1px solid var(--gray-1); + } + + &:first-child { + border-bottom: 1px solid var(--gray-1); + } + + & > div { + display: flex; + align-items: center; + gap: 0.4rem; + } +`; diff --git a/frontend/src/components/CelebDropDown/index.tsx b/frontend/src/components/CelebDropDown/index.tsx new file mode 100644 index 000000000..53c869e89 --- /dev/null +++ b/frontend/src/components/CelebDropDown/index.tsx @@ -0,0 +1,3 @@ +import DropDown from '~/components/CelebDropDown/CelebDropDown'; + +export default DropDown; diff --git a/frontend/src/components/InfoDropDown/InfoDropDown.stories.tsx b/frontend/src/components/InfoDropDown/InfoDropDown.stories.tsx new file mode 100644 index 000000000..1c62ce2f5 --- /dev/null +++ b/frontend/src/components/InfoDropDown/InfoDropDown.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import InfoDropDown from './InfoDropDown'; + +const meta: Meta = { + title: 'Selector/InfoDropDown', + component: InfoDropDown, +}; + +export default meta; + +const options = [ + { + id: 1, + value: '둜그인', + }, + { + id: 2, + value: 'νšŒμ›κ°€μž…', + }, + { + id: 3, + value: '기타', + }, + { + id: 4, + value: 'λ“± λ“±', + }, +]; + +type Story = StoryObj; + +export const Default: Story = { + args: { + options, + }, +}; diff --git a/frontend/src/components/InfoDropDown/InfoDropDown.tsx b/frontend/src/components/InfoDropDown/InfoDropDown.tsx new file mode 100644 index 000000000..7fb6db9db --- /dev/null +++ b/frontend/src/components/InfoDropDown/InfoDropDown.tsx @@ -0,0 +1,120 @@ +import styled from 'styled-components'; +import { MouseEvent } from 'react'; +import InfoButton from '~/components/@common/InfoButton'; +import useBooleanState from '~/hooks/useBooleanState'; + +interface Option { + id: number; + value: string; +} + +interface DropDownProps { + options: Option[]; + isOpen?: boolean; + externalOnClick?: (e?: React.MouseEvent) => void; + label: string; +} + +function InfoDropDown({ options, externalOnClick, isOpen = false, label }: DropDownProps) { + const { value: isShow, toggle: onToggleDropDown, setFalse: onCloseDropDown } = useBooleanState(isOpen); + + const onSelection = () => (event?: MouseEvent) => { + if (externalOnClick) externalOnClick(event); + }; + + return ( + + + + + + {isShow && ( + + + {options.map(({ id, value }) => ( + + {value} + + ))} + + + )} + + ); +} + +export default InfoDropDown; + +const StyledInfoButtonWrapper = styled.button` + border: none; + background: transparent; + + cursor: pointer; + outline: none; +`; + +const StyledInfoDropDown = styled.div` + display: relative; + + z-index: 100000000; + + width: 77px; + height: 42px; +`; + +const StyledDropDownWrapper = styled.ul` + display: flex; + flex-direction: column; + align-content: center; + + position: absolute; + top: calc(100% - 8px); + right: 18px; + + width: 216px; + height: 176px; + + padding: 1.8rem 0; + + border-radius: 10px; + background: white; + + font-size: 1.4rem; + + box-shadow: var(--shadow); +`; + +const StyledSelectContainer = styled.div` + width: 100%; + height: 150px; + + background: transparent; + + overflow-y: auto; +`; + +const StyledDropDownOption = styled.li` + display: flex; + justify-content: space-between; + align-items: center; + + height: 44px; + + margin: 0 1.8rem; + + cursor: pointer; + + & + & { + border-bottom: 1px solid var(--gray-1); + } + + &:first-child { + border-bottom: 1px solid var(--gray-1); + } + + & > div { + display: flex; + align-items: center; + gap: 0.4rem; + } +`; diff --git a/frontend/src/components/InfoDropDown/index.tsx b/frontend/src/components/InfoDropDown/index.tsx new file mode 100644 index 000000000..e4ff6498b --- /dev/null +++ b/frontend/src/components/InfoDropDown/index.tsx @@ -0,0 +1,3 @@ +import InfoDropDown from '~/components/InfoDropDown/InfoDropDown'; + +export default InfoDropDown; diff --git a/frontend/src/components/LoginModalContent/LoginModalContent.stories.tsx b/frontend/src/components/LoginModalContent/LoginModalContent.stories.tsx new file mode 100644 index 000000000..48f398ca7 --- /dev/null +++ b/frontend/src/components/LoginModalContent/LoginModalContent.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import LoginModalContent from './LoginModalContent'; + +const meta: Meta = { + title: 'Modal/LoginModalContent', + component: LoginModalContent, + decorators: [ + Story => ( + + + } /> + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/frontend/src/components/LoginModalContent/LoginModalContent.tsx b/frontend/src/components/LoginModalContent/LoginModalContent.tsx new file mode 100644 index 000000000..a7c7eef30 --- /dev/null +++ b/frontend/src/components/LoginModalContent/LoginModalContent.tsx @@ -0,0 +1,20 @@ +import styled from 'styled-components'; +import LoginButton from '~/components/@common/LoginButton'; + +function LoginModalContent() { + return ( + + + + + + ); +} + +export default LoginModalContent; + +const StyledLoginModalContent = styled.div` + a + a { + margin-top: 1.6rem; + } +`; diff --git a/frontend/src/components/LoginModalContent/index.tsx b/frontend/src/components/LoginModalContent/index.tsx new file mode 100644 index 000000000..c632d277c --- /dev/null +++ b/frontend/src/components/LoginModalContent/index.tsx @@ -0,0 +1,3 @@ +import LoginModalContent from '~/components/LoginModalContent/LoginModalContent'; + +export default LoginModalContent; diff --git a/frontend/src/components/MapModal/MapModal.stories.tsx b/frontend/src/components/MapModal/MapModal.stories.tsx new file mode 100644 index 000000000..35a81b377 --- /dev/null +++ b/frontend/src/components/MapModal/MapModal.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import MapModal from './MapModal'; + +const meta: Meta = { + title: 'MapModal', + component: MapModal, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + modalOpen: true, + isVisible: false, + onClickExit: () => {}, + modalRestaurantInfo: { + id: 1, + name: 'κΉ€μ²œμž¬μ˜μœ‘νšŒλ°˜ν•œμ—°μ–΄ μ‹ λ…Όν˜„λ³Έμ ', + category: 'μš”λ¦¬μ£Όμ ', + roadAddress: 'μ„œμšΈ 강남ꡬ κ°•λ‚¨λŒ€λ‘œ118κΈΈ 47 2μΈ΅', + phoneNumber: '0507-1415-1113', + naverMapUrl: + 'https://map.naver.com/v5/entry/place/38252334?lng=127.02682069999999&lat=37.50750299999999&placePath=%2Fhome&entry=plt', + + images: [ + { + id: 1, + name: 'RawFishEater_κΉ€μ²œμž¬μ˜μœ‘νšŒλ°˜ν•œμ—°μ–΄_μ‹ λ…Όν˜„λ³Έμ .png', + author: '@RawFishEater', + sns: '@RawFishEater', + }, + ], + }, + }, +}; diff --git a/frontend/src/components/MapModal/MapModal.tsx b/frontend/src/components/MapModal/MapModal.tsx new file mode 100644 index 000000000..1b5240ce0 --- /dev/null +++ b/frontend/src/components/MapModal/MapModal.tsx @@ -0,0 +1,84 @@ +import { keyframes, styled } from 'styled-components'; +import { BORDER_RADIUS } from '~/styles/common'; +import ExitButton from '../../assets/icons/exit.svg'; +import type { RestaurantModalInfo } from '~/@types/restaurant.types'; +import MapModalContent from '../MapModalContent'; + +type ModalProps = { + modalOpen: boolean; + isVisible: boolean; + onClickExit: () => void; + modalRestaurantInfo: RestaurantModalInfo; +}; + +function MapModal({ modalOpen, onClickExit, isVisible, modalRestaurantInfo }: ModalProps) { + return ( + + + + + + + + + ); +} + +export default MapModal; + +const StyledModalContent = styled.div<{ modalOpen: boolean }>` + position: absolute; + bottom: 0; + z-index: 1000; + + width: 50%; + + padding: 2rem; + + border-radius: ${BORDER_RADIUS.sm} ${BORDER_RADIUS.sm} 0 0; + background: var(--white); + + animation: ${({ modalOpen }) => (modalOpen ? slideUp : slideDown)} 0.4s ease-out; +`; + +const StyledExitButton = styled.button` + position: absolute; + top: -28px; + right: 0; + + border: none; + background-color: transparent; +`; + +const slideUp = keyframes` + 0% { + transform: translateY(100%); + opacity: 0; + } + 100% { + transform: translateY(0); + opacity: 1; + } +`; + +const slideDown = keyframes` + 0% { + transform: translateY(0%); + opacity: 1; + } + 100% { + transform: translateY(100%); + opacity: 0; + } +`; + +const StyledMapModalContainer = styled.div<{ modalOpen: boolean; isVisible: boolean }>` + display: flex; + justify-content: center; + + position: relative; + + width: 100%; + + visibility: ${({ modalOpen, isVisible }) => (modalOpen || isVisible ? 'visible' : 'hidden')}; +`; diff --git a/frontend/src/components/MapModal/index.tsx b/frontend/src/components/MapModal/index.tsx new file mode 100644 index 000000000..b62361221 --- /dev/null +++ b/frontend/src/components/MapModal/index.tsx @@ -0,0 +1,3 @@ +import MapModal from './MapModal'; + +export default MapModal; diff --git a/frontend/src/components/MapModalContent/MapModalContent.tsx b/frontend/src/components/MapModalContent/MapModalContent.tsx new file mode 100644 index 000000000..9b67d30b8 --- /dev/null +++ b/frontend/src/components/MapModalContent/MapModalContent.tsx @@ -0,0 +1,62 @@ +import { styled } from 'styled-components'; +import { RestaurantModalInfo } from '~/@types/restaurant.types'; +import { BORDER_RADIUS, FONT_SIZE } from '~/styles/common'; +import TextButton from '../@common/Button'; + +import { BASE_URL } from '~/App'; + +interface MapModalContentProps { + content: RestaurantModalInfo; +} + +function MapModalContent({ content }: MapModalContentProps) { + const { name, roadAddress, phoneNumber, images, naverMapUrl } = content; + return ( + + +
+
{name}
+
{roadAddress}
+
{phoneNumber}
+
+ +
+ { + window.open(naverMapUrl, '_blank'); + }} + /> +
+ ); +} + +export default MapModalContent; + +const StyledMapModalContent = styled.div` + display: flex; + flex-direction: column; + gap: 2.4rem; +`; + +const StyledRestaurantInfo = styled.div` + display: flex; + justify-content: space-between; + + & > div:first-child { + display: flex; + flex-direction: column; + gap: 0.8rem; + + font-size: ${FONT_SIZE.sm}; + } +`; + +const StyledRestaurantImage = styled.img` + width: 64px; + height: 64px; + + border-radius: ${BORDER_RADIUS.sm}; +`; diff --git a/frontend/src/components/MapModalContent/index.tsx b/frontend/src/components/MapModalContent/index.tsx new file mode 100644 index 000000000..f2fb14ff1 --- /dev/null +++ b/frontend/src/components/MapModalContent/index.tsx @@ -0,0 +1,3 @@ +import MapModalContent from './MapModalContent'; + +export default MapModalContent; diff --git a/frontend/src/components/RestaurantCard/RestaurantCard.stories.tsx b/frontend/src/components/RestaurantCard/RestaurantCard.stories.tsx new file mode 100644 index 000000000..b8fae7b73 --- /dev/null +++ b/frontend/src/components/RestaurantCard/RestaurantCard.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import RestaurantCard from './RestaurantCard'; + +const meta: Meta = { + title: 'RestaurantCard', + component: RestaurantCard, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + restaurant: { + id: 1, + name: 'μŠ€μ‹œλ Œ', + category: '일식당', + roadAddress: 'μ„œμšΈ 강남ꡬ μ„ λ¦‰λ‘œ146κΈΈ 27-8 2F', + lat: 37.5222779, + lng: 127.0423149, + phoneNumber: '010-8072-2032', + naverMapUrl: 'https://naver.me/58HxhMsl', + images: [ + { + id: 1, + name: 'https://search.pstatic.net/common/?src=http%3A%2F%2Fblogfiles.naver.net%2FMjAyMzAzMjhfMjY1%2FMDAxNjc5OTk0NjYxMDI5.Mo-i3h1Q8kR4yi0hOL2lQZdA6t6uiQ599aBNnnJ83q8g._NGlnMeHtVCiJVWenUbbtICefoddkW1Wg0g3PCxn9Q4g.JPEG.twinkle_paul%2F100V7467-2.jpg', + author: '@mheebab', + sns: 'YOUTUBE', + }, + ], + }, + celebs: [ + { + id: 1, + name: '히λ°₯', + youtubeChannelName: '@heebab', + profileImageUrl: + 'https://yt3.googleusercontent.com/sL5ugPfl9vvwRwhf6l5APY__BZBw8qWiwgHs-uVsMPFoD5-a4opTJIcRSyrY8aY5LEESOMWJ=s176-c-k-c0x00ffffff-no-rj', + }, + { + id: 2, + name: '히λ°₯', + youtubeChannelName: '@heebab', + profileImageUrl: + 'https://yt3.googleusercontent.com/sL5ugPfl9vvwRwhf6l5APY__BZBw8qWiwgHs-uVsMPFoD5-a4opTJIcRSyrY8aY5LEESOMWJ=s176-c-k-c0x00ffffff-no-rj', + }, + ], + size: '42px', + }, +}; diff --git a/frontend/src/components/RestaurantCard/RestaurantCard.tsx b/frontend/src/components/RestaurantCard/RestaurantCard.tsx new file mode 100644 index 000000000..8e9466dd9 --- /dev/null +++ b/frontend/src/components/RestaurantCard/RestaurantCard.tsx @@ -0,0 +1,121 @@ +import { styled } from 'styled-components'; +import ImageCarousel from '../@common/ImageCarousel'; +import Love from '~/assets/icons/love.svg'; +import ProfileImageList from '../@common/ProfileImageList'; +import { FONT_SIZE, truncateText } from '~/styles/common'; + +import type { Celeb } from '~/@types/celeb.types'; +import type { Restaurant } from '~/@types/restaurant.types'; + +interface RestaurantCardProps { + restaurant: Restaurant; + celebs?: Celeb[]; + size?: string; + type?: 'list' | 'map'; + onClick?: React.MouseEventHandler; + setHoveredId?: React.Dispatch>; +} + +function RestaurantCard({ + restaurant, + celebs, + size, + type = 'list', + onClick = () => {}, + setHoveredId = () => {}, +}: RestaurantCardProps) { + const { images, name, roadAddress, category, phoneNumber } = restaurant; + + const onMouseEnter = () => { + setHoveredId(restaurant.id); + }; + + const onMouseLeave = () => { + setHoveredId(null); + }; + + return ( + + + + + + +
+ + {category} + {name} + {roadAddress} + {phoneNumber} + + + {celebs && } + +
+
+ ); +} + +export default RestaurantCard; + +const StyledContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: start; + gap: 0.8rem; + + width: 100%; + height: 100%; + + & > section { + display: flex; + justify-content: space-between; + } + + cursor: pointer; +`; + +const StyledImageViewer = styled.div` + position: relative; +`; + +const StyledInfo = styled.div` + display: flex; + flex-direction: column; + gap: 0.6rem; + + position: relative; + + width: 100%; + + padding: 0.4rem; +`; + +const StyledName = styled.h5` + ${truncateText(1)} +`; + +const StyledAddress = styled.span` + ${truncateText(1)} + color: var(--gray-4); + font-size: ${FONT_SIZE.md}; +`; + +const StyledCategory = styled.span` + color: var(--gray-3); + font-size: ${FONT_SIZE.sm}; +`; + +const StyledProfileImageSection = styled.div` + align-self: flex-end; +`; + +const LikeButton = styled.button` + position: absolute; + top: 12px; + right: 12px; + + border: none; + background-color: transparent; +`; diff --git a/frontend/src/components/RestaurantCard/RestaurantCardSkeleton.tsx b/frontend/src/components/RestaurantCard/RestaurantCardSkeleton.tsx new file mode 100644 index 000000000..92ff4b945 --- /dev/null +++ b/frontend/src/components/RestaurantCard/RestaurantCardSkeleton.tsx @@ -0,0 +1,92 @@ +import { styled } from 'styled-components'; +import ProfileImageSkeleton from '../@common/ProfileImage/ProfileImageSkeleton'; +import { BORDER_RADIUS, paintSkeleton } from '~/styles/common'; + +function RestaurantCardSkeleton() { + return ( + + +
+ + + + + + + + + +
+
+ ); +} + +export default RestaurantCardSkeleton; + +const StyledContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: start; + gap: 0.8rem; + + width: 100%; + height: 100%; + + & > section { + display: flex; + justify-content: space-between; + } + + cursor: pointer; +`; + +const StyledImage = styled.div` + ${paintSkeleton} + width: 100%; + aspect-ratio: 1.05 / 1; + + object-fit: cover; + + border-radius: ${BORDER_RADIUS.md}; +`; + +const StyledInfo = styled.div` + display: flex; + flex: 1; + flex-direction: column; + gap: 0.4rem; + + position: relative; + + width: 100%; + + padding: 0.4rem; +`; + +const StyledName = styled.h5` + ${paintSkeleton} + width: 100%; + height: 20px; + + border-radius: ${BORDER_RADIUS.xs}; +`; + +const StyledAddress = styled.span` + ${paintSkeleton} + width: 50%; + height: 12px; + + border-radius: ${BORDER_RADIUS.xs}; +`; + +const StyledCategory = styled.span` + ${paintSkeleton} + width: 40%; + height: 12px; + + border-radius: ${BORDER_RADIUS.xs}; +`; + +const StyledProfileImageSection = styled.div` + align-self: flex-end; +`; diff --git a/frontend/src/components/RestaurantCard/index.tsx b/frontend/src/components/RestaurantCard/index.tsx new file mode 100644 index 000000000..7ba5a9924 --- /dev/null +++ b/frontend/src/components/RestaurantCard/index.tsx @@ -0,0 +1,3 @@ +import RestaurantCard from './RestaurantCard'; + +export default RestaurantCard; diff --git a/frontend/src/components/RestaurantCardList/RestaurantCardList.tsx b/frontend/src/components/RestaurantCardList/RestaurantCardList.tsx new file mode 100644 index 000000000..9cdf26be4 --- /dev/null +++ b/frontend/src/components/RestaurantCardList/RestaurantCardList.tsx @@ -0,0 +1,112 @@ +import { styled, css } from 'styled-components'; +import React, { useEffect, useState } from 'react'; +import RestaurantCard from '../RestaurantCard'; +import { FONT_SIZE } from '~/styles/common'; +import RestaurantCardListSkeleton from './RestaurantCardListSkeleton'; + +import type { RestaurantData, RestaurantListData } from '~/@types/api.types'; +import PageNationBar from '../@common/PageNationBar'; +import useMediaQuery from '~/hooks/useMediaQuery'; + +interface RestaurantCardListProps { + restaurantDataList: RestaurantListData | null; + loading: boolean; + setHoveredId: React.Dispatch>; + setCurrentPage: React.Dispatch>; +} + +function RestaurantCardList({ restaurantDataList, loading, setHoveredId, setCurrentPage }: RestaurantCardListProps) { + const { isMobile } = useMediaQuery(); + const [prevCardNumber, setPrevCardNumber] = useState(18); + + const clickPageButton: React.MouseEventHandler = e => { + e.stopPropagation(); + const pageValue = e.currentTarget.value; + window.scrollTo(0, 0); + + if (pageValue === 'prev') return setCurrentPage(prev => prev - 1); + if (pageValue === 'next') return setCurrentPage(prev => prev + 1); + return setCurrentPage(Number(pageValue) - 1); + }; + + useEffect(() => { + if (restaurantDataList) setPrevCardNumber(restaurantDataList.currentElementsCount); + }, [restaurantDataList?.currentElementsCount]); + + if (!restaurantDataList || loading) + return ( + + {' '} + {restaurantDataList && ( + + )} + + ); + + return ( + + {!isMobile && μŒμ‹μ  수 {restaurantDataList.totalElementsCount} 개} + + {restaurantDataList.content?.map(({ celebs, ...restaurant }: RestaurantData) => ( + + ))} + + + + ); +} + +export default React.memo(RestaurantCardList); + +const StyledSkeleton = styled.div` + padding-bottom: 3.2rem; +`; + +const StyledRestaurantCardListContainer = styled.div` + display: flex; + flex-direction: column; + gap: 3.2rem; + + margin: 3.2rem 2.4rem; +`; + +const StyledCardListHeader = styled.p` + font-size: ${FONT_SIZE.md}; + font-weight: 700; +`; + +const StyledRestaurantCardList = styled.div<{ isMobile: boolean }>` + display: grid; + gap: 4rem 2.4rem; + + height: 100%; + + grid-template-columns: 1fr 1fr 1fr; + + @media screen and (width <= 1240px) { + grid-template-columns: 1fr 1fr; + } + + ${({ isMobile }) => + isMobile + ? css` + grid-template-columns: 1fr 1fr; + + @media screen and (width <= 550px) { + grid-template-columns: 1fr; + } + ` + : css` + @media screen and (width <= 743px) { + grid-template-columns: 1fr; + } + `} +`; diff --git a/frontend/src/components/RestaurantCardList/RestaurantCardListSkeleton.tsx b/frontend/src/components/RestaurantCardList/RestaurantCardListSkeleton.tsx new file mode 100644 index 000000000..c50051995 --- /dev/null +++ b/frontend/src/components/RestaurantCardList/RestaurantCardListSkeleton.tsx @@ -0,0 +1,53 @@ +import { styled } from 'styled-components'; +import RestaurantCardSkeleton from '../RestaurantCard/RestaurantCardSkeleton'; +import { BORDER_RADIUS, paintSkeleton } from '~/styles/common'; +import useMediaQuery from '~/hooks/useMediaQuery'; + +interface RestaurantCardListSkeletonProps { + cardNumber: number; +} + +function RestaurantCardListSkeleton({ cardNumber }: RestaurantCardListSkeletonProps) { + const { isMobile } = useMediaQuery(); + + return ( +
+ {!isMobile && } + + {Array.from({ length: cardNumber }, () => ( + + ))} + +
+ ); +} + +export default RestaurantCardListSkeleton; + +const StyledCardListHeader = styled.p` + ${paintSkeleton} + width: 35%; + height: 16px; + + margin: 3.2rem 2.4rem; + + border-radius: ${BORDER_RADIUS.xs}; +`; + +const StyledRestaurantCardList = styled.div` + display: grid; + gap: 4rem 2.4rem; + + height: 100%; + + margin: 0 2.4rem; + grid-template-columns: 1fr 1fr 1fr; + + @media screen and (width <= 1240px) { + grid-template-columns: 1fr 1fr; + } + + @media screen and (width <= 743px) { + grid-template-columns: 1fr; + } +`; diff --git a/frontend/src/components/RestaurantCardList/index.tsx b/frontend/src/components/RestaurantCardList/index.tsx new file mode 100644 index 000000000..0f31b4b56 --- /dev/null +++ b/frontend/src/components/RestaurantCardList/index.tsx @@ -0,0 +1,3 @@ +import RestaurantCardList from './RestaurantCardList'; + +export default RestaurantCardList; diff --git a/frontend/src/components/VideoPreview/VideoPreview.stories.tsx b/frontend/src/components/VideoPreview/VideoPreview.stories.tsx new file mode 100644 index 000000000..ccf9af876 --- /dev/null +++ b/frontend/src/components/VideoPreview/VideoPreview.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import VideoPreview from './VideoPreview'; + +const meta: Meta = { + title: 'VideoPreview', + component: VideoPreview, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + title: 'ENG) 7500원에 λˆκ°€μŠ€ λ¬΄ν•œλ¦¬ν•„??? λŒ€ν•™κ΅ 근처 λ¬΄ν•œλ¦¬ν•„ 돈까슀 집 돈까슀 λ¨Ήλ°©', + celebName: '히λ°₯ @hebeap', + viewCount: 6_3000, + videoUrl: 'https://www.youtube.com/embed/D3lU6KokgVs?autoplay=1', + thumbnailUrl: 'https://img.youtube.com/vi/D3lU6KokgVs/mqdefault.jpg', + uploadDate: '2023-07-02T12:00:23Z', + profileImageUrl: + 'https://yt3.ggpht.com/sL5ugPfl9vvwRwhf6l5APY__BZBw8qWiwgHs-uVsMPFoD5-a4opTJIcRSyrY8aY5LEESOMWJ=s88-c-k-c0x00ffffff-no-rj', + }, +}; + +export const TextOverFlow: Story = { + args: { + title: + 'ENG) 7500원에 λˆκ°€μŠ€ λ¬΄ν•œλ¦¬ν•„??? λŒ€ν•™κ΅ 근처 λ¬΄ν•œλ¦¬ν•„ 돈까슀 집 돈까슀 λ¨Ήλ°© ENG) 7500원에 λˆκ°€μŠ€ λ¬΄ν•œλ¦¬ν•„??? λŒ€ν•™κ΅ 근처 λ¬΄ν•œλ¦¬ν•„ 돈까슀 집 돈까슀 λ¨Ήλ°© ENG) 7500원에 λˆκ°€μŠ€ λ¬΄ν•œλ¦¬ν•„??? λŒ€ν•™κ΅ 근처 λ¬΄ν•œλ¦¬ν•„ 돈까슀 집 돈까슀 λ¨Ήλ°©', + celebName: + '히λ°₯ @hebeap 히λ°₯ @hebeap 히λ°₯ @hebeap 히λ°₯ @hebeap 히λ°₯ @hebeap 히λ°₯ @hebeap 히λ°₯ @hebeap 히λ°₯ @hebeap 히λ°₯ @hebeap', + viewCount: 6_3000, + videoUrl: 'https://www.youtube.com/embed/D3lU6KokgVs?autoplay=1', + thumbnailUrl: 'https://img.youtube.com/vi/D3lU6KokgVs/mqdefault.jpg', + uploadDate: '2023-07-02T12:00:23Z', + profileImageUrl: + 'https://yt3.ggpht.com/sL5ugPfl9vvwRwhf6l5APY__BZBw8qWiwgHs-uVsMPFoD5-a4opTJIcRSyrY8aY5LEESOMWJ=s88-c-k-c0x00ffffff-no-rj', + }, +}; diff --git a/frontend/src/components/VideoPreview/VideoPreview.tsx b/frontend/src/components/VideoPreview/VideoPreview.tsx new file mode 100644 index 000000000..4b3c261ad --- /dev/null +++ b/frontend/src/components/VideoPreview/VideoPreview.tsx @@ -0,0 +1,123 @@ +import { styled } from 'styled-components'; +import ProfileImage from '../@common/ProfileImage'; +import { BORDER_RADIUS, FONT_SIZE, truncateText } from '~/styles/common'; +import formatDateToKorean from '~/utils/formatDateToKorean'; +import useBooleanState from '~/hooks/useBooleanState'; + +interface VideoPreviewProps { + title: string; + celebName: string; + videoUrl: string; + thumbnailUrl: string; + profileImageUrl: string; + uploadDate: string; + viewCount: number; +} + +function VideoPreview({ + title, + celebName, + videoUrl, + thumbnailUrl, + viewCount, + uploadDate, + profileImageUrl, +}: VideoPreviewProps) { + const { value: isClicked, setTrue: setClickTrue } = useBooleanState(false); + + return ( + + {isClicked ? ( +