diff --git a/.babelrc b/.babelrc index 1ff94f7..14ef043 100644 --- a/.babelrc +++ b/.babelrc @@ -1,3 +1,13 @@ { - "presets": ["next/babel"] + "presets": ["next/babel"], + "plugins": [ + [ + "styled-components", + { + "ssr": true, + "displayName": true, + "preprocess": false + } + ] + ] } diff --git a/.eslintrc.js b/.eslintrc.js index 1f0c9a8..a971e48 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,3 @@ -"use strict"; - module.exports = { extends: ["react-app", "prettier"], }; diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..6d8ebae --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @HTMLhead @jun094 @Sh031224 \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..42e7ec5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,5 @@ +## Steps ๐Ÿ” + + +## Description ๐Ÿ“ + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..7e3e8f4 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +## What is this PR? ๐Ÿ” + +// #issue_name ์— ์˜ํ•ด ~~~ ๊ธฐ๋Šฅ ์™„๋ฃŒ + +## Major Fix ๐Ÿ”Œ + + +## Minor Fix ๐Ÿ›  diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml new file mode 100644 index 0000000..e542e6b --- /dev/null +++ b/.github/workflows/deploy-dev.yml @@ -0,0 +1,29 @@ +name: deploy-dev +on: + push: + branches: [develop] + workflow_dispatch: +env: + prod_CLOUDFRONT: ${{ secrets.PROD_CLOUDFRONT }} + dev_CLOUDFRONT: ${{ secrets.DEV_CLOUDFRONT }} + NEXT_PUBLIC_SERVER_URL: ${{ secrets.SERVER_URL }} +jobs: + deploy-dev: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: "14.x" + - name: Install yarn + run: npm install -g yarn + - name: Install Packages + run: yarn + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET }} + aws-region: ap-northeast-2 + - name: Deploy Next.js app + run: yarn deploy:dev diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml new file mode 100644 index 0000000..852bbd5 --- /dev/null +++ b/.github/workflows/deploy-prod.yml @@ -0,0 +1,29 @@ +name: deploy-prod +on: + push: + branches: [master] + workflow_dispatch: +env: + prod_CLOUDFRONT: ${{ secrets.PROD_CLOUDFRONT }} + dev_CLOUDFRONT: ${{ secrets.DEV_CLOUDFRONT }} + NEXT_PUBLIC_SERVER_URL: ${{ secrets.SERVER_URL }} +jobs: + deploy-prod: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: "14.x" + - name: Install yarn + run: npm install -g yarn + - name: Install Packages + run: yarn + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET }} + aws-region: ap-northeast-2 + - name: Deploy Next.js app + run: yarn deploy:prod diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 0000000..8d19b23 --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,33 @@ +name: pull-request +on: pull_request_target +jobs: + eslint-prettier: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: "14.x" + - name: Install yarn + run: npm install -g yarn + - name: Install Packages + run: yarn + - name: Run linters + uses: wearerequired/lint-action@v1 + with: + eslint: true + prettier: true + eslint_args: '--max-warnings 0 "**/*.{ts,tsx}"' + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: "14.x" + - name: Install yarn + run: npm install -g yarn + - name: Install Packages + run: yarn + - name: Testing Code + run: yarn test diff --git a/.gitignore b/.gitignore index 74ba723..60c880a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,11 @@ +# package directories +jspm_packages + +# Serverless directories +.serverless +.serverless_nextjs +next.config.original*.js + # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies @@ -26,6 +34,7 @@ yarn-debug.log* yarn-error.log* # local env files +.env .env.local .env.development.local .env.test.local diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..2e1fa2d --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +*.md \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index 2730aeb..fa6153c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,5 @@ module.exports = { setupFilesAfterEnv: ["./jest.setup.js"], testEnvironment: "jsdom", + moduleDirectories: ["src", "node_modules"], }; diff --git a/next-i18next.config.js b/next-i18next.config.js new file mode 100644 index 0000000..210fbbe --- /dev/null +++ b/next-i18next.config.js @@ -0,0 +1,6 @@ +module.exports = { + i18n: { + locales: ["en", "ko"], + defaultLocale: "ko", + }, +}; diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..58f2963 --- /dev/null +++ b/next.config.js @@ -0,0 +1,9 @@ +const { i18n } = require("./next-i18next.config"); + +module.exports = { + target: "serverless", + images: { + domains: ["cdn.akamai.steamstatic.com"], + }, + i18n, +}; diff --git a/package.json b/package.json index 585885c..f770c9d 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,18 @@ "build": "next build", "start": "next start", "test": "jest", - "type-check": "tsc" + "type-check": "tsc", + "clear": "rm -rf .serverless && rm -rf .serverless_nextjs", + "deploy:dev": "yarn run clear && STAGE=dev serverless --verbose", + "deploy:prod": "yarn run clear && STAGE=prod serverless --verbose", + "lint": "eslint --max-warnings 0 \"**/*.{ts,tsx}\"" }, "dependencies": { "axios": "^0.21.1", + "dayjs": "^1.10.7", + "debounce": "^1.2.1", "next": "11.0.1", + "next-i18next": "^8.7.0", "react": "17.0.2", "react-dom": "17.0.2", "recoil": "^0.3.1", @@ -21,6 +28,7 @@ "@testing-library/dom": "^8.1.0", "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^12.0.0", + "@types/debounce": "^1.2.0", "@types/jest": "^26.0.24", "@types/react": "17.0.14", "@types/react-dom": "^17.0.9", @@ -41,8 +49,10 @@ "eslint-plugin-react-hooks": "^4.0.8", "jest": "^27.0.6", "jest-dom": "^4.0.0", + "jest-styled-components": "^7.0.5", "prettier": "^2.3.2", "react-is": "^17.0.2", + "serverless": "^2.55.0", "typescript": "4.3.5" } } diff --git a/public/browserconfig.xml b/public/browserconfig.xml new file mode 100644 index 0000000..fa39c7f --- /dev/null +++ b/public/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #ffffff + + + diff --git a/public/favicon.ico b/public/favicon.ico index 4965832..cafd6a9 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/icons/android-chrome-192x192.png b/public/icons/android-chrome-192x192.png new file mode 100644 index 0000000..67d36ca Binary files /dev/null and b/public/icons/android-chrome-192x192.png differ diff --git a/public/icons/android-chrome-512x512.png b/public/icons/android-chrome-512x512.png new file mode 100644 index 0000000..34e38f7 Binary files /dev/null and b/public/icons/android-chrome-512x512.png differ diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..6f26405 Binary files /dev/null and b/public/icons/apple-touch-icon.png differ diff --git a/public/icons/favicon-16x16.png b/public/icons/favicon-16x16.png new file mode 100644 index 0000000..634303f Binary files /dev/null and b/public/icons/favicon-16x16.png differ diff --git a/public/icons/favicon-32x32.png b/public/icons/favicon-32x32.png new file mode 100644 index 0000000..7bd3c2a Binary files /dev/null and b/public/icons/favicon-32x32.png differ diff --git a/public/icons/mstile-150x150.png b/public/icons/mstile-150x150.png new file mode 100644 index 0000000..f491dde Binary files /dev/null and b/public/icons/mstile-150x150.png differ diff --git a/public/icons/safari-pinned-tab.svg b/public/icons/safari-pinned-tab.svg new file mode 100644 index 0000000..22a0d4e --- /dev/null +++ b/public/icons/safari-pinned-tab.svg @@ -0,0 +1,21 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + diff --git a/public/locales/en/about.json b/public/locales/en/about.json new file mode 100644 index 0000000..bc48f26 --- /dev/null +++ b/public/locales/en/about.json @@ -0,0 +1,14 @@ +{ + "about_info_no_name": "No name", + "about_info_game_genre": "Game genre", + "about_info_game_date": "Release date", + "about_info_game_introduction": "Game introduction", + "about_info_button_goGame": "Go game", + "about_info_button_share": "Share", + "about_preview_title": "Preview", + "about_video_title": "Video", + "about_video_not_exist": "Video not exist yet.", + "about_similar_title": "Similar Tags Indie Chip", + "about_review_title": "Review", + "about_review_not_exist": "Reviews not exist yet." +} diff --git a/public/locales/en/common.json b/public/locales/en/common.json new file mode 100644 index 0000000..7a990cc --- /dev/null +++ b/public/locales/en/common.json @@ -0,0 +1,8 @@ +{ + "common_meta_title": "Jampick, Contact with various indie games", + "common_meta_description": "For gamers who do not know which indie game to play, we provide contact points with more indie games so that they can enjoy their game life more.", + "common_page_meta_title": "{{title}} | Jampick", + "common_footer_meta": "MetaData Source", + "common_footer_term": "Terms of Use", + "common_footer_privacy": "Privacy Policy" +} diff --git a/public/locales/en/error.json b/public/locales/en/error.json new file mode 100644 index 0000000..0f340f5 --- /dev/null +++ b/public/locales/en/error.json @@ -0,0 +1,5 @@ +{ + "error_desc_404": "You entered an address that does not exist,\n or The address of the requested page has been changed or deleted and cannot be found.", + "error_desc_500": "The page could not be displayed due to a temporary server error.\nPlease try again in a few minutes.", + "error_button": "Home" +} diff --git a/public/locales/en/main.json b/public/locales/en/main.json new file mode 100644 index 0000000..a9aad57 --- /dev/null +++ b/public/locales/en/main.json @@ -0,0 +1,28 @@ +{ + "main_roulette_title": "Choose indie chip keywords!", + "main_roulette_subtitle": "Click on keywords to get a random recommended game!", + "main_roulette_bubble_title": "Random Game Recommender Guide", + "main_roulette_bubble_content": "Jampick's indie chip means an indie game.\n\nSelect up to 6 keywords at the bottom\n and get a random game recommendation.", + "main_roulette_game_info": "View game details", + "main_roulette_keyword_indie": "Indie", + "main_roulette_keyword_action": "Action", + "main_roulette_keyword_casual": "Casual", + "main_roulette_keyword_adventure": "Adventure", + "main_roulette_keyword_strategy": "Strategy", + "main_roulette_keyword_simulation": "Simulation", + "main_roulette_keyword_rpg": "RPG", + "main_roulette_keyword_early_access": "Early Access", + "main_roulette_keyword_free_to_play": "Free to Play", + "main_roulette_keyword_sports": "Sports", + "main_roulette_keyword_racing": "Racing", + "main_roulette_keyword_massively_multiplayer": "Massively Multiplayer", + "main_roulette_keyword_gore": "Gore", + "main_roulette_keyword_audio_production": "Audio Production", + "main_roulette_keyword_video_production": "Video Production", + "main_roulette_keyword_animation_n_modeling": "Animation & Modeling", + "main_carousel_title": "How about this indiechip?", + "main_carousel_card_tag_free": "Free", + "main_indi_pick_title": "Indie Chip Pick", + "main_view_ranking_title": "Real Time VIEW Ranking", + "main_carousel_this_month_title": "Indie chips Released of the Month" +} diff --git a/public/locales/ko/about.json b/public/locales/ko/about.json new file mode 100644 index 0000000..25afb66 --- /dev/null +++ b/public/locales/ko/about.json @@ -0,0 +1,14 @@ +{ + "about_info_no_name": "์ด๋ฆ„ ์—†์Œ", + "about_info_game_genre": "๊ฒŒ์ž„ ์žฅ๋ฅด", + "about_info_game_date": "์ถœ์‹œ ๋‚ ์งœ", + "about_info_game_introduction": "๊ฒŒ์ž„ ์†Œ๊ฐœ", + "about_info_button_goGame": "๊ฒŒ์ž„ ํ•˜๋Ÿฌ๊ฐ€๊ธฐ", + "about_info_button_share": "๊ณต์œ ํ•˜๊ธฐ", + "about_preview_title": "ํ”„๋ฆฌ๋ทฐ", + "about_video_title": "์˜์ƒ", + "about_video_not_exist": "์˜์ƒ์ด ์•„์ง ์—†์Šต๋‹ˆ๋‹ค.", + "about_similar_title": "๋น„์Šทํ•œ ํƒœ๊ทธ ์ธ๋””์นฉ", + "about_review_title": "๋ฆฌ๋ทฐ", + "about_review_not_exist": "๋ฆฌ๋ทฐ๊ฐ€ ์•„์ง ์—†์Šต๋‹ˆ๋‹ค." +} diff --git a/public/locales/ko/common.json b/public/locales/ko/common.json new file mode 100644 index 0000000..4236e79 --- /dev/null +++ b/public/locales/ko/common.json @@ -0,0 +1,8 @@ +{ + "common_meta_title": "๋‹ค์–‘ํ•œ ์ธ๋””๊ฒŒ์ž„๊ณผ์˜ ์ ‘์ , Jampick", + "common_meta_description": "์–ด๋–ค ์ธ๋””๊ฒŒ์ž„์„ ํ• ์ง€ ๋ชจ๋ฅด๊ฒ ๋Š” ๊ฒŒ์ด๋จธ๋“ค์„ ์œ„ํ•ด ๊ทธ๋“ค์˜ ๊ฒŒ์ž„ ๋ผ์ดํ”„๋ฅผ ๋ณด๋‹ค ์ฆ๊ฒ๊ฒŒ ์˜์œ„ํ•  ์ˆ˜ ์žˆ๋„๋ก ๋” ๋‹ค์–‘ํ•œ ์ธ๋””๊ฒŒ์ž„๊ณผ์˜ ์ ‘์ ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.", + "common_page_meta_title": "{{title}} | Jampick", + "common_footer_meta": "๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์†Œ์Šค", + "common_footer_term": "์ด์šฉ ์•ฝ๊ด€", + "common_footer_privacy": "๊ฐœ์ธ์ •๋ณด ์ฒ˜๋ฆฌ๋ฐฉ์นจ" +} diff --git a/public/locales/ko/error.json b/public/locales/ko/error.json new file mode 100644 index 0000000..5641101 --- /dev/null +++ b/public/locales/ko/error.json @@ -0,0 +1,5 @@ +{ + "error_desc_404": "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ฃผ์†Œ๋ฅผ ์ž…๋ ฅํ•˜์…จ๊ฑฐ๋‚˜,\n์š”์ฒญํ•˜์‹  ํŽ˜์ด์ง€์˜ ์ฃผ์†Œ๊ฐ€ ๋ณ€๊ฒฝ, ์‚ญ์ œ๋˜์–ด ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", + "error_desc_500": "์ผ์‹œ์  ์„œ๋ฒ„์˜ค๋ฅ˜๋กœ ํŽ˜์ด์ง€๋ฅผ ํ‘œ์‹œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.\n์ž ์‹œํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.", + "error_button": "ํ™ˆ์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ" +} diff --git a/public/locales/ko/main.json b/public/locales/ko/main.json new file mode 100644 index 0000000..dfd6984 --- /dev/null +++ b/public/locales/ko/main.json @@ -0,0 +1,28 @@ +{ + "main_roulette_title": "์ธ๋””์นฉ ํ‚ค์›Œ๋“œ๋ฅผ ๊ณจ๋ผ๋ณด์„ธ์š”!", + "main_roulette_subtitle": "ํ‚ค์›Œ๋“œ๋ฅผ ํด๋ฆญํ•ด์„œ ๋žœ๋ค์œผ๋กœ ์ถ”์ฒœ ๊ฒŒ์ž„์„ ๋ฐ›์•„๋ณด์„ธ์š”!", + "main_roulette_bubble_title": "๋žœ๋ค ๊ฒŒ์ž„ ์ถ”์ฒœ๊ธฐ ์•ˆ๋‚ด", + "main_roulette_bubble_content": "์žผํ”ฝ์˜ ์ธ๋””์นฉ์€ ์ธ๋””๊ฒŒ์ž„์„ ์˜๋ฏธํ•ฉ๋‹ˆ๋‹ค.\n\nํ•˜๋‹จ์˜ ํ‚ค์›Œ๋“œ๋ฅผ ์ตœ๋Œ€ 6๊ฐœ๊นŒ์ง€ ์„ ํƒํ•˜์—ฌ ๋žœ๋ค\n์œผ๋กœ ๊ฒŒ์ž„์„ ์ถ”์ฒœ ๋ฐ›์•„๋ณด์„ธ์š”.", + "main_roulette_game_info": "๊ฒŒ์ž„ ์ƒ์„ธ๋ณด๊ธฐ", + "main_roulette_keyword_indie": "์ธ๋””", + "main_roulette_keyword_action": "์•ก์…˜", + "main_roulette_keyword_casual": "์บ์ฃผ์–ผ", + "main_roulette_keyword_adventure": "์–ด๋“œ๋ฒค์ฒ˜", + "main_roulette_keyword_strategy": "์ „๋žต", + "main_roulette_keyword_simulation": "์‹œ๋ฎฌ๋ ˆ์ด์…˜", + "main_roulette_keyword_rpg": "RPG", + "main_roulette_keyword_early_access": "์•ž์„œ ํ•ด๋ณด๊ธฐ", + "main_roulette_keyword_free_to_play": "๋ฌด๋ฃŒ", + "main_roulette_keyword_sports": "์Šคํฌ์ธ ", + "main_roulette_keyword_racing": "๋ ˆ์ด์‹ฑ", + "main_roulette_keyword_massively_multiplayer": "๋Œ€๊ทœ๋ชจ ๋ฉ€ํ‹ฐํ”Œ๋ ˆ์ด์–ด", + "main_roulette_keyword_gore": "๊ณ ์–ด", + "main_roulette_keyword_audio_production": "์˜ค๋””์˜ค ์ œ์ž‘", + "main_roulette_keyword_video_production": "๋™์˜์ƒ ์ œ์ž‘", + "main_roulette_keyword_animation_n_modeling": "์• ๋‹ˆ๋ฉ”์ด์…˜๊ณผ ๋ชจ๋ธ๋ง", + "main_carousel_title": "์ด๋Ÿฐ ์ธ๋””์นฉ ์–ด๋•Œ์š”?", + "main_carousel_card_tag_free": "๋ฌด๋ฃŒ", + "main_indi_pick_title": "์ธ๋””์นฉ Pick", + "main_view_ranking_title": "์‹ค์‹œ๊ฐ„ VIEW ๋žญํ‚น", + "main_carousel_this_month_title": "์ด๋‹ฌ์˜ ์ถœ์‹œ ์ธ๋””์นฉ" +} diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..77e785b --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "Jampick", + "short_name": "Jampick", + "icons": [ + { + "src": "/icons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/public/og.png b/public/og.png new file mode 100644 index 0000000..ccc5e05 Binary files /dev/null and b/public/og.png differ diff --git a/public/roulette/background.png b/public/roulette/background.png new file mode 100644 index 0000000..b6f98a0 Binary files /dev/null and b/public/roulette/background.png differ diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index fbf0e25..0000000 --- a/public/vercel.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - \ No newline at end of file diff --git a/serverless.yml b/serverless.yml new file mode 100644 index 0000000..604305f --- /dev/null +++ b/serverless.yml @@ -0,0 +1,14 @@ +stage: ${env.STAGE} +jampick-web: + component: "@sls-next/serverless-component@3.3.0" + inputs: + useServerlessTraceTarget: true + policy: arn:aws:iam::159698424543:policy/jampickweb-policy + bucketName: jampickweb-${stage} + buckerRegion: ap-northeast-2 + cloudfront: + distributionId: ${env.${stage}_CLOUDFRONT} + name: + defaultLambda: jampickweb-default-lambda-${stage} + apiLambda: jampickweb-api-lambda-${stage} + imageLambda: jampickweb-image-lambda-${stage} diff --git a/src/__test/index.test.tsx b/src/__test/index.test.tsx new file mode 100644 index 0000000..0550b9c --- /dev/null +++ b/src/__test/index.test.tsx @@ -0,0 +1,12 @@ +// import React from "react"; +// testing +// import IndexPage from "../pages/index"; +// import { render } from "@testing-library/react"; + +describe("", () => { + it("Hello๋ฌธ๊ตฌ๊ฐ€ ๋ณด์ธ๋‹ค.", async () => { + // const { findByText } = render(); + // findByText("Hello"); + // Sample + }); +}); diff --git a/src/__test/utils/FakeProvider.tsx b/src/__test/utils/FakeProvider.tsx new file mode 100644 index 0000000..cde446a --- /dev/null +++ b/src/__test/utils/FakeProvider.tsx @@ -0,0 +1,19 @@ +import { RecoilRoot } from "recoil"; + +import { ThemeProvider } from "styled-components"; + +import GlobalStyle from "style/GlobalStyle"; +import theme from "style/theme"; + +const FakeProvider: React.FC = ({ children }) => { + return ( + + + {children} + + + + ); +}; + +export default FakeProvider; diff --git a/src/assets/.keep b/src/assets/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/src/assets/images/close.svg b/src/assets/images/close.svg new file mode 100644 index 0000000..fcc70ec --- /dev/null +++ b/src/assets/images/close.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/images/like.svg b/src/assets/images/like.svg new file mode 100644 index 0000000..6233eed --- /dev/null +++ b/src/assets/images/like.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/images/logo.svg b/src/assets/images/logo.svg new file mode 100644 index 0000000..357475a --- /dev/null +++ b/src/assets/images/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/notExist.svg b/src/assets/images/notExist.svg new file mode 100644 index 0000000..47ec4d0 --- /dev/null +++ b/src/assets/images/notExist.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/assets/images/review/star.svg b/src/assets/images/review/star.svg new file mode 100644 index 0000000..0392c01 --- /dev/null +++ b/src/assets/images/review/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/review/star_fill.svg b/src/assets/images/review/star_fill.svg new file mode 100644 index 0000000..c7ecbfe --- /dev/null +++ b/src/assets/images/review/star_fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/roulette/btn_replay.svg b/src/assets/images/roulette/btn_replay.svg new file mode 100644 index 0000000..c5b23bb --- /dev/null +++ b/src/assets/images/roulette/btn_replay.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/roulette/btn_reset.svg b/src/assets/images/roulette/btn_reset.svg new file mode 100644 index 0000000..2dc15d7 --- /dev/null +++ b/src/assets/images/roulette/btn_reset.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/roulette/btn_spin.svg b/src/assets/images/roulette/btn_spin.svg new file mode 100644 index 0000000..b0179c9 --- /dev/null +++ b/src/assets/images/roulette/btn_spin.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/roulette/icon_1.svg b/src/assets/images/roulette/icon_1.svg new file mode 100644 index 0000000..e367a53 --- /dev/null +++ b/src/assets/images/roulette/icon_1.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/roulette/icon_2.svg b/src/assets/images/roulette/icon_2.svg new file mode 100644 index 0000000..5fbe4af --- /dev/null +++ b/src/assets/images/roulette/icon_2.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/roulette/icon_3.svg b/src/assets/images/roulette/icon_3.svg new file mode 100644 index 0000000..9158e77 --- /dev/null +++ b/src/assets/images/roulette/icon_3.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/roulette/icon_4.svg b/src/assets/images/roulette/icon_4.svg new file mode 100644 index 0000000..8ada089 --- /dev/null +++ b/src/assets/images/roulette/icon_4.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/roulette/icon_5.svg b/src/assets/images/roulette/icon_5.svg new file mode 100644 index 0000000..facbbea --- /dev/null +++ b/src/assets/images/roulette/icon_5.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/roulette/icon_6.svg b/src/assets/images/roulette/icon_6.svg new file mode 100644 index 0000000..4b414ba --- /dev/null +++ b/src/assets/images/roulette/icon_6.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/roulette/keyword_empty_l.svg b/src/assets/images/roulette/keyword_empty_l.svg new file mode 100644 index 0000000..e5d458a --- /dev/null +++ b/src/assets/images/roulette/keyword_empty_l.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/roulette/keyword_empty_r.svg b/src/assets/images/roulette/keyword_empty_r.svg new file mode 100644 index 0000000..0a98803 --- /dev/null +++ b/src/assets/images/roulette/keyword_empty_r.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/roulette/keyword_l.svg b/src/assets/images/roulette/keyword_l.svg new file mode 100644 index 0000000..6c436ef --- /dev/null +++ b/src/assets/images/roulette/keyword_l.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/roulette/keyword_r.svg b/src/assets/images/roulette/keyword_r.svg new file mode 100644 index 0000000..bc87a36 --- /dev/null +++ b/src/assets/images/roulette/keyword_r.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/roulette/loading.gif b/src/assets/images/roulette/loading.gif new file mode 100644 index 0000000..faadfe5 Binary files /dev/null and b/src/assets/images/roulette/loading.gif differ diff --git a/src/assets/images/roulette/random.svg b/src/assets/images/roulette/random.svg new file mode 100644 index 0000000..a93c23f --- /dev/null +++ b/src/assets/images/roulette/random.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/roulette/tooltip.svg b/src/assets/images/roulette/tooltip.svg new file mode 100644 index 0000000..c558bef --- /dev/null +++ b/src/assets/images/roulette/tooltip.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/atom/roulette.atom.ts b/src/atom/roulette.atom.ts new file mode 100644 index 0000000..ddce521 --- /dev/null +++ b/src/atom/roulette.atom.ts @@ -0,0 +1,22 @@ +import { atom } from "recoil"; + +import keywords from "models/keywords"; +import IKeyword from "types/IKeyword"; +import { IRouletteState } from "types/IRouletteResult"; + +export const keywordsDefaultState = keywords; + +export const rouletteDefaultState: IRouletteState = { + error: null, + data: null, +}; + +export const keywordsState = atom>({ + key: "keywordsState", + default: keywords, +}); + +export const rouletteState = atom({ + key: "rouletteState", + default: rouletteDefaultState, +}); diff --git a/src/components/About/GameInfo/GameInfo.style.ts b/src/components/About/GameInfo/GameInfo.style.ts new file mode 100644 index 0000000..a7ce830 --- /dev/null +++ b/src/components/About/GameInfo/GameInfo.style.ts @@ -0,0 +1,82 @@ +import styled from "styled-components"; + +export const GameInfoWrapper = styled.section` + display: flex; + justify-content: center; + width: 100%; + padding: 20rem 0 5rem 0; + background: ${({ theme }) => theme.palette.backgroundColors.dark}; +`; + +export const GameInfoContainer = styled.div` + display: flex; + flex-direction: column; + min-width: 102.4rem; +`; + +export const GameInfoHeader = styled.div` + display: flex; + justify-content: space-between; + margin-bottom: 3.8rem; +`; + +export const GameInfoContent = styled.div` + display: flex; + flex-direction: column; + max-width: 45rem; +`; + +export const GameInfoTitle = styled.h3` + font-size: 3.2rem; + font-weight: 700; + line-height: 4rem; + margin-bottom: 5.5rem; + color: ${({ theme }) => theme.palette.grayScale[100]}; +`; +export const GameInfoHashTags = styled.div` + display: flex; + flex-wrap: wrap; +`; + +export const GameInfoButtons = styled.div` + display: flex; + margin-top: 2.5rem; +`; + +export const GameInfoButton = styled.button` + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + line-height: 2.7rem; + padding: 0.4rem 2rem; + background-color: transparent; + border-radius: 0.8rem; + font-weight: 400; + font-size: 1.6rem; + color: ${({ theme }) => theme.palette.primary.main}; + border: 0.1rem solid ${({ theme }) => theme.palette.primary.main}; + + & + & { + margin-left: 1rem; + } +`; + +export const GameInfoImg = styled.div<{ url: string }>` + width: 46rem; + height: 21.5rem; + border-radius: 1rem; + background-image: url(${({ url }) => url}); +`; + +export const GameInfoBox = styled.div` + display: flex; + width: 100%; + padding: 4.1rem 9.5rem; + background: ${({ theme }) => theme.palette.backgroundColors.main}; +`; + +export const GameInfoBoxContent = styled.div` + display: flex; + flex-direction: column; +`; diff --git a/src/components/About/GameInfo/GameInfo.tsx b/src/components/About/GameInfo/GameInfo.tsx new file mode 100644 index 0000000..8638952 --- /dev/null +++ b/src/components/About/GameInfo/GameInfo.tsx @@ -0,0 +1,78 @@ +import { useTranslation } from "next-i18next"; + +import { AboutPageProps } from "pages/about/[id]"; + +import GameInfoHashtag from "./GameInfoHashtag"; +import GameInfoMeta from "./GameInfoMeta"; + +import { + GameInfoWrapper, + GameInfoContainer, + GameInfoHeader, + GameInfoContent, + GameInfoTitle, + GameInfoHashTags, + GameInfoButtons, + GameInfoButton, + GameInfoImg, + GameInfoBox, + GameInfoBoxContent, +} from "./GameInfo.style"; + +const GameInfo = ({ item }: AboutPageProps) => { + const { name, genres, release_date, short_description, header_image } = item; + const { t } = useTranslation("about"); + + const handleClickButton = () => { + alert("ํ•ด๋‹น ๊ธฐ๋Šฅ์€ ์ค€๋น„์ค‘์ž…๋‹ˆ๋‹ค."); + }; + return ( + + + + + + {name ? name : t("about_info_no_name")} + + + {genres.map((text: string, idx: number) => ( + + ))} + + + + + {t("about_info_button_goGame")} + + + {t("about_info_button_share")} + + + + + + + + + + + + + + + + + + ); +}; + +export default GameInfo; diff --git a/src/components/About/GameInfo/GameInfoHashtag/GameInfoHashtag.style.ts b/src/components/About/GameInfo/GameInfoHashtag/GameInfoHashtag.style.ts new file mode 100644 index 0000000..32777f4 --- /dev/null +++ b/src/components/About/GameInfo/GameInfoHashtag/GameInfoHashtag.style.ts @@ -0,0 +1,15 @@ +import styled from "styled-components"; + +export const GameInfoHashTagWrapper = styled.span` + display: flex; + align-items: center; + + padding: 0.3rem 1.9rem; + border-radius: 14px; + background: #836eff; + margin: 0 1rem 1rem 0; + + font-size: 1.4rem; + font-weight: 400; + color: ${({ theme }) => theme.palette.grayScale[100]}; +`; diff --git a/src/components/About/GameInfo/GameInfoHashtag/GameInfoHashtag.tsx b/src/components/About/GameInfo/GameInfoHashtag/GameInfoHashtag.tsx new file mode 100644 index 0000000..24da375 --- /dev/null +++ b/src/components/About/GameInfo/GameInfoHashtag/GameInfoHashtag.tsx @@ -0,0 +1,11 @@ +import { GameInfoHashTagWrapper } from "./GameInfoHashtag.style"; + +interface GameInfoHashtagProps { + text: string; +} + +const GameInfoHashtag = ({ text }: GameInfoHashtagProps) => { + return {text}; +}; + +export default GameInfoHashtag; diff --git a/src/components/About/GameInfo/GameInfoHashtag/index.ts b/src/components/About/GameInfo/GameInfoHashtag/index.ts new file mode 100644 index 0000000..50a0c95 --- /dev/null +++ b/src/components/About/GameInfo/GameInfoHashtag/index.ts @@ -0,0 +1 @@ +export { default } from "./GameInfoHashtag"; diff --git a/src/components/About/GameInfo/GameInfoMeta/GameInfoMeta.style.ts b/src/components/About/GameInfo/GameInfoMeta/GameInfoMeta.style.ts new file mode 100644 index 0000000..fca9313 --- /dev/null +++ b/src/components/About/GameInfo/GameInfoMeta/GameInfoMeta.style.ts @@ -0,0 +1,25 @@ +import styled from "styled-components"; + +export const GameInfoMetaWrapper = styled.div` + display: flex; + align-items: flex-start; + + & + & { + margin-top: 2.5rem; + } +`; + +export const GameInfoMetaTitle = styled.span` + font-size: 1.8rem; + font-weight: 400; + margin-right: 1.8rem; + color: ${({ theme }) => theme.palette.primary["light"]}; +`; +export const GameInfoMetaDesc = styled.span` + font-size: 1.4rem; + font-weight: 400; + line-height: 1.9rem; + max-width: 30rem; + min-width: 30rem; + color: ${({ theme }) => theme.palette.grayScale[200]}; +`; diff --git a/src/components/About/GameInfo/GameInfoMeta/GameInfoMeta.tsx b/src/components/About/GameInfo/GameInfoMeta/GameInfoMeta.tsx new file mode 100644 index 0000000..9eb70a8 --- /dev/null +++ b/src/components/About/GameInfo/GameInfoMeta/GameInfoMeta.tsx @@ -0,0 +1,21 @@ +import { + GameInfoMetaWrapper, + GameInfoMetaTitle, + GameInfoMetaDesc, +} from "./GameInfoMeta.style"; + +interface GameInfoMetaProps { + title: string; + desc: string; +} + +const GameInfoMeta = ({ title, desc }: GameInfoMetaProps) => { + return ( + + {title} + {desc} + + ); +}; + +export default GameInfoMeta; diff --git a/src/components/About/GameInfo/GameInfoMeta/index.ts b/src/components/About/GameInfo/GameInfoMeta/index.ts new file mode 100644 index 0000000..6991846 --- /dev/null +++ b/src/components/About/GameInfo/GameInfoMeta/index.ts @@ -0,0 +1 @@ +export { default } from "./GameInfoMeta"; diff --git a/src/components/About/GameInfo/index.ts b/src/components/About/GameInfo/index.ts new file mode 100644 index 0000000..8b7160a --- /dev/null +++ b/src/components/About/GameInfo/index.ts @@ -0,0 +1 @@ +export { default as GameInfo } from "./GameInfo"; diff --git a/src/components/About/PreviewCarousel/PreviewCarousel.style.ts b/src/components/About/PreviewCarousel/PreviewCarousel.style.ts new file mode 100644 index 0000000..8dba0b2 --- /dev/null +++ b/src/components/About/PreviewCarousel/PreviewCarousel.style.ts @@ -0,0 +1,50 @@ +import styled from "styled-components"; +import { + CarouselImageContainerProps, + PickImgProps, +} from "./PreviewCarousel.type"; + +const CarouselWrapper = styled.section` + display: flex; + justify-content: center; + width: 100%; + background: ${({ theme }) => theme.palette.backgroundColors.dark}; +`; + +const WholeContainer = styled.div` + width: 102.4rem; + display: flex; + justify-content: center; + flex-direction: column; +`; + +const CarouselContainer = styled.div` + position: relative; + display: flex; + justify-content: center; + padding: 4rem 0; +`; + +const CarouselImageContainer = styled.div` + display: flex; + max-width: ${(props) => `${props.width}px`}; + overflow-x: hidden; +`; + +const PickImg = styled.div` + margin: 0 0.9rem; + height: 23rem; + min-width: 41.6rem; + background-position: center center; + background-repeat: no-repeat; + background-size: cover; + filter: ${({ selected }) => (selected ? "unset;" : `brightness(20%);`)}; +`; + +export { + WholeContainer, + CarouselContainer, + CarouselImageContainer, + PickImg, + CarouselWrapper, +}; diff --git a/src/components/About/PreviewCarousel/PreviewCarousel.tsx b/src/components/About/PreviewCarousel/PreviewCarousel.tsx new file mode 100644 index 0000000..8864837 --- /dev/null +++ b/src/components/About/PreviewCarousel/PreviewCarousel.tsx @@ -0,0 +1,119 @@ +import React, { useEffect, useRef, useState } from "react"; +import { useTranslation } from "next-i18next"; + +import { + PickImg, + WholeContainer, + CarouselWrapper, + CarouselContainer, + CarouselImageContainer, +} from "./PreviewCarousel.style"; + +import { Modal } from "components/Modal"; +import { ArrowBtn } from "components/ArrowBtn"; +import { IndexTitle } from "components/IndexTitle"; +import { CarouselModal } from "components/CarouselModal"; + +const PrewviewCarousel: React.FC<{ thumbnailList: Array }> = ({ + thumbnailList, +}) => { + const { t } = useTranslation("about"); + + const [open, setOpen] = useState(false); + const [thumbnailListState, setThumbnailListState] = useState>( + [] + ); + const [selectedIndex, setSelectedIndex] = useState(1); + const carouselItemsRef = useRef([]); + const parentRef = useRef(null); + + useEffect(() => { + setThumbnailListState([ + thumbnailList[thumbnailList.length - 1], + ...thumbnailList, + thumbnailList[0], + ]); + }, [thumbnailList]); + + useEffect(() => { + if (parentRef?.current && carouselItemsRef?.current[1]) { + parentRef?.current?.scrollTo({ + left: carouselItemsRef?.current[1].clientWidth / 2, + }); + } + }, [thumbnailListState]); + + const handleSelectedImageChange = (newIdx: number) => { + if (thumbnailList && thumbnailList.length > 0) { + setSelectedIndex(newIdx); + if (carouselItemsRef?.current[newIdx]) { + carouselItemsRef?.current[newIdx]?.scrollIntoView({ + inline: "center", + block: "nearest", + }); + } + } + }; + + const handleRightClick = () => { + if (thumbnailList && thumbnailList.length > 0) { + let newIdx = selectedIndex + 1; + if (newIdx >= thumbnailList.length + 1) { + newIdx = 1; + } + handleSelectedImageChange(newIdx); + } + }; + + const handleLeftClick = () => { + if (thumbnailList && thumbnailList.length > 0) { + let newIdx = selectedIndex - 1; + if (newIdx < 1) { + newIdx = thumbnailList.length; + } + handleSelectedImageChange(newIdx); + } + }; + + return ( + + + + + + {thumbnailListState.map((thumbnail: string, idx: number) => ( + setOpen(true)} + selected={idx === selectedIndex} + key={`${thumbnail}-${idx}`} + ref={(el: HTMLDivElement) => + (carouselItemsRef.current[idx] = el) + } + style={{ backgroundImage: `url(${thumbnail})` }} + /> + ))} +
+ +
+
+ +
+
+
+ setOpen(false)}> + setOpen(false), + }} + /> + +
+
+ ); +}; + +export default PrewviewCarousel; diff --git a/src/components/About/PreviewCarousel/PreviewCarousel.type.ts b/src/components/About/PreviewCarousel/PreviewCarousel.type.ts new file mode 100644 index 0000000..f95c323 --- /dev/null +++ b/src/components/About/PreviewCarousel/PreviewCarousel.type.ts @@ -0,0 +1,7 @@ +export interface PickImgProps { + selected: boolean; +} + +export interface CarouselImageContainerProps { + width: number; +} diff --git a/src/components/About/PreviewCarousel/index.ts b/src/components/About/PreviewCarousel/index.ts new file mode 100644 index 0000000..64e9a73 --- /dev/null +++ b/src/components/About/PreviewCarousel/index.ts @@ -0,0 +1 @@ +export { default as PreviewCarousel } from "./PreviewCarousel"; diff --git a/src/components/About/Review/Review.style.ts b/src/components/About/Review/Review.style.ts new file mode 100644 index 0000000..2c764d6 --- /dev/null +++ b/src/components/About/Review/Review.style.ts @@ -0,0 +1,22 @@ +import styled from "styled-components"; + +export const WholeContainer = styled.div` + width: 102.4rem; + display: flex; + justify-content: center; + flex-direction: column; +`; + +export const ReviewWrapper = styled.section` + display: flex; + justify-content: center; + width: 100%; + background: ${({ theme }) => theme.palette.backgroundColors.dark}; +`; + +export const ReviewContent = styled.div` + display: flex; + flex-direction: column; + align-items: center; + padding: 4rem 0%; +`; diff --git a/src/components/About/Review/Review.tsx b/src/components/About/Review/Review.tsx new file mode 100644 index 0000000..dddf0fc --- /dev/null +++ b/src/components/About/Review/Review.tsx @@ -0,0 +1,67 @@ +import { useTranslation } from "next-i18next"; + +import { IndexTitle } from "components/IndexTitle"; +import { ReviewItem } from "./ReviewItem"; + +import { ReviewContent, ReviewWrapper, WholeContainer } from "./Review.style"; +import { NotExist } from "components/NotExist"; +import IReviewResult from "types/IReviewResult"; + +interface IReviewProps { + id: number; +} + +// const mock = [ +// { +// recommendationid: "", +// author: { steamid: "" }, +// review: "์ง„์งœ ํ•ต์ด ๋„ˆ๋ฌด ๋งŽ์Œ.", +// timestamp_created: 1631123234, +// voted_up: false, +// }, +// { +// recommendationid: "", +// author: { steamid: "" }, +// review: +// "This game is older than half of the steam community users, love it.", +// timestamp_created: 1631273637, +// voted_up: true, +// }, +// { +// recommendationid: "", +// author: { steamid: "" }, +// review: "๊ตฟ", +// timestamp_created: 1631273637, +// voted_up: true, +// }, +// ]; + +const Review: React.FC = () => { + const { t } = useTranslation("about"); + + const data: IReviewResult[] = []; + + return ( + + + + + {data.length ? ( + <> + {data.map((v, i) => ( + + ))} + + ) : ( + + )} + + + + ); +}; + +export default Review; diff --git a/src/components/About/Review/ReviewItem/ReviewItem.style.ts b/src/components/About/Review/ReviewItem/ReviewItem.style.ts new file mode 100644 index 0000000..e9ee5ba --- /dev/null +++ b/src/components/About/Review/ReviewItem/ReviewItem.style.ts @@ -0,0 +1,60 @@ +import styled from "styled-components"; + +export const ReviewItemWrapper = styled.div` + width: 89rem; + height: 15rem; + background-color: ${({ theme }) => theme.palette.backgroundColors.main}; + padding: 4rem 5rem; + border-radius: 1rem; + display: flex; + margin: 2rem 0; +`; + +export const ReviewItemName = styled.span` + display: inline-block; + margin-top: 0.4rem; + font-style: normal; + font-weight: normal; + font-size: 1.6rem; + line-height: 28px; + letter-spacing: -0.03em; + color: ${({ theme }) => theme.palette.grayScale[100]}; +`; + +export const ReviewItemContent = styled.div` + display: flex; + flex-direction: column; + margin-left: 3.9rem; +`; + +export const ReviewItemContentTitle = styled.div` + display: flex; + align-items: center; + font-style: normal; + font-weight: normal; + font-size: 1.4rem; + line-height: 19px; + letter-spacing: -0.03em; + color: ${({ theme }) => theme.palette.grayScale[300]}; + span { + padding-top: 0.5rem; + + &:first-of-type { + margin-left: 1rem; + } + + &:last-of-type { + margin-left: 2.4rem; + } + } +`; + +export const ReviewItemContentText = styled.p` + margin-top: 1.5rem; + font-style: normal; + font-weight: normal; + font-size: 1.4rem; + line-height: 19px; + letter-spacing: -0.03em; + color: ${({ theme }) => theme.palette.grayScale[100]}; +`; diff --git a/src/components/About/Review/ReviewItem/ReviewItem.tsx b/src/components/About/Review/ReviewItem/ReviewItem.tsx new file mode 100644 index 0000000..9aa88b5 --- /dev/null +++ b/src/components/About/Review/ReviewItem/ReviewItem.tsx @@ -0,0 +1,39 @@ +import Image from "next/image"; +import dayjs from "dayjs"; + +import IReviewResult from "types/IReviewResult"; + +import star from "assets/images/review/star.svg"; +import starFill from "assets/images/review/star_fill.svg"; + +import { + ReviewItemName, + ReviewItemWrapper, + ReviewItemContent, + ReviewItemContentTitle, + ReviewItemContentText, +} from "./ReviewItem.style"; + +interface IReviewItemProps { + item: IReviewResult; +} + +const ReviewItem: React.FC = ({ item }) => { + return ( + + anonymous + + + + ์ถ”์ฒœ + + {dayjs(item.timestamp_created * 1000).format("YYYY-MM-DD")} + + + {item.review} + + + ); +}; + +export default ReviewItem; diff --git a/src/components/About/Review/ReviewItem/index.ts b/src/components/About/Review/ReviewItem/index.ts new file mode 100644 index 0000000..2650ec5 --- /dev/null +++ b/src/components/About/Review/ReviewItem/index.ts @@ -0,0 +1 @@ +export { default as ReviewItem } from "./ReviewItem"; diff --git a/src/components/About/Review/index.ts b/src/components/About/Review/index.ts new file mode 100644 index 0000000..5965e05 --- /dev/null +++ b/src/components/About/Review/index.ts @@ -0,0 +1 @@ +export { default as Review } from "./Review"; diff --git a/src/components/About/VideoCarousel/VideoCarousel.style.ts b/src/components/About/VideoCarousel/VideoCarousel.style.ts new file mode 100644 index 0000000..f9a4abb --- /dev/null +++ b/src/components/About/VideoCarousel/VideoCarousel.style.ts @@ -0,0 +1,83 @@ +import styled from "styled-components"; + +const CarouselWrapper = styled.section` + display: flex; + justify-content: center; + width: 100%; + background: ${({ theme }) => theme.palette.backgroundColors.dark}; +`; + +const WholeContainer = styled.div` + width: 102.4rem; + display: flex; + justify-content: center; + flex-direction: column; +`; + +const CarouselContainer = styled.div` + position: relative; + display: flex; + justify-content: center; + padding: 4rem 0; +`; + +const SelectedVideo = styled.video` + width: 71.2rem; + height: 40rem; +`; + +const VideoListWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + width: 15.2rem; + height: 40rem; + margin-left: 2.6rem; +`; + +const VideoWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + overflow-y: hidden; + width: 15.2rem; + height: 28rem; +`; + +const VideoChip = styled.video` + position: absolute; + width: 100%; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); +`; + +const ButtonWrapper = styled.div<{ upper?: boolean }>` + transform: rotate(90deg); + position: relative; + top: ${({ upper }) => (upper ? "-1rem" : "1rem")}; +`; + +const VideoItemWrapper = styled.div<{ selected: boolean }>` + min-height: 7.5rem; + width: 15rem; + transform: skew(0, -5deg); + border-radius: 0.4rem; + position: relative; + margin: 1rem 0; + overflow: hidden; + border: ${({ selected, theme }) => + selected ? `.2rem solid ${theme.palette.primary.main}` : `unset`}; +`; + +export { + WholeContainer, + VideoChip, + CarouselContainer, + VideoListWrapper, + SelectedVideo, + VideoWrapper, + ButtonWrapper, + CarouselWrapper, + VideoItemWrapper, +}; diff --git a/src/components/About/VideoCarousel/VideoCarousel.tsx b/src/components/About/VideoCarousel/VideoCarousel.tsx new file mode 100644 index 0000000..eff041b --- /dev/null +++ b/src/components/About/VideoCarousel/VideoCarousel.tsx @@ -0,0 +1,121 @@ +import React, { useRef, useState } from "react"; +import { useTranslation } from "next-i18next"; + +import { + WholeContainer, + CarouselWrapper, + VideoChip, + VideoWrapper, + CarouselContainer, + VideoListWrapper, + VideoItemWrapper, + SelectedVideo, + ButtonWrapper, +} from "./VideoCarousel.style"; +import { ArrowBtn } from "components/ArrowBtn"; + +import { CarouselModal } from "components/CarouselModal"; +import { IndexTitle } from "components/IndexTitle"; +import { Modal } from "components/Modal"; +import { NotExist } from "components/NotExist"; + +const VideoCarousel: React.FC<{ movies: Array }> = ({ movies }) => { + const { t } = useTranslation("about"); + + const [open, setOpen] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + const carouselItemsRef = useRef([]); + const videoRef = useRef(null); + + const handleSelectedImageChange = (newIdx: number) => { + if (movies && movies.length > 0) { + setSelectedIndex(newIdx); + if (carouselItemsRef?.current[newIdx]) { + carouselItemsRef?.current[newIdx]?.scrollIntoView({ + inline: "center", + behavior: "smooth", + block: "nearest", + }); + } + } + }; + + const handleDownClick = () => { + if (movies && movies.length > 0) { + let newIdx = selectedIndex + 1; + if (newIdx >= movies.length) { + newIdx = 0; + } + handleSelectedImageChange(newIdx); + } + }; + + const handleUpClick = () => { + if (movies && movies.length > 0) { + let newIdx = selectedIndex - 1; + if (newIdx < 0) { + newIdx = movies.length - 1; + } + handleSelectedImageChange(newIdx); + } + }; + + const handleVideoModalClose = () => { + videoRef?.current?.pause(); + setOpen(false); + }; + + return ( + + + + {movies.length ? ( + <> + + setOpen(true)}> + + + + + + + + {movies.map((moviesrc: string, idx) => ( + + (carouselItemsRef.current[idx] = el) + } + selected={selectedIndex === idx}> + + + ))} + + + + + + + + + + + ) : ( + + )} + + + ); +}; + +export default VideoCarousel; diff --git a/src/components/About/VideoCarousel/index.ts b/src/components/About/VideoCarousel/index.ts new file mode 100644 index 0000000..78371b5 --- /dev/null +++ b/src/components/About/VideoCarousel/index.ts @@ -0,0 +1 @@ +export { default as VideoCarousel } from "./VideoCarousel"; diff --git a/src/components/ArrowBtn/ArrowBtn.style.ts b/src/components/ArrowBtn/ArrowBtn.style.ts new file mode 100644 index 0000000..ac78069 --- /dev/null +++ b/src/components/ArrowBtn/ArrowBtn.style.ts @@ -0,0 +1,39 @@ +import styled from "styled-components"; + +interface ButtonContainerProps { + left?: boolean; + white?: boolean; +} + +const ButtonContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 6rem; + width: 4rem; + border-radius: 0.5rem; + border: 0.2rem solid + ${({ theme, white }) => + white ? theme.palette.grayScale[100] : theme.palette.primary.main}; + transform: ${({ left }) => (left ? "rotate(180deg)" : "unset")}; + cursor: pointer; +`; + +interface ArrowProps { + white?: boolean; +} + +const Arrow = styled.div` + height: 1.5rem; + width: 1.5rem; + border-radius: 0.1rem; + border-top: 0.4rem solid + ${({ theme, white }) => + white ? theme.palette.grayScale[100] : theme.palette.primary.main}; + border-right: 0.4rem solid + ${({ theme, white }) => + white ? theme.palette.grayScale[100] : theme.palette.primary.main}; + transform: rotate(45deg) translate(-0.2rem, 0.2rem); +`; + +export { ButtonContainer, Arrow }; diff --git a/src/components/ArrowBtn/ArrowBtn.tsx b/src/components/ArrowBtn/ArrowBtn.tsx new file mode 100644 index 0000000..ea6ac17 --- /dev/null +++ b/src/components/ArrowBtn/ArrowBtn.tsx @@ -0,0 +1,13 @@ +import { IArrowBtnProps } from "./ArrowBtn.type"; +// Style +import { Arrow, ButtonContainer } from "./ArrowBtn.style"; + +const ArrowBtn: React.FC = ({ onClick, left, white }) => { + return ( + + + + ); +}; + +export default ArrowBtn; diff --git a/src/components/ArrowBtn/ArrowBtn.type.ts b/src/components/ArrowBtn/ArrowBtn.type.ts new file mode 100644 index 0000000..29820b8 --- /dev/null +++ b/src/components/ArrowBtn/ArrowBtn.type.ts @@ -0,0 +1,7 @@ +import React from "react"; + +export interface IArrowBtnProps { + onClick: (e: React.MouseEvent) => void; + left?: boolean; + white?: boolean; +} diff --git a/src/components/ArrowBtn/index.ts b/src/components/ArrowBtn/index.ts new file mode 100644 index 0000000..3224b37 --- /dev/null +++ b/src/components/ArrowBtn/index.ts @@ -0,0 +1 @@ +export { default as ArrowBtn } from "./ArrowBtn"; diff --git a/src/components/CarouselModal/CarouselModal.style.ts b/src/components/CarouselModal/CarouselModal.style.ts new file mode 100644 index 0000000..f8c9a94 --- /dev/null +++ b/src/components/CarouselModal/CarouselModal.style.ts @@ -0,0 +1,88 @@ +import styled from "styled-components"; + +const PickedImg = styled.div` + height: 33.7rem; + width: 60rem; + background-position: center center; + background-repeat: no-repeat; + background-size: cover; +`; + +const PickedVideo = styled.video` + height: 48rem; + width: 85.4rem; + background-color: ${({ theme }) => theme.palette.grayScale[100]}; +`; + +const ButtonWrapper = styled.div` + top: 50%; + position: absolute; + transform: translateY(-50%); +`; + +const ModalHeader = styled.div` + display: flex; + justify-content: center; + position: relative; + bottom: 2rem; +`; + +const ModalWrapper = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + position: relative; +`; + +const CloseIconWrapper = styled.div` + position: absolute; + right: 0; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + opacity: 0.8; + z-index: 2; + &:hover { + opacity: 1; + } +`; + +const IndexWrapper = styled.div` + color: ${({ theme }) => theme.palette.grayScale[100]}; + align-self: center; + justify-content: center; + font-size: 1.4rem; + display: flex; +`; + +const DotWrapper = styled.div` + top: 3rem; + display: flex; + position: relative; + justify-content: center; +`; + +interface IDotProps { + selected: boolean; +} +const Dot = styled.div` + width: 1rem; + height: 1rem; + background-color: ${({ theme, selected }) => + selected ? theme.palette.primary.main : theme.palette.grayScale[200]}; + border-radius: 1rem; + margin: 0 0.75rem; +`; + +export { + Dot, + PickedImg, + PickedVideo, + DotWrapper, + IndexWrapper, + ButtonWrapper, + CloseIconWrapper, + ModalWrapper, + ModalHeader, +}; diff --git a/src/components/CarouselModal/CarouselModal.tsx b/src/components/CarouselModal/CarouselModal.tsx new file mode 100644 index 0000000..d318cdb --- /dev/null +++ b/src/components/CarouselModal/CarouselModal.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import Image from "next/image"; + +import close from "assets/images/close.svg"; + +import { + Dot, + PickedImg, + PickedVideo, + DotWrapper, + IndexWrapper, + ButtonWrapper, + CloseIconWrapper, + ModalWrapper, + ModalHeader, +} from "./CarouselModal.style"; + +import { ArrowBtn } from "components/ArrowBtn"; + +export interface ICarouselModal { + selectedIndex: number; + itemList: Array; + handlePrevClick: () => void; + handleNextClick: () => void; + handleModalClose: () => void; + video?: boolean; + videoRef?: React.RefObject; +} + +const CarouselModal: React.FC = ({ + selectedIndex, + itemList, + handlePrevClick, + handleNextClick, + handleModalClose, + video, + videoRef, +}) => { + return ( + <> + + + + + + + {selectedIndex} / {video ? itemList.length : itemList.length - 2} + + + + + + {video ? ( + <> + + + + + {itemList.map((item, i) => ( + + ))} + + + ) : ( + + )} + + + + + + ); +}; + +export default CarouselModal; diff --git a/src/components/CarouselModal/index.ts b/src/components/CarouselModal/index.ts new file mode 100644 index 0000000..22f8ca4 --- /dev/null +++ b/src/components/CarouselModal/index.ts @@ -0,0 +1 @@ +export { default as CarouselModal } from "./CarouselModal"; diff --git a/src/components/Error/Error.style.ts b/src/components/Error/Error.style.ts new file mode 100644 index 0000000..be5be8a --- /dev/null +++ b/src/components/Error/Error.style.ts @@ -0,0 +1,56 @@ +import styled from "styled-components"; + +const ErrorWrapper = styled.section` + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100vh; +`; +const ErrorContainer = styled.section` + display: flex; + flex-direction: column; + max-width: 53rem; +`; +const ErrorHeader = styled.h1` + font-weight: 500; + font-size: 4rem; + line-height: 5rem; + color: ${({ theme }) => theme.palette.grayScale[200]}; + margin-bottom: 1.5rem; +`; + +const ErrorDesc = styled.div` + display: flex; + flex-direction: column; + margin-bottom: 3.5rem; +`; +const ErrorText = styled.p` + font-weight: 400; + font-size: 2.2rem; + line-height: 2.8rem; + color: ${({ theme }) => theme.palette.grayScale[400]}; + margin: 0; +`; + +const ErrorButton = styled.button` + font-weight: 400; + font-size: 1.6rem; + line-height: 2.8rem; + color: ${({ theme }) => theme.palette.primary.main}; + padding: 0.4rem 1.8rem; + border: ${({ theme }) => `1px solid ${theme.palette.primary.main}`}; + border-radius: 0.5rem; + background-color: transparent; + width: 14rem; + cursor: pointer; +`; + +export { + ErrorWrapper, + ErrorContainer, + ErrorHeader, + ErrorText, + ErrorDesc, + ErrorButton, +}; diff --git a/src/components/Error/Error.tsx b/src/components/Error/Error.tsx new file mode 100644 index 0000000..101238a --- /dev/null +++ b/src/components/Error/Error.tsx @@ -0,0 +1,41 @@ +import { useRouter } from "next/router"; +import { useTranslation } from "next-i18next"; + +import { + ErrorWrapper, + ErrorContainer, + ErrorHeader, + ErrorDesc, + ErrorText, + ErrorButton, +} from "./Error.style"; + +import { ErrorPageProps } from "pages/_error"; + +const Error: React.FC = ({ statusCode }) => { + const router = useRouter(); + const { t } = useTranslation("error"); + return ( + + + + {statusCode === 404 ? "404 Error" : "500 Error"} + + + {statusCode === 404 + ? t("error_desc_404") + .split("\n") + .map((text, idx) => {text}) + : t("error_desc_500") + .split("\n") + .map((text, idx) => {text})} + + router.push("/")}> + {t("error_button")} + + + + ); +}; + +export default Error; diff --git a/src/components/Error/index.ts b/src/components/Error/index.ts new file mode 100644 index 0000000..9e7ab08 --- /dev/null +++ b/src/components/Error/index.ts @@ -0,0 +1 @@ +export { default as Error } from "./Error"; diff --git a/src/components/Footer/Footer.style.ts b/src/components/Footer/Footer.style.ts new file mode 100644 index 0000000..d6efb90 --- /dev/null +++ b/src/components/Footer/Footer.style.ts @@ -0,0 +1,39 @@ +import styled from "styled-components"; + +export const FooterWrapper = styled.footer` + display: flex; + justify-content: center; + width: 100%; + background: ${({ theme }) => theme.palette.backgroundColors.dark}; +`; + +export const FooterContainer = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + min-width: 102.4rem; + padding: 3rem 0; +`; + +export const FooterText = styled.p` + font-size: 1.4rem; + font-weight: 400; + line-height: 1.9rem; + color: ${({ theme }) => theme.palette.grayScale[400]}; +`; + +export const FooterLinks = styled.div` + display: flex; +`; + +export const LinkStyle = styled.a` + cursor: pointer; + font-size: 1.4rem; + font-weight: 400; + line-height: 1.9rem; + color: ${({ theme }) => theme.palette.grayScale[400]}; + + & + & { + margin-left: 3rem; + } +`; diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 0000000..d2bb7b5 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,38 @@ +import { useTranslation } from "next-i18next"; +import Link from "next/link"; + +import { + FooterWrapper, + FooterContainer, + FooterText, + FooterLinks, + LinkStyle, +} from "./Footer.style"; + +const Footer = () => { + const { t } = useTranslation(); + + return ( + + + + Copyright 2021. wearecastle. All rights reserved. + + + + + {t("common_footer_meta")} + + + {t("common_footer_term")} + + + {t("common_footer_privacy")} + + + + + ); +}; + +export default Footer; diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts new file mode 100644 index 0000000..3738288 --- /dev/null +++ b/src/components/Footer/index.ts @@ -0,0 +1 @@ +export { default } from "./Footer"; diff --git a/src/components/Header/Header.style.ts b/src/components/Header/Header.style.ts new file mode 100644 index 0000000..b6d307b --- /dev/null +++ b/src/components/Header/Header.style.ts @@ -0,0 +1,38 @@ +import styled from "styled-components"; + +export const HeaderWrapper = styled.header` + position: absolute; + width: 100%; + top: 4.2rem; + display: flex; + justify-content: center; +`; + +export const HeaderTranslationWrapper = styled.div` + position: absolute; + top: -0.5rem; + right: 100px; + font-style: normal; + font-weight: normal; + font-size: 1.6rem; + line-height: 28px; + letter-spacing: -0.03em; + color: ${({ theme }) => theme.palette.grayScale[300]}; + width: 10rem; + display: flex; + justify-content: space-around; +`; + +export const HeaderTranslation = styled.button<{ isActive?: boolean }>` + color: ${({ theme, isActive }) => + isActive ? theme.palette.grayScale[100] : theme.palette.grayScale[300]}; + cursor: pointer; + border: none; + background-color: transparent; + font-style: normal; + font-weight: normal; + font-size: 1.6rem; + line-height: 28px; + letter-spacing: -0.03em; + padding: 0; +`; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 0000000..641e3e7 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,47 @@ +import Image from "next/image"; +import Link from "next/link"; + +import logo from "assets/images/logo.svg"; + +import { + HeaderTranslation, + HeaderTranslationWrapper, + HeaderWrapper, +} from "./Header.style"; +import { useRouter } from "next/router"; + +const Header: React.FC = () => { + const router = useRouter(); + + const handleChangeLanguage = (newLocale: string) => { + router.push(router.asPath, undefined, { + locale: newLocale, + }); + }; + + return ( + + + {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} + + + + + + handleChangeLanguage("ko")}> + KOR + + | + handleChangeLanguage("en")}> + ENG + + + + ); +}; + +export default Header; diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts new file mode 100644 index 0000000..2764567 --- /dev/null +++ b/src/components/Header/index.ts @@ -0,0 +1 @@ +export { default } from "./Header"; diff --git a/src/components/IndexTitle/IndexTitle.style.ts b/src/components/IndexTitle/IndexTitle.style.ts new file mode 100644 index 0000000..69d9d66 --- /dev/null +++ b/src/components/IndexTitle/IndexTitle.style.ts @@ -0,0 +1,54 @@ +import styled from "styled-components"; + +const Title = styled.div` + display: flex; + font-size: 2.8rem; + line-height: 3.5rem; + color: ${({ theme }) => theme.palette.grayScale[100]}; +`; + +const IndexContainer = styled.div` + display: flex; +`; + +const SelectedIndex = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 3.5rem; + height: 3.5rem; + border-radius: 0.5rem; + border: 0.2rem solid ${({ theme }) => theme.palette.primary.main}; + color: ${({ theme }) => theme.palette.primary.main}; +`; + +const Index = styled.div` + font-size: 1.8rem; + line-height: 2.3rem; +`; + +const NonSelectedIndex = styled.div` + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + width: 3.5rem; + height: 3.5rem; + color: ${({ theme }) => theme.palette.primary.main}; +`; + +const TitleContainer = styled.div` + display: flex; + justify-content: space-between; + padding: 2.5rem 2.5rem 2.5rem 0; + border-bottom: 0.2rem solid ${({ theme }) => theme.palette.grayScale[500]}; +`; + +export { + Title, + TitleContainer, + NonSelectedIndex, + SelectedIndex, + Index, + IndexContainer, +}; diff --git a/src/components/IndexTitle/IndexTitle.test.tsx b/src/components/IndexTitle/IndexTitle.test.tsx new file mode 100644 index 0000000..17c9a7c --- /dev/null +++ b/src/components/IndexTitle/IndexTitle.test.tsx @@ -0,0 +1,78 @@ +import "jest-styled-components"; +import React from "react"; +import { fireEvent, render } from "@testing-library/react"; + +import { IIndexTitleProps } from "./IndexTitle"; + +import { IndexTitle } from "./index"; + +import FakeProvider from "__test/utils/FakeProvider"; + +describe("", () => { + const clickHandler = jest.fn(); + const makeIndexTitle = (props: IIndexTitleProps) => { + return render( + + + + ); + }; + + it("withoutIndex props๊ฐ€ true๋ผ๋ฉด ์ œ๋ชฉ๋งŒ ๋ณด์—ฌ์ง„๋‹ค.", () => { + const { getByText } = makeIndexTitle({ + title: "์ œ๋ชฉ", + withoutIndex: true, + }); + + getByText("์ œ๋ชฉ"); + }); + + const makeIndexTitleWithIndex = () => + makeIndexTitle({ + title: "์ œ๋ชฉ", + total: 13, + clickHandler, + onScreenCount: 3, + selectedIndex: 0, + }); + + it("ํ™”๋ฉด์— ๋ณด์—ฌ์งˆ ์•„์ดํ…œ์˜ ๊ฐ€์ง“์ˆ˜, ์ „์ฒด ์•„์ดํ…œ์˜ ๊ฐ€์ง“์ˆ˜๋ฅผ ํ†ตํ•ด ์ˆซ์ž๊ฐ€ ๊ฒฐ์ •๋˜์–ด ๋ณด์—ฌ์ง„๋‹ค.", async () => { + const { getByText, queryByText } = makeIndexTitleWithIndex(); + + getByText("1"); + getByText("2"); + expect(queryByText("6")).toBeNull(); + }); + it("์ˆซ์ž๋ฅผ ํด๋ฆญํ•˜๋ฉด clickHandlerํ•จ์ˆ˜๊ฐ€ ์ˆซ์ž์˜ index๊ฐ’์„ ์ธ์ž๋กœ ๋ฐ›์•„ ์‹คํ–‰๋œ๋‹ค. - ๋‘๋ฒˆ์จฐ ๋ฒ„ํŠผ ํด๋ฆญ", () => { + const { getByText } = makeIndexTitleWithIndex(); + + const secondItem = getByText("2"); + fireEvent.click(secondItem); + + expect(clickHandler).toBeCalled(); + expect(clickHandler).toBeCalledWith(1); + }); + it("์ˆซ์ž๋ฅผ ํด๋ฆญํ•˜๋ฉด clickHandlerํ•จ์ˆ˜๊ฐ€ ์ˆซ์ž์˜ index๊ฐ’์„ ์ธ์ž๋กœ ๋ฐ›์•„ ์‹คํ–‰๋œ๋‹ค. - ๋„ค๋ฒˆ์งธ ๋ฒ„ํŠผ ํด๋ฆญ", () => { + const { getByText } = makeIndexTitleWithIndex(); + + const forthItem = getByText("4"); + fireEvent.click(forthItem); + + expect(clickHandler).toBeCalled(); + expect(clickHandler).toBeCalledWith(3); + }); + it("ํด๋ฆญ๋œ ์ˆซ์ž๋Š” ํ…Œํˆฌ๋ฆฌ๋ฅผ ๊ฐ€์ง€๊ณ  ์žˆ๋‹ค.", () => { + const { getByText } = makeIndexTitleWithIndex(); + + const firstItem = getByText("1"); + const secondItem = getByText("2"); + + const firstIndexWrapper = firstItem.parentElement; + const secondIndexWrapper = secondItem.parentElement; + expect(firstIndexWrapper).toHaveStyleRule("border", "0.2rem solid #836EFF"); + expect(secondIndexWrapper).not.toHaveStyleRule( + "border", + "0.2rem solid #836EFF" + ); + }); +}); diff --git a/src/components/IndexTitle/IndexTitle.tsx b/src/components/IndexTitle/IndexTitle.tsx new file mode 100644 index 0000000..99024f0 --- /dev/null +++ b/src/components/IndexTitle/IndexTitle.tsx @@ -0,0 +1,61 @@ +import { + Index, + Title, + SelectedIndex, + TitleContainer, + IndexContainer, + NonSelectedIndex, +} from "./IndexTitle.style"; + +export interface IIndexTitleProps { + title: string; + total?: number; + clickHandler?: (idx: number) => void; + selectedIndex?: number; + onScreenCount?: number; + withoutIndex?: true; +} + +const IndexTitle: React.FC = ({ + total, + withoutIndex, + title, + clickHandler, + selectedIndex, + onScreenCount, +}) => { + if (withoutIndex) + return ( + + {title} + + + ); + + if (total && onScreenCount && clickHandler) { + const indexList = [...Array(Math.ceil(total / onScreenCount)).keys()]; + + return ( + + {title} + + {indexList.map((i: number) => + selectedIndex === i ? ( + + {i + 1} + + ) : ( + clickHandler(i)}> + {i + 1} + + ) + )} + + + ); + } + + return null; +}; + +export default IndexTitle; diff --git a/src/components/IndexTitle/index.ts b/src/components/IndexTitle/index.ts new file mode 100644 index 0000000..1f88c43 --- /dev/null +++ b/src/components/IndexTitle/index.ts @@ -0,0 +1 @@ +export { default as IndexTitle } from "./IndexTitle"; diff --git a/src/components/IndiPick/IndiPick.style.ts b/src/components/IndiPick/IndiPick.style.ts new file mode 100644 index 0000000..b1322b7 --- /dev/null +++ b/src/components/IndiPick/IndiPick.style.ts @@ -0,0 +1,157 @@ +import styled, { css } from "styled-components"; + +export const IndiPickWrapper = styled.section` + display: flex; + justify-content: center; + width: 100%; + background-color: ${({ theme }) => theme.palette.backgroundColors.dark}; +`; +export const IndiPickContainer = styled.div` + display: flex; + flex-direction: column; + padding: 10rem 0; + min-width: 102.4rem; +`; +export const IndiPickHeader = styled.h2` + font-weight: 400; + font-size: 2.8rem; + line-height: 3.5rem; + color: ${({ theme }) => theme.palette.grayScale[200]}; + margin-bottom: 2.5rem; +`; +export const IndiPickContents = styled.div` + display: flex; + justify-content: space-between; + padding: 4rem 5.2rem 0 5.2rem; + border-top: 0.1rem solid ${({ theme }) => theme.palette.grayScale[500]}; +`; +export const IndiPickVersus = styled.span` + display: flex; + align-items: center; + font-weight: 500; + font-size: 2.8rem; + line-height: 2.8rem; + color: ${({ theme }) => theme.palette.primary.main}; +`; + +export const IndiPickItemWrapper = styled.div<{ isLeft?: boolean }>` + display: flex; + flex-direction: ${({ isLeft }) => (isLeft ? "row" : "row-reverse")}; + + & a { + text-decoration: none; + color: inherit; + } +`; +export const IndiPickLikeBox = styled.button<{ btnState?: string }>` + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 10.2rem; + height: 21.8rem; + border-radius: 0.5rem; + + & h2 { + font-weight: 500; + font-size: 2.8rem; + line-height: 2.8rem; + margin-bottom: 0.7rem; + } + & h4 { + font-weight: 500; + font-size: 2rem; + margin-bottom: 1rem; + } + & p { + font-weight: 400; + font-size: 1.8rem; + line-height: 2.5rem; + text-align: center; + margin-left: 0.6rem; + } + + ${({ btnState }) => + btnState === "default" + ? css` + border: 0.1rem solid ${({ theme }) => theme.palette.primary.main}; + color: ${({ theme }) => theme.palette.grayScale[100]}; + background-color: transparent; + ` + : btnState === "active" + ? css` + cursor: not-allowed; + pointer-events: none; + border: none; + color: ${({ theme }) => theme.palette.grayScale[100]}; + background-color: ${({ theme }) => theme.palette.primary.main}; + ` + : css` + cursor: not-allowed; + pointer-events: none; + border: none; + color: ${({ theme }) => theme.palette.grayScale[300]}; + background-color: ${({ theme }) => theme.palette.grayScale[100]}; + `} +`; + +export const IndiPickLikeBoxContainer = styled.div` + pointer-events: none; +`; +export const IndiPickLikeBoxBottom = styled.span` + display: flex; + align-items: center; +`; + +export const IndiPickItemBox = styled.span<{ isLeft?: boolean }>` + display: flex; + flex-direction: column; + width: 26.26rem; + height: 21.8rem; + border-radius: 0.5rem; + margin: ${({ isLeft }) => (isLeft ? "0 0 0 2rem" : "0 2rem 0 0")}; + background-color: ${({ theme }) => theme.palette.grayScale[100]}; +`; + +export const IndiPickItemBoxImg = styled.div<{ img: string }>` + width: 100%; + height: 12.3rem; + background-image: url(${({ img }) => img}); + background-repeat: no-repeat; + background-size: cover; + background-position: center; + border-radius: 0.5rem 0.5rem 0 0; +`; +export const IndiPickItemBoxContents = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 0 1rem; + width: 100%; + height: calc(100% - 12.3rem); + overflow: hidden; +`; +export const IndiPickItemBoxName = styled.h5` + font-weight: normal; + font-size: 1.8rem; + line-height: 2.5rem; + margin-bottom: 1rem; ; +`; +export const IndiPickItemBoxGenres = styled.div` + display: flex; +`; +export const IndiPickItemGenre = styled.span` + padding: 0.2rem 1.4rem; + font-weight: normal; + font-size: 1.4rem; + line-height: 1.9rem; + border-radius: 1.4rem; + color: ${({ theme }) => theme.palette.grayScale[100]}; + background-color: ${({ theme }) => theme.palette.primary.main}; + + & + & { + margin-left: 0.5rem; + } +`; diff --git a/src/components/IndiPick/IndiPick.tsx b/src/components/IndiPick/IndiPick.tsx new file mode 100644 index 0000000..c364fb0 --- /dev/null +++ b/src/components/IndiPick/IndiPick.tsx @@ -0,0 +1,165 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "next-i18next"; +import Link from "next/link"; +import Image from "next/image"; + +import LikeIcon from "assets/images/like.svg"; + +import { + IndiPickWrapper, + IndiPickContainer, + IndiPickHeader, + IndiPickContents, + IndiPickVersus, + // + IndiPickItemWrapper, + IndiPickLikeBox, + IndiPickLikeBoxContainer, + IndiPickLikeBoxBottom, + IndiPickItemBox, + // + IndiPickItemBoxImg, + IndiPickItemBoxContents, + IndiPickItemBoxName, + IndiPickItemBoxGenres, + IndiPickItemGenre, +} from "./IndiPick.style"; + +interface IndiPickItemProps { + name: string; + id: number; + like: number; + header_image: string; + genres: [string]; + isLeft: boolean; + btnState: string; + onClick: (id: number) => void; +} +interface IndiPickProps { + indiPickList: [ + { + name: string; + id: number; + like: number; + header_image: string; + genres: [string]; + } + ]; +} + +const IndiPickItem = ({ + name, + id, + like, + header_image, + genres, + isLeft, + btnState, + onClick, +}: IndiPickItemProps) => { + const handleClick = () => onClick(id); + const [num, setNum] = useState(like); + + useEffect(() => { + setNum(like); + }, [like]); + return ( + + + {btnState === "default" ? ( + +

Click!

+ + +

{num}

+
+
+ ) : btnState === "active" ? ( + +

Like

+ + +

{num}

+
+
+ ) : ( + +

+ Vote
Complete +

+
+ )} +
+ + + {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} + + + + + {name} + + {genres.map((genre: string) => ( + {genre} + ))} + + + + + +
+ ); +}; +const IndiPick = ({ indiPickList }: IndiPickProps) => { + const items = [...indiPickList]; + + const { t } = useTranslation("main"); + const [pickState, setPickState] = useState([ + { id: items[0].id, state: "default", like: items[0].like }, + { id: items[1].id, state: "default", like: items[1].like }, + ]); + + const onClick = (id: number) => { + setPickState( + pickState.map((obj) => + obj.id === id + ? { ...obj, state: "active", like: obj.like + 1 } + : { ...obj, state: "inactive" } + ) + ); + }; + + return ( + + + {t("main_indi_pick_title")} + + + + VS + + + + + ); +}; + +export default IndiPick; diff --git a/src/components/IndiPick/dummyDatasEn.json b/src/components/IndiPick/dummyDatasEn.json new file mode 100644 index 0000000..6593f07 --- /dev/null +++ b/src/components/IndiPick/dummyDatasEn.json @@ -0,0 +1,16 @@ +[ + { + "id": 1047860, + "genres": ["Action", "Adventure"], + "like": 250, + "name": "Dunrog", + "header_image": "https://cdn.akamai.steamstatic.com/steam/apps/1047860/header.jpg?t=1603318528" + }, + { + "id": 488440, + "genres": ["Action", "Casual"], + "like": 140, + "name": "Angeldust", + "header_image": "https://cdn.akamai.steamstatic.com/steam/apps/488440/header.jpg?t=1584195882" + } +] diff --git a/src/components/IndiPick/dummyDatasKo.json b/src/components/IndiPick/dummyDatasKo.json new file mode 100644 index 0000000..5ee1bea --- /dev/null +++ b/src/components/IndiPick/dummyDatasKo.json @@ -0,0 +1,16 @@ +[ + { + "id": 1047860, + "genres": ["์•ก์…˜", "์–ด๋“œ๋ฒค์ฒ˜"], + "like": 250, + "name": "Dunrog", + "header_image": "https://cdn.akamai.steamstatic.com/steam/apps/1047860/header.jpg?t=1603318528" + }, + { + "id": 488440, + "genres": ["์•ก์…˜", "์บ์ฃผ์–ผ"], + "like": 140, + "name": "Angeldust", + "header_image": "https://cdn.akamai.steamstatic.com/steam/apps/488440/header.jpg?t=1584195882" + } +] diff --git a/src/components/IndiPick/index.ts b/src/components/IndiPick/index.ts new file mode 100644 index 0000000..176454d --- /dev/null +++ b/src/components/IndiPick/index.ts @@ -0,0 +1 @@ +export { default as IndiPick } from "./IndiPick"; diff --git a/src/components/LocaleProvider/LocaleProvider.tsx b/src/components/LocaleProvider/LocaleProvider.tsx new file mode 100644 index 0000000..db10ea6 --- /dev/null +++ b/src/components/LocaleProvider/LocaleProvider.tsx @@ -0,0 +1,17 @@ +import { useRouter } from "next/dist/client/router"; +import { useEffect } from "react"; + +import { updateLocale } from "lib/customAxios"; + +const LocaleProvider: React.FC = ({ children }) => { + const router = useRouter(); + const { locale } = router; + + useEffect(() => { + updateLocale(locale); + }, [locale]); + + return <>{children}; +}; + +export default LocaleProvider; diff --git a/src/components/LocaleProvider/index.ts b/src/components/LocaleProvider/index.ts new file mode 100644 index 0000000..b26a476 --- /dev/null +++ b/src/components/LocaleProvider/index.ts @@ -0,0 +1 @@ +export { default } from "./LocaleProvider"; diff --git a/src/components/Main/Carousel/Carousel.style.ts b/src/components/Main/Carousel/Carousel.style.ts new file mode 100644 index 0000000..95dbbd7 --- /dev/null +++ b/src/components/Main/Carousel/Carousel.style.ts @@ -0,0 +1,47 @@ +import styled from "styled-components"; + +interface CarouselWrapperProps { + aboutPage?: boolean; +} +const CarouselWrapper = styled.div` + padding: 10rem 0 5rem 0; + display: flex; + justify-content: center; + width: 100%; + background-color: ${({ theme, aboutPage }) => + theme.palette.backgroundColors[aboutPage ? "main" : "dark"]}; ; +`; + +const CarouselWidthContainer = styled.div` + width: 102.4rem; + display: flex; + justify-content: center; + flex-direction: column; +`; + +const CarouselContainer = styled.div` + display: flex; + justify-content: center; +`; + +interface CarouselImageContainerProps { + width: number; +} + +const CarouselImageContainer = styled.div` + display: flex; + max-width: ${(props) => `${props.width}px`}; + overflow-x: hidden; +`; + +const Container = styled.div` + display: flex; +`; + +export { + CarouselWidthContainer, + CarouselContainer, + CarouselImageContainer, + Container, + CarouselWrapper, +}; diff --git a/src/components/Main/Carousel/Carousel.tsx b/src/components/Main/Carousel/Carousel.tsx new file mode 100644 index 0000000..9b37079 --- /dev/null +++ b/src/components/Main/Carousel/Carousel.tsx @@ -0,0 +1,104 @@ +import React, { useEffect, useRef, useState } from "react"; +// styled +import { + CarouselWidthContainer, + CarouselContainer, + CarouselImageContainer, + CarouselWrapper, + Container, +} from "./Carousel.style"; +// custom-components +import { IndexTitle } from "components/IndexTitle"; +import { CarouselCard } from "./CarouselCard"; +import { useTranslation } from "next-i18next"; + +export type pickType = { + id: number; + name: string; + is_free: boolean; + header_image: string; + genres: [string]; +}; + +interface ICarouselProps { + recommendList: [pickType]; + onScreenCount: number; + aboutPage?: boolean; +} + +const Carousel: React.FC = ({ + recommendList, + onScreenCount, + aboutPage, +}) => { + const { t } = useTranslation(aboutPage ? "about" : "main"); + + const [selectedIndex, setSelectedIndex] = useState(0); + const carouselItemsRef = useRef([]); + + const onScreenCountArr = React.useMemo(() => { + const tempDatas = [...recommendList]; + const tempArr = []; + for (let i = 0; i < tempDatas.length; i + onScreenCount) { + tempArr.push(tempDatas.splice(i, i + onScreenCount)); + } + + return tempArr; + }, [recommendList, onScreenCount]); + + useEffect(() => { + if (recommendList && recommendList[0]) { + carouselItemsRef.current = carouselItemsRef.current.slice( + 0, + recommendList.length + ); + + setSelectedIndex(0); + } + }, [recommendList]); + + const handleSelectedImageChange = (newIdx: number) => { + if (recommendList && recommendList.length > 0) { + setSelectedIndex(newIdx); + if (carouselItemsRef?.current[newIdx]) { + carouselItemsRef?.current[newIdx]?.scrollIntoView({ + inline: "center", + block: "nearest", + behavior: "smooth", + }); + } + } + }; + + return ( + + + + + + {onScreenCountArr.map((recommandArr, idx) => ( + + (carouselItemsRef.current[idx] = el) + }> + {recommandArr.map((recommand) => ( + + ))} + + ))} + + + + + ); +}; + +export default Carousel; diff --git a/src/components/Main/Carousel/CarouselCard/CarouselCard.style.ts b/src/components/Main/Carousel/CarouselCard/CarouselCard.style.ts new file mode 100644 index 0000000..83d0dce --- /dev/null +++ b/src/components/Main/Carousel/CarouselCard/CarouselCard.style.ts @@ -0,0 +1,75 @@ +import styled from "styled-components"; + +const CardContainer = styled.a` + display: flex; + flex-direction: column; + text-decoration: none; + width: 20rem; + height: 20rem; + margin: 4rem 1.5rem; +`; + +const CardInfo = styled.div` + background-color: ${({ theme }) => theme.palette.grayScale[100]}; + padding: 1.2rem 0 1.4rem; + display: flex; + justify-content: center; + flex-direction: column; + border-radius: 0 0 1rem 1rem; +`; + +const CardTagContainer = styled.div` + display: flex; + justify-content: center; + margin-top: 1.4rem; +`; + +const CardTag = styled.div` + display: flex; + justify-content: center; + align-items: center; + font-size: 1.4rem; + line-height: 1.9rem; + height: 2.4rem; + padding: 0.2rem 1.5rem; + border-radius: 1.4rem; + margin: 0 0.25rem; + color: ${({ theme }) => theme.palette.grayScale[100]}; + background-color: ${({ theme }) => theme.palette.primary.main}; +`; + +const CardTitle = styled.div` + color: ${({ theme }) => theme.palette.grayScale[700]}; + width: 100%; + font-size: 1.8rem; + line-height: 2.3rem; + height: 4.6rem; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; + text-decoration: none; + + white-space: normal; + word-wrap: break-word; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +`; + +const CardThumbnail = styled.div` + border-radius: 1rem 1rem 0 0; + height: 9rem; + min-width: 15rem; + background-position: center center; + background-repeat: no-repeat; + background-size: cover; +`; + +export { + CardContainer, + CardInfo, + CardTag, + CardTagContainer, + CardThumbnail, + CardTitle, +}; diff --git a/src/components/Main/Carousel/CarouselCard/CarouselCard.tsx b/src/components/Main/Carousel/CarouselCard/CarouselCard.tsx new file mode 100644 index 0000000..17661c6 --- /dev/null +++ b/src/components/Main/Carousel/CarouselCard/CarouselCard.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { useTranslation } from "next-i18next"; + +import { pickType } from "../Carousel"; + +import { + CardContainer, + CardInfo, + CardTag, + CardTagContainer, + CardThumbnail, + CardTitle, +} from "./CarouselCard.style"; + +const CarouselCard: React.FC<{ recommand: pickType }> = ({ recommand }) => { + const { t } = useTranslation("main"); + + const { id, name, is_free, header_image, genres } = recommand; + + return ( + + + + {name} + + {is_free && {t("main_carousel_card_tag_free")}} + {genres.map((genre: string) => ( + {genre} + ))} + + + + ); +}; + +export default CarouselCard; diff --git a/src/components/Main/Carousel/CarouselCard/index.ts b/src/components/Main/Carousel/CarouselCard/index.ts new file mode 100644 index 0000000..e835d49 --- /dev/null +++ b/src/components/Main/Carousel/CarouselCard/index.ts @@ -0,0 +1 @@ +export { default as CarouselCard } from "./CarouselCard"; diff --git a/src/components/Main/Carousel/index.ts b/src/components/Main/Carousel/index.ts new file mode 100644 index 0000000..d50f6cd --- /dev/null +++ b/src/components/Main/Carousel/index.ts @@ -0,0 +1 @@ +export { default as Carousel } from "./Carousel"; diff --git a/src/components/Main/ReleasedIndieChip/ReleasedIndieChip.style.ts b/src/components/Main/ReleasedIndieChip/ReleasedIndieChip.style.ts new file mode 100644 index 0000000..04f0518 --- /dev/null +++ b/src/components/Main/ReleasedIndieChip/ReleasedIndieChip.style.ts @@ -0,0 +1,94 @@ +import styled from "styled-components"; + +export interface PickImgProps { + selected: boolean; +} + +export interface CarouselImageContainerProps { + width: number; +} + +const CarouselWrapper = styled.section` + padding: 10rem 0 20rem 0; + display: flex; + justify-content: center; + width: 100%; +`; + +const WholeContainer = styled.div` + width: 102.4rem; + display: flex; + justify-content: center; + flex-direction: column; +`; + +const CarouselContainer = styled.div` + position: relative; + display: flex; + justify-content: center; + padding: 4rem 0; +`; + +const CarouselImageContainer = styled.div` + display: flex; + max-width: ${(props) => `${props.width}px`}; + overflow-x: hidden; +`; + +const PickImg = styled.div` + margin: 0 0.8rem; + height: 16.6rem; + min-width: 33rem; + background-position: center center; + background-repeat: no-repeat; + background-size: cover; + filter: ${({ selected }) => (selected ? "unset;" : `brightness(20%);`)}; + border-radius: 1rem; +`; + +const CurrentItemInfoContainer = styled.div` + position: absolute; + top: 18rem; + width: 26.4rem; + height: 13rem; + border-radius: 1rem; + background-color: ${({ theme }) => theme.palette.primary.main}; + color: ${({ theme }) => theme.palette.grayScale[100]}; + + display: flex; + align-items: center; + flex-direction: column; +`; + +const ItemIndex = styled.div` + font-size: 1.4rem; + padding: 1.5rem 0 2rem 0; +`; + +const GameTitle = styled.div` + font-size: 2.2rem; + line-height: 2.754rem; + width: 26rem; + padding: 0 1rem; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const ReleaseDate = styled.div` + font-size: 1.4rem; + padding: 2rem 0 1.5rem 0; +`; + +export { + WholeContainer, + CarouselContainer, + CarouselImageContainer, + CurrentItemInfoContainer, + PickImg, + CarouselWrapper, + ItemIndex, + GameTitle, + ReleaseDate, +}; diff --git a/src/components/Main/ReleasedIndieChip/ReleasedIndieChip.tsx b/src/components/Main/ReleasedIndieChip/ReleasedIndieChip.tsx new file mode 100644 index 0000000..da2454c --- /dev/null +++ b/src/components/Main/ReleasedIndieChip/ReleasedIndieChip.tsx @@ -0,0 +1,120 @@ +import React, { useEffect, useRef, useState } from "react"; +import { useTranslation } from "next-i18next"; + +import { pickType } from "../Carousel/Carousel"; + +import { + PickImg, + ItemIndex, + GameTitle, + ReleaseDate, + WholeContainer, + CarouselWrapper, + CarouselContainer, + CarouselImageContainer, + CurrentItemInfoContainer, +} from "./ReleasedIndieChip.style"; + +import { ArrowBtn } from "components/ArrowBtn"; +import { IndexTitle } from "components/IndexTitle"; + +const ReleasedIndieChip: React.FC<{ releasedIndieChips: Array }> = ({ + releasedIndieChips, +}) => { + const { t } = useTranslation("main"); + + const [thumbnailListState, setThumbnailListState] = useState>( + [] + ); + const [selectedIndex, setSelectedIndex] = useState(1); + const [selectedItem, setSelectedItem] = useState(releasedIndieChips[0]); + const carouselItemsRef = useRef([]); + const parentRef = useRef(null); + + useEffect(() => { + const thumbnailList = releasedIndieChips.map((chip) => chip.header_image); + setThumbnailListState([ + thumbnailList[thumbnailList.length - 1], + ...thumbnailList, + thumbnailList[0], + ]); + }, [releasedIndieChips]); + + useEffect(() => { + if (parentRef?.current && carouselItemsRef?.current[1]) { + parentRef?.current?.scrollTo({ + left: carouselItemsRef?.current[1].clientWidth / 5.5, + }); + } + }, [thumbnailListState]); + + const handleSelectedImageChange = (newIdx: number) => { + if (releasedIndieChips && releasedIndieChips.length > 0) { + setSelectedItem(releasedIndieChips[newIdx - 1]); + setSelectedIndex(newIdx); + if (carouselItemsRef?.current[newIdx]) { + carouselItemsRef?.current[newIdx]?.scrollIntoView({ + inline: "center", + block: "nearest", + }); + } + } + }; + + const handleRightClick = () => { + if (releasedIndieChips && releasedIndieChips.length > 0) { + let newIdx = selectedIndex + 1; + if (newIdx >= releasedIndieChips.length + 1) { + newIdx = 1; + } + handleSelectedImageChange(newIdx); + } + }; + + const handleLeftClick = () => { + if (releasedIndieChips && releasedIndieChips.length > 0) { + let newIdx = selectedIndex - 1; + if (newIdx < 1) { + newIdx = releasedIndieChips.length; + } + handleSelectedImageChange(newIdx); + } + }; + + return ( + + + + + + {thumbnailListState.map((thumbnail: string, idx: number) => ( + + (carouselItemsRef.current[idx] = el) + } + style={{ backgroundImage: `url(${thumbnail})` }} + /> + ))} + +
+ +
+ + {`${selectedIndex} / ${releasedIndieChips.length}`} + {selectedItem?.name} + 21.09.22 + +
+ +
+
+
+
+ ); +}; + +export default ReleasedIndieChip; diff --git a/src/components/Main/Roulette/RoluletteBubble/RoluletteBubble.style.ts b/src/components/Main/Roulette/RoluletteBubble/RoluletteBubble.style.ts new file mode 100644 index 0000000..8460b59 --- /dev/null +++ b/src/components/Main/Roulette/RoluletteBubble/RoluletteBubble.style.ts @@ -0,0 +1,70 @@ +import styled from "styled-components"; + +export const Bubble = styled.div<{ isShow: boolean }>` + position: absolute; + top: calc(50% + 2.5rem); + right: calc(-50% - 4.1rem); + opacity: ${({ isShow }) => (isShow ? 1 : 0)}; + transition: opacity 0.3s 0s, + z-index 0s ${({ isShow }) => (isShow ? "0s" : "0.3s")}; + z-index: ${({ isShow }) => (isShow ? 100 : -1)}; +`; + +export const BubbleWrapper = styled.div` + position: relative; + background-color: ${({ theme }) => theme.palette.grayScale[100]}; + border-radius: 0.4em; + box-shadow: 0rem 0.4rem 0.8rem rgba(77, 77, 77, 0.3); + border-radius: 2rem; + + &:after { + content: ""; + position: absolute; + top: 0; + left: 3.9rem; + width: 0; + height: 0; + border: 1rem solid transparent; + border-right: 0.65rem solid transparent; + border-left: 0.65rem solid transparent; + border-bottom-color: ${({ theme }) => theme.palette.grayScale[100]}; + border-top: 0; + margin-top: -1rem; + } +`; + +export const BubbleContainer = styled.div` + color: ${({ theme }) => theme.palette.grayScale[600]}; + letter-spacing: -0.03em; + padding: 2.5rem; +`; + +export const BubbleTtile = styled.h4` + line-height: 2.3rem; + font-size: 1.8rem; + padding: 0 0.3rem; + margin-bottom: 1rem; +`; + +export const BubbleUnderline = styled.div` + width: 100%; + border-bottom: 0.1rem solid ${({ theme }) => theme.palette.grayScale[200]}; +`; + +export const BubbleContent = styled.p` + margin-top: 2rem; + font-size: 1.4rem; + line-height: 1.9rem; + white-space: pre-line; +`; + +export const BubbleClose = styled.button` + color: ${({ theme }) => theme.palette.primary.main}; + width: 100%; + border: none; + border-top: 0.1rem solid ${({ theme }) => theme.palette.grayScale[200]}; + background-color: transparent; + cursor: pointer; + text-align: center; + padding: 1.5rem 0rem; +`; diff --git a/src/components/Main/Roulette/RoluletteBubble/RoluletteBubble.tsx b/src/components/Main/Roulette/RoluletteBubble/RoluletteBubble.tsx new file mode 100644 index 0000000..4f4f1e1 --- /dev/null +++ b/src/components/Main/Roulette/RoluletteBubble/RoluletteBubble.tsx @@ -0,0 +1,38 @@ +import { + Bubble, + BubbleClose, + BubbleContainer, + BubbleContent, + BubbleTtile, + BubbleUnderline, + BubbleWrapper, +} from "./RoluletteBubble.style"; + +interface IRoluletteBubbleProps { + title: string; + content: string; + isShow: boolean; + onClose: () => void; +} + +const RoluletteBubble: React.FC = ({ + title, + content, + isShow, + onClose, +}) => { + return ( + + + + {title} + + {content} + + ํ™•์ธ + + + ); +}; + +export default RoluletteBubble; diff --git a/src/components/Main/Roulette/RoluletteBubble/index.ts b/src/components/Main/Roulette/RoluletteBubble/index.ts new file mode 100644 index 0000000..87bea78 --- /dev/null +++ b/src/components/Main/Roulette/RoluletteBubble/index.ts @@ -0,0 +1 @@ +export { default } from "./RoluletteBubble"; diff --git a/src/components/Main/Roulette/Roulette.style.ts b/src/components/Main/Roulette/Roulette.style.ts new file mode 100644 index 0000000..8c7dc79 --- /dev/null +++ b/src/components/Main/Roulette/Roulette.style.ts @@ -0,0 +1,51 @@ +import Image from "next/image"; + +import styled from "styled-components"; + +export const RouletteWrapper = styled.section` + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + height: 112.6rem; + padding-top: 20rem; + background-image: url("/roulette/background.png"); + background-size: cover; + background-position: center; +`; + +export const RouletteContent = styled.div` + display: flex; + flex-direction: column; + align-items: center; + padding: 0rem 6.1rem; +`; + +export const RouletteTitle = styled.h1` + font-size: 4rem; + letter-spacing: -0.03em; + line-height: 5rem; + color: ${({ theme }) => theme.palette.grayScale[100]}; +`; + +export const RouletteSubTitleWrapper = styled.span` + display: flex; + align-items: center; + justify-content: center; + margin-top: 2rem; + position: relative; +`; + +export const RouletteSubTitle = styled.h3` + font-size: 1.8rem; + line-height: 2.254rem; + letter-spacing: -0.03em; + margin-right: 0.5rem; + color: ${({ theme }) => theme.palette.grayScale[100]}; +`; + +export const RouletteSubTitleTooltip = styled(Image)` + cursor: help; + width: 24px; + height: 24px; +`; diff --git a/src/components/Main/Roulette/Roulette.tsx b/src/components/Main/Roulette/Roulette.tsx new file mode 100644 index 0000000..06324ed --- /dev/null +++ b/src/components/Main/Roulette/Roulette.tsx @@ -0,0 +1,54 @@ +import debounce from "debounce"; +import { useState } from "react"; +import { useTranslation } from "next-i18next"; + +import tooltip from "assets/images/roulette/tooltip.svg"; + +import { + RouletteContent, + RouletteSubTitle, + RouletteSubTitleTooltip, + RouletteSubTitleWrapper, + RouletteTitle, + RouletteWrapper, +} from "./Roulette.style"; +import RouletteKeyword from "./RouletteKeyword"; +import RoluletteBubble from "./RoluletteBubble"; +import RoulettePlay from "./RoulettePlay"; + +const Roulette: React.FC = () => { + const { t } = useTranslation("main"); + + const [isTooltipShow, setIsTooltipShow] = useState(false); + + const handleOnMouseEnter = debounce(() => setIsTooltipShow(true), 500); + + const handleOnCloseTooltip = () => { + setIsTooltipShow(false); + }; + + return ( + + + {t("main_roulette_title")} + + {t("main_roulette_subtitle")} + + + + + + + + ); +}; + +export default Roulette; diff --git a/src/components/Main/Roulette/RouletteKeyword/RouletteKeyword.style.ts b/src/components/Main/Roulette/RouletteKeyword/RouletteKeyword.style.ts new file mode 100644 index 0000000..9f78708 --- /dev/null +++ b/src/components/Main/Roulette/RouletteKeyword/RouletteKeyword.style.ts @@ -0,0 +1,28 @@ +import styled from "styled-components"; + +export const KeywordWrapper = styled.div` + display: flex; + flex-wrap: wrap; + margin-top: 4.25rem; + width: 67.5rem; +`; + +export const KeywordItem = styled.button<{ isSelected: boolean }>` + border: none; + width: 15rem; + height: 4rem; + padding: 1rem; + border-radius: 3.3rem; + margin: 0.75rem; + font-size: 1.4rem; + line-height: 1.9rem; + letter-spacing: -0.03em; + cursor: pointer; + background: ${({ theme, isSelected }) => + isSelected + ? theme.palette.primary.main + : theme.palette.backgroundColors.light}; + color: ${({ theme, isSelected }) => + isSelected ? theme.palette.grayScale[100] : theme.palette.grayScale[600]}; + transition: all 0.3s 0s; +`; diff --git a/src/components/Main/Roulette/RouletteKeyword/RouletteKeyword.tsx b/src/components/Main/Roulette/RouletteKeyword/RouletteKeyword.tsx new file mode 100644 index 0000000..b805d1b --- /dev/null +++ b/src/components/Main/Roulette/RouletteKeyword/RouletteKeyword.tsx @@ -0,0 +1,25 @@ +import { useTranslation } from "next-i18next"; + +import { useKeywords } from "hooks/main"; + +import { KeywordItem, KeywordWrapper } from "./RouletteKeyword.style"; + +const RouletteKeyword: React.FC = () => { + const { t } = useTranslation("main"); + const { keywords, handleOnClick } = useKeywords(); + + return ( + + {keywords.map((item, idx) => ( + handleOnClick(item)} + isSelected={item.isSelected}> + {t(item.key)} + + ))} + + ); +}; + +export default RouletteKeyword; diff --git a/src/components/Main/Roulette/RouletteKeyword/index.ts b/src/components/Main/Roulette/RouletteKeyword/index.ts new file mode 100644 index 0000000..98e4638 --- /dev/null +++ b/src/components/Main/Roulette/RouletteKeyword/index.ts @@ -0,0 +1 @@ +export { default } from "./RouletteKeyword"; diff --git a/src/components/Main/Roulette/RoulettePlay/RoulettePlay.style.ts b/src/components/Main/Roulette/RoulettePlay/RoulettePlay.style.ts new file mode 100644 index 0000000..3ff028a --- /dev/null +++ b/src/components/Main/Roulette/RoulettePlay/RoulettePlay.style.ts @@ -0,0 +1,25 @@ +import styled from "styled-components"; + +export const RoulettePlayWrapper = styled.div` + margin-top: 7.15rem; +`; + +export const RoulettePlayContainer = styled.div` + display: flex; + width: 80rem; + justify-content: center; +`; + +export const RoulettePlayButtonContainer = styled.div` + margin-top: 9.6rem; + display: flex; + justify-content: center; +`; + +export const RoulettePlayButton = styled.button` + width: 150px; + height: auto; + cursor: pointer; + background-color: transparent; + border: none; +`; diff --git a/src/components/Main/Roulette/RoulettePlay/RoulettePlay.tsx b/src/components/Main/Roulette/RoulettePlay/RoulettePlay.tsx new file mode 100644 index 0000000..8c3dfa8 --- /dev/null +++ b/src/components/Main/Roulette/RoulettePlay/RoulettePlay.tsx @@ -0,0 +1,50 @@ +import Image from "next/image"; + +import { useRoulette } from "hooks/main"; + +import spin from "assets/images/roulette/btn_spin.svg"; +import reset from "assets/images/roulette/btn_reset.svg"; +import replay from "assets/images/roulette/btn_replay.svg"; + +import { + RoulettePlayWrapper, + RoulettePlayContainer, + RoulettePlayButton, + RoulettePlayButtonContainer, +} from "./RoulettePlay.style"; +import RoulettePlayIcons from "./RoulettePlayIcons"; +import RoulettePlayContent from "./RoulettePlayContent"; + +const RoulettePlay: React.FC = () => { + const { + roulette, + selectedCount, + played, + skip, + waiting, + loading, + onClickReset, + onClickStart, + } = useRoulette(); + + return ( + + + + + + + + + + + + + + + + + ); +}; + +export default RoulettePlay; diff --git a/src/components/Main/Roulette/RoulettePlay/RoulettePlayContent/RoulettePlayContent.style.ts b/src/components/Main/Roulette/RoulettePlay/RoulettePlayContent/RoulettePlayContent.style.ts new file mode 100644 index 0000000..1ff42ea --- /dev/null +++ b/src/components/Main/Roulette/RoulettePlay/RoulettePlayContent/RoulettePlayContent.style.ts @@ -0,0 +1,152 @@ +import styled from "styled-components"; +import Image from "next/image"; + +export const RoulettePlayContentWrapper = styled.div` + margin: 0 1.9rem; + position: relative; + width: 46rem; + height: 21.5rem; + background-color: ${({ theme }) => theme.palette.grayScale[700]}; + overflow: hidden; +`; + +export const RoulettePlayContentGroup = styled.div` + position: absolute; + width: 200%; + height: 100%; +`; + +export const RoulettePlayContentCircle = styled.div<{ isPlay: boolean }>` + position: relative; + width: 100%; + height: calc(46rem * 2); + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + border-radius: 50%; + + @keyframes animate { + from { + transform: translate(-50%, -50%) rotate(0deg); + } + to { + transform: translate(-50%, -50%) rotate(2160deg); + } + } + animation: ${({ isPlay }) => + isPlay ? "animate 3.5s 0s ease-in-out forwards" : "none"}; +`; + +export const RoulettePlayContentBox = styled.div` + width: 46rem; + height: 21.5rem; + display: flex; + opacity: 0; + transition: opacity 0.5s 0s; + position: absolute; + flex-direction: column; + align-items: center; + justify-content: center; + left: 0; + top: 50%; + transform: translate(0, -50%); + background-color: #000000ac; + z-index: 3; + padding: 2rem; +`; + +export const RoulettePlayContentRandom = styled.div` + position: absolute; + background-color: black; + display: flex; + align-items: center; + justify-content: center; + width: 46rem; + height: 21.5rem; + font-size: 5rem; + color: ${({ theme }) => theme.palette.grayScale[100]}; + text-align: center; + + &:first-of-type { + left: 0; + top: 50%; + transform: translate(0, -50%); + z-index: 2; + &:hover { + ${RoulettePlayContentBox} { + opacity: 1; + } + } + } + + &:nth-child(2) { + left: 50%; + top: 23rem; + transform: translate(-50%, -10.75rem) rotate(90deg); + } + + &:nth-child(3) { + right: 0; + top: 50%; + transform: translate(0, -50%) rotate(180deg); + } + + &:last-of-type { + left: 50%; + bottom: 23rem; + transform: translate(-50%, 10.75rem) rotate(270deg); + } +`; + +export const RoulettePlayContentTitle = styled.h3` + color: ${({ theme }) => theme.palette.grayScale[100]}; + font-weight: 500; + font-size: 28px; + line-height: 28px; + letter-spacing: -0.03em; +`; + +export const RoulettePlayContentGenres = styled.div` + margin-top: 1.5rem; + display: flex; + flex-wrap: wrap; + font-style: normal; + font-weight: normal; + font-size: 14px; + line-height: 19px; + letter-spacing: -0.03em; + align-items: center; + justify-content: center; + color: ${({ theme }) => theme.palette.grayScale[100]}; + span { + background-color: ${({ theme }) => theme.palette.primary.main}; + border-radius: 14px; + min-width: 64px; + padding: 0.3rem 1rem; + display: inline-block; + margin: 0.5rem; + } +`; + +export const RoulettePlayContentBtn = styled.span` + cursor: pointer; + margin-top: 2rem; + font-style: normal; + font-weight: normal; + font-size: 16px; + line-height: 28px; + letter-spacing: -0.03em; + color: #c7c7c7; + display: inline-block; + border: 2px solid #c7c7c7; + border-radius: 5px; + padding: 0.15rem 1.5rem; +`; + +export const RoulettePlayContentImage = styled(Image)<{ + width: number; + height: number; +}>` + width: ${({ width }) => width}px; + height: ${({ height }) => height}px; +`; diff --git a/src/components/Main/Roulette/RoulettePlay/RoulettePlayContent/RoulettePlayContent.tsx b/src/components/Main/Roulette/RoulettePlay/RoulettePlayContent/RoulettePlayContent.tsx new file mode 100644 index 0000000..ca7890c --- /dev/null +++ b/src/components/Main/Roulette/RoulettePlay/RoulettePlayContent/RoulettePlayContent.tsx @@ -0,0 +1,92 @@ +import Image from "next/image"; +import Link from "next/link"; +import { useTranslation } from "next-i18next"; + +import { IRouletteState } from "types/IRouletteResult"; + +import loadingGif from "assets/images/roulette/loading.gif"; +import random from "assets/images/roulette/random.svg"; + +import { + RoulettePlayContentWrapper, + RoulettePlayContentRandom, + RoulettePlayContentCircle, + RoulettePlayContentGroup, + RoulettePlayContentBox, + RoulettePlayContentTitle, + RoulettePlayContentGenres, + RoulettePlayContentBtn, + RoulettePlayContentImage, +} from "./RoulettePlayContent.style"; + +interface IRoulettePlayContentProps { + item: IRouletteState; + skip: boolean; + waiting: boolean; + loading: boolean; +} + +const RoulettePlayContent: React.FC = ({ + item, + skip, + waiting, + loading, +}) => { + const { t } = useTranslation("main"); + + const { error, data } = item; + + return ( + + + + {data && !error && !loading ? ( + + + + {data.name} + + {data.genres.map((v, i) => ( + {v} + ))} + + + + {t("main_roulette_game_info")} + + + + + ) : ( + + {waiting ? ( + + ) : ( + + )} + + )} + + + + + + + + + + + + + ); +}; + +export default RoulettePlayContent; diff --git a/src/components/Main/Roulette/RoulettePlay/RoulettePlayContent/index.ts b/src/components/Main/Roulette/RoulettePlay/RoulettePlayContent/index.ts new file mode 100644 index 0000000..a4daf95 --- /dev/null +++ b/src/components/Main/Roulette/RoulettePlay/RoulettePlayContent/index.ts @@ -0,0 +1 @@ +export { default } from "./RoulettePlayContent"; diff --git a/src/components/Main/Roulette/RoulettePlay/RoulettePlayIcons/RoulettePlayIcons.style.ts b/src/components/Main/Roulette/RoulettePlay/RoulettePlayIcons/RoulettePlayIcons.style.ts new file mode 100644 index 0000000..d021d54 --- /dev/null +++ b/src/components/Main/Roulette/RoulettePlay/RoulettePlayIcons/RoulettePlayIcons.style.ts @@ -0,0 +1,31 @@ +import styled from "styled-components"; + +export const RoulettePlayIconsWrapper = styled.div` + display: flex; + flex-direction: column; + justify-content: space-around; +`; + +export const RoulettePlayIconsContainer = styled.div` + position: relative; + width: 76px; + height: 66px; +`; + +export const RoulettePlayIconsImgWrapper = styled.div` + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +`; + +export const RoulettePlayIconsImg = styled.div<{ isLeft: boolean }>` + ${({ isLeft }) => (isLeft ? "margin-left: 2.6rem" : "")}; + &:nth-child(2) { + ${({ isLeft }) => (isLeft ? "margin: 0rem" : "margin-left: 2.6rem")}; + } +`; + +export const RoulettePlayIconsPreload = styled.div` + display: none; +`; diff --git a/src/components/Main/Roulette/RoulettePlay/RoulettePlayIcons/RoulettePlayIcons.tsx b/src/components/Main/Roulette/RoulettePlay/RoulettePlayIcons/RoulettePlayIcons.tsx new file mode 100644 index 0000000..61ce4bb --- /dev/null +++ b/src/components/Main/Roulette/RoulettePlay/RoulettePlayIcons/RoulettePlayIcons.tsx @@ -0,0 +1,124 @@ +import React from "react"; +import Image from "next/image"; + +import keywordL from "assets/images/roulette/keyword_l.svg"; +import keywordR from "assets/images/roulette/keyword_r.svg"; +import keywordEmptyL from "assets/images/roulette/keyword_empty_l.svg"; +import keywordEmptyR from "assets/images/roulette/keyword_empty_r.svg"; +import icon1 from "assets/images/roulette/icon_1.svg"; +import icon2 from "assets/images/roulette/icon_2.svg"; +import icon3 from "assets/images/roulette/icon_3.svg"; +import icon4 from "assets/images/roulette/icon_4.svg"; +import icon5 from "assets/images/roulette/icon_5.svg"; +import icon6 from "assets/images/roulette/icon_6.svg"; + +import { + RoulettePlayIconsWrapper, + RoulettePlayIconsContainer, + RoulettePlayIconsImg, + RoulettePlayIconsImgWrapper, + RoulettePlayIconsPreload, +} from "./RoulettePlayIcons.style"; + +interface IRoulettePlayIconsProps { + count: number; + position: "left" | "right"; +} + +const RoulettePlayIcons: React.FC = ({ + count, + position, +}) => { + const imageSources = [ + keywordL, + keywordR, + keywordEmptyL, + keywordEmptyR, + icon1, + icon2, + icon3, + icon4, + icon5, + icon6, + ]; + + const getIconSource = (idx: number) => { + if (position === "left") { + switch (idx) { + case 0: + return icon1; + case 1: + return icon3; + case 2: + return icon5; + } + } else { + switch (idx) { + case 0: + return icon2; + case 1: + return icon4; + case 2: + return icon6; + } + } + }; + + const getIconOrder = (idx: number) => { + if (position === "left") { + switch (idx) { + case 0: + return 1; + case 1: + return 3; + case 2: + return 5; + } + } else { + switch (idx) { + case 0: + return 2; + case 1: + return 4; + case 2: + return 6; + } + } + + return 6; + }; + + return ( + <> + + {imageSources.map((v, i) => ( + + ))} + + + {[...Array(3)].map((_v, i) => ( + + {getIconOrder(i) <= count ? ( + + + + + + + + + ) : ( + + + + )} + + ))} + + + ); +}; + +export default RoulettePlayIcons; diff --git a/src/components/Main/Roulette/RoulettePlay/RoulettePlayIcons/index.ts b/src/components/Main/Roulette/RoulettePlay/RoulettePlayIcons/index.ts new file mode 100644 index 0000000..8bb303b --- /dev/null +++ b/src/components/Main/Roulette/RoulettePlay/RoulettePlayIcons/index.ts @@ -0,0 +1 @@ +export { default } from "./RoulettePlayIcons"; diff --git a/src/components/Main/Roulette/RoulettePlay/index.ts b/src/components/Main/Roulette/RoulettePlay/index.ts new file mode 100644 index 0000000..76b8d9b --- /dev/null +++ b/src/components/Main/Roulette/RoulettePlay/index.ts @@ -0,0 +1 @@ +export { default } from "./RoulettePlay"; diff --git a/src/components/Main/Roulette/index.ts b/src/components/Main/Roulette/index.ts new file mode 100644 index 0000000..3122fd2 --- /dev/null +++ b/src/components/Main/Roulette/index.ts @@ -0,0 +1 @@ +export { default } from "./Roulette"; diff --git a/src/components/Modal/Modal.style.ts b/src/components/Modal/Modal.style.ts new file mode 100644 index 0000000..dc94efc --- /dev/null +++ b/src/components/Modal/Modal.style.ts @@ -0,0 +1,32 @@ +import styled from "styled-components"; +interface IVisibleProps { + open: boolean; +} +const ModalWrapper = styled.div` + box-sizing: border-box; + display: ${({ open }) => (open ? "flex" : "none")}; + position: fixed; + justify-content: center; + align-items: center; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1000; + overflow: auto; + outline: 0; +`; + +const ModalOverlay = styled.div` + box-sizing: border-box; + display: ${({ open }) => (open ? "block" : "none")}; + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + background-color: rgba(0, 0, 0, 0.6); + z-index: 999; +`; + +export { ModalWrapper, ModalOverlay }; diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx new file mode 100644 index 0000000..8944761 --- /dev/null +++ b/src/components/Modal/Modal.tsx @@ -0,0 +1,34 @@ +import React, { useEffect } from "react"; + +import { ModalOverlay, ModalWrapper } from "./Modal.style"; + +interface IModalProps { + onClose: (e: React.MouseEvent) => void; + open: boolean; +} + +const Modal: React.FC = ({ onClose, open, children }) => { + const onMaskClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) onClose(e); + }; + + useEffect(() => { + const isDocumentExist = typeof window !== "undefined"; + if (open && isDocumentExist) + document.body.style.cssText = `overflow: hidden`; + return () => { + if (isDocumentExist) document.body.style.cssText = `overflow: ""`; + }; + }, [open]); + + return ( + <> + + + {children} + + + ); +}; + +export default Modal; diff --git a/src/components/Modal/index.ts b/src/components/Modal/index.ts new file mode 100644 index 0000000..38a0f44 --- /dev/null +++ b/src/components/Modal/index.ts @@ -0,0 +1 @@ +export { default as Modal } from "./Modal"; diff --git a/src/components/NotExist/NotExist.style.ts b/src/components/NotExist/NotExist.style.ts new file mode 100644 index 0000000..b64f71a --- /dev/null +++ b/src/components/NotExist/NotExist.style.ts @@ -0,0 +1,25 @@ +import styled from "styled-components"; + +const NotExistWrapper = styled.div` + width: 100%; + display: flex; + justify-content: center; +`; +const NotExistContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + width: 89rem; + height: 40rem; + margin: 4rem; + background-color: ${({ theme }) => theme.palette.backgroundColors.main}; +`; + +const NotExistText = styled.div` + font-size: 1.8rem; + color: ${({ theme }) => theme.palette.grayScale[400]}; + margin: 2rem; +`; + +export { NotExistWrapper, NotExistText, NotExistContainer }; diff --git a/src/components/NotExist/NotExist.tsx b/src/components/NotExist/NotExist.tsx new file mode 100644 index 0000000..6d8f087 --- /dev/null +++ b/src/components/NotExist/NotExist.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import Image from "next/image"; + +import notExist from "assets/images/notExist.svg"; + +import { + NotExistWrapper, + NotExistText, + NotExistContainer, +} from "./NotExist.style"; + +interface INotExistProps { + text: string; + style?: React.CSSProperties; +} + +const NotExist: React.FC = ({ text, style }) => { + return ( + + + + {text} + + + ); +}; + +export default NotExist; diff --git a/src/components/NotExist/index.ts b/src/components/NotExist/index.ts new file mode 100644 index 0000000..ea889a8 --- /dev/null +++ b/src/components/NotExist/index.ts @@ -0,0 +1 @@ +export { default as NotExist } from "./NotExist"; diff --git a/src/components/PageWrapper/PageWrapper.style.ts b/src/components/PageWrapper/PageWrapper.style.ts new file mode 100644 index 0000000..7b6addb --- /dev/null +++ b/src/components/PageWrapper/PageWrapper.style.ts @@ -0,0 +1,8 @@ +import styled from "styled-components"; + +export const Wrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + min-height: 100vh; +`; diff --git a/src/components/PageWrapper/PageWrapper.tsx b/src/components/PageWrapper/PageWrapper.tsx new file mode 100644 index 0000000..f38cd8b --- /dev/null +++ b/src/components/PageWrapper/PageWrapper.tsx @@ -0,0 +1,11 @@ +import { Wrapper } from "./PageWrapper.style"; + +interface PageWrapperProps { + children: JSX.Element; +} + +const PageWrapper = ({ children }: PageWrapperProps) => { + return {children}; +}; + +export default PageWrapper; diff --git a/src/components/PageWrapper/index.ts b/src/components/PageWrapper/index.ts new file mode 100644 index 0000000..ee7a656 --- /dev/null +++ b/src/components/PageWrapper/index.ts @@ -0,0 +1 @@ +export { default } from "./PageWrapper"; diff --git a/src/components/RankingView/RankingView.style.ts b/src/components/RankingView/RankingView.style.ts new file mode 100644 index 0000000..981430e --- /dev/null +++ b/src/components/RankingView/RankingView.style.ts @@ -0,0 +1,78 @@ +import styled, { css } from "styled-components"; + +export const RankingViewWrapper = styled.section` + display: flex; + justify-content: center; + width: 100%; +`; + +export const RankingViewContainer = styled.div` + display: flex; + flex-direction: column; + padding: 10rem 0; + min-width: 102.4rem; +`; + +export const RankingViewHeader = styled.h2` + font-weight: 400; + font-size: 2.8rem; + line-height: 3.5rem; + color: ${({ theme }) => theme.palette.grayScale[200]}; + margin-bottom: 2.5rem; +`; +export const RankingViewContents = styled.div` + display: flex; + padding: 4rem 5.2rem 0 5.2rem; + border-top: 0.1rem solid ${({ theme }) => theme.palette.grayScale[500]}; +`; + +export const RankingViewAutoScrollWrapper = styled.div` + margin-right: 3.8rem; + min-width: 38.5rem; + height: 25rem; + position: relative; + overflow: hidden; +`; + +export const RankingViewTitle = styled.span<{ isActive: boolean }>` + display: flex; + height: 5rem; + padding: 0 2.1rem; + + font-weight: 400; + font-size: 1.8rem; + cursor: pointer; + color: ${({ theme }) => theme.palette.grayScale[400]}; + & a { + display: flex; + align-items: center; + color: inherit; + text-decoration: none; + } + + ${({ isActive, theme }) => + isActive && + css` + border: 0.2rem solid ${theme.palette.primary.main}; + border-radius: 0.5rem; + color: ${theme.palette.grayScale[100]}; + `} +`; + +export const RankingViewText = styled.p` + overflow: hidden; + & + & { + margin-left: 2.8rem; + } +`; + +export const RankingViewImage = styled.div<{ url: string }>` + cursor: pointer; + width: 100%; + height: 100%; + border-radius: 1rem; + background-image: url(${({ url }) => url}); + background-repeat: no-repeat; + background-size: cover; + background-position: center; +`; diff --git a/src/components/RankingView/RankingView.tsx b/src/components/RankingView/RankingView.tsx new file mode 100644 index 0000000..6341027 --- /dev/null +++ b/src/components/RankingView/RankingView.tsx @@ -0,0 +1,97 @@ +import React, { useState, useRef } from "react"; +import Link from "next/link"; +import { useTranslation } from "next-i18next"; + +import { useInterval } from "hooks/main"; +import { useIntersect } from "hooks/main"; + +import { + RankingViewWrapper, + RankingViewContainer, + RankingViewHeader, + RankingViewContents, + RankingViewAutoScrollWrapper, + RankingViewTitle, + RankingViewText, + RankingViewImage, +} from "./RankingView.style"; + +interface RankingViewProps { + rankingList: [ + { + name: string; + id: number; + header_image: string; + } + ]; +} + +const RankingView = ({ rankingList }: RankingViewProps) => { + const [activeNum, setActiveNum] = useState(0); + + const scrollWrapperRef = useRef(null); + const scrollRefTop = useRef(null); + const scrollRefBottom = useRef(null); + + const entry = useIntersect(scrollWrapperRef, {}); + const isVisible = !!entry?.isIntersecting; //threshold์— ๋“ค์–ด์™”์„๋•Œ๋งŒ setInterval + + const { t } = useTranslation("main"); + + const RankingScrollToTop = () => { + return
; + }; + const RankingScrollToBottom = () => { + return
; + }; + + useInterval(() => { + if (isVisible) { + if (activeNum === 4) { + scrollRefBottom?.current?.scrollIntoView({ behavior: "smooth" }); + } + + if (activeNum === 10) { + scrollRefTop?.current?.scrollIntoView({ behavior: "smooth" }); + setActiveNum(0); + } else { + setActiveNum(activeNum + 1); + } + } + }, 3000); + + return ( + + + {t("main_view_ranking_title")} + + + + {rankingList.map((list, idx) => ( + + + {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} + + {idx + 1} + {list.name} + + + + ))} + + + + + + + + ); +}; + +export default React.memo(RankingView); diff --git a/src/components/RankingView/dummyDatas.json b/src/components/RankingView/dummyDatas.json new file mode 100644 index 0000000..5fbd06a --- /dev/null +++ b/src/components/RankingView/dummyDatas.json @@ -0,0 +1,52 @@ +[ + { + "id": 4000, + "header_image": "https://cdn.akamai.steamstatic.com/steam/apps/4000/header.jpg?t=1617307042", + "name": "Garry's Mod" + }, + { + "id": 2720, + "header_image": "https://cdn.akamai.steamstatic.com/steam/apps/2720/header.jpg?t=1591036722", + "name": "ThreadSpace: Hyperbol" + }, + { + "id": 2570, + "header_image": "https://cdn.akamai.steamstatic.com/steam/apps/2570/header.jpg?t=1472555208", + "name": "Vigil: Blood Bitternessโ„ข" + }, + { + "id": 2540, + "header_image": "https://cdn.akamai.steamstatic.com/steam/apps/1002/header.jpg?t=1611415705", + "name": "RIP - Trilogyโ„ข" + }, + { + "id": 2520, + "header_image": "https://cdn.akamai.steamstatic.com/steam/apps/2520/header.jpg?t=1529320674", + "name": "Gumboy - Crazy Adventuresโ„ข" + }, + { + "id": 2420, + "header_image": "https://cdn.akamai.steamstatic.com/steam/apps/2420/header.jpg?t=1447350944", + "name": "The Ship: Single Player" + }, + { + "id": 2400, + "header_image": "https://cdn.akamai.steamstatic.com/steam/apps/2400/header.jpg?t=1594373405", + "name": "The Ship: Murder Party" + }, + { + "id": 1530, + "header_image": "https://cdn.akamai.steamstatic.com/steam/apps/1530/header.jpg?t=1616679171", + "name": "Multiwinia" + }, + { + "id": 1520, + "header_image": "https://cdn.akamai.steamstatic.com/steam/apps/1520/header.jpg?t=1586365976", + "name": "DEFCON" + }, + { + "id": 1002, + "header_image": "https://cdn.akamai.steamstatic.com/steam/apps/1002/header.jpg?t=1611415705", + "name": "Rag Doll Kung Fu" + } +] diff --git a/src/components/RankingView/index.ts b/src/components/RankingView/index.ts new file mode 100644 index 0000000..d6f6aac --- /dev/null +++ b/src/components/RankingView/index.ts @@ -0,0 +1 @@ +export { default } from "./RankingView"; diff --git a/src/hooks/.keep b/src/hooks/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/src/hooks/main/index.ts b/src/hooks/main/index.ts new file mode 100644 index 0000000..11b2ead --- /dev/null +++ b/src/hooks/main/index.ts @@ -0,0 +1,4 @@ +export { default as useKeywords } from "./useKeywords"; +export { default as useRoulette } from "./useRoulette"; +export { default as useInterval } from "./useInterval"; +export { default as useIntersect } from "./useIntersect"; diff --git a/src/hooks/main/useIntersect.tsx b/src/hooks/main/useIntersect.tsx new file mode 100644 index 0000000..a9eb09d --- /dev/null +++ b/src/hooks/main/useIntersect.tsx @@ -0,0 +1,41 @@ +import { RefObject, useEffect, useState } from "react"; + +interface Args extends IntersectionObserverInit { + freezeOnceVisible?: boolean; +} + +const useIntersectionObserver = ( + elementRef: RefObject, + { + threshold = 0, + root = null, + rootMargin = "0%", + freezeOnceVisible = false, + }: Args +): IntersectionObserverEntry | undefined => { + const [entry, setEntry] = useState(); + + const frozen = entry?.isIntersecting && freezeOnceVisible; + + const updateEntry = ([entry]: IntersectionObserverEntry[]): void => { + setEntry(entry); + }; + + useEffect(() => { + const node = elementRef?.current; + const hasIOSupport = !!window.IntersectionObserver; + + if (!hasIOSupport || frozen || !node) return; + + const observerParams = { threshold, root, rootMargin }; + const observer = new IntersectionObserver(updateEntry, observerParams); + + observer.observe(node); + + return () => observer.disconnect(); + }, [elementRef, threshold, root, rootMargin, frozen]); + + return entry; +}; + +export default useIntersectionObserver; diff --git a/src/hooks/main/useInterval.tsx b/src/hooks/main/useInterval.tsx new file mode 100644 index 0000000..b9f27fb --- /dev/null +++ b/src/hooks/main/useInterval.tsx @@ -0,0 +1,21 @@ +import { useEffect, useRef } from "react"; + +const useInterval = (callback: () => void, delay: number | null) => { + const savedCallback = useRef(callback); + + useEffect(() => { + savedCallback.current = callback; + }, [callback]); + + useEffect(() => { + if (delay === null) { + return; + } + + const id = setInterval(() => savedCallback.current(), delay); + + return () => clearInterval(id); + }, [delay]); +}; + +export default useInterval; diff --git a/src/hooks/main/useKeywords.tsx b/src/hooks/main/useKeywords.tsx new file mode 100644 index 0000000..2024edc --- /dev/null +++ b/src/hooks/main/useKeywords.tsx @@ -0,0 +1,34 @@ +import { useCallback, useMemo } from "react"; +import { useRecoilState } from "recoil"; + +import { keywordsState } from "atom/roulette.atom"; +import IKeyword from "types/IKeyword"; + +const useKeywords = () => { + const [keywords, setKeywords] = useRecoilState(keywordsState); + + const selectedKeywords = useMemo>( + () => keywords.filter((v) => v.isSelected), + [keywords] + ); + + const handleOnClick = useCallback( + (keyword: IKeyword) => { + if (!keyword.isSelected && selectedKeywords.length === 6) { + return alert("์ตœ๋Œ€ 6๊ฐœ๊นŒ์ง€ ์„ ํƒ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค."); + } + + const newValue: IKeyword = { + ...keyword, + isSelected: !keyword.isSelected, + }; + + setKeywords(keywords.map((v) => (v.key === keyword.key ? newValue : v))); + }, + [keywords, selectedKeywords, setKeywords] + ); + + return { keywords, handleOnClick }; +}; + +export default useKeywords; diff --git a/src/hooks/main/useRoulette.tsx b/src/hooks/main/useRoulette.tsx new file mode 100644 index 0000000..cbfe1f7 --- /dev/null +++ b/src/hooks/main/useRoulette.tsx @@ -0,0 +1,139 @@ +import { useMemo, useRef, useState } from "react"; +import { useRecoilState, useRecoilValue } from "recoil"; +import axios, { CancelTokenSource } from "axios"; +import { useTranslation } from "next-i18next"; + +import { + keywordsState, + rouletteDefaultState, + rouletteState, +} from "atom/roulette.atom"; +import client from "lib/customAxios"; + +const useRoulette = () => { + const { t } = useTranslation("main"); + + const keywords = useRecoilValue(keywordsState); + const [roulette, setRoulette] = useRecoilState(rouletteState); + + const [waiting, setWaiting] = useState(false); + const [skip, setSkip] = useState(true); + const [played, setPlayed] = useState(false); + + const loading = useRef(false); + const timeout = useRef>(null); + const callback = useRef void)>(null); + const source = useRef(null); + + const selectedCount = useMemo( + () => keywords.filter((v) => v.isSelected).length, + [keywords] + ); + const selectedKeywords = useMemo( + () => + keywords.reduce( + (result: string[], { key, isSelected }) => [ + ...result, + ...(isSelected ? [t(key)] : []), + ], + [] + ), + [keywords, t] + ); + + const clear = () => { + if (timeout.current) { + clearTimeout(timeout.current); + timeout.current = null; + } + }; + + const onClickSkip = () => { + clear(); + setWaiting(true); + setSkip(true); + if (callback.current) { + callback.current(); + callback.current = null; + } + }; + + const onClickReset = () => { + clear(); + setPlayed(false); + setSkip(true); + setWaiting(false); + setRoulette(rouletteDefaultState); + callback.current = null; + loading.current = false; + if (source.current) { + source.current.cancel(); + source.current = null; + } + }; + + const onClickStart = () => { + if (loading.current) { + onClickSkip(); + } else { + loading.current = true; + setSkip(true); + + // ์ด๋ฒคํŠธ ๋ฃจํ”„ ํ™œ์šฉ + setTimeout(() => { + setSkip(false); + }, 0); + setWaiting(false); + setRoulette(rouletteDefaultState); + + source.current = axios.CancelToken.source(); + client + .post( + "api/roulette-recommendation", + { keywords: selectedKeywords }, + { + cancelToken: source.current.token, + } + ) + .then((res) => { + const fn = () => { + setWaiting(false); + setPlayed(true); + setRoulette({ ...rouletteDefaultState, data: res.data.data }); + loading.current = false; + }; + + if (timeout.current) { + callback.current = fn; + } else { + fn(); + } + }) + .catch(() => { + onClickReset(); + }); + + timeout.current = setTimeout(() => { + if (loading.current) { + setWaiting(true); + } + if (typeof callback.current === "function") { + callback.current(); + } + }, 3000); + } + }; + + return { + selectedCount, + roulette, + played, + skip, + waiting, + loading: loading.current, + onClickReset, + onClickStart, + }; +}; + +export default useRoulette; diff --git a/src/hooks/useAxios.tsx b/src/hooks/useAxios.tsx new file mode 100644 index 0000000..2ea9e9e --- /dev/null +++ b/src/hooks/useAxios.tsx @@ -0,0 +1,42 @@ +import { useState, useEffect } from "react"; +import { AxiosRequestConfig, AxiosResponse, AxiosError } from "axios"; + +import api from "lib/customAxios"; + +interface IUseAxiosParam { + url: string; + method: "get" | "delete" | "head" | "options" | "post" | "put" | "patch"; + data?: any | null; + config?: AxiosRequestConfig; +} + +export const useAxios = ({ + url, + method, + data = null, + config = undefined, +}: IUseAxiosParam) => { + const [response, setResponse] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchData = async () => { + api[method](url, data, config) + .then((res: AxiosResponse) => { + setResponse(res.data); + }) + .catch((err: AxiosError) => { + setError(err.message); + }) + .finally(() => { + setLoading(false); + }); + }; + + useEffect(() => { + fetchData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { response, error, loading }; +}; diff --git a/src/lib/customAxios.ts b/src/lib/customAxios.ts new file mode 100644 index 0000000..862eb62 --- /dev/null +++ b/src/lib/customAxios.ts @@ -0,0 +1,20 @@ +import axios from "axios"; + +const client = axios.create({ + baseURL: process.env.NEXT_PUBLIC_SERVER_URL, +}); + +client.defaults.headers = { + "Cache-Control": "no-cache", + "Accept-Language": "ko-KR", + Accept: "application/json", + Pragma: "no-cache", + Expires: "0", +}; + +export default client; + +export const updateLocale = (locale: string | undefined) => { + const lang = locale === "en" ? "en-US" : "ko-KR"; + client.defaults.headers["Accept-Language"] = lang; +}; diff --git a/src/lib/doPlus.ts b/src/lib/doPlus.ts deleted file mode 100644 index 35456e5..0000000 --- a/src/lib/doPlus.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default function doPlus(a: number, b: number) { - return a + b; -} diff --git a/src/models/keywords.ts b/src/models/keywords.ts new file mode 100644 index 0000000..ee5ccf6 --- /dev/null +++ b/src/models/keywords.ts @@ -0,0 +1,22 @@ +import IKeyword from "types/IKeyword"; + +const keywords: Array = [ + { key: "main_roulette_keyword_indie", isSelected: false }, + { key: "main_roulette_keyword_action", isSelected: false }, + { key: "main_roulette_keyword_casual", isSelected: false }, + { key: "main_roulette_keyword_adventure", isSelected: false }, + { key: "main_roulette_keyword_strategy", isSelected: false }, + { key: "main_roulette_keyword_simulation", isSelected: false }, + { key: "main_roulette_keyword_rpg", isSelected: false }, + { key: "main_roulette_keyword_early_access", isSelected: false }, + { key: "main_roulette_keyword_free_to_play", isSelected: false }, + { key: "main_roulette_keyword_sports", isSelected: false }, + { key: "main_roulette_keyword_racing", isSelected: false }, + { key: "main_roulette_keyword_massively_multiplayer", isSelected: false }, + { key: "main_roulette_keyword_gore", isSelected: false }, + { key: "main_roulette_keyword_audio_production", isSelected: false }, + { key: "main_roulette_keyword_video_production", isSelected: false }, + { key: "main_roulette_keyword_animation_n_modeling", isSelected: false }, +]; + +export default keywords; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index ccb78bb..b8a5734 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,19 +1,34 @@ -import React from "react"; import App from "next/app"; +import { appWithTranslation } from "next-i18next"; import { RecoilRoot } from "recoil"; +import { ThemeProvider } from "styled-components"; + +import LocaleProvider from "components/LocaleProvider"; +import Header from "components/Header"; +import PageWrapper from "components/PageWrapper"; +import Footer from "components/Footer"; import GlobalStyle from "style/GlobalStyle"; +import theme from "style/theme"; class MyApp extends App { render() { const { Component, pageProps } = this.props; return ( - - - - + + + +
+ + + +