diff --git a/.github/workflows/dev-server-cd.yml b/.github/workflows/dev-server-cd.yml index 826580a..3326bf5 100644 --- a/.github/workflows/dev-server-cd.yml +++ b/.github/workflows/dev-server-cd.yml @@ -12,9 +12,10 @@ jobs: - uses: technote-space/get-diff-action@v4 with: PATTERNS: | - server/src/**/*.+(ts|tsx) + src/**/*.+(ts|tsx) FILES: | - server/package.json + package.json + RELATIVE: "server" - name: Configure SSH run: | mkdir -p ~/.ssh/ @@ -33,10 +34,10 @@ jobs: SSH_KEY: ${{ secrets.DEV_PRIVATE_KEY }} - name: Checkout and Pull origin run: ssh develop 'cd ~/store-4 && git checkout develop && git pull origin' - - - name: if changed dependencies, install dependencies + + - name: if changed dependencies, install dependencies run: ssh develop 'cd ~/store-4/server && npm i' - if: env.GIT_MATCHED_FILES + if: env.MATCHED_FILES - name: Reload Pm2 run: ssh develop 'cd ~/store-4/server && npm run build && sudo pm2 reload store-4' @@ -72,9 +73,12 @@ jobs: SSH_HOST: ${{ secrets.DEV_HOSTNAME }} SSH_USER: ${{ secrets.DEV_USERNAME }} SSH_KEY: ${{ secrets.DEV_PRIVATE_KEY }} + - name: Touch .env file + run: | + cat >>./.env < ~/.ssh/production.key + chmod 600 ~/.ssh/production.key + cat >>~/.ssh/config < ~/.ssh/production.key + chmod 600 ~/.ssh/production.key + cat >>~/.ssh/config <>./.env <= 11"] }, + "useBuiltIns": "entry", + "corejs": "3.16" + } + ], + "@babel/preset-react", + "@babel/preset-typescript" + ], + "plugins": [ + ["@babel/plugin-proposal-decorators", { "legacy": true }], + [ + "@babel/plugin-transform-runtime", + { + "corejs": 3, + "regenerator": true + } + ] + ] } diff --git a/client/index.d.ts b/client/index.d.ts deleted file mode 100644 index 15d9ab5..0000000 --- a/client/index.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare module '*.png'; -declare module '*.jpg'; -declare module '*.svg'; diff --git a/client/jest.config.js b/client/jest.config.js index 1473472..e01213f 100644 --- a/client/jest.config.js +++ b/client/jest.config.js @@ -1,6 +1,6 @@ module.exports = { testEnvironment: 'jsdom', moduleNameMapper: { - '.+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2|svg)$': 'jest-transform-stub', + '.+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2|svg|gif)$': 'jest-transform-stub', }, }; diff --git a/client/module.d.ts b/client/module.d.ts new file mode 100644 index 0000000..88598fc --- /dev/null +++ b/client/module.d.ts @@ -0,0 +1,6 @@ +declare module '*.png'; +declare module '*.jpg'; +declare module '*.svg'; +declare module '*.gif'; +declare module '*.mp4'; +declare module '*.webm'; diff --git a/client/package-lock.json b/client/package-lock.json index 10e451f..dbaed1c 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,12 +8,18 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "mobx": "^6.3.2", + "mobx-react": "^7.2.0", "react": "^17.0.2", "react-dom": "^17.0.2", - "styled-components": "^5.3.0" + "react-icons": "^4.2.0", + "styled-components": "^5.3.0", + "styled-normalize": "^8.0.7" }, "devDependencies": { "@babel/core": "^7.15.0", + "@babel/plugin-proposal-decorators": "^7.14.5", + "@babel/plugin-transform-runtime": "^7.15.0", "@babel/preset-env": "^7.15.0", "@babel/preset-react": "^7.14.5", "@babel/preset-typescript": "^7.15.0", @@ -26,6 +32,7 @@ "@typescript-eslint/eslint-plugin": "^4.29.1", "@typescript-eslint/parser": "^4.29.1", "babel-loader": "^8.2.2", + "core-js": "^3.16.1", "dotenv": "^10.0.0", "eslint": "^7.32.0", "eslint-config-prettier": "^8.3.0", @@ -645,6 +652,23 @@ "@babel/core": "^7.12.0" } }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.14.5.tgz", + "integrity": "sha512-LYz5nvQcvYeRVjui1Ykn28i+3aUiXwQ/3MGoEy0InTaz1pJo/lAzmIDXX+BQny/oufgHzJ6vnEEiXQ8KZjEVFg==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.14.5", + "@babel/helper-plugin-utils": "^7.14.5", + "@babel/plugin-syntax-decorators": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-proposal-dynamic-import": { "version": "7.14.5", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.14.5.tgz", @@ -894,6 +918,21 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.14.5.tgz", + "integrity": "sha512-c4sZMRWL4GSvP1EXy0woIP7m4jkVcEuG8R1TOZxPBPtp4FSM/kiPZub9UIs/Jrb5ZAOzvTUSGYrWsrSu1JvoPw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-syntax-dynamic-import": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", @@ -1545,6 +1584,35 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.15.0.tgz", + "integrity": "sha512-sfHYkLGjhzWTq6xsuQ01oEsUYjkHRux9fW1iUA68dC7Qd8BS1Unq4aZ8itmQp95zUzIcyR2EbNMTzAicFj+guw==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.14.5", + "@babel/helper-plugin-utils": "^7.14.5", + "babel-plugin-polyfill-corejs2": "^0.2.2", + "babel-plugin-polyfill-corejs3": "^0.2.2", + "babel-plugin-polyfill-regenerator": "^0.2.2", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/plugin-transform-shorthand-properties": { "version": "7.14.5", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.14.5.tgz", @@ -4712,6 +4780,17 @@ "node": ">=0.10.0" } }, + "node_modules/core-js": { + "version": "3.16.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.16.1.tgz", + "integrity": "sha512-AAkP8i35EbefU+JddyWi12AWE9f2N/qr/pwnDtWz4nyUIBGMJPX99ANFFRSw6FefM374lDujdtLDyhN2A/btHw==", + "dev": true, + "hasInstallScript": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-js-compat": { "version": "3.16.1", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.16.1.tgz", @@ -9468,6 +9547,60 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mobx": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/mobx/-/mobx-6.3.2.tgz", + "integrity": "sha512-xGPM9dIE1qkK9Nrhevp0gzpsmELKU4MFUJRORW/jqxVFIHHWIoQrjDjL8vkwoJYY3C2CeVJqgvl38hgKTalTWg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mobx" + } + }, + "node_modules/mobx-react": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/mobx-react/-/mobx-react-7.2.0.tgz", + "integrity": "sha512-KHUjZ3HBmZlNnPd1M82jcdVsQRDlfym38zJhZEs33VxyVQTvL77hODCArq6+C1P1k/6erEeo2R7rpE7ZeOL7dg==", + "dependencies": { + "mobx-react-lite": "^3.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mobx" + }, + "peerDependencies": { + "mobx": "^6.1.0", + "react": "^16.8.0 || ^17" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/mobx-react-lite": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-3.2.0.tgz", + "integrity": "sha512-q5+UHIqYCOpBoFm/PElDuOhbcatvTllgRp3M1s+Hp5j0Z6XNgDbgqxawJ0ZAUEyKM8X1zs70PCuhAIzX1f4Q/g==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mobx" + }, + "peerDependencies": { + "mobx": "^6.1.0", + "react": "^16.8.0 || ^17" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -10541,6 +10674,14 @@ "react": "17.0.2" } }, + "node_modules/react-icons": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.2.0.tgz", + "integrity": "sha512-rmzEDFt+AVXRzD7zDE21gcxyBizD/3NqjbX6cmViAgdqfJ2UiLer8927/QhhrXQV7dEj/1EGuOTPp7JnLYVJKQ==", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -12106,6 +12247,14 @@ "node": ">=4" } }, + "node_modules/styled-normalize": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/styled-normalize/-/styled-normalize-8.0.7.tgz", + "integrity": "sha512-qQV4O7B9g7ZUnStCwGde7Dc/mcFF/pz0Ha/LL7+j/r6uopf6kJCmmR7jCPQMCBrDkYiQ4xvw1hUoceVJkdaMuQ==", + "peerDependencies": { + "styled-components": "^4.0.0 || ^5.0.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -14091,6 +14240,17 @@ "@babel/plugin-syntax-class-static-block": "^7.14.5" } }, + "@babel/plugin-proposal-decorators": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.14.5.tgz", + "integrity": "sha512-LYz5nvQcvYeRVjui1Ykn28i+3aUiXwQ/3MGoEy0InTaz1pJo/lAzmIDXX+BQny/oufgHzJ6vnEEiXQ8KZjEVFg==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.14.5", + "@babel/helper-plugin-utils": "^7.14.5", + "@babel/plugin-syntax-decorators": "^7.14.5" + } + }, "@babel/plugin-proposal-dynamic-import": { "version": "7.14.5", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.14.5.tgz", @@ -14253,6 +14413,15 @@ "@babel/helper-plugin-utils": "^7.14.5" } }, + "@babel/plugin-syntax-decorators": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.14.5.tgz", + "integrity": "sha512-c4sZMRWL4GSvP1EXy0woIP7m4jkVcEuG8R1TOZxPBPtp4FSM/kiPZub9UIs/Jrb5ZAOzvTUSGYrWsrSu1JvoPw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.14.5" + } + }, "@babel/plugin-syntax-dynamic-import": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", @@ -14675,6 +14844,28 @@ "@babel/helper-plugin-utils": "^7.14.5" } }, + "@babel/plugin-transform-runtime": { + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.15.0.tgz", + "integrity": "sha512-sfHYkLGjhzWTq6xsuQ01oEsUYjkHRux9fW1iUA68dC7Qd8BS1Unq4aZ8itmQp95zUzIcyR2EbNMTzAicFj+guw==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.14.5", + "@babel/helper-plugin-utils": "^7.14.5", + "babel-plugin-polyfill-corejs2": "^0.2.2", + "babel-plugin-polyfill-corejs3": "^0.2.2", + "babel-plugin-polyfill-regenerator": "^0.2.2", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, "@babel/plugin-transform-shorthand-properties": { "version": "7.14.5", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.14.5.tgz", @@ -17235,6 +17426,12 @@ "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=", "dev": true }, + "core-js": { + "version": "3.16.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.16.1.tgz", + "integrity": "sha512-AAkP8i35EbefU+JddyWi12AWE9f2N/qr/pwnDtWz4nyUIBGMJPX99ANFFRSw6FefM374lDujdtLDyhN2A/btHw==", + "dev": true + }, "core-js-compat": { "version": "3.16.1", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.16.1.tgz", @@ -20917,6 +21114,25 @@ "minimist": "^1.2.5" } }, + "mobx": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/mobx/-/mobx-6.3.2.tgz", + "integrity": "sha512-xGPM9dIE1qkK9Nrhevp0gzpsmELKU4MFUJRORW/jqxVFIHHWIoQrjDjL8vkwoJYY3C2CeVJqgvl38hgKTalTWg==" + }, + "mobx-react": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/mobx-react/-/mobx-react-7.2.0.tgz", + "integrity": "sha512-KHUjZ3HBmZlNnPd1M82jcdVsQRDlfym38zJhZEs33VxyVQTvL77hODCArq6+C1P1k/6erEeo2R7rpE7ZeOL7dg==", + "requires": { + "mobx-react-lite": "^3.2.0" + } + }, + "mobx-react-lite": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-3.2.0.tgz", + "integrity": "sha512-q5+UHIqYCOpBoFm/PElDuOhbcatvTllgRp3M1s+Hp5j0Z6XNgDbgqxawJ0ZAUEyKM8X1zs70PCuhAIzX1f4Q/g==", + "requires": {} + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -21750,6 +21966,12 @@ "scheduler": "^0.20.2" } }, + "react-icons": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.2.0.tgz", + "integrity": "sha512-rmzEDFt+AVXRzD7zDE21gcxyBizD/3NqjbX6cmViAgdqfJ2UiLer8927/QhhrXQV7dEj/1EGuOTPp7JnLYVJKQ==", + "requires": {} + }, "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -23021,6 +23243,12 @@ } } }, + "styled-normalize": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/styled-normalize/-/styled-normalize-8.0.7.tgz", + "integrity": "sha512-qQV4O7B9g7ZUnStCwGde7Dc/mcFF/pz0Ha/LL7+j/r6uopf6kJCmmR7jCPQMCBrDkYiQ4xvw1hUoceVJkdaMuQ==", + "requires": {} + }, "supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/client/package.json b/client/package.json index 3122072..e77e658 100644 --- a/client/package.json +++ b/client/package.json @@ -7,6 +7,7 @@ "start": "webpack serve --config webpack.dev.js --progress", "build": "webpack --config webpack.prod.js --progress", "test": "jest", + "test:w": "jest --watch", "lint": "eslint 'src/**/*.{ts,tsx}'" }, "keywords": [], @@ -14,6 +15,8 @@ "license": "ISC", "devDependencies": { "@babel/core": "^7.15.0", + "@babel/plugin-proposal-decorators": "^7.14.5", + "@babel/plugin-transform-runtime": "^7.15.0", "@babel/preset-env": "^7.15.0", "@babel/preset-react": "^7.14.5", "@babel/preset-typescript": "^7.15.0", @@ -26,6 +29,7 @@ "@typescript-eslint/eslint-plugin": "^4.29.1", "@typescript-eslint/parser": "^4.29.1", "babel-loader": "^8.2.2", + "core-js": "^3.16.1", "dotenv": "^10.0.0", "eslint": "^7.32.0", "eslint-config-prettier": "^8.3.0", @@ -43,8 +47,12 @@ "webpack-merge": "^5.8.0" }, "dependencies": { + "mobx": "^6.3.2", + "mobx-react": "^7.2.0", "react": "^17.0.2", "react-dom": "^17.0.2", - "styled-components": "^5.3.0" + "react-icons": "^4.2.0", + "styled-components": "^5.3.0", + "styled-normalize": "^8.0.7" } } diff --git a/client/src/api/.gitkeep b/client/src/api/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/api/category.ts b/client/src/api/category.ts new file mode 100644 index 0000000..dedec13 --- /dev/null +++ b/client/src/api/category.ts @@ -0,0 +1,16 @@ +import request from '../lib/request'; +import { CategoryResponse } from '../types/category'; + +class CategoryAPI { + private baseURL: string; + + constructor(baseURL: string) { + this.baseURL = baseURL; + } + + fetchCategories(): Promise { + return request({ url: `${this.baseURL}/api/category` }); + } +} + +export default CategoryAPI; diff --git a/client/src/api/deliveryAddress.ts b/client/src/api/deliveryAddress.ts new file mode 100644 index 0000000..447c458 --- /dev/null +++ b/client/src/api/deliveryAddress.ts @@ -0,0 +1,68 @@ +import request from '../lib/request'; +import { + CreateDeliveryAddressRequest, + CreateDeliveryAddressResponse, + DeliveryAddressResponse, + ModifyDeliveryAddressRequest, + ModifyDeliveryAddressResponse, +} from '../types/deliveryAddress'; + +class DeliveryAddressAPI { + private baseURL: string; + + constructor(baseURL: string) { + this.baseURL = baseURL; + } + + fetchDeliveryAddresses(token: string): Promise { + return request({ + url: `${this.baseURL}/api/delivery-address`, + headers: { + Authorization: token, + }, + }); + } + + createDeliveryAddress( + token: string, + data: CreateDeliveryAddressRequest + ): Promise { + return request({ + url: `${this.baseURL}/api/delivery-address`, + method: 'POST', + body: data, + headers: { + 'Content-Type': 'application/json', + Authorization: token, + }, + }); + } + + modifyDeliveryAddress( + token: string, + id: number, + data: ModifyDeliveryAddressRequest + ): Promise { + return request({ + url: `${this.baseURL}/api/delivery-address/${id}`, + method: 'PUT', + body: data, + headers: { + 'Content-Type': 'application/json', + Authorization: token, + }, + }); + } + + deleteDeliveryAddress(token: string, id: number): Promise { + return request({ + url: `${this.baseURL}/api/delivery-address/${id}`, + method: 'DELETE', + headers: { + Authorization: token, + }, + }); + } +} + +export default DeliveryAddressAPI; diff --git a/client/src/api/index.ts b/client/src/api/index.ts new file mode 100644 index 0000000..d85d817 --- /dev/null +++ b/client/src/api/index.ts @@ -0,0 +1,19 @@ +import CategoryAPI from './category'; +import DeliveryAddressAPI from './deliveryAddress'; +import ProductAPI from './product'; +import UserAPI from './user'; +import ReviewAPI from './review'; +import OrderAPI from './order'; + +const baseURL = process.env.SERVER_URL as string; + +const apis = { + categoryAPI: new CategoryAPI(baseURL), + productAPI: new ProductAPI(baseURL), + userAPI: new UserAPI(baseURL), + reviewAPI: new ReviewAPI(baseURL), + deliveryAddressAPI: new DeliveryAddressAPI(baseURL), + orderAPI: new OrderAPI(baseURL), +}; + +export default apis; diff --git a/client/src/api/order.ts b/client/src/api/order.ts new file mode 100644 index 0000000..e7d34a1 --- /dev/null +++ b/client/src/api/order.ts @@ -0,0 +1,35 @@ +import request from '../lib/request'; +import { CreateOrderRequest, OrdersReponse } from '../types/order'; + +class OrderAPI { + private baseURL: string; + + constructor(baseURL: string) { + this.baseURL = baseURL; + } + + createOrder(token: string, data: CreateOrderRequest): Promise { + return request({ + url: `${this.baseURL}/api/order`, + method: 'POST', + body: data, + headers: { + 'Content-Type': 'application/json', + Authorization: token, + }, + }); + } + + fetchOrders(token: string): Promise { + return request({ + url: `${this.baseURL}/api/order`, + method: 'GET', + + headers: { + Authorization: token, + }, + }); + } +} + +export default OrderAPI; diff --git a/client/src/api/product.ts b/client/src/api/product.ts new file mode 100644 index 0000000..c79f75c --- /dev/null +++ b/client/src/api/product.ts @@ -0,0 +1,73 @@ +import request from '../lib/request'; +import { Option } from '../types/option'; +import { MainProductsResponse, ProductDetailResponse, ProductResponse } from '../types/product'; +import { WishResponse } from '../types/Wish'; +import buildQueryString from '../utils/build-query-string'; + +class ProductAPI { + private baseURL: string; + + constructor(baseURL: string) { + this.baseURL = baseURL; + } + + fetchProducts(token: string | null, option: Option): Promise { + const query = buildQueryString(option); + + return request({ + url: `${this.baseURL}/api/product${query}`, + headers: { + authorization: token ?? '', + }, + }); + } + + fetchProduct(token: string | null, id: number): Promise { + return request({ + url: `${this.baseURL}/api/product/${id}`, + headers: { + authorization: token ?? '', + }, + }); + } + + fetchWishList(token: string | null): Promise<{ wishList: WishResponse[] }> { + return request({ + url: `${this.baseURL}/api/product/wishList`, + headers: { + authorization: token ?? '', + }, + }); + } + + wish(token: string, id: number): Promise { + return request({ + url: `${this.baseURL}/api/product/${id}/wish`, + method: 'POST', + headers: { + authorization: token, + }, + }); + } + + cancelWish(token: string, id: number): Promise { + return request({ + url: `${this.baseURL}/api/product/${id}/wish`, + method: 'DELETE', + headers: { + authorization: token, + }, + }); + } + + fetchMainProducts(token: string | null): Promise { + return request({ + url: `${this.baseURL}/api/product/main`, + headers: { + authorization: token ?? '', + }, + }); + } +} + +export default ProductAPI; diff --git a/client/src/api/review.ts b/client/src/api/review.ts new file mode 100644 index 0000000..232fa02 --- /dev/null +++ b/client/src/api/review.ts @@ -0,0 +1,41 @@ +import request from '../lib/request'; +import { ReviewsByUserResponse } from '../types/review'; + +class ReviewAPI { + private baseURL: string; + + constructor(baseURL: string) { + this.baseURL = baseURL; + } + + getReviewsByUser(userId: number): Promise { + return request({ + url: `${this.baseURL}/api/review/user/${userId}`, + }); + } + + postReview(formData: FormData, token: string): Promise { + return request({ + url: `${this.baseURL}/api/review`, + method: 'POST', + headers: { + Authorization: token, + }, + body: formData, + }); + } + + deleteReviews(reviewIds: number[], token: string): Promise { + return request({ + url: `${this.baseURL}/api/review`, + method: 'DELETE', + headers: { + Authorization: token, + 'Content-Type': 'application/json', + }, + body: { reviewIds }, + }); + } +} + +export default ReviewAPI; diff --git a/client/src/api/user.ts b/client/src/api/user.ts new file mode 100644 index 0000000..a6b2f19 --- /dev/null +++ b/client/src/api/user.ts @@ -0,0 +1,22 @@ +import request from '../lib/request'; +import { UserResponse } from '../types/user'; + +class UserAPI { + private baseURL: string; + + constructor(baseURL: string) { + this.baseURL = baseURL; + } + + fetchUser(token: string): Promise { + return request({ + url: `${this.baseURL}/auth/user`, + method: 'POST', + headers: { + Authorization: token, + }, + }); + } +} + +export default UserAPI; diff --git a/client/src/components/login/facebook.svg b/client/src/assets/icons/facebook.svg similarity index 100% rename from client/src/components/login/facebook.svg rename to client/src/assets/icons/facebook.svg diff --git a/client/src/components/login/google.svg b/client/src/assets/icons/google.svg similarity index 100% rename from client/src/components/login/google.svg rename to client/src/assets/icons/google.svg diff --git a/client/src/components/login/kakao.svg b/client/src/assets/icons/kakao.svg similarity index 100% rename from client/src/components/login/kakao.svg rename to client/src/assets/icons/kakao.svg diff --git a/client/src/assets/images/footer-logo.png b/client/src/assets/images/footer-logo.png new file mode 100644 index 0000000..a9d3f6f Binary files /dev/null and b/client/src/assets/images/footer-logo.png differ diff --git a/client/src/assets/images/no-image.png b/client/src/assets/images/no-image.png new file mode 100644 index 0000000..6fd2fb9 Binary files /dev/null and b/client/src/assets/images/no-image.png differ diff --git a/client/src/components/Account/AccountLanding/AccountLanding.tsx b/client/src/components/Account/AccountLanding/AccountLanding.tsx new file mode 100644 index 0000000..7102de2 --- /dev/null +++ b/client/src/components/Account/AccountLanding/AccountLanding.tsx @@ -0,0 +1,14 @@ +import { observer } from 'mobx-react'; +import React from 'react'; +import styled from 'styled-components'; +import userStore from '../../../stores/userStore'; + +const Container = styled.div` + font-size: ${(props) => props.theme.fontSize.medium}; +`; + +const AccountLanding = (): JSX.Element => { + return 환영합니다, {userStore.user?.name} 님; +}; + +export default observer(AccountLanding); diff --git a/client/src/components/Account/AccountNavList/AccountNavList.tsx b/client/src/components/Account/AccountNavList/AccountNavList.tsx new file mode 100644 index 0000000..20d9504 --- /dev/null +++ b/client/src/components/Account/AccountNavList/AccountNavList.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Link } from '../../../lib/router'; + +const PAGE_TITLE_TEXT = '마이페이지'; + +const Container = styled.nav` + flex-shrink: 0; + width: 200px; + margin-right: 120px; +`; + +const PageTitle = styled.h2` + font-size: 20px; + padding: 0 0 16px 8px; + border-bottom: 1px solid ${(props) => props.theme.color.grey1}; +`; + +const NavList = styled.ul` + padding-left: 10px; + margin-top: 16px; +`; + +const NavListItem = styled.li` + font-size: ${(props) => props.theme.fontSize.small}; + margin: 8px 0; + padding: 8px 0; +`; + +const NavListItemText = styled.span` + position: relative; + + :hover { + ::before { + display: block; + position: absolute; + content: ' '; + background: ${(props) => props.theme.color.mint2}; + width: calc(100% + 4px); + height: 9px; + opacity: 0.4; + left: -2px; + bottom: -1px; + } + } +`; + +type Props = { + pathTextList: { path: string; text: string }[]; +}; +const AccountNavList = (props: Props): JSX.Element => { + const { pathTextList } = props; + + const NavListItems = pathTextList.map(({ path, text }, i) => ( + + + {text} + + + )); + + return ( + + + {PAGE_TITLE_TEXT} + + {NavListItems} + + ); +}; + +export default AccountNavList; diff --git a/client/src/components/Account/AccountOrder/AccountOrder.tsx b/client/src/components/Account/AccountOrder/AccountOrder.tsx new file mode 100644 index 0000000..b38996c --- /dev/null +++ b/client/src/components/Account/AccountOrder/AccountOrder.tsx @@ -0,0 +1,85 @@ +import React, { useState, useCallback } from 'react'; +import styled from 'styled-components'; +import Order from '../../../models/order'; +import AccountOrderTableHeader from './AccountOrderTable/AccountOrderTableHeader'; +import AccountOrderTableItem from './AccountOrderTable/AccountOrderTableItem'; +import getPaginatedArray from '../../../utils/getPaginatedArray'; +import ReviewPagination from '../../Review/ReviewPagination/ReviewPagination'; + +const ORDER_EMPTY_TEXT = '결제한 내역이 없습니다'; +const ORDER_PER_PAGE = 5; + +const Container = styled.div` + flex: 1; +`; + +const OrderTitle = styled.div` + font-size: ${(props) => props.theme.fontSize.medium}; + font-weight: 600; + margin-bottom: 20px; +`; + +const OrderEmpty = styled.div` + margin-top: 32px; + text-align: center; + font-size: ${(props) => props.theme.fontSize.small}; + color: ${(props) => props.theme.color.grey4}; +`; + +const AccountOrderTable = styled.div` + width: 100%; +`; + +const OrderPagination = styled(ReviewPagination)``; + +type Props = { + orders: Order[]; +}; + +const AccountOrder = (props: Props): JSX.Element => { + const { orders } = props; + const [currentPage, setCurrentPage] = useState(1); + const totalPages = Math.ceil(orders.length / ORDER_PER_PAGE); + const showPagination = totalPages > 1; + const displayedOrders = getPaginatedArray(orders, ORDER_PER_PAGE, currentPage); + + const handlePageNumClick = useCallback((pageNum: number) => setCurrentPage(pageNum), []); + + const handlePageNavButtonClick = useCallback( + (type: 'prev' | 'next') => + setCurrentPage((prevCurrentPage) => + type === 'prev' ? prevCurrentPage - 1 : prevCurrentPage + 1 + ), + [] + ); + + const AccountOrderItems = displayedOrders.map((order) => ( + + )); + + return ( + + 주문 관리 + {orders.length === 0 ? ( + {ORDER_EMPTY_TEXT} + ) : ( + <> + + + {AccountOrderItems} + + {showPagination && ( + + )} + + )} + + ); +}; + +export default AccountOrder; diff --git a/client/src/components/Account/AccountOrder/AccountOrderTable/AccountOrderTableHeader.tsx b/client/src/components/Account/AccountOrder/AccountOrderTable/AccountOrderTableHeader.tsx new file mode 100644 index 0000000..2e2558c --- /dev/null +++ b/client/src/components/Account/AccountOrder/AccountOrderTable/AccountOrderTableHeader.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Date, Recipient, Address, TotalPrice, ItemCount, Column } from './AccountOrderTableItem'; + +const Container = styled.div` + display: flex; + background-color: ${(props) => props.theme.color.grey1}; +`; + +const OrderDate = styled(Date)``; + +const OrderRecipient = styled(Recipient)``; + +const OrderAddress = styled(Address)``; + +const OrderTotalPrice = styled(TotalPrice)` + font-size: ${(props) => props.theme.fontSize.normal}; +`; + +const OrderItemCount = styled(ItemCount)``; + +const Padding = styled(Column)` + flex: 1; +`; + +const AccountOrderTableHeader = (): JSX.Element => { + return ( + + +
주문 날짜
+
+ +
받으실 분
+
+ +
받으실 곳
+
+ +
상품
+
+ +
최종 결제 가격
+
+ +
+ ); +}; + +export default AccountOrderTableHeader; diff --git a/client/src/components/Account/AccountOrder/AccountOrderTable/AccountOrderTableItem.tsx b/client/src/components/Account/AccountOrder/AccountOrderTable/AccountOrderTableItem.tsx new file mode 100644 index 0000000..7903f02 --- /dev/null +++ b/client/src/components/Account/AccountOrder/AccountOrderTable/AccountOrderTableItem.tsx @@ -0,0 +1,96 @@ +import React, { useState, useCallback } from 'react'; +import styled from 'styled-components'; +import Order from '../../../../models/order'; +import { getDate } from '../../../../utils/formatDate'; +import { toKoreanMoneyFormat } from '../../../../utils/moneyFormater'; +import AccountOrderTableItemDetail from './AccountOrderTableItemDetail'; +import { RiArrowDownSLine } from 'react-icons/ri'; + +export const Container = styled.div``; + +const AccountOrderTableItemSummary = styled.div` + flex: 1; + display: flex; + :hover { + cursor: pointer; + } +`; + +export const Column = styled.div` + font-size: ${(props) => props.theme.fontSize.small}; + display: flex; + justify-content: center; + align-items: center; + height: 60px; + border-bottom: 1px solid ${(props) => props.theme.color.grey2}; +`; + +export const Date = styled(Column)` + flex: 1; +`; + +export const Address = styled(Column)` + flex: 3; +`; + +export const Recipient = styled(Column)` + flex: 1; +`; + +export const TotalPrice = styled(Column)` + font-size: ${(props) => props.theme.fontSize.normal}; + font-weight: 500; + flex: 1; +`; + +export const ItemCount = styled(Column)` + flex: 1; +`; + +const ArrowDownWrapper = styled(Column)` + flex: 1; +`; + +type SeemoreProps = { + isCloseIcon: boolean; +}; + +const Seemore = styled.div` + transform: rotate(${(props) => (props.isCloseIcon ? '-180' : '0')}deg); + transition: transform 0.3s ease-in-out; +`; + +type Props = { + order: Order; +}; + +const AccountOrderTableItem = (props: Props): JSX.Element => { + const { order } = props; + const { recipientName, address, totalPrice, orderDetails, createdAt } = order; + const [orderDetailOpen, setOrderDetailOpen] = useState(false); + + const handleReviewSummaryClick = useCallback( + () => setOrderDetailOpen((orderDetailOpen) => !orderDetailOpen), + [] + ); + + return ( + + + {getDate(createdAt)} + {recipientName} +
{address}
+ {`${orderDetails.length}`} + {toKoreanMoneyFormat(totalPrice)} + + + + + +
+ {orderDetailOpen && } +
+ ); +}; + +export default AccountOrderTableItem; diff --git a/client/src/components/Account/AccountOrder/AccountOrderTable/AccountOrderTableItemDetail.tsx b/client/src/components/Account/AccountOrder/AccountOrderTable/AccountOrderTableItemDetail.tsx new file mode 100644 index 0000000..eedeb71 --- /dev/null +++ b/client/src/components/Account/AccountOrder/AccountOrderTable/AccountOrderTableItemDetail.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import styled from 'styled-components'; +import OrderDetail from '../../../../models/orderDetail'; +import NO_IMAGE from '../../../../assets/images/no-image.png'; +import { toKoreanMoneyFormat } from '../../../../utils/moneyFormater'; +import { Link } from '../../../../lib/router'; + +const Container = styled.div` + border-bottom: 1px solid ${(props) => props.theme.color.grey2}; +`; + +const Row = styled.div` + display: flex; +`; + +const Column = styled.div` + flex: 1; + display: flex; + justify-content: center; + align-items: center; + height: 80px; + margin: 10px 0px; +`; + +const ProductThumbnailWrapper = styled(Column)``; + +const ProductThumbnail = styled.img` + height: 60px; + :hover { + transform: scale(1.1); + } +`; + +const ProductName = styled(Column)` + flex: 2; + overflow: scroll; + white-space: nowrap; + text-overflow: ellipsis; + :hover { + color: ${(props) => props.theme.color.grey5}; + } +`; +const ProductOption = styled(Column)` + flex: 2; +`; +const Quantity = styled(Column)` + flex: 1; +`; +const Price = styled(Column)` + flex: 2; + display: flex; +`; + +const OriginPrice = styled.div``; + +const DiscountRate = styled.div` + font-size: ${(props) => props.theme.fontSize.small}; + color: ${(props) => props.theme.color.grey4}; +`; + +type Props = { + orderDetails: OrderDetail[]; +}; + +const AccountOrderTableItemDetail = (props: Props): JSX.Element => { + const { orderDetails } = props; + + const OrderDetailItems = orderDetails.map((orderDetail) => { + return ( + + + + + + + + {orderDetail.product.name} + + {orderDetail.option || 'X'} + {`${orderDetail.quantity}개`} + + {toKoreanMoneyFormat(orderDetail.price * orderDetail.quantity)} + {orderDetail.discountRate > 0 && ( + {`(${orderDetail.discountRate}%)`} + )} + + + ); + }); + + return {OrderDetailItems}; +}; + +export default AccountOrderTableItemDetail; diff --git a/client/src/components/Account/AccountReview/AccountReview.tsx b/client/src/components/Account/AccountReview/AccountReview.tsx new file mode 100644 index 0000000..5dc58b6 --- /dev/null +++ b/client/src/components/Account/AccountReview/AccountReview.tsx @@ -0,0 +1,122 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import styled from 'styled-components'; +import { ReviewWithProduct } from '../../../models/review'; +import getPaginatedArray from '../../../utils/getPaginatedArray'; +import ReviewList from './AccountReviewList/AccountReviewList'; +import ReviewPagination from '../../Review/ReviewPagination/ReviewPagination'; + +const REVIEW_EMPTY_TEXT = '작성한 후기가 없습니다'; +const REVIEW_PER_PAGE = 10; + +const Container = styled.div` + width: 100%; + margin: 0 auto; + margin-left: -80px; +`; + +const ReviewEmpty = styled.div` + margin-top: 32px; + text-align: center; + font-size: ${(props) => props.theme.fontSize.small}; + color: ${(props) => props.theme.color.grey4}; +`; + +const ReviewButtonGroup = styled.div` + display: flex; + justify-content: flex-end; + padding: 12px 0; +`; + +const Button = styled.button` + font-size: ${(props) => props.theme.fontSize.tiny}; + background-color: ${(props) => props.theme.color.white1}; + border: 1px solid ${(props) => props.theme.color.black}; + padding: 8px 12px; + cursor: pointer; +`; +const SelectAllButton = styled(Button)` + color: ${(props) => props.theme.color.black}; + margin-right: 12px; +`; + +const DeleteButton = styled(Button)` + color: ${(props) => props.theme.color.white1}; + background-color: ${(props) => props.theme.color.black}; +`; + +type Props = { + reviews: ReviewWithProduct[]; + onDeleteButtonClick: (reviews: ReviewWithProduct[]) => void; +}; +const Review = (props: Props): JSX.Element => { + const { reviews, onDeleteButtonClick } = props; + + const [currentPage, setCurrentPage] = useState(1); + const totalPages = Math.ceil(reviews.length / REVIEW_PER_PAGE); + const showPagination = totalPages > 1; + const displayedReviews = getPaginatedArray(reviews, REVIEW_PER_PAGE, currentPage); + + const [isSelectedList, setIsSelectedList] = useState([]); + const isAllSelected = isSelectedList.length && isSelectedList.every(Boolean); + + const getCheckboxClickHandler = (index: number) => () => + setIsSelectedList((prevList) => [ + ...prevList.slice(0, index), + !prevList[index], + ...prevList.slice(index + 1), + ]); + + const handlePageNumClick = useCallback((pageNum: number) => setCurrentPage(pageNum), []); + const handlePageNavButtonClick = useCallback( + (type: 'prev' | 'next') => + setCurrentPage((prevCurrentPage) => + type === 'prev' ? prevCurrentPage - 1 : prevCurrentPage + 1 + ), + [] + ); + const handleCheckAllButtonClick = useCallback( + () => setIsSelectedList(new Array(displayedReviews.length).fill(!isAllSelected)), + [displayedReviews.length, isAllSelected] + ); + const handleDeleteButtonClick = useCallback(() => { + const deleteReviews = displayedReviews.filter((review, index) => isSelectedList[index]); + + onDeleteButtonClick(deleteReviews); + }, [displayedReviews, isSelectedList, onDeleteButtonClick]); + + useEffect(() => { + setIsSelectedList(new Array(displayedReviews.length).fill(false)); + }, [displayedReviews.length]); + + return ( + + {reviews.length === 0 ? ( + {REVIEW_EMPTY_TEXT} + ) : ( + <> + + + {isAllSelected ? '선택 해제' : '전체 선택'} + + 삭제 + + + {showPagination && ( + + )} + + )} + + ); +}; + +export default Review; diff --git a/client/src/components/Account/AccountReview/AccountReviewList/AccountReviewList.tsx b/client/src/components/Account/AccountReview/AccountReviewList/AccountReviewList.tsx new file mode 100644 index 0000000..ba5f8ea --- /dev/null +++ b/client/src/components/Account/AccountReview/AccountReviewList/AccountReviewList.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import styled from 'styled-components'; +import { ReviewWithProduct } from '../../../../models/review'; +import ReviewListItem from './AccountReviewListItem/AccountReviewListItem'; + +const Container = styled.ul` + margin: 16px 0; +`; + +type Props = { + reviews: ReviewWithProduct[]; + isSelectedList: boolean[]; + getCheckboxClickHandler: (index: number) => () => void; +}; + +const ReviewList = (props: Props): JSX.Element => { + const { reviews, isSelectedList, getCheckboxClickHandler } = props; + + const ReviewListItems = reviews.map((review, i) => ( + + )); + + return {ReviewListItems}; +}; + +export default ReviewList; diff --git a/client/src/components/Account/AccountReview/AccountReviewList/AccountReviewListItem/AccountReviewListItem.tsx b/client/src/components/Account/AccountReview/AccountReviewList/AccountReviewListItem/AccountReviewListItem.tsx new file mode 100644 index 0000000..758d2fe --- /dev/null +++ b/client/src/components/Account/AccountReview/AccountReviewList/AccountReviewListItem/AccountReviewListItem.tsx @@ -0,0 +1,90 @@ +import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import styled from 'styled-components'; +import { ReviewWithProduct } from '../../../../../models/review'; +import formatDate from '../../../../../utils/formatDate'; +import ReviewDetail from '../../../../Review/ReviewList/ReviewListItem/ReviewDetail/ReviewDetail'; +import ReviewSummary from '../../../../Review/ReviewList/ReviewListItem/ReviewSummary/ReviewSummary'; +import Star from '../../../../Review/ReviewList/ReviewListItem/Star/Star'; + +const MAX_TITLE_WIDTH = 550; +const MAX_REVIEW_POINT = 5; + +const Container = styled.li` + padding: 10px 20px; + border-bottom: 1px solid ${(props) => props.theme.color.grey2}; + + :first-child { + padding-top: 0; + } +`; + +const ReviewDisplayContainer = styled.div` + display: flex; + align-items: center; +`; + +const ReviewStarsContainer = styled.div` + flex-shrink: 0; + width: 80px; + margin-right: 30px; + display: flex; +`; + +const ReviewDate = styled.div` + flex-shrink: 0; + width: 100px; + font-size: ${(props) => props.theme.fontSize.tiny}; + text-align: right; +`; + +const ReviewCheckbox = styled.input` + margin-left: 20px; +`; + +type Props = { + review: ReviewWithProduct; + isSelected: boolean; + onCheckboxClick: () => void; +}; + +const ReviewListItem = (props: Props): JSX.Element => { + const { review, isSelected, onCheckboxClick } = props; + const [reviewDetailOpen, setReviewDetailOpen] = useState(false); + const titleText = `[${review.productName}] ${review.content}`; + const titleRef = useRef(null); + const [hasMoreContent, setHasMoreContent] = useState(review.reviewImages.length > 0); + const Stars = Array.from({ length: MAX_REVIEW_POINT }).map((_, i) => ( + + )); + + useLayoutEffect(() => { + const isTitleOverflowed = titleRef.current?.clientWidth === MAX_TITLE_WIDTH; + setHasMoreContent((hasMoreContent) => hasMoreContent || isTitleOverflowed); + }, []); + + const handleReviewSummaryClick = useCallback( + () => setReviewDetailOpen((reviewDetailOpen) => !reviewDetailOpen), + [setReviewDetailOpen] + ); + + return ( + + + {Stars} + + {formatDate(review.updatedAt)} + + + {reviewDetailOpen && hasMoreContent && } + + ); +}; + +export default ReviewListItem; diff --git a/client/src/components/App/App.tsx b/client/src/components/App/App.tsx new file mode 100644 index 0000000..c99763e --- /dev/null +++ b/client/src/components/App/App.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import theme from '../../styles/theme'; +import GlobalStyle from '../../styles/global'; +import { Route, Router, Switch } from '../../lib/router'; +import Header from '../Header/Header'; +import HomePage from '../../pages/Home'; +import LoginPage from '../../pages/Login'; +import CartPage from '../../pages/Cart'; +import LogoutPage from '../../pages/Logout'; +import ProductPage from '../../pages/Product'; +import ProductsPage from '../../pages/Products'; +import AccountPage from '../../pages/Account'; +import NotfoundPage from '../../pages/Notfound'; +import ErrorPage from '../../pages/Error'; +import ToastPortal from '../Portal/ToastPortal'; +import OrderPaymentPage from '../../pages/OrderPayment'; +import ConfirmModalPortal from '../Portal/ConfirmModalPortal'; +import Footer from '../Footer/Footer'; + +const App = (): JSX.Element => { + return ( + + + +
+ + + + + + + + + + + + + + + +