├── .eslintignore ├── .eslintrc ├── .gitattributes ├── .github └── workflows │ ├── deploy.yml │ └── test-pr.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── apps ├── api │ ├── .env-sample │ ├── .eslintrc │ ├── app.js │ ├── docker-compose.yml │ ├── init.sql │ ├── jest-after-all.ts │ ├── jest-cleanup.ts │ ├── jest-setup.ts │ ├── jest-utils.ts │ ├── jest.config.js │ ├── my-postgres.conf │ ├── package.json │ ├── prisma │ │ ├── migrations │ │ │ ├── 20210525173859_ │ │ │ │ └── migration.sql │ │ │ └── migration_lock.toml │ │ └── schema.prisma │ ├── src │ │ ├── config.ts │ │ ├── db.ts │ │ ├── global.d.ts │ │ ├── index.ts │ │ ├── models.ts │ │ ├── modules │ │ │ └── products │ │ │ │ ├── productRoutes.test.ts │ │ │ │ ├── productRoutes.ts │ │ │ │ ├── productSchemas.ts │ │ │ │ ├── taxesRoutes.test.ts │ │ │ │ ├── taxesRoutes.ts │ │ │ │ └── taxesSchemas.ts │ │ ├── plugins │ │ │ ├── auth │ │ │ │ ├── auth.test.ts │ │ │ │ ├── authSchemas.ts │ │ │ │ ├── functions.ts │ │ │ │ ├── includes.ts │ │ │ │ └── index.ts │ │ │ ├── cart │ │ │ │ ├── cart.test.ts │ │ │ │ ├── cartFunctions.ts │ │ │ │ ├── cartSchemas.ts │ │ │ │ └── index.ts │ │ │ ├── email │ │ │ │ └── index.ts │ │ │ ├── media │ │ │ │ ├── index.ts │ │ │ │ ├── mediaFunctions.ts │ │ │ │ └── mediaSchemas.ts │ │ │ └── order │ │ │ │ ├── index.ts │ │ │ │ ├── order.test.ts │ │ │ │ ├── orderFunctions.ts │ │ │ │ └── orderSchemas.ts │ │ ├── prisma │ │ │ ├── prisma-errors.ts │ │ │ └── prisma-helpers.ts │ │ ├── server.ts │ │ └── types.ts │ ├── tsconfig.json │ └── typings │ │ └── global │ │ └── index.d.ts ├── calculations │ ├── .eslintrc │ ├── index.test.ts │ ├── index.ts │ ├── jest-setup.ts │ ├── jest.config.js │ ├── package.json │ └── tsconfig.json ├── types │ ├── index.ts │ ├── package.json │ └── types.ts └── www │ ├── .babelrc │ ├── .env.development │ ├── .eslintignore │ ├── .eslintrc │ ├── .gitignore │ ├── app.js │ ├── assets │ ├── bag.svg │ ├── blik.png │ ├── cart.svg │ ├── hamburger.svg │ ├── mastercard_icon.svg │ ├── paypal.png │ ├── success.svg │ ├── user.svg │ └── visa_master.png │ ├── components │ ├── admin │ │ ├── Header.tsx │ │ ├── adminLayout │ │ │ └── AdminLayout.tsx │ │ ├── adminOrders │ │ │ └── AdminOrders.tsx │ │ ├── adminProducts │ │ │ └── AdminProducts.tsx │ │ ├── adminSingleOrder │ │ │ └── AdminSingleOrder.tsx │ │ ├── adminSingleProduct │ │ │ ├── AdminSingleProduct.test.tsx │ │ │ └── AdminSingleProduct.tsx │ │ ├── auth │ │ │ └── Auth.tsx │ │ ├── contentWrapper │ │ │ ├── ContentWrapper.tsx │ │ │ └── contentWrapper.module.scss │ │ ├── deleteProductConfirmationModal │ │ │ └── DeleteProductConfirmationModal.tsx │ │ ├── loadingIndicator │ │ │ ├── LoadingIndicator.tsx │ │ │ └── loadingIndicator.module.scss │ │ ├── loginForm │ │ │ ├── LoginForm.module.scss │ │ │ ├── LoginForm.test.tsx │ │ │ ├── LoginForm.tsx │ │ │ └── loginFormUtils.ts │ │ ├── orderForm │ │ │ ├── OrderForm.tsx │ │ │ ├── OrderFormSkeleton.tsx │ │ │ └── OrderStatusSelect.tsx │ │ ├── ordersList │ │ │ ├── OrdersList.tsx │ │ │ ├── OrdersListCell.tsx │ │ │ ├── OrdersTable.tsx │ │ │ ├── constants.ts │ │ │ └── utils.ts │ │ ├── productsForm │ │ │ ├── ProductSlug.tsx │ │ │ ├── ProductsForm.module.scss │ │ │ ├── ProductsForm.test.tsx │ │ │ ├── ProductsForm.tsx │ │ │ └── ProductsFormSkeleton.tsx │ │ ├── productsList │ │ │ ├── ProductFields.ts │ │ │ ├── ProductListUtils.ts │ │ │ ├── ProductsList.module.scss │ │ │ ├── ProductsList.tsx │ │ │ ├── ProductsTable.tsx │ │ │ ├── ProductsTableToolbar.tsx │ │ │ └── productsListCells │ │ │ │ └── ProductsListCells.tsx │ │ ├── sideNav │ │ │ └── SideNav.tsx │ │ ├── toasts │ │ │ ├── Toasts.tsx │ │ │ └── toasts.module.scss │ │ └── utils.ts │ └── klient │ │ ├── modules │ │ ├── cart │ │ │ ├── Cart.tsx │ │ │ └── components │ │ │ │ ├── item │ │ │ │ ├── CartItem.tsx │ │ │ │ ├── quantity │ │ │ │ │ ├── CartQuantityButton.tsx │ │ │ │ │ └── CartQuantityInput.tsx │ │ │ │ └── removeButton │ │ │ │ │ └── RemoveButton.tsx │ │ │ │ ├── list │ │ │ │ └── CartList.tsx │ │ │ │ └── summary │ │ │ │ ├── CartSummary.tsx │ │ │ │ └── summaryButton │ │ │ │ └── SummaryButton.tsx │ │ ├── checkout │ │ │ ├── Checkout.tsx │ │ │ ├── CheckoutInProgress.tsx │ │ │ ├── components │ │ │ │ ├── addressForm │ │ │ │ │ └── AddressForm.tsx │ │ │ │ ├── formErrorMessage │ │ │ │ │ └── FormErrorMessage.tsx │ │ │ │ ├── item │ │ │ │ │ └── CheckoutItemRow.tsx │ │ │ │ ├── list │ │ │ │ │ └── CheckoutList.tsx │ │ │ │ ├── stripeAfterPaymentMessage │ │ │ │ │ └── StripeAfterPaymentMessage.tsx │ │ │ │ └── summary │ │ │ │ │ ├── CheckoutSummary.tsx │ │ │ │ │ ├── payment │ │ │ │ │ ├── PaymentMethod.tsx │ │ │ │ │ └── StripeCard.tsx │ │ │ │ │ ├── shippment │ │ │ │ │ └── ShippmentMethod.tsx │ │ │ │ │ └── total │ │ │ │ │ └── CheckoutTotal.tsx │ │ │ └── utils │ │ │ │ └── useStripePayment.tsx │ │ ├── featuredProduct │ │ │ ├── FeaturedProduct.tsx │ │ │ ├── amount │ │ │ │ └── Amount.tsx │ │ │ ├── breadcrumbs │ │ │ │ ├── Breadcrumbs.module.css │ │ │ │ └── Breadcrumbs.tsx │ │ │ └── productInfo │ │ │ │ └── ProductInfo.tsx │ │ ├── hero │ │ │ └── Hero.tsx │ │ └── productCollection │ │ │ ├── ProductCollection.tsx │ │ │ └── components │ │ │ ├── product │ │ │ ├── Product.tsx │ │ │ └── components │ │ │ │ ├── addToCartButton │ │ │ │ └── AddToCartButton.tsx │ │ │ │ ├── description │ │ │ │ └── ProductDescription.tsx │ │ │ │ └── image │ │ │ │ └── ProductImage.tsx │ │ │ └── topBar │ │ │ └── TopBar.tsx │ │ ├── shared │ │ ├── api │ │ │ └── addToCart.ts │ │ ├── button │ │ │ └── Button.tsx │ │ ├── components │ │ │ ├── betaNotification │ │ │ │ ├── BetaNotification.module.scss │ │ │ │ └── BetaNotification.tsx │ │ │ ├── betterLink │ │ │ │ ├── BetterLink.tsx │ │ │ │ └── betterLink.module.css │ │ │ ├── cartStatus │ │ │ │ ├── CartStatus.test.tsx │ │ │ │ └── CartStatus.tsx │ │ │ ├── footer │ │ │ │ └── Footer.tsx │ │ │ ├── header │ │ │ │ └── Header.tsx │ │ │ ├── icons │ │ │ │ ├── BagIcon.tsx │ │ │ │ ├── HamburgerIcon.tsx │ │ │ │ ├── HeartIcon.tsx │ │ │ │ ├── SearchIcon.tsx │ │ │ │ ├── ShoppingCartIcon.tsx │ │ │ │ ├── SortIcon.tsx │ │ │ │ ├── SuccessIcon.tsx │ │ │ │ └── UserIcon.tsx │ │ │ ├── layout │ │ │ │ └── Layout.tsx │ │ │ ├── menu │ │ │ │ └── Menu.tsx │ │ │ ├── price │ │ │ │ └── Price.tsx │ │ │ └── toast │ │ │ │ └── Toast.tsx │ │ ├── image │ │ │ └── CartItemImage.tsx │ │ └── utils │ │ │ └── useCart.ts │ │ └── utils │ │ └── formUtils.tsx │ ├── cypress.json │ ├── cypress │ ├── fixtures │ │ └── example.json │ ├── integration │ │ └── klient │ │ │ └── home.spec.ts │ ├── plugins │ │ └── index.js │ ├── support │ │ ├── commands.ts │ │ └── index.ts │ └── tsconfig.json │ ├── jest-setup.ts │ ├── jest-utils.tsx │ ├── jest.config.js │ ├── next-env.d.ts │ ├── next.config.js │ ├── package.json │ ├── pages │ ├── _app.tsx │ ├── admin │ │ ├── add-product.tsx │ │ ├── index.tsx │ │ ├── login.tsx │ │ ├── orders │ │ │ ├── [orderId].tsx │ │ │ └── index.tsx │ │ └── products │ │ │ ├── [productId].tsx │ │ │ └── index.tsx │ ├── index.tsx │ ├── koszyk │ │ └── index.tsx │ ├── produkty │ │ ├── [productSlug].tsx │ │ └── index.tsx │ └── zamowienie │ │ ├── [orderId].tsx │ │ └── index.tsx │ ├── postcss.config.js │ ├── public │ └── favicon.ico │ ├── styles │ ├── components │ │ ├── AdminSingleProduct.module.scss │ │ ├── cart.css │ │ ├── hero.css │ │ ├── stripe.css │ │ └── toast.css │ ├── index.css │ └── utils │ │ └── utils.css │ ├── tailwind.config.js │ ├── test │ └── __mocks__ │ │ └── fileMock.js │ ├── tsconfig.json │ ├── types │ ├── order.ts │ └── product.ts │ └── utils │ ├── api │ ├── createProduct.ts │ ├── deleteProduct.ts │ ├── deleteProducts.ts │ ├── getAllOrderStatuses.ts │ ├── queryHooks.ts │ ├── updateOrder.ts │ └── updateProduct.ts │ ├── currency.ts │ ├── fetcher.test.ts │ ├── fetcher.ts │ ├── fetcherTypes.ts │ ├── formUtils.tsx │ ├── hooks.ts │ ├── serverErrorHandler.test.ts │ ├── serverErrorHandler.ts │ └── types.ts ├── lerna.json ├── package.json ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | .next 3 | out 4 | previous-size-snapshot.json 5 | current-size-snapshot.json 6 | size-snapshot.json 7 | analyze.next 8 | .deployment-url 9 | .basebranch 10 | package-lock.json 11 | spmdb/ 12 | spmlogs/ 13 | newrelic.js 14 | 15 | 16 | node_modules 17 | .tmp 18 | .idea 19 | .DS_Store 20 | .version 21 | dist 22 | .history 23 | 24 | # Logs 25 | logs 26 | *.log 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | # Runtime data 32 | pids 33 | *.pid 34 | *.seed 35 | *.pid.lock 36 | 37 | junit 38 | test-results.xml 39 | 40 | # Directory for instrumented libs generated by jscoverage/JSCover 41 | lib-cov 42 | 43 | # Coverage directory used by tools like istanbul 44 | coverage 45 | 46 | # nyc test coverage 47 | .nyc_output 48 | 49 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 50 | .grunt 51 | 52 | # Bower dependency directory (https://bower.io/) 53 | bower_components 54 | 55 | # node-waf configuration 56 | .lock-wscript 57 | 58 | # Compiled binary addons (http://nodejs.org/api/addons.html) 59 | build/Release 60 | 61 | # Dependency directories 62 | node_modules/ 63 | jspm_packages/ 64 | 65 | # Optional npm cache directory 66 | .npm 67 | 68 | # Optional eslint cache 69 | .eslintcache 70 | 71 | # Optional REPL history 72 | .node_repl_history 73 | 74 | # Output of 'npm pack' 75 | *.tgz 76 | 77 | # Yarn Integrity file 78 | .yarn-integrity 79 | 80 | # dotenv environment variables file 81 | .env 82 | .env.dev 83 | apps/www/.env.staging 84 | apps/www/.env.production 85 | 86 | *.tsbuildinfo 87 | 88 | # cypress 89 | 90 | apps/types/types.ts 91 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | apps/api/prisma/migrations/** linguist-generated=true 2 | apps/types/types.ts linguist-generated=true 3 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to staging and production 2 | 3 | on: 4 | push: 5 | branches: [develop] 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: typeofweb/typeofweb-deploy-mydevil-action@v0.0.20 13 | with: 14 | host: 's18.mydevil.net' 15 | username: 'typeofweb' 16 | ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }} 17 | frontend_subdomain: 'www' 18 | api_subdomain: 'api' 19 | env: 'production' 20 | domain: typeof.shop 21 | project_directory: '/home/typeofweb/domains/typeof.shop' 22 | -------------------------------------------------------------------------------- /.github/workflows/test-pr.yml: -------------------------------------------------------------------------------- 1 | name: Test and Build 2 | 3 | on: 4 | pull_request: 5 | branches: [develop, main] 6 | 7 | jobs: 8 | tests: 9 | if: "! contains(toJSON(github.event.commits.*.message), '[skip-ci]')" 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | with: 15 | fetch-depth: 100 16 | 17 | - name: Read .nvmrc 18 | run: echo "##[set-output name=NVMRC;]$(cat .nvmrc)" 19 | id: nvm 20 | - name: Use Node.js 21 | uses: actions/setup-node@v1 22 | with: 23 | node-version: '${{ steps.nvm.outputs.NVMRC }}' 24 | - name: Get yarn cache directory path 25 | id: yarn-cache-dir-path 26 | run: echo "::set-output name=dir::$(yarn cache dir)" 27 | 28 | - uses: actions/cache@v2 29 | id: yarn-cache 30 | with: 31 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 32 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 33 | restore-keys: | 34 | ${{ runner.os }}-yarn- 35 | ${{ runner.os }}- 36 | 37 | - name: Install dependencies 38 | run: yarn install --frozen-lockfile 39 | 40 | - name: Copy .env 41 | run: cp apps/api/.env-sample apps/api/.env 42 | 43 | - name: Run tests 44 | run: | 45 | yarn tsc 46 | yarn eslint 47 | yarn test:ci 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .tmp 3 | .idea 4 | .idea/ 5 | .DS_Store 6 | .env 7 | .version 8 | dist 9 | .history 10 | 11 | # Logs 12 | logs 13 | *.log 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | 18 | # Runtime data 19 | pids 20 | *.pid 21 | *.seed 22 | *.pid.lock 23 | 24 | junit 25 | test-results.xml 26 | 27 | # Directory for instrumented libs generated by jscoverage/JSCover 28 | lib-cov 29 | 30 | # Coverage directory used by tools like istanbul 31 | coverage 32 | 33 | # nyc test coverage 34 | .nyc_output 35 | 36 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | bower_components 41 | 42 | # node-waf configuration 43 | .lock-wscript 44 | 45 | # Compiled binary addons (http://nodejs.org/api/addons.html) 46 | build/Release 47 | 48 | # Dependency directories 49 | node_modules/ 50 | jspm_packages/ 51 | 52 | # Optional npm cache directory 53 | .npm 54 | 55 | # Optional eslint cache 56 | .eslintcache 57 | 58 | # Optional REPL history 59 | .node_repl_history 60 | 61 | # Output of 'npm pack' 62 | *.tgz 63 | 64 | # Yarn Integrity file 65 | .yarn-integrity 66 | 67 | # dotenv environment variables file 68 | .env 69 | .env.dev 70 | 71 | *.tsbuildinfo 72 | .version 73 | apps/www/.next 74 | yarn.lock 75 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 14 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .next 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "dskwrk.vscode-generate-getter-setter", 5 | "esbenp.prettier-vscode", 6 | "pmneo.tsimporter", 7 | "prisma.prisma", 8 | "redhat.vscode-yaml", 9 | "vscode-icons-team.vscode-icons", 10 | "bradlc.vscode-tailwindcss" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "editor.formatOnSave": true, 5 | "[prisma]": { 6 | "editor.defaultFormatter": "Prisma.prisma" 7 | }, 8 | "eslint.run": "onSave", 9 | "editor.codeActionsOnSave": { 10 | "source.fixAll": true 11 | }, 12 | "typescript.preferences.importModuleSpecifier": "relative", 13 | "javascript.preferences.importModuleSpecifier": "relative" 14 | } 15 | -------------------------------------------------------------------------------- /apps/api/.env-sample: -------------------------------------------------------------------------------- 1 | HOST=api.sklep.localhost 2 | PORT=3002 3 | 4 | DATABASE_URL="postgresql://postgres:sklep@localhost:5432/sklep?schema=public" 5 | 6 | COOKIE_DOMAIN="sklep.localhost" 7 | COOKIE_PASSWORD="ACY7cq729YWpn-tfGBm8QMRa4@WRBhmCorEyK4L9boPiQK-cjGAwVc!FcpMnxxZhx8kM3d2d4JvbUxm7@Fejz84KDGHNBcxZB6F9" 8 | 9 | CART_COOKIE_PASSWORD=".bvuG*vvbUEuyTR@WxUZf4RUhhu@J3h7" 10 | 11 | STRIPE_API_KEY=TWÓJ_KLUCZ 12 | STRIPE_API_KEY='sk_test_51HXZFYFCiYl0PHOKTuIu7snasL9K9doVQUckx5MAGtB57v2hXsrraSuKH5SsyH4pMp9cMdukDOETOgpPlMGQbdq400vR29pbsm' 13 | STRIPE_WEBHOOK_SECRET='' 14 | -------------------------------------------------------------------------------- /apps/api/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": false, 3 | "parserOptions": { 4 | "project": "tsconfig.json" 5 | }, 6 | "rules": { 7 | "import/no-default-export": "error" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /apps/api/app.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | require('ts-node/register/transpile-only'); 3 | require('./src/index.ts'); 4 | -------------------------------------------------------------------------------- /apps/api/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | postgres: 5 | image: postgres:11.5-alpine 6 | ports: 7 | - '5432:5432' 8 | environment: 9 | POSTGRES_USER: postgres 10 | POSTGRES_DB: sklep 11 | POSTGRES_PASSWORD: sklep 12 | volumes: 13 | - ./init.sql:/docker-entrypoint-initdb.d/init.sql 14 | -------------------------------------------------------------------------------- /apps/api/init.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE sklep_test; 2 | 3 | -------------------------------------------------------------------------------- /apps/api/jest-after-all.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from './src/db'; 2 | 3 | afterAll(async () => { 4 | await prisma.$disconnect(); 5 | }); 6 | -------------------------------------------------------------------------------- /apps/api/jest-cleanup.ts: -------------------------------------------------------------------------------- 1 | import { getConfig } from './src/config'; 2 | import { prisma } from './src/db'; 3 | const dbName = getConfig('DB_NAME'); 4 | if (!dbName.includes('test')) { 5 | process.exitCode = 2; 6 | throw new Error('Invalid DB'); 7 | } 8 | 9 | void (async () => { 10 | await prisma.$queryRaw(`DROP SCHEMA IF EXISTS public CASCADE;`); 11 | await prisma.$queryRaw(`CREATE SCHEMA public;`); 12 | })() 13 | .catch((err) => { 14 | console.error(err); 15 | process.exit(3); 16 | }) 17 | .then(() => { 18 | process.exit(); 19 | }); 20 | -------------------------------------------------------------------------------- /apps/api/jest-setup.ts: -------------------------------------------------------------------------------- 1 | import Dotenv from 'dotenv'; 2 | Dotenv.config(); 3 | 4 | process.on('unhandledRejection', (err) => { 5 | fail(err); 6 | }); 7 | -------------------------------------------------------------------------------- /apps/api/jest-utils.ts: -------------------------------------------------------------------------------- 1 | import type { Server, ServerInjectOptions, ServerInjectResponse } from '@hapi/hapi'; 2 | import { reduce } from 'bluebird'; 3 | import Cookie from 'cookie'; 4 | import Faker from 'faker'; 5 | import { join, chain, map, path, pipe, reject, toPairs, flatten, isNil } from 'ramda'; 6 | 7 | import { prisma } from './src/db'; 8 | import { Enums } from './src/models'; 9 | import { getServerWithPlugins } from './src/server'; 10 | 11 | export const getServerForTest = async () => { 12 | const server = await getServerWithPlugins(); 13 | 14 | // @note we could replace this with a mock in the future 15 | // @ts-expect-error 16 | server.app.db = prisma; 17 | 18 | return server; 19 | }; 20 | 21 | export const createAndAuthRole = async ( 22 | server: Server, 23 | role: keyof Enums['UserRole'] = Enums.UserRole.ADMIN, 24 | ) => { 25 | const firstName = Faker.name.firstName(); 26 | const lastName = Faker.name.lastName(); 27 | const email = Faker.internet.email(firstName, lastName, 'typeofweb.com'); 28 | const password = 'asdASD123!@#'; 29 | 30 | await server.inject({ 31 | method: 'POST', 32 | url: '/auth/register', 33 | payload: { 34 | email, 35 | password, 36 | }, 37 | }); 38 | await server.app.db.user.update({ where: { email }, data: { role } }); 39 | 40 | const loginInjection = await server.inject({ 41 | method: 'POST', 42 | url: '/auth/login', 43 | payload: { 44 | email, 45 | password, 46 | }, 47 | }); 48 | const cookies = loginInjection.headers['set-cookie']; 49 | const parsedCookies = Cookie.parse(String(cookies ?? '')); 50 | 51 | return { 52 | email, 53 | password, 54 | headers: { 55 | Cookie: `session=${parsedCookies['session']}`, 56 | }, 57 | }; 58 | }; 59 | 60 | export const repeatRequest = (n: number, fn: () => Promise): Promise => { 61 | const repetitions = Array.from({ length: n }, () => fn()); 62 | return Promise.all(repetitions); 63 | }; 64 | 65 | export const execute = (server: Server, injections: readonly ServerInjectOptions[]) => { 66 | return reduce( 67 | injections, 68 | async (acc, injection) => { 69 | const getCookieHeader = path([ 70 | 'headers', 71 | 'set-cookie', 72 | ]); 73 | const allCookies = pipe( 74 | map(getCookieHeader), 75 | flatten, 76 | reject(isNil), 77 | )(acc) as readonly string[]; 78 | 79 | const parsedCookies = pipe( 80 | map(Cookie.parse), 81 | chain(toPairs), 82 | map(join('=')), 83 | join('; '), 84 | )(allCookies); 85 | 86 | const result = await server.inject({ 87 | ...injection, 88 | headers: { 89 | ...injection.headers, 90 | Cookie: parsedCookies, 91 | }, 92 | }); 93 | return [...acc, result]; 94 | }, 95 | [] as readonly ServerInjectResponse[], 96 | ); 97 | }; 98 | -------------------------------------------------------------------------------- /apps/api/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | transform: { 4 | '^.+\\.(t|j)sx?$': ['@swc-node/jest'], 5 | }, 6 | setupFiles: ['./jest-setup.ts'], 7 | setupFilesAfterEnv: ['jest-extended', './jest-after-all.ts'], 8 | }; 9 | -------------------------------------------------------------------------------- /apps/api/my-postgres.conf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/sklep/174cbe47d8b134140c248d8df5f77aa2adb4c30b/apps/api/my-postgres.conf -------------------------------------------------------------------------------- /apps/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "version": "1.0.0-alpha.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "dev": "concurrently --kill-others-on-fail \"yarn:dev:start:*\"", 8 | "dev:start:server": "wait-on tcp:5432 --interval 5000 && yarn db:migrate:up && ts-node-dev -s -H -T --exit-child --respawn src/index.ts", 9 | "dev:start:db": "docker-compose up", 10 | "db:stop": "docker-compose down", 11 | "db:migrate:up": "prisma migrate dev && prisma generate", 12 | "eslint": "eslint ./src --ext .js,.jsx,.ts,.tsx --fix", 13 | "test": "cross-env DATABASE_URL=\"postgresql://postgres:sklep@localhost:5432/sklep_test?schema=public\" concurrently --kill-others-on-fail \"yarn:test:*\"", 14 | "test:db": "docker-compose up", 15 | "test:server": "wait-on tcp:5432 --interval 5000 && (yarn ts-node-script ./jest-cleanup.ts || true) && yarn db:migrate:up && jest --detectOpenHandles --verbose", 16 | "test:lint": "wait-on tcp:5432 --interval 5000 && yarn run eslint && yarn run tsc", 17 | "tsc": "tsc --noEmit", 18 | "test_:ci": "docker-compose up -d && cross-env DATABASE_URL=\"postgresql://postgres:sklep@localhost:5432/sklep_test?schema=public\" yarn test:server && yarn test:lint" 19 | }, 20 | "dependencies": { 21 | "@hapi/bell": "12.2.0", 22 | "@hapi/boom": "9.1.2", 23 | "@hapi/cookie": "11.0.2", 24 | "@hapi/hapi": "20.1.3", 25 | "@hapi/inert": "6.0.3", 26 | "@hapi/joi": "17.1.1", 27 | "@hapi/vision": "6.1.0", 28 | "@prisma/client": "2.23.0", 29 | "@sklep/calculations": "^1.0.0-alpha.0", 30 | "bcrypt": "5.0.1", 31 | "bluebird": "3.7.2", 32 | "dotenv": "10.0.0", 33 | "hapi-swagger": "14.1.3", 34 | "image-size": "1.0.0", 35 | "joi": "17.4.0", 36 | "ms": "2.1.3", 37 | "ramda": "0.27.1", 38 | "slugify": "1.5.3", 39 | "stripe": "8.150.0", 40 | "zxcvbn": "4.4.2" 41 | }, 42 | "devDependencies": { 43 | "@swc-node/jest": "1.3.0", 44 | "@tsconfig/node12": "1.0.7", 45 | "@types/bcrypt": "5.0.0", 46 | "@types/bluebird": "3.5.35", 47 | "@types/cookie": "0.4.0", 48 | "@types/faker": "5.5.5", 49 | "@types/hapi__bell": "11.0.2", 50 | "@types/hapi__cookie": "10.1.2", 51 | "@types/hapi__hapi": "20.0.8", 52 | "@types/hapi__inert": "5.2.2", 53 | "@types/hapi__joi": "17.1.6", 54 | "@types/hapi__vision": "5.5.2", 55 | "@types/jest": "26.0.23", 56 | "@types/ms": "0.7.31", 57 | "@types/node": "15.6.1", 58 | "@types/ramda": "0.27.40", 59 | "@types/zxcvbn": "4.4.1", 60 | "cookie": "0.4.1", 61 | "cross-env": "7.0.3", 62 | "eslint": "7.27.0", 63 | "faker": "5.5.3", 64 | "jest": "27.0.2", 65 | "jest-extended": "0.11.5", 66 | "prisma": "2.23.0", 67 | "ts-node-dev": "1.1.6", 68 | "wait-on": "5.3.0" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /apps/api/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /apps/api/src/config.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'url'; 2 | 3 | import type { Nil } from '@sklep/types'; 4 | 5 | type NameToType = { 6 | readonly CART_COOKIE_PASSWORD: string; 7 | readonly COOKIE_DOMAIN: string; 8 | readonly COOKIE_PASSWORD: string; 9 | readonly DATABASE_URL: string; 10 | readonly DB_HOSTNAME: string; 11 | readonly DB_NAME: string; 12 | readonly DB_PASSWORD: string; 13 | readonly DB_USERNAME: string; 14 | readonly ENV: 'production' | 'staging' | 'development' | 'test'; 15 | readonly HOST: string; 16 | readonly NODE_ENV: 'production' | 'development'; 17 | readonly PORT: number; 18 | readonly STRIPE_API_KEY: string; 19 | readonly STRIPE_WEBHOOK_SECRET: string; 20 | }; 21 | 22 | function getConfigForName(name: T): Nil; 23 | function getConfigForName(name: keyof NameToType): Nil { 24 | const val = process.env[name]; 25 | const parsed = parse(process.env.DATABASE_URL || ''); 26 | 27 | switch (name) { 28 | case 'NODE_ENV': 29 | return val || 'development'; 30 | case 'ENV': 31 | return val || 'development'; 32 | case 'PORT': 33 | return Number.parseInt(val?.trim() || '3000', 10); 34 | case 'DB_USERNAME': 35 | return val || parsed.auth?.split(':')[0]; 36 | case 'DB_PASSWORD': 37 | return val || parsed.auth?.split(':')[1]; 38 | case 'DB_NAME': 39 | return val || parsed.pathname?.slice(1); 40 | case 'DB_HOSTNAME': 41 | return val || parsed.hostname; 42 | default: 43 | return val; 44 | } 45 | } 46 | 47 | export function getConfig(name: T): NameToType[T]; 48 | export function getConfig(name: keyof NameToType): NameToType[keyof NameToType] { 49 | const val = getConfigForName(name); 50 | 51 | if (!val) { 52 | throw new Error(`Cannot find environmental variable: ${name}`); 53 | } 54 | 55 | return val; 56 | } 57 | 58 | export const isProd = () => getConfig('ENV') === 'production'; 59 | export const isStaging = () => getConfig('ENV') === 'staging'; 60 | -------------------------------------------------------------------------------- /apps/api/src/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | const client = new PrismaClient(); 4 | 5 | export const initDb = async () => { 6 | const result = await client.$queryRaw< 7 | ReadonlyArray> 8 | >`SELECT 1=1 AS "database ready";`; 9 | return result[0]; 10 | }; 11 | 12 | /** 13 | * @deprecated Use `request.server.app.db` instead 14 | */ 15 | export const prisma = client; 16 | -------------------------------------------------------------------------------- /apps/api/src/global.d.ts: -------------------------------------------------------------------------------- 1 | import 'jest-extended'; 2 | import type { PrismaClient } from '@prisma/client'; 3 | 4 | declare module '@hapi/hapi' { 5 | export interface ServerApplicationState { 6 | readonly db: PrismaClient; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /apps/api/src/index.ts: -------------------------------------------------------------------------------- 1 | import Dotenv from 'dotenv'; 2 | 3 | import { initDb, prisma } from './db'; 4 | import { getServerWithPlugins } from './server'; 5 | 6 | Dotenv.config(); 7 | 8 | const start = async () => { 9 | console.log(await initDb()); 10 | const sklepServer = await getServerWithPlugins(); 11 | 12 | // @ts-expect-error this field is readonly 13 | sklepServer.app.db = prisma; 14 | 15 | await sklepServer.start(); 16 | 17 | console.info('Server running at:', sklepServer.info.uri); 18 | }; 19 | 20 | start().catch((err) => { 21 | console.error(err); 22 | process.exit(1); 23 | }); 24 | -------------------------------------------------------------------------------- /apps/api/src/models.ts: -------------------------------------------------------------------------------- 1 | import * as Prisma from '@prisma/client'; 2 | 3 | type PrismaDelegates = Pick>; 4 | type Awaited = T extends Promise ? R : never; 5 | 6 | export type Models = { 7 | readonly [K in keyof PrismaDelegates]: 'findFirst' extends keyof PrismaDelegates[K] 8 | ? NonNullable>> 9 | : never; 10 | }; 11 | 12 | // https://stackoverflow.com/questions/49579094/typescript-conditional-types-filter-out-readonly-properties-pick-only-requir 13 | type IfEquals = (() => T extends X ? 1 : 2) extends () => T extends Y 14 | ? 1 15 | : 2 16 | ? A 17 | : B; 18 | 19 | // eslint-disable-next-line functional/prefer-readonly-type -- not readonly 20 | type ReadonlyKeys = { 21 | // eslint-disable-next-line functional/prefer-readonly-type -- not readonly 22 | [P in keyof T]-?: IfEquals<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, never, P>; 23 | // eslint-disable-next-line functional/prefer-readonly-type -- not readonly 24 | }[keyof T]; 25 | 26 | type EnumsKeys = { 27 | readonly [K in keyof typeof Prisma]: typeof Prisma[K] extends Record 28 | ? typeof Prisma[K] extends (...args: readonly unknown[]) => unknown 29 | ? never 30 | : K 31 | : never; 32 | }[keyof typeof Prisma]; 33 | 34 | export type Enums = Pick; 35 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- extracting enums from Prisma 36 | export const Enums = Prisma as Enums; 37 | -------------------------------------------------------------------------------- /apps/api/src/modules/products/productSchemas.ts: -------------------------------------------------------------------------------- 1 | import type { SklepTypes } from '@sklep/types'; 2 | import Joi from 'joi'; 3 | 4 | import { Enums } from '../../models'; 5 | 6 | const productSchema = Joi.object({ 7 | id: Joi.number().required(), 8 | slug: Joi.string().required(), 9 | name: Joi.string().required(), 10 | description: Joi.string().required(), 11 | isPublic: Joi.boolean().required(), 12 | regularPrice: Joi.number().required(), 13 | discountPrice: Joi.number().optional().allow(null), 14 | type: Joi.string() 15 | .valid(...Object.keys(Enums.ProductType)) 16 | .required(), 17 | coverImageId: Joi.number().optional(), 18 | }); 19 | 20 | export const addProductPayloadSchema = Joi.object({ 21 | name: Joi.string().required(), 22 | description: Joi.string().required(), 23 | isPublic: Joi.boolean().required(), 24 | regularPrice: Joi.number().required(), 25 | discountPrice: Joi.number().optional().allow(null), 26 | type: Joi.string() 27 | .valid(...Object.keys(Enums.ProductType)) 28 | .required(), 29 | }).required(); 30 | 31 | export const addProductResponseSchema = Joi.object({ 32 | data: productSchema.required(), 33 | }).required(); 34 | 35 | export const editProductResponseSchema = Joi.object({ 36 | data: productSchema.required(), 37 | }).required(); 38 | 39 | export const editProductPayloadSchema = addProductPayloadSchema; 40 | 41 | export const editProductParamsSchema = Joi.object({ 42 | productId: Joi.number().required(), 43 | }).required(); 44 | 45 | export const getProductParamsSchema = Joi.object({ 46 | productIdOrSlug: Joi.alternatives().try(Joi.number(), Joi.string()).required(), 47 | }).required(); 48 | 49 | export const getProductResponseSchema = Joi.object({ 50 | data: productSchema.required(), 51 | }).required(); 52 | 53 | export const getProductsResponseSchema = Joi.object({ 54 | data: Joi.array().items(productSchema.optional()).required(), 55 | meta: Joi.object({ 56 | total: Joi.number().integer().required(), 57 | }).required(), 58 | }).required(); 59 | 60 | export const getProductsQuerySchema = Joi.object({ 61 | take: Joi.number().integer(), 62 | skip: Joi.number().integer(), 63 | }); 64 | -------------------------------------------------------------------------------- /apps/api/src/modules/products/taxesSchemas.ts: -------------------------------------------------------------------------------- 1 | import type { SklepTypes } from '@sklep/types'; 2 | import Joi from 'joi'; 3 | 4 | export const taxSchema = Joi.object({ 5 | id: Joi.number().required(), 6 | name: Joi.string().required(), 7 | taxRate: Joi.number().required(), 8 | }); 9 | 10 | export const addTaxResponseSchema = Joi.object({ 11 | data: taxSchema.required(), 12 | }).required(); 13 | 14 | export const addTaxPayloadSchema = Joi.object({ 15 | name: Joi.string().required(), 16 | taxRate: Joi.number().required(), 17 | }).required(); 18 | 19 | export const getTaxesResponseSchema = Joi.object({ 20 | data: Joi.array().items(taxSchema.optional()).required(), 21 | }).required(); 22 | 23 | export const getTaxParamsSchema = Joi.object({ 24 | taxId: Joi.number().required(), 25 | }).required(); 26 | 27 | export const getTaxResponseSchema = Joi.object({ 28 | data: taxSchema.required(), 29 | }).required(); 30 | 31 | export const editTaxPayloadSchema = addTaxPayloadSchema; 32 | 33 | export const editTaxParamsSchema = Joi.object({ 34 | taxId: Joi.number().required(), 35 | }).required(); 36 | 37 | export const editTaxResponseSchema = Joi.object({ 38 | data: taxSchema.required(), 39 | }).required(); 40 | -------------------------------------------------------------------------------- /apps/api/src/plugins/auth/authSchemas.ts: -------------------------------------------------------------------------------- 1 | import type { SklepTypes } from '@sklep/types'; 2 | import Joi from 'joi'; 3 | 4 | import { Enums } from '../../models'; 5 | 6 | export const loginPayloadSchema = Joi.object({ 7 | email: Joi.string().email().required(), 8 | password: Joi.string().required(), 9 | }); 10 | 11 | export const registerPayloadSchema = Joi.object({ 12 | email: Joi.string().email().required(), 13 | password: Joi.string().required(), 14 | }); 15 | 16 | export const meAuthSchema = Joi.object({ 17 | id: Joi.string().required(), 18 | validUntil: Joi.date().required(), 19 | userId: Joi.number().required(), 20 | createdAt: Joi.date(), 21 | updatedAt: Joi.date(), 22 | user: Joi.object({ 23 | id: Joi.number().required(), 24 | name: Joi.string().optional().allow('', null), 25 | email: Joi.string().email().required(), 26 | role: Joi.string() 27 | .valid(...Object.keys(Enums.UserRole)) 28 | .required(), 29 | createdAt: Joi.date(), 30 | updatedAt: Joi.date(), 31 | }).required(), 32 | }); 33 | 34 | export const meAuthResponseSchema = Joi.object({ 35 | data: meAuthSchema.required().allow(null), 36 | }).required(); 37 | -------------------------------------------------------------------------------- /apps/api/src/plugins/auth/functions.ts: -------------------------------------------------------------------------------- 1 | import Crypto from 'crypto'; 2 | 3 | import Boom from '@hapi/boom'; 4 | import type { Request } from '@hapi/hapi'; 5 | import Bcrypt from 'bcrypt'; 6 | import ms from 'ms'; 7 | import Zxcvbn from 'zxcvbn'; 8 | 9 | import type { Models } from '../../models'; 10 | 11 | import { sessionInclude } from './includes'; 12 | 13 | const SESSION_VALIDITY = ms('2 weeks'); 14 | 15 | export async function loginUser(request: Request, user: Models['user']) { 16 | const session = await request.server.app.db.session.create({ 17 | data: { 18 | id: Crypto.randomBytes(32).toString('hex'), 19 | validUntil: new Date(Date.now() + SESSION_VALIDITY), 20 | user: { 21 | connect: { id: user.id }, 22 | }, 23 | }, 24 | include: sessionInclude, 25 | }); 26 | 27 | request.cookieAuth.set(session); 28 | return session; 29 | } 30 | 31 | export function isPasswordStrongEnough(password: string) { 32 | const result = Zxcvbn(password); 33 | return result.score >= 3; 34 | } 35 | 36 | export async function createUser( 37 | request: Request, 38 | { email, password }: { readonly email: string; readonly password: string }, 39 | ) { 40 | if (!isPasswordStrongEnough(password)) { 41 | throw Boom.badRequest('TOO_EASY'); 42 | } 43 | 44 | const passwordHash = await Bcrypt.hash(password, 10); 45 | 46 | const user = await request.server.app.db.user.create({ 47 | data: { 48 | email, 49 | password: passwordHash, 50 | }, 51 | }); 52 | 53 | void request.server.events.emit('auth:user:registered', user); 54 | 55 | return user; 56 | } 57 | -------------------------------------------------------------------------------- /apps/api/src/plugins/auth/includes.ts: -------------------------------------------------------------------------------- 1 | export const sessionInclude = { 2 | user: { 3 | select: { 4 | id: true, 5 | name: true, 6 | email: true, 7 | role: true, 8 | createdAt: true, 9 | updatedAt: true, 10 | }, 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /apps/api/src/plugins/cart/cartSchemas.ts: -------------------------------------------------------------------------------- 1 | import type { SklepTypes } from '@sklep/types'; 2 | import Joi from 'joi'; 3 | 4 | export const addToCartPayloadSchema = Joi.object({ 5 | productId: Joi.number().integer().required(), 6 | quantity: Joi.number().integer().required(), 7 | }).required(); 8 | 9 | export const removeFromCartPayloadSchema = Joi.object({ 10 | productId: Joi.number().integer().required(), 11 | }).required(); 12 | 13 | export const cartResponseSchema = Joi.object({ 14 | id: Joi.string().required(), 15 | createdAt: Joi.date().iso().required(), 16 | updatedAt: Joi.date().iso().required(), 17 | regularSubTotal: Joi.number().integer().required(), 18 | discountSubTotal: Joi.number().integer().required(), 19 | totalQuantity: Joi.number().integer().required(), 20 | cartProducts: Joi.array() 21 | .items( 22 | Joi.object({ 23 | quantity: Joi.number().integer().required(), 24 | product: Joi.object({ 25 | id: Joi.number().integer().required(), 26 | name: Joi.string().required(), 27 | slug: Joi.string().required(), 28 | regularPrice: Joi.number().integer().required(), 29 | discountPrice: Joi.number().integer().optional().allow(null), 30 | }).required(), 31 | }).optional(), 32 | ) 33 | .required(), 34 | }); 35 | 36 | export const createCartResponseSchema = Joi.object({ 37 | data: cartResponseSchema.required(), 38 | }).required(); 39 | 40 | export const getAllCartsResponseSchema = Joi.object({ 41 | data: Joi.array().items(cartResponseSchema.optional()).required(), 42 | }).required(); 43 | -------------------------------------------------------------------------------- /apps/api/src/plugins/email/index.ts: -------------------------------------------------------------------------------- 1 | import type Hapi from '@hapi/hapi'; 2 | 3 | export type EmailPluginOptions = Record; 4 | 5 | export const AuthPlugin: Hapi.Plugin = { 6 | multiple: false, 7 | name: 'Sklep Email Plugin', 8 | version: '1.0.0', 9 | register(server, _options) { 10 | server.events.on('auth:user:registered', (_user) => { 11 | // @todo send confirmation email 12 | }); 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /apps/api/src/plugins/media/mediaFunctions.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | import Boom from '@hapi/boom'; 4 | import type Hapi from '@hapi/hapi'; 5 | import Joi from 'joi'; 6 | 7 | type Paylaod = { 8 | readonly alt: string; 9 | readonly description?: string | null; 10 | readonly productId?: number | null; 11 | }; 12 | 13 | export function getAllImages(server: Hapi.Server) { 14 | return server.app.db.image.findMany(); 15 | } 16 | 17 | export async function getImagePath(server: Hapi.Server, imageId: number) { 18 | const image = await server.app.db.image.findFirst({ 19 | where: { 20 | id: imageId, 21 | }, 22 | }); 23 | 24 | if (image) { 25 | return image.path; 26 | } 27 | 28 | throw Boom.badRequest(`Invalid imageId`); 29 | } 30 | 31 | export function updateImage(server: Hapi.Server, imageId: number, payload: Paylaod) { 32 | return server.app.db.image.update({ 33 | where: { 34 | id: imageId, 35 | }, 36 | data: payload, 37 | }); 38 | } 39 | 40 | export function deleteImage(filePath: string) { 41 | return fs.promises.unlink(filePath); 42 | } 43 | 44 | const filenameSchema = Joi.string() 45 | .regex(/\.(png|jpg|jpeg|svg|gif)$/) 46 | .required(); 47 | 48 | const contentTypeSchema = Joi.string() 49 | .equal('image/jpeg', 'image/png', 'image/svg+xml', 'image/gif') 50 | .required(); 51 | 52 | export const assertImageFiletype = ( 53 | filename: string, 54 | headers: Record, 55 | ): asserts filename is `${string}.${'png' | 'jpg' | 'jpeg' | 'svg' | 'gif'}` => { 56 | Joi.assert(filename, filenameSchema); 57 | Joi.assert(headers['content-type'], contentTypeSchema); 58 | }; 59 | 60 | export const addFileExtension = (filePath: string, fileExtension: string) => { 61 | return fs.promises.rename(filePath, `${filePath}.${fileExtension}`); 62 | }; 63 | -------------------------------------------------------------------------------- /apps/api/src/plugins/media/mediaSchemas.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | 3 | const imageResponseSchema = Joi.object({ 4 | id: Joi.number().required(), 5 | width: Joi.number().required(), 6 | height: Joi.number().required(), 7 | path: Joi.string().required(), 8 | alt: Joi.string().required(), 9 | description: Joi.string().optional().allow(null), 10 | createdAt: Joi.date().iso().required(), 11 | updatedAt: Joi.date().iso().required(), 12 | productId: Joi.number().optional().allow(null), 13 | }).required(); 14 | 15 | export const getAllImagesResponseSchema = Joi.object({ 16 | data: Joi.array().items(imageResponseSchema.optional()).required(), 17 | }); 18 | 19 | export const putImageResponseSchema = Joi.object({ 20 | data: imageResponseSchema.required(), 21 | }); 22 | 23 | export const postImageResponseSchema = Joi.object({ 24 | data: imageResponseSchema.required(), 25 | }); 26 | 27 | export const deleteImageParamsSchema = Joi.object({ 28 | imageId: Joi.number().required(), 29 | }).required(); 30 | 31 | export const putImageParamsSchema = Joi.object({ 32 | imageId: Joi.number().required(), 33 | }).required(); 34 | 35 | export const putImagePayloadSchema = Joi.object({ 36 | alt: Joi.string().required(), 37 | description: Joi.string().optional(), 38 | productId: Joi.number().optional(), 39 | }).required(); 40 | 41 | export const postImagePayloadSchema = Joi.object({ 42 | file: Joi.any().meta({ swaggerType: 'file' }).description('file').required(), 43 | alt: Joi.string().required(), 44 | description: Joi.string().optional(), 45 | productId: Joi.number().optional(), 46 | }).required(); 47 | -------------------------------------------------------------------------------- /apps/api/src/plugins/order/orderSchemas.ts: -------------------------------------------------------------------------------- 1 | import type { SklepTypes } from '@sklep/types'; 2 | import Joi from 'joi'; 3 | 4 | import { Enums } from '../../models'; 5 | import { cartResponseSchema } from '../cart/cartSchemas'; 6 | 7 | export const addressSchema = Joi.object({ 8 | firstName: Joi.string().required(), 9 | lastName: Joi.string().required(), 10 | streetName: Joi.string().required(), 11 | houseNumber: Joi.string().required(), 12 | apartmentNumber: Joi.string(), 13 | city: Joi.string().required(), 14 | zipCode: Joi.string().required(), 15 | phone: Joi.string().required(), 16 | email: Joi.string().required(), 17 | }); 18 | 19 | export const initiateStripePaymentResponse = Joi.object< 20 | SklepTypes['patchOrdersInitiateStripePayment200Response'] 21 | >({ 22 | data: Joi.object({ 23 | orderId: Joi.string().required(), 24 | stripeClientSecret: Joi.string().required(), 25 | }).required(), 26 | }).required(); 27 | 28 | export const orderResponseSchema = Joi.object({ 29 | id: Joi.string().required(), 30 | cart: cartResponseSchema.required(), 31 | total: Joi.number().required(), 32 | status: Joi.string() 33 | .valid(...Object.keys(Enums.OrderStatus)) 34 | .required(), 35 | address: addressSchema.required(), 36 | createdAt: Joi.date().iso().required(), 37 | updatedAt: Joi.date().iso().required(), 38 | }).required(); 39 | 40 | export const getAllOrdersQuerySchema = Joi.object({ 41 | take: Joi.number().integer(), 42 | skip: Joi.number().integer(), 43 | }); 44 | 45 | export const getAllOrdersResponseSchema = Joi.object({ 46 | data: Joi.array().items(orderResponseSchema.optional()).required(), 47 | meta: Joi.object({ 48 | total: Joi.number().integer().required(), 49 | }).required(), 50 | }).required(); 51 | 52 | export const getOrderByIdParamsSchema = Joi.object( 53 | { 54 | orderId: Joi.string().required(), 55 | }, 56 | ); 57 | export const getOrderByIdResponseSchema = Joi.object({ 58 | data: orderResponseSchema.required(), 59 | }).required(); 60 | 61 | export const updateOrderResponseSchema = getOrderByIdResponseSchema; 62 | 63 | export const updateOrderPayloadSchema = Joi.object({ 64 | status: Joi.string() 65 | .valid(...Object.keys(Enums.OrderStatus)) 66 | .required(), 67 | }).required(); 68 | 69 | export const updateOrderParamsSchema = Joi.object({ 70 | orderId: Joi.string().required(), 71 | }).required(); 72 | 73 | export const getAllOrderStatusesSchema = Joi.object({ 74 | data: Joi.array() 75 | .items(Joi.string().valid(...Object.values(Enums.OrderStatus))) 76 | .required(), 77 | }).required(); 78 | -------------------------------------------------------------------------------- /apps/api/src/prisma/prisma-helpers.ts: -------------------------------------------------------------------------------- 1 | import type { PrismaError } from './prisma-errors'; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any -- any 4 | export function isPrismaError(err: any): err is PrismaError { 5 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- ok 6 | return Boolean(err?.code) && /^P\d{4}$/.test(err.code); 7 | } 8 | -------------------------------------------------------------------------------- /apps/api/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Models } from './models'; 2 | 3 | export type Model> = T & { 4 | readonly createdAt: Date; 5 | readonly updatedAt: Date; 6 | }; 7 | 8 | export type DeepPartial = { 9 | readonly [P in keyof T]?: T[P] extends ReadonlyArray 10 | ? ReadonlyArray> 11 | : T[P] extends ReadonlyArray 12 | ? ReadonlyArray> 13 | : DeepPartial; 14 | }; 15 | 16 | export type Awaited = T extends Promise ? R : T; 17 | -------------------------------------------------------------------------------- /apps/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node12/tsconfig.json", 3 | "compilerOptions": { 4 | "incremental": false, 5 | "alwaysStrict": true, 6 | "noFallthroughCasesInSwitch": true, 7 | "noImplicitReturns": true, 8 | "noUnusedLocals": true, 9 | "noUnusedParameters": true, 10 | "resolveJsonModule": true 11 | // "typeRoots": ["./node_modules/@types", "./typings", "jest-extended"] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /apps/api/typings/global/index.d.ts: -------------------------------------------------------------------------------- 1 | interface Array { 2 | // pop(): T extends readonly [infer Head, ...infer _] ? Head : T | undefined; 3 | pop(): typeof this extends readonly [infer Head, ...infer _] ? 1 : 0; 4 | includes(searchElement: unknown, fromIndex?: number): searchElement is T; 5 | } 6 | 7 | interface ReadonlyArray { 8 | pop(): T extends readonly [infer Head, ...infer _] ? Head : T | undefined; 9 | includes(searchElement: unknown, fromIndex?: number): searchElement is T; 10 | } 11 | -------------------------------------------------------------------------------- /apps/calculations/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": false, 3 | "parserOptions": { 4 | "project": "tsconfig.json" 5 | }, 6 | "rules": { 7 | "import/no-default-export": "error" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /apps/calculations/index.test.ts: -------------------------------------------------------------------------------- 1 | import * as Faker from 'faker'; 2 | 3 | import { calculateCartTotals } from './index'; 4 | 5 | describe('calculations', () => { 6 | describe('calculateCartTotals', () => { 7 | it('should calculate subtotals', () => { 8 | type CartArg = Parameters[0]; 9 | const cart: CartArg = { 10 | id: Faker.datatype.uuid(), 11 | createdAt: Faker.date.past(), 12 | updatedAt: Faker.date.past(), 13 | cartProducts: [ 14 | { 15 | quantity: 1, 16 | product: { 17 | id: Faker.datatype.number({ min: 0, precision: 0 }), 18 | name: Faker.commerce.productName(), 19 | slug: Faker.internet.userName(), 20 | regularPrice: 123_00, 21 | discountPrice: 100_00, 22 | }, 23 | }, 24 | { 25 | quantity: 1, 26 | product: { 27 | id: Faker.datatype.number({ min: 0, precision: 0 }), 28 | name: Faker.commerce.productName(), 29 | slug: Faker.internet.userName(), 30 | regularPrice: 15_23, 31 | discountPrice: null, 32 | }, 33 | }, 34 | { 35 | quantity: 3, 36 | product: { 37 | id: Faker.datatype.number({ min: 0, precision: 0 }), 38 | name: Faker.commerce.productName(), 39 | slug: Faker.internet.userName(), 40 | regularPrice: 89_99, 41 | discountPrice: 59_99, 42 | }, 43 | }, 44 | ], 45 | }; 46 | 47 | expect(calculateCartTotals(cart)).toEqual({ 48 | regularSubTotal: 408_20, 49 | discountSubTotal: 295_20, 50 | totalQuantity: 5, 51 | }); 52 | }); 53 | 54 | it('rounds correctly', () => { 55 | type CartArg = Parameters[0]; 56 | const cart: CartArg = { 57 | id: Faker.datatype.uuid(), 58 | createdAt: Faker.date.past(), 59 | updatedAt: Faker.date.past(), 60 | cartProducts: [ 61 | { 62 | quantity: 1.05, 63 | product: { 64 | id: Faker.datatype.number({ min: 0, precision: 0 }), 65 | name: Faker.commerce.productName(), 66 | slug: Faker.internet.userName(), 67 | regularPrice: 5713, 68 | }, 69 | }, 70 | ], 71 | }; 72 | 73 | expect(calculateCartTotals(cart)).toEqual({ 74 | regularSubTotal: 5999, 75 | discountSubTotal: 5999, 76 | totalQuantity: 1.05, 77 | }); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /apps/calculations/index.ts: -------------------------------------------------------------------------------- 1 | type CartFromDB = { 2 | readonly id: string; 3 | readonly createdAt: Date; 4 | readonly updatedAt: Date; 5 | readonly cartProducts: readonly { 6 | readonly quantity: number; 7 | readonly product: { 8 | readonly id: number; 9 | readonly name: string; 10 | readonly slug: string; 11 | readonly regularPrice: number; 12 | readonly discountPrice?: number | null; 13 | }; 14 | }[]; 15 | }; 16 | 17 | export function calculateCartTotals(cart: CartFromDB) { 18 | return cart.cartProducts.reduce( 19 | (acc, cartProduct) => { 20 | const regularSum = cartProduct.product.regularPrice * cartProduct.quantity; 21 | const discountSum = 22 | (cartProduct.product.discountPrice ?? cartProduct.product.regularPrice) * 23 | cartProduct.quantity; 24 | 25 | acc.regularSubTotal += Math.round(regularSum); 26 | acc.discountSubTotal += Math.round(discountSum); 27 | acc.totalQuantity += cartProduct.quantity; 28 | return acc; 29 | }, 30 | { 31 | regularSubTotal: 0, 32 | discountSubTotal: 0, 33 | totalQuantity: 0, 34 | }, 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /apps/calculations/jest-setup.ts: -------------------------------------------------------------------------------- 1 | process.on('unhandledRejection', (err) => { 2 | fail(err); 3 | }); 4 | -------------------------------------------------------------------------------- /apps/calculations/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | transform: { 4 | '^.+\\.(t|j)sx?$': ['@swc-node/jest'], 5 | }, 6 | setupFiles: ['./jest-setup.ts'], 7 | setupFilesAfterEnv: [], 8 | }; 9 | -------------------------------------------------------------------------------- /apps/calculations/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sklep/calculations", 3 | "version": "1.0.0-alpha.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "jest --watchAll", 7 | "test": "jest", 8 | "test_:ci": "jest" 9 | }, 10 | "dependencies": {}, 11 | "devDependencies": { 12 | "@swc-node/jest": "1.3.0", 13 | "@tsconfig/node12": "1.0.7", 14 | "@types/faker": "5.5.5", 15 | "faker": "5.5.3", 16 | "jest": "27.0.2", 17 | "typescript": "4.3.2" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /apps/calculations/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node12/tsconfig.json", 3 | "compilerOptions": { 4 | "incremental": false, 5 | "alwaysStrict": true, 6 | "noFallthroughCasesInSwitch": true, 7 | "noImplicitReturns": true, 8 | "noUnusedLocals": true, 9 | "noUnusedParameters": true, 10 | "resolveJsonModule": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /apps/types/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { definitions } from './types'; 3 | 4 | type DeepNil = { 5 | [K in keyof T]: (undefined extends T[K] ? T[K] | null : T[K]) extends infer R 6 | ? R extends object 7 | ? DeepNil 8 | : R 9 | : never; 10 | }; 11 | 12 | // pretty-print type 13 | type _2LevelsPretty = { 14 | [K in keyof T]: T[K] extends object ? { [L in keyof T[K]]: T[K][L] } : T[K]; 15 | }; 16 | 17 | export type SklepTypes = _2LevelsPretty>; 18 | 19 | export type Nil = T | undefined | null; 20 | -------------------------------------------------------------------------------- /apps/types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sklep/types", 3 | "version": "1.0.0-alpha.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "chokidar \"../api/src/**/*.ts\" --ignore \"../api/src/**/*.test.ts\" --initial -c \"yarn generate-api-types\"", 7 | "generate-api-types": "wait-on tcp:3002 --interval 5000 && yarn swagger-to-ts http://api.sklep.localhost:3002/swagger.json --output types.ts --prettier-config ../../.prettierrc --fileType \"{readonly path: string; readonly bytes: number; readonly filename: string; readonly headers: Record;}\"" 8 | }, 9 | "dependencies": { 10 | "@manifoldco/swagger-to-ts": "github:mmiszy/swagger-to-ts#66810ad69d7f74ee56a787fa7de3648e5f691f1e" 11 | }, 12 | "devDependencies": { 13 | "chokidar-cli": "2.1.0", 14 | "wait-on": "5.3.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /apps/www/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"] 3 | } 4 | -------------------------------------------------------------------------------- /apps/www/.env.development: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_API_URL="http://api.sklep.localhost:3002" 2 | NEXT_PUBLIC_STRIPE_KEY = pk_test_51HXZFYFCiYl0PHOKhy4Qk2vJkOE4ij5TjOdmHcql1DSQxPULJuuDq2bRRgsVhvm2BkUhg4DvBCCPS7vuMzuZUh2x00X4AYgLw4 3 | -------------------------------------------------------------------------------- /apps/www/.eslintignore: -------------------------------------------------------------------------------- 1 | cypress 2 | test 3 | *.js 4 | -------------------------------------------------------------------------------- /apps/www/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": false, 3 | "parserOptions": { 4 | "project": "tsconfig.json" 5 | }, 6 | "extends": [ 7 | "react-app", 8 | "plugin:jsx-a11y/recommended", 9 | "plugin:css-modules/recommended", 10 | "plugin:testing-library/react" 11 | ], 12 | "env": { 13 | "es6": true, 14 | "browser": true 15 | }, 16 | "plugins": ["jsx-a11y", "css-modules"], 17 | "rules": { 18 | "react-hooks/rules-of-hooks": "error", 19 | "react-hooks/exhaustive-deps": ["error"], 20 | "jsx-a11y/anchor-is-valid": "off", 21 | "@typescript-eslint/no-unused-vars": [ 22 | "error", 23 | { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_", "ignoreRestSiblings": true } 24 | ], 25 | "import/no-default-export": "error", 26 | "react/display-name": "error", 27 | "import/no-restricted-paths": [ 28 | "error", 29 | { 30 | "zones": [ 31 | { "target": "./components", "from": "./pages" }, 32 | { "target": "./components/klient", "from": "./components/admin" }, 33 | { "target": "./components/admin", "from": "./components/klient" }, 34 | { "target": "./pages/admin", "from": "./components/klient" } 35 | ] 36 | } 37 | ] 38 | }, 39 | "overrides": [ 40 | { 41 | "files": "pages/**/*.tsx", 42 | "rules": { 43 | "import/no-default-export": "off" 44 | } 45 | }, 46 | { 47 | "files": ["**/*.test.*", "**/*.spec.*"], 48 | "rules": { 49 | "@typescript-eslint/no-var-requires": "off", 50 | "@typescript-eslint/no-empty-function": "off", 51 | "@typescript-eslint/consistent-type-assertions": "off", 52 | "@typescript-eslint/no-explicit-any": "off" 53 | } 54 | } 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /apps/www/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | .env* 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | -------------------------------------------------------------------------------- /apps/www/app.js: -------------------------------------------------------------------------------- 1 | // server.js 2 | const { createServer } = require('http') 3 | const { parse } = require('url') 4 | const next = require('next') 5 | 6 | const dev = process.env.NODE_ENV !== 'production' 7 | const app = next({ dev }) 8 | const handle = app.getRequestHandler() 9 | 10 | app.prepare().then(() => { 11 | const port = process.env.PORT || 3000 12 | return createServer((req, res) => { 13 | // Be sure to pass `true` as the second argument to `url.parse`. 14 | // This tells it to parse the query portion of the URL. 15 | const parsedUrl = parse(req.url, true) 16 | return handle(req, res, parsedUrl) 17 | }).listen(port, (err) => { 18 | if (err) { 19 | throw err; 20 | } 21 | console.log(`> Ready on http://www.sklep.localhost:3000/`); 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /apps/www/assets/bag.svg: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /apps/www/assets/blik.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/sklep/174cbe47d8b134140c248d8df5f77aa2adb4c30b/apps/www/assets/blik.png -------------------------------------------------------------------------------- /apps/www/assets/cart.svg: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /apps/www/assets/hamburger.svg: -------------------------------------------------------------------------------- 1 | 3 | menu 4 | 5 | 6 | -------------------------------------------------------------------------------- /apps/www/assets/paypal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/sklep/174cbe47d8b134140c248d8df5f77aa2adb4c30b/apps/www/assets/paypal.png -------------------------------------------------------------------------------- /apps/www/assets/success.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/www/assets/user.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | -------------------------------------------------------------------------------- /apps/www/assets/visa_master.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/sklep/174cbe47d8b134140c248d8df5f77aa2adb4c30b/apps/www/assets/visa_master.png -------------------------------------------------------------------------------- /apps/www/components/admin/Header.tsx: -------------------------------------------------------------------------------- 1 | import { UserProfile20 } from '@carbon/icons-react'; 2 | import { 3 | HeaderContainer, 4 | HeaderGlobalAction, 5 | HeaderGlobalBar, 6 | HeaderMenuButton, 7 | HeaderName, 8 | SkipToContent, 9 | Header as CarbonHeader, 10 | HeaderPanel, 11 | } from 'carbon-components-react'; 12 | import React, { useState } from 'react'; 13 | 14 | import { AdminSideNav } from './sideNav/SideNav'; 15 | 16 | export const Header = () => { 17 | const [isNotificationExpanded, setNotificationExpand] = useState(false); 18 | const [isUserInfoExpanded, setUserInfoExpand] = useState(false); 19 | 20 | // const toggleNotificationClick = React.useCallback(() => { 21 | // setUserInfoExpand(false); 22 | // setNotificationExpand((isExpanded) => !isExpanded); 23 | // }, []); 24 | 25 | const toggleUserInfoClick = React.useCallback(() => { 26 | setNotificationExpand(false); 27 | setUserInfoExpand((isExpanded) => !isExpanded); 28 | }, []); 29 | 30 | return ( 31 | ( 33 | 34 | 35 | 41 | Type of Web 42 | 43 | 44 | {/* @todo 45 | 50 | 51 | */} 52 | 58 | 59 | 60 | 61 | 62 | 66 | {isNotificationExpanded ?
Notifications
:
User info
} 67 |
68 | 69 | 70 |
71 | )} 72 | /> 73 | ); 74 | }; 75 | Header.displayName = 'Header'; 76 | -------------------------------------------------------------------------------- /apps/www/components/admin/adminLayout/AdminLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Column } from 'carbon-components-react'; 2 | import Head from 'next/head'; 3 | import React from 'react'; 4 | 5 | import { Header } from '../Header'; 6 | import { Auth } from '../auth/Auth'; 7 | import { ContentWrapper } from '../contentWrapper/ContentWrapper'; 8 | 9 | type AdminLayoutProps = { 10 | readonly allowUnauthorized?: boolean; 11 | readonly hideHud?: boolean; 12 | readonly children?: React.ReactChild; 13 | }; 14 | 15 | export const AdminLayout = React.memo( 16 | ({ children, allowUnauthorized = false, hideHud = false }) => { 17 | return ( 18 | <> 19 | 20 | 24 | 25 | 26 | {!hideHud &&
} 27 | 28 | {children} 29 | 30 | 31 | 32 | ); 33 | }, 34 | ); 35 | AdminLayout.displayName = 'AdminLayout'; 36 | -------------------------------------------------------------------------------- /apps/www/components/admin/adminOrders/AdminOrders.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useGetOrders } from '../../../utils/api/queryHooks'; 4 | import { OrdersList } from '../ordersList/OrdersList'; 5 | 6 | export const AdminOrders = React.memo(() => { 7 | const [page, setPage] = React.useState(1); 8 | const [pageSize, setPageSize] = React.useState(20); 9 | const { data, isLoading } = useGetOrders({ take: pageSize, skip: (page - 1) * pageSize }); 10 | 11 | const handlePageChange = React.useCallback( 12 | (data: { readonly page: number; readonly pageSize: number }) => { 13 | setPage(data.page); 14 | setPageSize(data.pageSize); 15 | }, 16 | [], 17 | ); 18 | 19 | return ( 20 | 28 | ); 29 | }); 30 | AdminOrders.displayName = 'AdminOrders'; 31 | -------------------------------------------------------------------------------- /apps/www/components/admin/adminProducts/AdminProducts.tsx: -------------------------------------------------------------------------------- 1 | import type { Nil } from '@sklep/types'; 2 | import React from 'react'; 3 | import { useMutation, useQueryClient } from 'react-query'; 4 | 5 | import { deleteProduct } from '../../../utils/api/deleteProduct'; 6 | import { useGetProducts } from '../../../utils/api/queryHooks'; 7 | import { DeleteProductConfirmationModal } from '../deleteProductConfirmationModal/DeleteProductConfirmationModal'; 8 | import type { Product } from '../productsList/ProductListUtils'; 9 | import { ProductsList } from '../productsList/ProductsList'; 10 | import { PRODUCTS_QUERY_KEY } from '../productsList/ProductsTableToolbar'; 11 | import { useToasts } from '../toasts/Toasts'; 12 | 13 | export const AdminProducts = React.memo(() => { 14 | const [page, setPage] = React.useState(1); 15 | const [pageSize, setPageSize] = React.useState(20); 16 | const { data, isLoading } = useGetProducts({ 17 | take: pageSize, 18 | skip: (page - 1) * pageSize, 19 | }); 20 | const { addToast } = useToasts(); 21 | const cache = useQueryClient(); 22 | 23 | const { 24 | mutateAsync, 25 | status: deletionStatus, 26 | reset: resetDeletionStatus, 27 | } = useMutation(deleteProduct, { 28 | onSettled: () => cache.invalidateQueries(PRODUCTS_QUERY_KEY), 29 | onSuccess() { 30 | addToast({ 31 | kind: 'success', 32 | title: 'Operacja udana', 33 | caption: 'Produkt został usunięty pomyślnie', 34 | }); 35 | closeDeletionModal(); 36 | resetDeletionStatus(); 37 | }, 38 | onError(error?: Error) { 39 | const message = 'Nie udało się usunąć produktu'; 40 | addToast({ 41 | kind: 'error', 42 | title: 'Wystąpił błąd', 43 | caption: error?.message ? `${message}: ${error.message}` : message, 44 | }); 45 | }, 46 | }); 47 | 48 | const handlePageChange = React.useCallback( 49 | (data: { readonly page: number; readonly pageSize: number }) => { 50 | setPage(data.page); 51 | setPageSize(data.pageSize); 52 | }, 53 | [], 54 | ); 55 | 56 | const handleDeleteProduct = React.useCallback((product: Product) => { 57 | setProductForDeletion(product); 58 | setShowDeletionModal(true); 59 | }, []); 60 | 61 | // we need 2 states to avoid flash of "UNDEFINED" in the modal 62 | const [showDeletionModal, setShowDeletionModal] = React.useState(false); 63 | const [productForDeletion, setProductForDeletion] = React.useState>(null); 64 | 65 | const closeDeletionModal = React.useCallback(() => setShowDeletionModal(false), []); 66 | 67 | return ( 68 | <> 69 | 78 | 79 | 86 | 87 | ); 88 | }); 89 | -------------------------------------------------------------------------------- /apps/www/components/admin/adminSingleOrder/AdminSingleOrder.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useGetOrderById } from '../../../utils/api/queryHooks'; 4 | import { useParams } from '../../../utils/hooks'; 5 | import { OrderForm } from '../orderForm/OrderForm'; 6 | import { OrderFormSkeleton } from '../orderForm/OrderFormSkeleton'; 7 | 8 | export const AdminSingleOrder = React.memo(() => { 9 | const { orderId } = useParams(['orderId']); 10 | const { data, isLoading, isError } = useGetOrderById(orderId, { 11 | enabled: Boolean(orderId), 12 | }); 13 | 14 | if (!orderId) { 15 | return null; 16 | } 17 | 18 | return ( 19 | <> 20 | {isLoading && } 21 | {data && } 22 | {isError && Wystąpił błąd podczas pobierania zamówienia.} 23 | 24 | ); 25 | }); 26 | AdminSingleOrder.displayName = 'AdminSingleOrder'; 27 | -------------------------------------------------------------------------------- /apps/www/components/admin/adminSingleProduct/AdminSingleProduct.test.tsx: -------------------------------------------------------------------------------- 1 | import type { SklepTypes } from '@sklep/types'; 2 | import { screen } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | import React from 'react'; 5 | 6 | import { initMockServer, renderWithProviders } from '../../../jest-utils'; 7 | 8 | import { AdminSingleProduct } from './AdminSingleProduct'; 9 | 10 | const useRouter = jest.spyOn(require('next/router'), 'useRouter'); 11 | const productId = 1; 12 | useRouter.mockImplementation(() => ({ query: { productId }, replace() {} })); 13 | 14 | const TEST_PRODUCTS: ReadonlyArray = [ 15 | { 16 | data: { 17 | id: 1, 18 | slug: 'computer', 19 | name: 'Computer', 20 | description: 'Computer for games', 21 | isPublic: true, 22 | regularPrice: 1999, 23 | discountPrice: 1599, 24 | type: 'SINGLE', 25 | }, 26 | }, 27 | ]; 28 | 29 | function renderAdminSingleProduct() { 30 | return renderWithProviders(); 31 | } 32 | 33 | describe('single product page', () => { 34 | const server = initMockServer(); 35 | 36 | it('loads product data', async () => { 37 | server.get(`/products/${productId}`).reply( 38 | 200, 39 | TEST_PRODUCTS.find((el) => el.data.id === productId), 40 | ); 41 | 42 | renderAdminSingleProduct(); 43 | const name = await screen.findByLabelText('Nazwa produktu'); 44 | const description = await screen.findByLabelText('Opis produktu'); 45 | const regularPrice = await screen.findByLabelText('Cena produktu'); 46 | const discountPrice = await screen.findByLabelText('Promocyjna cena produktu'); 47 | const isPublic = await screen.findByLabelText('Czy produkt ma być widoczny na stronie?', { 48 | exact: false, 49 | }); 50 | 51 | expect(name).toHaveDisplayValue('Computer'); 52 | expect(description).toHaveDisplayValue('Computer for games'); 53 | expect(isPublic).toBeChecked(); 54 | expect(regularPrice).toHaveDisplayValue('19.99'); 55 | expect(discountPrice).toHaveDisplayValue('15.99'); 56 | }); 57 | 58 | it('deletes product', async () => { 59 | server.get(`/products/${productId}`).reply( 60 | 200, 61 | TEST_PRODUCTS.find((el) => el.data.id === productId), 62 | ); 63 | 64 | server.delete(`/products/${productId}`).reply( 65 | 200, 66 | TEST_PRODUCTS.find((el) => el.data.id === productId), 67 | ); 68 | 69 | renderAdminSingleProduct(); 70 | const deleteButton = await screen.findByText('Usuń produkt'); 71 | 72 | userEvent.click(deleteButton); 73 | 74 | const confirmDeleteButton = await screen.findByText('Usuń'); 75 | expect(confirmDeleteButton).toBeInTheDocument(); 76 | 77 | userEvent.click(confirmDeleteButton); 78 | 79 | const notification = await screen.findByRole('alert'); 80 | expect(notification).toHaveTextContent('Produkt został usunięty pomyślnie'); 81 | }); 82 | 83 | it('shows error message after it fails to load a product', async () => { 84 | server.get(`/products/${productId}`).times(4).reply(400, { message: 'Bad data' }); 85 | 86 | renderAdminSingleProduct(); 87 | const errorMessage = await screen.findByText( 88 | 'Wystąpił błąd podczas pobierania danych produktu', 89 | {}, 90 | ); 91 | expect(errorMessage).toBeInTheDocument(); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /apps/www/components/admin/auth/Auth.tsx: -------------------------------------------------------------------------------- 1 | import ms from 'ms'; 2 | import { useRouter } from 'next/router'; 3 | import React from 'react'; 4 | 5 | import { useToWQuery } from '../../../utils/fetcher'; 6 | 7 | type AuthProps = { 8 | readonly children: React.ReactNode; 9 | readonly allowUnauthorized: boolean; 10 | }; 11 | 12 | export const useAuth = () => 13 | useToWQuery(['/auth/me', 'GET', {}], { keepPreviousData: true, staleTime: ms('60 seconds') }); 14 | 15 | export const Auth = React.memo(({ children, allowUnauthorized }) => { 16 | const { data, isLoading } = useAuth(); 17 | 18 | const router = useRouter(); 19 | 20 | if (allowUnauthorized) { 21 | return <>{children}; 22 | } 23 | 24 | if (isLoading) { 25 | return null; 26 | } 27 | 28 | if (!data?.data || data.data.user.role !== 'ADMIN') { 29 | void router.replace('/admin/login'); 30 | return null; 31 | } 32 | 33 | return <>{children}; 34 | }); 35 | Auth.displayName = 'Auth'; 36 | -------------------------------------------------------------------------------- /apps/www/components/admin/contentWrapper/ContentWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { Content } from 'carbon-components-react'; 2 | import type { ReactNode } from 'react'; 3 | import React from 'react'; 4 | 5 | import { LoadingIndicator } from '../loadingIndicator/LoadingIndicator'; 6 | import { ToastsContextProvider } from '../toasts/Toasts'; 7 | 8 | import styles from './contentWrapper.module.scss'; 9 | 10 | export const ContentWrapper = React.memo<{ readonly children: ReactNode }>(({ children }) => ( 11 | 12 | {children} 13 | 14 | 15 | )); 16 | ContentWrapper.displayName = 'ContentWrapper'; 17 | -------------------------------------------------------------------------------- /apps/www/components/admin/contentWrapper/contentWrapper.module.scss: -------------------------------------------------------------------------------- 1 | .contentWraper { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: center; 5 | align-content: center; 6 | padding: 0; 7 | } 8 | -------------------------------------------------------------------------------- /apps/www/components/admin/deleteProductConfirmationModal/DeleteProductConfirmationModal.tsx: -------------------------------------------------------------------------------- 1 | import type { Nil } from '@sklep/types'; 2 | import { InlineLoading, Modal } from 'carbon-components-react'; 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | 6 | import type { Product } from '../productsList/ProductListUtils'; 7 | 8 | type DeleteProductConfirmationModalProps = { 9 | readonly isOpen: boolean; 10 | readonly product: Nil; 11 | readonly handleDelete: (productId: number) => void; 12 | readonly handleClose: () => void; 13 | readonly status: 'loading' | 'success' | 'error' | 'idle'; 14 | }; 15 | 16 | const statusToPrimaryText = ( 17 | status: DeleteProductConfirmationModalProps['status'], 18 | ): React.ReactNode => { 19 | switch (status) { 20 | case 'loading': 21 | return ; 22 | case 'success': 23 | return ; 24 | default: 25 | return 'Usuń'; 26 | } 27 | }; 28 | 29 | export const DeleteProductConfirmationModal = React.memo( 30 | ({ isOpen, product, handleDelete, handleClose, status }) => { 31 | const submit = React.useCallback(() => { 32 | if (product) { 33 | return handleDelete(product.id); 34 | } 35 | }, [handleDelete, product]); 36 | const primaryButtonText = statusToPrimaryText(status); 37 | const primaryButtonDisabled = status === 'loading' || status === 'success'; 38 | 39 | if (typeof document === 'undefined' || !product) { 40 | return null; 41 | } 42 | 43 | return ReactDOM.createPortal( 44 | , 58 | document.body, 59 | ); 60 | }, 61 | ); 62 | DeleteProductConfirmationModal.displayName = 'DeleteProductConfirmationModal'; 63 | -------------------------------------------------------------------------------- /apps/www/components/admin/loadingIndicator/LoadingIndicator.tsx: -------------------------------------------------------------------------------- 1 | import { Loading } from 'carbon-components-react'; 2 | import React from 'react'; 3 | import { useIsFetching } from 'react-query'; 4 | 5 | import styles from './loadingIndicator.module.scss'; 6 | 7 | export const LoadingIndicator = () => { 8 | const isFetching = useIsFetching() > 0; 9 | 10 | React.useEffect(() => { 11 | if (typeof document !== 'undefined') { 12 | document.body.classList.toggle('react-query-is-loading', isFetching); 13 | } 14 | }, [isFetching]); 15 | 16 | return isFetching ? ( 17 | 23 | ) : null; 24 | }; 25 | -------------------------------------------------------------------------------- /apps/www/components/admin/loadingIndicator/loadingIndicator.module.scss: -------------------------------------------------------------------------------- 1 | .loading { 2 | position: fixed; 3 | top: 4em; 4 | right: 0; 5 | } 6 | -------------------------------------------------------------------------------- /apps/www/components/admin/loginForm/LoginForm.module.scss: -------------------------------------------------------------------------------- 1 | .form { 2 | padding: 1rem; 3 | max-width: 50rem; 4 | } 5 | 6 | .form :global { 7 | .bx--form-item { 8 | margin-bottom: 2rem; 9 | 10 | > * { 11 | width: 100%; 12 | } 13 | } 14 | .bx--fieldset > .bx--form-item { 15 | margin-bottom: initial; 16 | } 17 | } 18 | 19 | .title { 20 | font-size: 2rem; 21 | margin-bottom: 2rem; 22 | } 23 | -------------------------------------------------------------------------------- /apps/www/components/admin/loginForm/LoginForm.test.tsx: -------------------------------------------------------------------------------- 1 | import { waitFor, screen } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | import React from 'react'; 4 | 5 | import { initMockServer, renderWithProviders } from '../../../jest-utils'; 6 | 7 | import { LoginForm } from './LoginForm'; 8 | 9 | const useRouter = jest.spyOn(require('next/router'), 'useRouter'); 10 | useRouter.mockImplementation(() => ({ replace() {} })); 11 | 12 | describe('login form', () => { 13 | const renderLoginForm = () => renderWithProviders(); 14 | const server = initMockServer(); 15 | 16 | it('shows error after confirming without required data', () => { 17 | renderLoginForm(); 18 | 19 | userEvent.click(screen.getByText('Zaloguj się', { selector: 'button' })); 20 | 21 | expect(screen.getByText('Wpisz poprawny adres email')).toBeInTheDocument(); 22 | expect(screen.getByText('Pole jest wymagane')).toBeInTheDocument(); 23 | }); 24 | 25 | it('shows error after confirming with improper data', async () => { 26 | server.post(`/auth/login`).reply(401); 27 | 28 | renderLoginForm(); 29 | 30 | userEvent.type(screen.getByLabelText('Adres e-mail'), 'testowy@test.pl'); 31 | userEvent.type(screen.getByLabelText('Hasło'), 'niepoprawne'); 32 | 33 | userEvent.click(screen.getByText('Zaloguj się', { selector: 'button' })); 34 | 35 | await waitFor(async () => { 36 | const notification = await screen.findByRole('alert'); 37 | expect(notification).toHaveTextContent('Wprowadzone dane nie są poprawne'); 38 | }); 39 | }); 40 | 41 | it('shows notification after successful login', async () => { 42 | server.post(`/auth/login`).reply(204); 43 | 44 | renderLoginForm(); 45 | 46 | userEvent.type(screen.getByLabelText('Adres e-mail'), 'test@test1.pl'); 47 | userEvent.type(screen.getByLabelText('Hasło'), 'qwertyTESTOWY'); 48 | 49 | userEvent.click(screen.getByText('Zaloguj się', { selector: 'button' })); 50 | 51 | await waitFor(async () => { 52 | const notification = await screen.findByRole('alert'); 53 | expect(notification).toHaveTextContent('Logowanie udane'); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /apps/www/components/admin/loginForm/loginFormUtils.ts: -------------------------------------------------------------------------------- 1 | import { fetcher } from '../../../utils/fetcher'; 2 | 3 | import type { LoginType } from './LoginForm'; 4 | 5 | export const login = (body: LoginType) => fetcher('/auth/login', 'POST', { body }); 6 | -------------------------------------------------------------------------------- /apps/www/components/admin/orderForm/OrderFormSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonSkeleton, Form, SelectSkeleton } from 'carbon-components-react'; 2 | import React from 'react'; 3 | 4 | export const OrderFormSkeleton = React.memo(() => { 5 | return ( 6 |
7 | 8 | 9 | 10 | ); 11 | }); 12 | 13 | OrderFormSkeleton.displayName = 'OrdersFormSkeleton'; 14 | -------------------------------------------------------------------------------- /apps/www/components/admin/orderForm/OrderStatusSelect.tsx: -------------------------------------------------------------------------------- 1 | import type { SklepTypes } from '@sklep/types'; 2 | import { Select, SelectItem, SelectSkeleton } from 'carbon-components-react'; 3 | import React from 'react'; 4 | import type { FieldInputProps, FieldMetaState } from 'react-final-form'; 5 | 6 | import { useGetOrderStatuses } from '../../../utils/api/getAllOrderStatuses'; 7 | import { getErrorProps } from '../../../utils/formUtils'; 8 | 9 | type OrderStatusSelectProps = { 10 | readonly input: FieldInputProps; 11 | readonly meta: FieldMetaState; 12 | }; 13 | export const OrderStatusSelect = React.memo(({ input, meta }) => { 14 | const { data, isLoading, isError } = useGetOrderStatuses(); 15 | return ( 16 | <> 17 | {data && ( 18 | 27 | )} 28 | {isLoading && } 29 | {isError && Wystąpił błąd podczas pobierania możliwych statusów zamówienia} 30 | 31 | ); 32 | }); 33 | OrderStatusSelect.displayName = 'OrderStatusSelect'; 34 | 35 | export const TRANSLATED_STATUS_ORDERS: Record< 36 | SklepTypes['getOrdersStatuses200Response']['data'][number], 37 | string 38 | > = { 39 | CANCELLED: 'Anulowane', 40 | COMPLETED: 'Zrealizoawne', 41 | FAILED: 'Nie powiodło się', 42 | ON_HOLD: 'Wstrzymane', 43 | PENDING: 'Oczekiwanie na potwierdzenie', 44 | PROCESSING: 'Przetwarzane', 45 | REFUNDED: 'Zrefundowane', 46 | }; 47 | -------------------------------------------------------------------------------- /apps/www/components/admin/ordersList/OrdersList.tsx: -------------------------------------------------------------------------------- 1 | import { DataTableSkeleton, Pagination, DataTable, TableContainer } from 'carbon-components-react'; 2 | import React from 'react'; 3 | 4 | import { OrdersTable } from './OrdersTable'; 5 | import type { Order } from './utils'; 6 | import { headers, getRows } from './utils'; 7 | 8 | type OrdersListProps = { 9 | readonly isLoading: boolean; 10 | readonly orders: readonly Order[]; 11 | readonly ordersCount: number; 12 | readonly page: number; 13 | readonly pageSize: number; 14 | onPageChange(data: { readonly page: number; readonly pageSize: number }): void; 15 | }; 16 | 17 | export const OrdersList = React.memo( 18 | ({ isLoading, orders, ordersCount, page, pageSize, onPageChange }) => { 19 | const rows = React.useMemo(() => getRows(orders), [orders]); 20 | 21 | return ( 22 |
23 | { 27 | return ( 28 | 32 | {isLoading ? ( 33 | 41 | ) : ( 42 | 43 | )} 44 | 54 | 55 | ); 56 | }} 57 | > 58 |
59 | ); 60 | }, 61 | ); 62 | OrdersList.displayName = 'OrdersList'; 63 | -------------------------------------------------------------------------------- /apps/www/components/admin/ordersList/OrdersListCell.tsx: -------------------------------------------------------------------------------- 1 | import type { DenormalizedRow } from 'carbon-components-react'; 2 | import { TableCell } from 'carbon-components-react'; 3 | import React from 'react'; 4 | 5 | import { getCellValue } from '../utils'; 6 | 7 | import { ORDER_FIELDS } from './constants'; 8 | 9 | type OrdersListCellsProps = { 10 | readonly row: DenormalizedRow; 11 | }; 12 | 13 | export const OrdersListCells = React.memo(({ row }) => { 14 | return ( 15 | <> 16 | {ORDER_FIELDS.map(({ name }) => ( 17 | {getCellValue({ row, name })} 18 | ))} 19 | 20 | ); 21 | }); 22 | OrdersListCells.displayName = 'OrdersListRow'; 23 | -------------------------------------------------------------------------------- /apps/www/components/admin/ordersList/OrdersTable.tsx: -------------------------------------------------------------------------------- 1 | import type { DataTableCustomRenderProps } from 'carbon-components-react'; 2 | import { 3 | Table, 4 | TableHead, 5 | TableRow, 6 | TableSelectAll, 7 | TableHeader, 8 | TableBody, 9 | TableSelectRow, 10 | TableCell, 11 | OverflowMenu, 12 | OverflowMenuItem, 13 | } from 'carbon-components-react'; 14 | import { useRouter } from 'next/router'; 15 | import React from 'react'; 16 | 17 | import { OrdersListCells } from './OrdersListCell'; 18 | 19 | export const OrdersTable = React.memo( 20 | ({ rows, headers, getHeaderProps, getTableProps, getRowProps, getSelectionProps }) => { 21 | const router = useRouter(); 22 | return ( 23 | 24 | 25 | 26 | 27 | {headers.map((header) => ( 28 | {header.header} 29 | ))} 30 | 31 | 32 | 33 | {rows.map((row) => ( 34 | 35 | 36 | 37 | 38 | 39 | router.push(`/admin/orders/${row.id}`)} 42 | /> 43 | 44 | 45 | 46 | ))} 47 | 48 |
49 | ); 50 | }, 51 | ); 52 | OrdersTable.displayName = 'OrdersTable'; 53 | -------------------------------------------------------------------------------- /apps/www/components/admin/ordersList/constants.ts: -------------------------------------------------------------------------------- 1 | export const ORDER_FIELDS = [ 2 | { 3 | name: 'total', 4 | label: 'Total', 5 | }, 6 | { 7 | name: 'status', 8 | label: 'Order status', 9 | }, 10 | ] as const; 11 | -------------------------------------------------------------------------------- /apps/www/components/admin/ordersList/utils.ts: -------------------------------------------------------------------------------- 1 | import type { SklepTypes } from '@sklep/types'; 2 | 3 | import { formatCurrency } from '../../../utils/currency'; 4 | import { TRANSLATED_STATUS_ORDERS } from '../orderForm/OrderStatusSelect'; 5 | 6 | import { ORDER_FIELDS } from './constants'; 7 | 8 | export type Order = SklepTypes['getOrders200Response']['data'][number]; 9 | 10 | export const headers = [ 11 | ...ORDER_FIELDS.map(({ name, label }) => { 12 | return { 13 | key: name, 14 | header: label, 15 | }; 16 | }), 17 | { key: 'actions', header: 'Actions' }, 18 | ]; 19 | export type OrdersTableHeader = typeof headers[number]; 20 | 21 | export const getRows = (orders: readonly Order[]) => { 22 | return orders.map((order) => { 23 | return { 24 | ...order, 25 | total: formatCurrency(order.total / 100), 26 | status: TRANSLATED_STATUS_ORDERS[order.status], 27 | isSelected: undefined, 28 | isExpanded: undefined, 29 | disabled: undefined, 30 | }; 31 | }); 32 | }; 33 | export type OrdersTableRow = ReturnType[number]; 34 | -------------------------------------------------------------------------------- /apps/www/components/admin/productsForm/ProductSlug.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Slugify from 'slugify'; 3 | 4 | export const ProductSlug = ({ name }: { readonly name: string }) => { 5 | return {Slugify(name)}; 6 | }; 7 | -------------------------------------------------------------------------------- /apps/www/components/admin/productsForm/ProductsForm.module.scss: -------------------------------------------------------------------------------- 1 | .form { 2 | padding: 1rem; 3 | max-width: 50rem; 4 | } 5 | 6 | .form :global { 7 | .bx--form-item { 8 | margin-bottom: 2rem; 9 | 10 | > * { 11 | width: 100%; 12 | } 13 | } 14 | .bx--fieldset > .bx--form-item { 15 | margin-bottom: initial; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/www/components/admin/productsForm/ProductsForm.test.tsx: -------------------------------------------------------------------------------- 1 | import { screen } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | import React from 'react'; 4 | 5 | import { initMockServer, renderWithProviders } from '../../../jest-utils'; 6 | import { createProduct } from '../../../utils/api/createProduct'; 7 | import { ToastsContextProvider } from '../toasts/Toasts'; 8 | 9 | import type { ProductsFormProps } from './ProductsForm'; 10 | import { ProductsForm } from './ProductsForm'; 11 | 12 | const useRouter = jest.spyOn(require('next/router'), 'useRouter'); 13 | useRouter.mockImplementation(() => ({ replace() {} })); 14 | 15 | function renderProductsForm(productsFormProps: ProductsFormProps) { 16 | return renderWithProviders( 17 | 18 | 19 | , 20 | ); 21 | } 22 | 23 | describe('form for adding products', () => { 24 | const server = initMockServer(); 25 | 26 | it('shows error after confirming without required data', () => { 27 | renderProductsForm({ mode: 'ADDING', mutation: createProduct }); 28 | 29 | userEvent.click(screen.getByText('Dodaj produkt')); 30 | 31 | expect(screen.getByText('Nazwa produktu jest polem wymaganym')).toBeInTheDocument(); 32 | expect(screen.getByText('Cena produktu jest polem wymaganym')).toBeInTheDocument(); 33 | expect(screen.getByText('Opis produktu jest polem wymaganym')).toBeInTheDocument(); 34 | }); 35 | 36 | it('allows user to add product', async () => { 37 | server.post('/products').reply(200, { data: {} }); 38 | 39 | renderProductsForm({ 40 | mode: 'ADDING', 41 | mutation: createProduct, 42 | }); 43 | 44 | userEvent.type(screen.getByLabelText('Nazwa produktu'), 'Buty XYZ'); 45 | userEvent.type(screen.getByLabelText('Cena produktu'), '99.9'); 46 | userEvent.type(screen.getByLabelText('Opis produktu'), 'Dobra rzecz'); 47 | 48 | userEvent.click(screen.getByText('Dodaj produkt')); 49 | 50 | const notification = await screen.findByRole('alert'); 51 | expect(notification).toHaveTextContent('Dodałeś produkt do bazy danych'); 52 | }); 53 | 54 | it('shows error notification after bad request', async () => { 55 | server.post('/products').reply(400, { details: [] }); 56 | 57 | renderProductsForm({ 58 | mode: 'ADDING', 59 | mutation: createProduct, 60 | }); 61 | 62 | userEvent.type(screen.getByLabelText('Nazwa produktu'), 'Buty XYZ'); 63 | userEvent.type(screen.getByLabelText('Cena produktu'), '99.9'); 64 | userEvent.type(screen.getByLabelText('Opis produktu'), 'Dobra rzecz'); 65 | 66 | userEvent.click(screen.getByText('Dodaj produkt')); 67 | 68 | const notification = await screen.findByRole('alert'); 69 | expect(notification).toHaveTextContent( 70 | 'Wystąpił błąd podczas dodawania produktu do bazy danych', 71 | ); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /apps/www/components/admin/productsForm/ProductsFormSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ButtonSkeleton, 3 | Column, 4 | Form, 5 | Grid, 6 | NumberInputSkeleton, 7 | Row, 8 | TextAreaSkeleton, 9 | TextInputSkeleton, 10 | ToggleSkeleton, 11 | } from 'carbon-components-react'; 12 | import React from 'react'; 13 | 14 | import styles from './ProductsForm.module.scss'; 15 | 16 | export const ProductsFormSkeleton = React.memo(() => { 17 | return ( 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 |
34 | ); 35 | }); 36 | 37 | ProductsFormSkeleton.displayName = 'ProductsFormSkeleton'; 38 | -------------------------------------------------------------------------------- /apps/www/components/admin/productsList/ProductFields.ts: -------------------------------------------------------------------------------- 1 | export const PRODUCT_FIELDS = [ 2 | { 3 | name: 'name', 4 | label: 'Name', 5 | }, 6 | { 7 | name: 'isPublic', 8 | label: 'Public', 9 | }, 10 | { 11 | name: 'regularPrice', 12 | label: 'Regular price', 13 | }, 14 | { 15 | name: 'discountPrice', 16 | label: 'Discount price', 17 | }, 18 | ] as const; 19 | -------------------------------------------------------------------------------- /apps/www/components/admin/productsList/ProductListUtils.ts: -------------------------------------------------------------------------------- 1 | import type { SklepTypes } from '@sklep/types'; 2 | 3 | import { formatCurrency } from '../../../utils/currency'; 4 | 5 | import { PRODUCT_FIELDS } from './ProductFields'; 6 | 7 | export type Product = SklepTypes['getProducts200Response']['data'][number]; 8 | 9 | export const headers = [ 10 | ...PRODUCT_FIELDS.map(({ name, label }) => { 11 | return { 12 | key: name, 13 | header: label, 14 | }; 15 | }), 16 | { key: 'actions', header: 'Actions' }, 17 | ]; 18 | export type ProductsTableHeader = typeof headers[number]; 19 | 20 | export const getRows = (products: readonly Product[]) => { 21 | return products.map((product) => { 22 | return { 23 | ...product, 24 | regularPrice: formatCurrency(product.regularPrice / 100), 25 | discountPrice: product.discountPrice && formatCurrency(product.discountPrice / 100), 26 | id: String(product.id), 27 | description: product.description?.slice(0, 50) + '…', 28 | isSelected: undefined, 29 | isExpanded: undefined, 30 | disabled: undefined, 31 | }; 32 | }); 33 | }; 34 | export type ProductsTableRow = ReturnType[number]; 35 | -------------------------------------------------------------------------------- /apps/www/components/admin/productsList/ProductsList.module.scss: -------------------------------------------------------------------------------- 1 | .productsList { 2 | max-width: 60rem; 3 | } 4 | -------------------------------------------------------------------------------- /apps/www/components/admin/productsList/ProductsList.tsx: -------------------------------------------------------------------------------- 1 | import type { DenormalizedRow } from 'carbon-components-react'; 2 | import { DataTableSkeleton, Pagination, DataTable, TableContainer } from 'carbon-components-react'; 3 | import React from 'react'; 4 | 5 | import type { Product } from './ProductListUtils'; 6 | import { headers, getRows } from './ProductListUtils'; 7 | import styles from './ProductsList.module.scss'; 8 | import { ProductsTable } from './ProductsTable'; 9 | import { ProductsTableToolbar } from './ProductsTableToolbar'; 10 | 11 | type ProductsListProps = { 12 | readonly isLoading: boolean; 13 | readonly products: readonly Product[]; 14 | readonly page: number; 15 | readonly pageSize: number; 16 | readonly productsCount: number; 17 | deleteProduct(produt: Product): void; 18 | changePage(data: { readonly page: number; readonly pageSize: number }): void; 19 | }; 20 | 21 | export const ProductsList = React.memo( 22 | ({ isLoading, products, deleteProduct, page, pageSize, productsCount, changePage }) => { 23 | const rows = React.useMemo(() => getRows(products), [products]); 24 | 25 | const handleDeleteAction = React.useCallback( 26 | (row: DenormalizedRow) => { 27 | const productId = Number(row.id); 28 | const product = products.find((p) => p.id === productId); 29 | if (product) { 30 | deleteProduct(product); 31 | } 32 | }, 33 | [products, deleteProduct], 34 | ); 35 | 36 | return ( 37 |
38 | { 42 | return ( 43 | 47 | 48 | {isLoading ? ( 49 | 57 | ) : ( 58 | 59 | )} 60 | 70 | 71 | ); 72 | }} 73 | > 74 |
75 | ); 76 | }, 77 | ); 78 | ProductsList.displayName = 'ProductsList'; 79 | -------------------------------------------------------------------------------- /apps/www/components/admin/productsList/ProductsTable.tsx: -------------------------------------------------------------------------------- 1 | import type { DataTableCustomRenderProps, DenormalizedRow } from 'carbon-components-react'; 2 | import { 3 | Table, 4 | TableHead, 5 | TableRow, 6 | TableSelectAll, 7 | TableHeader, 8 | TableBody, 9 | TableSelectRow, 10 | TableCell, 11 | OverflowMenu, 12 | OverflowMenuItem, 13 | } from 'carbon-components-react'; 14 | import { useRouter } from 'next/router'; 15 | import React from 'react'; 16 | 17 | import { ProductsListCells } from './productsListCells/ProductsListCells'; 18 | 19 | export const ProductsTable = React.memo< 20 | DataTableCustomRenderProps & { 21 | readonly onDelete: (row: DenormalizedRow) => void; 22 | } 23 | >(({ rows, headers, getHeaderProps, getTableProps, getRowProps, getSelectionProps, onDelete }) => { 24 | const router = useRouter(); 25 | return ( 26 | 27 | 28 | 29 | 30 | {headers.map((header) => ( 31 | {header.header} 32 | ))} 33 | 34 | 35 | 36 | {rows.map((row) => { 37 | return ( 38 | 39 | 40 | 41 | 42 | 43 | router.push(`/admin/products/${row.id}`)} 46 | /> 47 | onDelete(row)} 52 | /> 53 | 54 | 55 | 56 | ); 57 | })} 58 | 59 |
60 | ); 61 | }); 62 | ProductsTable.displayName = 'ProductsTable'; 63 | -------------------------------------------------------------------------------- /apps/www/components/admin/productsList/ProductsTableToolbar.tsx: -------------------------------------------------------------------------------- 1 | import { Delete16 } from '@carbon/icons-react'; 2 | import type { DataTableCustomRenderProps } from 'carbon-components-react'; 3 | import { 4 | TableToolbar, 5 | TableBatchActions, 6 | TableBatchAction, 7 | TableToolbarContent, 8 | Button, 9 | } from 'carbon-components-react'; 10 | import { useRouter } from 'next/router'; 11 | import React from 'react'; 12 | import { useMutation, useQueryClient } from 'react-query'; 13 | 14 | import { deleteProducts } from '../../../utils/api/deleteProducts'; 15 | import { useToasts } from '../toasts/Toasts'; 16 | 17 | import type { ProductsTableRow, ProductsTableHeader } from './ProductListUtils'; 18 | 19 | export const PRODUCTS_QUERY_KEY = ['/products', 'GET']; 20 | 21 | export const ProductsTableToolbar = React.memo< 22 | DataTableCustomRenderProps 23 | >(({ getBatchActionProps, selectedRows }) => { 24 | const router = useRouter(); 25 | const cache = useQueryClient(); 26 | const { addToast } = useToasts(); 27 | const { mutateAsync } = useMutation(deleteProducts, { 28 | onSettled: () => cache.invalidateQueries(PRODUCTS_QUERY_KEY), 29 | onSuccess(settledPromises: readonly PromiseSettledResult[]) { 30 | const totalNumberOfPromises = settledPromises.length; 31 | const resolvedPromises = settledPromises.filter( 32 | ({ status }) => status === 'fulfilled', 33 | ).length; 34 | if (resolvedPromises === totalNumberOfPromises) { 35 | return addToast({ 36 | kind: 'success', 37 | title: 'Operacja udana', 38 | caption: 'Wszystkie produkty zostały usunięte pomyślnie', 39 | }); 40 | } 41 | addToast({ 42 | kind: 'warning', 43 | title: 'Coś poszło nie tak', 44 | caption: `Udało się usunąć ${resolvedPromises} z ${totalNumberOfPromises} przedmiotów`, 45 | }); 46 | }, 47 | }); 48 | const handleRedirectToAddProduct = React.useCallback(() => { 49 | void router.push('/admin/add-product'); 50 | }, [router]); 51 | 52 | const handleDeleteProducts = React.useCallback(() => { 53 | const productsIdsArray = selectedRows.map(({ id }) => Number(id)); 54 | void mutateAsync(productsIdsArray); 55 | }, [mutateAsync, selectedRows]); 56 | const batchActionsProps = getBatchActionProps(); 57 | 58 | return ( 59 | 60 | 61 | 66 | Usuń produkt(y) 67 | 68 | 69 | 70 | 78 | 79 | 80 | ); 81 | }); 82 | ProductsTableToolbar.displayName = 'ProductsTableToolbar'; 83 | -------------------------------------------------------------------------------- /apps/www/components/admin/productsList/productsListCells/ProductsListCells.tsx: -------------------------------------------------------------------------------- 1 | import type { DenormalizedRow } from 'carbon-components-react'; 2 | import { TableCell } from 'carbon-components-react'; 3 | import React from 'react'; 4 | 5 | import { getCellValue } from '../../utils'; 6 | import { PRODUCT_FIELDS } from '../ProductFields'; 7 | 8 | type Props = { 9 | readonly row: DenormalizedRow; 10 | }; 11 | 12 | export const ProductsListCells = React.memo(({ row }) => { 13 | return ( 14 | <> 15 | {PRODUCT_FIELDS.map(({ name }) => ( 16 | {getCellValue({ row, name })} 17 | ))} 18 | 19 | ); 20 | }); 21 | ProductsListCells.displayName = 'ProductListRow'; 22 | -------------------------------------------------------------------------------- /apps/www/components/admin/toasts/Toasts.tsx: -------------------------------------------------------------------------------- 1 | import type { Nil } from '@sklep/types'; 2 | import type { ToastNotificationProps } from 'carbon-components-react'; 3 | import { ToastNotification } from 'carbon-components-react'; 4 | import React, { useContext, useRef } from 'react'; 5 | import ReactDOM from 'react-dom'; 6 | 7 | import styles from './toasts.module.scss'; 8 | 9 | const ToastsContext = React.createContext< 10 | Nil<{ 11 | readonly toasts: readonly ToastNotificationProps[]; 12 | readonly setToasts: React.Dispatch>; 13 | }> 14 | >(null); 15 | 16 | const Toasts = React.memo<{ 17 | readonly toasts: readonly ToastNotificationProps[]; 18 | readonly hideToast: (toast: ToastNotificationProps) => void; 19 | }>(({ toasts, hideToast }) => { 20 | if (typeof document === 'undefined') { 21 | return null; 22 | } 23 | 24 | return ReactDOM.createPortal( 25 | toasts.map((props) => ( 26 | hideToast(props)} 29 | timeout={2000} 30 | className={styles.toast} 31 | key={props.id} 32 | /> 33 | )), 34 | document.body, 35 | ); 36 | }); 37 | Toasts.displayName = 'Toasts'; 38 | 39 | export const ToastsContextProvider = ({ children }: { readonly children: React.ReactNode }) => { 40 | const [toasts, setToasts] = React.useState([]); 41 | const hideToast = React.useCallback((props: ToastNotificationProps) => { 42 | setToasts((toasts) => toasts.filter((toast) => toast.id !== props.id)); 43 | }, []); 44 | 45 | return ( 46 | 47 | {children} 48 | 49 | 50 | ); 51 | }; 52 | 53 | export const useToasts = () => { 54 | const toastsContext = useContext(ToastsContext); 55 | const nextToastId = useRef(0); 56 | if (!toastsContext) { 57 | throw new Error('Missing ToastsContextProvider!'); 58 | } 59 | 60 | const addToast = React.useCallback( 61 | (props: ToastNotificationProps) => { 62 | toastsContext.setToasts((toasts) => [ 63 | ...toasts, 64 | { id: String(nextToastId.current++), ...props }, 65 | ]); 66 | }, 67 | [toastsContext], 68 | ); 69 | 70 | return React.useMemo(() => ({ addToast }), [addToast]); 71 | }; 72 | -------------------------------------------------------------------------------- /apps/www/components/admin/toasts/toasts.module.scss: -------------------------------------------------------------------------------- 1 | .toast { 2 | position: absolute; 3 | top: 2em; 4 | right: 0; 5 | z-index: 99999; 6 | } 7 | -------------------------------------------------------------------------------- /apps/www/components/admin/utils.ts: -------------------------------------------------------------------------------- 1 | import type { DenormalizedRow } from 'carbon-components-react'; 2 | 3 | export const getCellValue = ({ 4 | row, 5 | name, 6 | }: { 7 | readonly row: DenormalizedRow; 8 | readonly name: T; 9 | }) => { 10 | const cell = row.cells.find((c) => c.info.header === name); 11 | switch (typeof cell?.value) { 12 | case 'boolean': 13 | return cell?.value ? 'Yes' : 'No'; 14 | case 'number': 15 | case 'string': 16 | return cell?.value; 17 | default: 18 | return '-'; 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/cart/Cart.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useCart } from '../../shared/utils/useCart'; 4 | 5 | import { CartList } from './components/list/CartList'; 6 | import { CartSummary } from './components/summary/CartSummary'; 7 | 8 | export const Cart = () => { 9 | const { isLoading, cartResponseData } = useCart(); 10 | 11 | return ( 12 |
13 | {!isLoading && cartResponseData && ( 14 |
15 | 16 | 17 |
18 | )} 19 |
20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/cart/components/item/quantity/CartQuantityButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | type CartQuantityButtonProps = { 4 | readonly text: string; 5 | readonly onClick: React.MouseEventHandler; 6 | readonly ariaLabel: string; 7 | }; 8 | 9 | export const CartQuantityButton = React.memo( 10 | ({ text, onClick, ariaLabel }) => { 11 | return ( 12 | 15 | ); 16 | }, 17 | ); 18 | CartQuantityButton.displayName = 'CartQuantityButton'; 19 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/cart/components/item/quantity/CartQuantityInput.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | type CartQuantityInputProps = { 4 | readonly quantity: number; 5 | readonly handleChangeQuantity: React.FormEventHandler; 6 | }; 7 | 8 | // todo: to implement functionality of this input 9 | export const CartQuantityInput = React.memo( 10 | ({ quantity, handleChangeQuantity }) => { 11 | return ( 12 | 21 | ); 22 | }, 23 | ); 24 | CartQuantityInput.displayName = 'CartQuantityInput'; 25 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/cart/components/item/removeButton/RemoveButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | type RemoveButtonProps = { 4 | readonly onClick: React.FormEventHandler; 5 | }; 6 | 7 | export const RemoveButton = React.memo(({ onClick }) => { 8 | return ( 9 | 16 | ); 17 | }); 18 | RemoveButton.displayName = 'RemoveButton'; 19 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/cart/components/list/CartList.tsx: -------------------------------------------------------------------------------- 1 | import type { SklepTypes } from '@sklep/types'; 2 | import React from 'react'; 3 | 4 | import { CartItemRow } from '../item/CartItem'; 5 | 6 | type CartListProps = { 7 | readonly cart: SklepTypes['postCart200Response']['data']; 8 | }; 9 | 10 | export const CartList = React.memo(({ cart }) => { 11 | return ( 12 |
13 |

Koszyk zakupowy

14 | 15 | 16 | {cart.cartProducts.map((cartProduct) => ( 17 | 18 | ))} 19 | 20 |
21 |
22 | ); 23 | }); 24 | CartList.displayName = 'CartList'; 25 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/cart/components/summary/CartSummary.tsx: -------------------------------------------------------------------------------- 1 | import type { SklepTypes } from '@sklep/types'; 2 | import React from 'react'; 3 | 4 | import { BetterLink } from '../../../../shared/components/betterLink/BetterLink'; 5 | import { Price } from '../../../../shared/components/price/Price'; 6 | 7 | type CartSummaryProps = { 8 | readonly cart: SklepTypes['postCart200Response']['data']; 9 | }; 10 | 11 | export const CartSummary = React.memo(({ cart }) => { 12 | return ( 13 |
14 |

Podsumowanie

15 |
16 |
17 |

Razem

18 | 23 |
24 |
25 | 26 | 27 | Przejdź do płatności 28 | 29 | 30 |
31 | ); 32 | }); 33 | CartSummary.displayName = 'CartSummary'; 34 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/cart/components/summary/summaryButton/SummaryButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | type SummaryButtonProps = { 4 | readonly onClick: React.FormEventHandler; 5 | }; 6 | 7 | export const SummaryButton = React.memo(({ onClick }) => { 8 | return ( 9 | 16 | ); 17 | }); 18 | SummaryButton.displayName = 'SummaryButton'; 19 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/checkout/Checkout.tsx: -------------------------------------------------------------------------------- 1 | import type { SklepTypes } from '@sklep/types'; 2 | import { useRouter } from 'next/router'; 3 | import React from 'react'; 4 | import * as Yup from 'yup'; 5 | 6 | import { FinalFormWrapper } from '../../utils/formUtils'; 7 | 8 | import { AddressForm } from './components/addressForm/AddressForm'; 9 | import { CheckoutSummary } from './components/summary/CheckoutSummary'; 10 | import { useStripePayment } from './utils/useStripePayment'; 11 | 12 | type CheckoutProps = { 13 | readonly cart: SklepTypes['postCart200Response']['data']; 14 | }; 15 | 16 | export type AddressDetails = { 17 | readonly firstName: string; 18 | readonly lastName: string; 19 | readonly streetName: string; 20 | readonly houseNumber: string; 21 | readonly apartmentNumber?: string; 22 | readonly city: string; 23 | readonly zipCode: string; 24 | readonly phone: string; 25 | readonly email: string; 26 | }; 27 | 28 | const checkoutSchema = Yup.object({ 29 | firstName: Yup.string().required('Pole jest wymagane'), 30 | lastName: Yup.string().required('Pole jest wymagane'), 31 | streetName: Yup.string().required('Pole jest wymagane'), 32 | houseNumber: Yup.string().required('Pole jest wymagane'), 33 | apartmentNumber: Yup.string(), 34 | city: Yup.string().required('Pole jest wymagane'), 35 | zipCode: Yup.string().required('Pole jest wymagane'), 36 | phone: Yup.string().required('Pole jest wymagane'), 37 | email: Yup.string().email().required('Pole jest wymagane'), 38 | shippment: Yup.string().required('Pole jest wymagane'), 39 | }).required(); 40 | 41 | export type CheckoutType = Yup.InferType; 42 | 43 | export const Checkout = React.memo(({ cart }) => { 44 | const router = useRouter(); 45 | const { mutateAsync: processPayment, isLoading } = useStripePayment(); 46 | 47 | const handleSubmit = React.useCallback( 48 | async (values: AddressDetails) => { 49 | const response = await processPayment(values); 50 | if (response?.orderId) { 51 | await router.replace(`/zamowienie/${response.orderId}`); 52 | } 53 | }, 54 | [processPayment, router], 55 | ); 56 | 57 | return ( 58 | <> 59 | 67 | 68 | 69 | 70 | {/* @todo błędy płatności */} 71 | 72 | ); 73 | }); 74 | 75 | Checkout.displayName = 'Checkout'; 76 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/checkout/components/formErrorMessage/FormErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import type { FieldState } from 'final-form'; 2 | import React from 'react'; 3 | 4 | interface FormErrorMessageProps { 5 | readonly meta: Pick, 'error' | 'touched'>; 6 | } 7 | 8 | export const FormErrorMessage = React.memo(({ meta }) => { 9 | if (!meta.error || !meta.touched) { 10 | return null; 11 | } 12 | return {meta.error}; 13 | }); 14 | 15 | FormErrorMessage.displayName = 'FormError'; 16 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/checkout/components/item/CheckoutItemRow.tsx: -------------------------------------------------------------------------------- 1 | import type { SklepTypes } from '@sklep/types'; 2 | import React from 'react'; 3 | 4 | import { Price } from '../../../../shared/components/price/Price'; 5 | import { CartItemImage } from '../../../../shared/image/CartItemImage'; 6 | 7 | type CheckoutItemProps = { 8 | readonly cartProduct: SklepTypes['cartProducts'][number]; 9 | }; 10 | 11 | export const CheckoutItemRow = React.memo(({ cartProduct }) => { 12 | const { quantity, product } = cartProduct; 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 |

{product.name}

20 | 21 | x{quantity} 22 | 23 | 28 | 29 | 30 | ); 31 | }); 32 | 33 | CheckoutItemRow.displayName = 'CheckoutItemRow'; 34 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/checkout/components/list/CheckoutList.tsx: -------------------------------------------------------------------------------- 1 | import type { SklepTypes } from '@sklep/types'; 2 | import React from 'react'; 3 | 4 | import { CheckoutItemRow } from '../item/CheckoutItemRow'; 5 | 6 | type CartListProps = { 7 | readonly cart: SklepTypes['postCart200Response']['data']; 8 | }; 9 | 10 | export const CheckoutList = React.memo(({ cart }) => { 11 | return ( 12 | 13 | 14 | {cart.cartProducts.map((cartProduct) => ( 15 | 16 | ))} 17 | 18 |
19 | ); 20 | }); 21 | 22 | CheckoutList.displayName = 'CheckoutList'; 23 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/checkout/components/stripeAfterPaymentMessage/StripeAfterPaymentMessage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface StripeAfterPaymentMessageProps { 4 | readonly payloadError?: string; 5 | } 6 | 7 | export const StripeAfterPaymentMessage = React.memo( 8 | ({ payloadError }) => { 9 | return ( 10 | <> 11 | {payloadError ? ( 12 |

{payloadError}

13 | ) : ( 14 |

Udało się

15 | )} 16 | 17 | ); 18 | }, 19 | ); 20 | 21 | StripeAfterPaymentMessage.displayName = 'StripeAfterPaymentMessage'; 22 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/checkout/components/summary/CheckoutSummary.tsx: -------------------------------------------------------------------------------- 1 | import type { SklepTypes } from '@sklep/types'; 2 | import React from 'react'; 3 | import { useFormState } from 'react-final-form'; 4 | 5 | import { Button } from '../../../../shared/button/Button'; 6 | import { CheckoutList } from '../list/CheckoutList'; 7 | 8 | import { PaymentMethod } from './payment/PaymentMethod'; 9 | import { CheckoutTotal } from './total/CheckoutTotal'; 10 | 11 | export type CheckoutSummaryProps = { 12 | readonly cart: SklepTypes['postCart200Response']['data']; 13 | readonly processing: boolean; 14 | }; 15 | 16 | export const CheckoutSummary = React.memo(({ cart, processing }) => { 17 | const { pristine, hasValidationErrors } = useFormState(); 18 | 19 | return ( 20 |
21 |

Twoje zamówienie

22 | 23 | 24 | 25 | 28 |
29 | ); 30 | }); 31 | 32 | CheckoutSummary.displayName = 'CheckoutSummary'; 33 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/checkout/components/summary/payment/PaymentMethod.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Field } from 'react-final-form'; 3 | 4 | import visa_master from '../../../../../../../assets/visa_master.png'; 5 | 6 | import { StripeCard } from './StripeCard'; 7 | 8 | export enum SelectedOption { 9 | Card = 'CARD', 10 | } 11 | 12 | export const PaymentMethod = React.memo(() => { 13 | const [selectedOption, setSelectedOption] = useState(SelectedOption.Card); 14 | 15 | const handleChange = React.useCallback((value: SelectedOption) => { 16 | setSelectedOption(value); 17 | }, []); 18 | 19 | return ( 20 |
21 |

Metoda płatności

22 |

Wszystkie transakcje są bezpieczne i szyfrowane

23 |
24 |
25 | 40 |
41 | visa 42 |
43 | {selectedOption === SelectedOption.Card && } 44 |
45 | ); 46 | }); 47 | 48 | PaymentMethod.displayName = 'PaymentMethod'; 49 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/checkout/components/summary/payment/StripeCard.tsx: -------------------------------------------------------------------------------- 1 | import { CardElement } from '@stripe/react-stripe-js'; 2 | import type { StripeCardElementChangeEvent } from '@stripe/stripe-js'; 3 | import React from 'react'; 4 | import { Field } from 'react-final-form'; 5 | 6 | import { FormErrorMessage } from '../../formErrorMessage/FormErrorMessage'; 7 | 8 | const stripeCardValidator = (value: StripeCardElementChangeEvent) => { 9 | if (value) { 10 | return value.error ? value.error.message : undefined; 11 | } 12 | return 'Pole jest wymagane'; 13 | }; 14 | 15 | export const StripeCard = React.memo(() => { 16 | const cardOptions = { 17 | hidePostalCode: true, 18 | style: { 19 | base: { 20 | color: '#32325d', 21 | fontFamily: 'Arial, sans-serif', 22 | fontSmoothing: 'antialiased', 23 | fontSize: '16px', 24 | '::placeholder': { 25 | color: '#32325d', 26 | }, 27 | }, 28 | invalid: { 29 | color: '#fa755a', 30 | iconColor: '#fa755a', 31 | }, 32 | }, 33 | }; 34 | 35 | return ( 36 | 37 | {({ input, meta }) => ( 38 |
39 | (e.complete ? input.onChange(e) : input.onChange(undefined))} 43 | onBlur={input.onBlur} 44 | onFocus={input.onFocus} 45 | /> 46 | 47 |
48 | )} 49 |
50 | ); 51 | }); 52 | 53 | StripeCard.displayName = 'StripeCard'; 54 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/checkout/components/summary/shippment/ShippmentMethod.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Field } from 'react-final-form'; 3 | 4 | export const ShippmentMethod = React.memo(() => { 5 | return ( 6 |
7 |

Forma dostawy

8 |
9 |
10 | 16 |
17 |

20 zł

18 |
19 |
20 |
21 | 27 |
28 |

30 zł

29 |
30 |
31 |
32 | 38 |
39 |

20 zł

40 |
41 |
42 | ); 43 | }); 44 | 45 | ShippmentMethod.displayName = 'ShippmentMethod'; 46 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/checkout/components/summary/total/CheckoutTotal.tsx: -------------------------------------------------------------------------------- 1 | import type { SklepTypes } from '@sklep/types'; 2 | import React from 'react'; 3 | 4 | import { formatCurrency } from '../../../../../../../utils/currency'; 5 | import { Price } from '../../../../../shared/components/price/Price'; 6 | import { ShippmentMethod } from '../shippment/ShippmentMethod'; 7 | 8 | type CheckoutTotalProps = { 9 | readonly cart: SklepTypes['postCart200Response']['data']; 10 | }; 11 | 12 | export const CheckoutTotal = React.memo(({ cart }) => { 13 | return ( 14 |
15 |
16 |
17 | Kwota 18 | 19 |
20 | 21 |
22 | Do zapłaty 23 | {/* @todo add shipping cost */} 24 | {formatCurrency(cart.discountSubTotal / 100)} 25 |
26 |
27 |
28 | ); 29 | }); 30 | 31 | CheckoutTotal.displayName = 'CheckoutTotal'; 32 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/checkout/utils/useStripePayment.tsx: -------------------------------------------------------------------------------- 1 | import { CardElement, useElements, useStripe } from '@stripe/react-stripe-js'; 2 | import React from 'react'; 3 | import { useMutation, useQueryClient } from 'react-query'; 4 | 5 | import { fetcher } from '../../../../../utils/fetcher'; 6 | import { CART_QUERY_KEY } from '../../../shared/utils/useCart'; 7 | import type { AddressDetails } from '../Checkout'; 8 | 9 | export function useStripePayment() { 10 | const stripe = useStripe(); 11 | const elements = useElements(); 12 | const queryClient = useQueryClient(); 13 | 14 | const pay = React.useCallback( 15 | async (addressDetails: AddressDetails) => { 16 | const cardElement = elements?.getElement(CardElement); 17 | 18 | if (!stripe || !cardElement) { 19 | throw new Error('Missing Stripe elements'); 20 | } 21 | 22 | const { 23 | data: { stripeClientSecret, orderId }, 24 | } = await fetcher(`/orders/initiate-stripe-payment`, 'PATCH', { 25 | params: undefined, 26 | query: undefined, 27 | body: { ...addressDetails }, 28 | }); 29 | 30 | if (!stripeClientSecret) { 31 | throw new Error(`Couldn't obtain stripe client secret`); 32 | } 33 | 34 | const stripeResponse = await stripe.confirmCardPayment(stripeClientSecret, { 35 | payment_method: { 36 | card: cardElement, 37 | }, 38 | }); 39 | if (stripeResponse.error) { 40 | throw stripeResponse.error; 41 | } 42 | return { paymentIntent: stripeResponse.paymentIntent, orderId }; 43 | }, 44 | [elements, stripe], 45 | ); 46 | 47 | return useMutation(pay, { 48 | onSettled: () => queryClient.invalidateQueries(CART_QUERY_KEY), 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/featuredProduct/FeaturedProduct.tsx: -------------------------------------------------------------------------------- 1 | import type { SklepTypes } from '@sklep/types'; 2 | import React from 'react'; 3 | 4 | import { Breadcrumbs } from './breadcrumbs/Breadcrumbs'; 5 | import { ProductInfo } from './productInfo/ProductInfo'; 6 | 7 | type FeaturedProductProps = { 8 | readonly product: SklepTypes['getProducts200Response']['data'][number]; 9 | }; 10 | 11 | export const FeaturedProduct = React.memo(({ product }) => { 12 | return ( 13 |
14 |
15 |
16 | 17 |
18 |
19 | {`Zdjęcie 24 |
25 | 26 |
27 |
28 |
29 |
30 | ); 31 | }); 32 | FeaturedProduct.displayName = 'FeaturedProduct'; 33 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/featuredProduct/amount/Amount.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Field } from 'react-final-form'; 3 | import NumberInput from 'react-number-format'; 4 | 5 | import { FormErrorMessage } from '../../checkout/components/formErrorMessage/FormErrorMessage'; 6 | 7 | type AmountProps = { 8 | readonly increaseAmount: () => void; 9 | readonly decreaseAmount: () => void; 10 | }; 11 | 12 | export const Amount = React.memo(({ increaseAmount, decreaseAmount }) => ( 13 |
14 | name="quantity"> 15 | {({ input: { onBlur, onFocus, value, onChange, name }, meta }) => ( 16 | <> 17 |
18 | 27 | 36 | 44 |
45 |
46 | 47 |
48 | 49 | )} 50 | 51 |
52 | )); 53 | 54 | Amount.displayName = 'Amount'; 55 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/featuredProduct/breadcrumbs/Breadcrumbs.module.css: -------------------------------------------------------------------------------- 1 | .breadcrumb { 2 | @apply text-sm text-gray-900 transition duration-300 ease-in-out pr-2 relative; 3 | } 4 | 5 | .breadcrumb:after { 6 | content: '/'; 7 | margin-left: 0.5rem; 8 | } 9 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/featuredProduct/breadcrumbs/Breadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import React from 'react'; 3 | 4 | import styles from './Breadcrumbs.module.css'; 5 | 6 | type BreadcrumbsProps = { 7 | readonly productName: string; 8 | }; 9 | 10 | const LinkWrapper = React.memo<{ readonly title: string; readonly href: string }>( 11 | ({ title, href }) => ( 12 | 13 | {title} 14 | 15 | ), 16 | ); 17 | 18 | LinkWrapper.displayName = 'LinkWrapper'; 19 | 20 | export const Breadcrumbs = React.memo(({ productName }) => { 21 | return ( 22 | 27 | ); 28 | }); 29 | 30 | Breadcrumbs.displayName = 'Breadcrumbs'; 31 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/featuredProduct/productInfo/ProductInfo.tsx: -------------------------------------------------------------------------------- 1 | import type { SklepTypes } from '@sklep/types'; 2 | import React, { useCallback } from 'react'; 3 | import { Form as FinalForm } from 'react-final-form'; 4 | import * as Yup from 'yup'; 5 | 6 | import { Price } from '../../../shared/components/price/Price'; 7 | import { useCart } from '../../../shared/utils/useCart'; 8 | import { createFormValidator } from '../../../utils/formUtils'; 9 | import { AddToCartButton } from '../../productCollection/components/product/components/addToCartButton/AddToCartButton'; 10 | import { Amount } from '../amount/Amount'; 11 | 12 | const quantitySchema = Yup.object({ 13 | quantity: Yup.number() 14 | .typeError('Podaj poprawną wartość') 15 | .min(1, 'Podaj min. 1') 16 | .required('To pole jest wymagane'), 17 | }).required(); 18 | 19 | type ContentProps = { 20 | readonly product: SklepTypes['getProducts200Response']['data'][number]; 21 | }; 22 | 23 | type FormData = { 24 | readonly quantity: number; 25 | }; 26 | 27 | const validate = createFormValidator(quantitySchema); 28 | 29 | export const ProductInfo = React.memo(({ product }) => { 30 | const { addToCartByQuantity } = useCart(); 31 | 32 | const handleSubmit = useCallback( 33 | async ({ quantity }: FormData) => { 34 | await addToCartByQuantity({ productId: product.id, quantity }); 35 | }, 36 | [addToCartByQuantity, product.id], 37 | ); 38 | 39 | return ( 40 |
41 |

{product.name}

42 | 43 |

{product.description}

44 |
45 | { 51 | utils.changeValue(state, 'quantity', (value) => Number(value) - 1); 52 | }, 53 | increment: (_args, state, utils) => { 54 | utils.changeValue(state, 'quantity', (value) => Number(value) + 1 || 1); 55 | }, 56 | }} 57 | > 58 | {({ handleSubmit, form }) => { 59 | return ( 60 |
61 | 65 | 66 | 67 | ); 68 | }} 69 |
70 |
71 |
72 | ); 73 | }); 74 | 75 | ProductInfo.displayName = 'Amount'; 76 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/hero/Hero.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const Hero = () => { 4 | return ( 5 |
6 |
7 |
8 |

9 | Przykładowy obrazek HERO 10 |

11 | 15 | produkty 16 | 17 |
18 |
19 |
20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/productCollection/ProductCollection.tsx: -------------------------------------------------------------------------------- 1 | import type { SklepTypes } from '@sklep/types'; 2 | import React, { useState, useMemo } from 'react'; 3 | 4 | import { ProductItem } from './components/product/Product'; 5 | import { TopBar } from './components/topBar/TopBar'; 6 | 7 | const filterByLetter = (arr: SklepTypes['getProducts200Response']['data'], value: string) => 8 | value ? arr.filter((item) => item.name.toLowerCase().includes(value.toLowerCase())) : arr; 9 | 10 | type ProductCollectionProps = { 11 | readonly products: SklepTypes['getProducts200Response']['data']; 12 | }; 13 | 14 | export const ProductCollection = React.memo(({ products }) => { 15 | const [inputValue, setInputValue] = useState(''); 16 | 17 | const handleSetInputValue = React.useCallback>((e) => { 18 | setInputValue(e.target.value); 19 | }, []); 20 | 21 | const filteredProducts = useMemo(() => filterByLetter(products, inputValue), [ 22 | products, 23 | inputValue, 24 | ]); 25 | 26 | return ( 27 |
28 | 29 | {filteredProducts.length ? ( 30 | filteredProducts.map((product) => ) 31 | ) : ( 32 |

Nie ma takiego produktu...

33 | )} 34 |
35 | ); 36 | }); 37 | ProductCollection.displayName = 'ProductCollection'; 38 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/productCollection/components/product/Product.tsx: -------------------------------------------------------------------------------- 1 | import type { SklepTypes } from '@sklep/types'; 2 | import Link from 'next/link'; 3 | import React from 'react'; 4 | 5 | import { Price } from '../../../../shared/components/price/Price'; 6 | import { useCart } from '../../../../shared/utils/useCart'; 7 | 8 | import { AddToCartButton } from './components/addToCartButton/AddToCartButton'; 9 | import { ProductDescription } from './components/description/ProductDescription'; 10 | import { ProductImage } from './components/image/ProductImage'; 11 | 12 | type Product = SklepTypes['getProducts200Response']['data'][number]; 13 | 14 | type ProductItemProps = { 15 | readonly product: Product; 16 | }; 17 | 18 | export const ProductItem = React.memo( 19 | ({ product: { name, regularPrice, discountPrice, slug, id } }) => { 20 | const { addToCart } = useCart(); 21 | const handleAddToCartClick = React.useCallback(() => addToCart(id), [addToCart, id]); 22 | 23 | return ( 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 |
34 | ); 35 | }, 36 | ); 37 | 38 | ProductItem.displayName = 'ProductItem'; 39 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/productCollection/components/product/components/addToCartButton/AddToCartButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { ShoppingCartIcon } from '../../../../../../shared/components/icons/ShoppingCartIcon'; 4 | 5 | type AddToCartButtonProps = { 6 | readonly onClick?: React.MouseEventHandler; 7 | readonly type?: 'submit' | 'reset' | 'button'; 8 | }; 9 | 10 | export const AddToCartButton = React.memo(({ onClick, type = 'button' }) => { 11 | return ( 12 | 24 | ); 25 | }); 26 | AddToCartButton.displayName = 'AddToCartButton'; 27 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/productCollection/components/product/components/description/ProductDescription.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { HeartIcon } from '../../../../../../shared/components/icons/HeartIcon'; 4 | 5 | type ProductDescriptionProps = { 6 | readonly name: string; 7 | }; 8 | 9 | export const ProductDescription = React.memo(({ name }) => { 10 | return ( 11 |
12 |

{name}

13 |
14 | 17 |
18 |
19 | ); 20 | }); 21 | ProductDescription.displayName = 'ProductDescription'; 22 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/productCollection/components/product/components/image/ProductImage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const ProductImage = React.memo(() => { 4 | return ( 5 |
6 | Placeholder 7 |
8 | ); 9 | }); 10 | ProductImage.displayName = 'ProductImage'; 11 | -------------------------------------------------------------------------------- /apps/www/components/klient/modules/productCollection/components/topBar/TopBar.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import React, { useState } from 'react'; 3 | 4 | import { SearchIcon } from '../../../../shared/components/icons/SearchIcon'; 5 | 6 | type TopBarProps = { 7 | readonly handleSetInputValue: (e: React.ChangeEvent) => void; 8 | }; 9 | 10 | export const TopBar = React.memo(({ handleSetInputValue }) => { 11 | const [isSearchVisible, setIsSearchVisible] = useState(false); 12 | 13 | function handleSearchVisible() { 14 | setIsSearchVisible((prevState) => !prevState); 15 | } 16 | 17 | const searchStylesDesktop = clsx( 18 | 'block mr-2 mt-10 sm:mt-0 p-1 w-full sm:w-auto absolute left-0 sm:static border border-gray-600 rounded-md shadow-sd ', 19 | isSearchVisible && 'hidden', 20 | ); 21 | 22 | return ( 23 |
24 |
25 |

26 | Produkty 27 |

28 |
29 | 37 | 40 | 47 |
48 |
49 |
50 | ); 51 | }); 52 | TopBar.displayName = 'TopBar'; 53 | -------------------------------------------------------------------------------- /apps/www/components/klient/shared/api/addToCart.ts: -------------------------------------------------------------------------------- 1 | import { fetcher } from '../../../../utils/fetcher'; 2 | 3 | export function addToCart(body: { readonly productId: number; readonly quantity: number }) { 4 | return fetcher('/cart/add', 'PATCH', { body }); 5 | } 6 | 7 | export function removeFromCart(body: { readonly productId: number }) { 8 | return fetcher('/cart/remove', 'PATCH', { body }); 9 | } 10 | 11 | export function setCartQuantity(body: { readonly productId: number; readonly quantity: number }) { 12 | return fetcher('/cart/set', 'PATCH', { body }); 13 | } 14 | -------------------------------------------------------------------------------- /apps/www/components/klient/shared/button/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const Button = React.memo( 4 | ({ className = '', ...props }) => { 5 | return ( 6 | 21 | 22 | 30 |
31 | 32 |
33 | 34 | 35 |
36 | ); 37 | }); 38 | Header.displayName = 'Header'; 39 | -------------------------------------------------------------------------------- /apps/www/components/klient/shared/components/icons/BagIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const BagIcon = ({ className }: { readonly className: string }) => { 4 | return ( 5 | 12 | 17 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /apps/www/components/klient/shared/components/icons/HamburgerIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const HamburgerIcon = ({ className }: { readonly className: string }) => { 4 | return ( 5 | 12 | menu 13 | 14 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /apps/www/components/klient/shared/components/icons/HeartIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const HeartIcon = () => { 4 | return ( 5 | 10 | 11 | 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /apps/www/components/klient/shared/components/icons/SearchIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const SearchIcon = () => { 4 | return ( 5 | 12 | 13 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /apps/www/components/klient/shared/components/icons/ShoppingCartIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const ShoppingCartIcon = ({ className }: { readonly className: string }) => { 4 | return ( 5 | 12 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /apps/www/components/klient/shared/components/icons/SortIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const SortIcon = () => { 4 | return ( 5 | 12 | 13 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /apps/www/components/klient/shared/components/icons/SuccessIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const SuccessIcon = () => { 4 | return ( 5 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /apps/www/components/klient/shared/components/icons/UserIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const UserIcon = ({ className }: { readonly className: string }) => { 4 | return ( 5 | 12 | 13 | 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /apps/www/components/klient/shared/components/layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import type { ReactNode } from 'react'; 3 | import React from 'react'; 4 | import { useIsFetching } from 'react-query'; 5 | 6 | import { BetaNotification } from '../betaNotification/BetaNotification'; 7 | import { Footer } from '../footer/Footer'; 8 | import { Header } from '../header/Header'; 9 | 10 | type LayoutProps = { 11 | readonly children: ReactNode; 12 | readonly title: string; 13 | }; 14 | 15 | export const LoadingIndicator = () => { 16 | const isFetching = useIsFetching() > 0; 17 | 18 | React.useEffect(() => { 19 | if (typeof document !== 'undefined') { 20 | document.body.classList.toggle('react-query-is-loading', isFetching); 21 | } 22 | }, [isFetching]); 23 | 24 | return null; 25 | }; 26 | 27 | export const Layout = React.memo(({ children, title }) => { 28 | const fullTitle = title.trim() ? `${title.trim()} | Sklep Type of Web` : 'Sklep Type of Web'; 29 | return ( 30 |
31 | 32 | {fullTitle} 33 | 37 | 38 |
39 |
{children}
40 |
41 | 42 | 43 |
44 | ); 45 | }); 46 | Layout.displayName = 'Layout'; 47 | -------------------------------------------------------------------------------- /apps/www/components/klient/shared/components/menu/Menu.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import Link from 'next/link'; 3 | import React from 'react'; 4 | 5 | import { BagIcon } from '../icons/BagIcon'; 6 | 7 | type MenuProps = { 8 | readonly isMenuOpen: boolean; 9 | }; 10 | 11 | export const Menu = React.memo(({ isMenuOpen }) => { 12 | const menuStyle = clsx( 13 | 'md:flex md:items-center md:w-auto w-full order-3 md:order-1', 14 | isMenuOpen ? 'flex' : 'hidden', 15 | ); 16 | 17 | return ( 18 | 30 | ); 31 | }); 32 | 33 | Menu.displayName = 'Menu'; 34 | -------------------------------------------------------------------------------- /apps/www/components/klient/shared/components/price/Price.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import React from 'react'; 3 | 4 | import { formatCurrency } from '../../../../../utils/currency'; 5 | 6 | type PriceProps = { 7 | readonly regularPrice: number; 8 | readonly discountPrice?: number | null; 9 | readonly direction?: 'row' | 'column'; 10 | }; 11 | 12 | export const Price = React.memo( 13 | ({ regularPrice, discountPrice, direction = 'row' }) => { 14 | const priceClassName = clsx( 15 | 'pt-1 text-gray-900 flex items-end', 16 | direction === 'column' && 'flex-col', 17 | ); 18 | const discountClassName = direction === 'column' ? 'pl-0' : 'pl-2'; 19 | 20 | if (!discountPrice) { 21 | return

{formatCurrency(regularPrice / 100)}

; 22 | } 23 | 24 | return ( 25 |

26 | {formatCurrency(regularPrice / 100)}{' '} 27 | {formatCurrency(discountPrice / 100)} 28 |

29 | ); 30 | }, 31 | ); 32 | Price.displayName = 'Price'; 33 | -------------------------------------------------------------------------------- /apps/www/components/klient/shared/components/toast/Toast.tsx: -------------------------------------------------------------------------------- 1 | import type { Nil } from '@sklep/types'; 2 | import React, { useEffect } from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | 5 | import { SuccessIcon } from '../icons/SuccessIcon'; 6 | 7 | export const ToastContext = React.createContext< 8 | Nil<{ 9 | readonly isVisible: boolean; 10 | readonly setIsVisible: React.Dispatch>; 11 | }> 12 | >(null); 13 | 14 | interface ToastProps { 15 | readonly isVisible: boolean; 16 | readonly hideToast: () => void; 17 | } 18 | 19 | export const Toast = ({ isVisible, hideToast }: ToastProps) => { 20 | useEffect(() => { 21 | if (isVisible) { 22 | const timeout = setTimeout(() => { 23 | hideToast(); 24 | }, 2000); 25 | return () => clearTimeout(timeout); 26 | } 27 | return; 28 | }, [isVisible, hideToast]); 29 | 30 | if (!isVisible) { 31 | return null; 32 | } 33 | 34 | return ReactDOM.createPortal( 35 |
36 |
37 | 38 |

Dodano do koszyka

39 |
40 |
, 41 | document.body, 42 | ); 43 | }; 44 | 45 | export const ToastContextProvider = ({ children }: { readonly children: React.ReactNode }) => { 46 | const [isVisible, setIsVisible] = React.useState(false); 47 | const hideToast = React.useCallback(() => { 48 | setIsVisible(false); 49 | }, []); 50 | 51 | return ( 52 | 53 | {children} 54 | 55 | 56 | ); 57 | }; 58 | 59 | export const useToast = () => { 60 | const context = React.useContext(ToastContext); 61 | if (!context) { 62 | throw new Error('useToast must be used within a ToastContextProvider'); 63 | } 64 | return context; 65 | }; 66 | -------------------------------------------------------------------------------- /apps/www/components/klient/shared/image/CartItemImage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // todo: to fill with correct images 4 | export const CartItemImage = React.memo(() => { 5 | return ( 6 | 24 | ); 25 | }); 26 | CartItemImage.displayName = 'CartItemImage'; 27 | -------------------------------------------------------------------------------- /apps/www/components/klient/shared/utils/useCart.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useMutation, useQueryClient } from 'react-query'; 3 | 4 | import { useToWQuery } from '../../../../utils/fetcher'; 5 | import { addToCart, removeFromCart, setCartQuantity } from '../api/addToCart'; 6 | import { useToast } from '../components/toast/Toast'; 7 | 8 | export const CART_QUERY_KEY = ['/cart', 'POST', {}] as const; 9 | 10 | export const useCart = () => { 11 | const { data: cartResponse, isLoading } = useToWQuery(CART_QUERY_KEY); 12 | 13 | const queryClient = useQueryClient(); 14 | const toast = useToast(); 15 | 16 | const { mutateAsync: addToCartMutation } = useMutation( 17 | ({ productId, quantity }: { readonly productId: number; readonly quantity: number }) => 18 | addToCart({ productId, quantity }), 19 | { 20 | onSettled: () => queryClient.invalidateQueries(CART_QUERY_KEY), 21 | onSuccess: () => toast.setIsVisible(true), 22 | }, 23 | ); 24 | 25 | const { mutateAsync: setCartQuantityMutation } = useMutation( 26 | ({ productId, quantity }: { readonly productId: number; readonly quantity: number }) => 27 | setCartQuantity({ productId, quantity }), 28 | { onSettled: () => queryClient.invalidateQueries(CART_QUERY_KEY) }, 29 | ); 30 | 31 | const incrementQuantity = React.useCallback( 32 | (productId: number) => addToCartMutation({ productId, quantity: 1 }), 33 | [addToCartMutation], 34 | ); 35 | 36 | const decrementQuantity = React.useCallback( 37 | (productId: number) => addToCartMutation({ productId, quantity: -1 }), 38 | [addToCartMutation], 39 | ); 40 | 41 | const { mutateAsync: removeFromCartMutation } = useMutation( 42 | (id: number) => removeFromCart({ productId: id }), 43 | { 44 | onSettled: () => queryClient.invalidateQueries(CART_QUERY_KEY), 45 | }, 46 | ); 47 | 48 | return React.useMemo( 49 | () => ({ 50 | numberOfItemsInCart: cartResponse?.data.totalQuantity ?? 0, 51 | cartResponseData: cartResponse?.data, 52 | addToCart: incrementQuantity, 53 | addToCartByQuantity: addToCartMutation, 54 | removeFromCart: removeFromCartMutation, 55 | incrementQuantity: incrementQuantity, 56 | decrementQuantity: decrementQuantity, 57 | setCartQuantity: setCartQuantityMutation, 58 | isLoading, 59 | }), 60 | [ 61 | cartResponse?.data, 62 | decrementQuantity, 63 | incrementQuantity, 64 | isLoading, 65 | removeFromCartMutation, 66 | setCartQuantityMutation, 67 | addToCartMutation, 68 | ], 69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /apps/www/components/klient/utils/formUtils.tsx: -------------------------------------------------------------------------------- 1 | import type { ValidationErrors } from 'final-form'; 2 | import { setIn } from 'final-form'; 3 | import React from 'react'; 4 | import type { FieldMetaState, FormProps } from 'react-final-form'; 5 | import { Form as FinalForm } from 'react-final-form'; 6 | import { ValidationError } from 'yup'; 7 | import type { ObjectSchema, InferType } from 'yup'; 8 | import type { ObjectShape } from 'yup/lib/object'; 9 | 10 | type FinalFormWrapperProps< 11 | Schema extends ObjectSchema, 12 | InitialFormValues = Partial>, 13 | > = FormProps, InitialFormValues> & { 14 | readonly schema: Schema; 15 | readonly className?: string; 16 | }; 17 | 18 | export const FinalFormWrapper: < 19 | Schema extends ObjectSchema, 20 | InitialFormValues = Partial>, 21 | >( 22 | props: FinalFormWrapperProps, 23 | ) => React.ReactElement = ({ schema, onSubmit, className, children, ...props }) => { 24 | const validate = React.useMemo(() => createFormValidator(schema), [schema]); 25 | const handleSubmit = React.useCallback( 26 | async (values, ...args) => { 27 | const validatedValues = await schema.validate(values); 28 | return onSubmit(validatedValues, ...args); 29 | }, 30 | [onSubmit, schema], 31 | ); 32 | 33 | return ( 34 | 35 | {({ handleSubmit }) => { 36 | return ( 37 |
38 | {children} 39 |
40 | ); 41 | }} 42 |
43 | ); 44 | }; 45 | 46 | export const createFormValidator = 47 | >(schema: Schema) => 48 | (values: InferType): ValidationErrors | Promise => { 49 | try { 50 | schema.validateSync(values, { abortEarly: false }); 51 | } catch (err) { 52 | if (err instanceof ValidationError) { 53 | return err.inner.reduce((formError, innerError) => { 54 | return innerError.path 55 | ? setIn(formError, innerError.path, innerError.message) 56 | : formError; 57 | }, {}); 58 | } 59 | console.error(err); 60 | } 61 | return {}; 62 | }; 63 | 64 | export const getErrorProps = (meta: FieldMetaState) => { 65 | const isInvalid = 66 | (meta.error || (meta.submitError && !meta.dirtySinceLastSubmit)) && meta.touched; 67 | 68 | return { 69 | invalid: isInvalid, 70 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- it's okay 71 | invalidText: meta.error, 72 | }; 73 | }; 74 | -------------------------------------------------------------------------------- /apps/www/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://www.sklep.localhost:3000", 3 | "supportFile": "cypress/support/index.ts", 4 | "video": false, 5 | "chromeWebSecurity": false, 6 | "experimentalNetworkStubbing": true 7 | } 8 | -------------------------------------------------------------------------------- /apps/www/cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /apps/www/cypress/integration/klient/home.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 3 | 4 | describe('Home Page', () => { 5 | beforeEach(() => { 6 | cy.server(); 7 | cy.visit('http://www.sklep.localhost:3000'); 8 | }); 9 | 10 | it('should properly render home page', () => { 11 | cy.get('title').should('contain.text', 'Sklep strona główna'); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /apps/www/cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | const wp = require('@cypress/webpack-preprocessor'); 19 | 20 | module.exports = (on) => { 21 | const options = { 22 | webpackOptions: { 23 | resolve: { 24 | extensions: ['.ts', '.tsx', '.js'], 25 | }, 26 | module: { 27 | rules: [ 28 | { 29 | test: /\.tsx?$/, 30 | loader: 'ts-loader', 31 | options: { transpileOnly: true }, 32 | }, 33 | ], 34 | }, 35 | }, 36 | }; 37 | on('file:preprocessor', wp(options)); 38 | }; 39 | -------------------------------------------------------------------------------- /apps/www/cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-namespace */ 2 | 3 | // declare namespace Cypress {} 4 | -------------------------------------------------------------------------------- /apps/www/cypress/support/index.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands'; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | 22 | import '@testing-library/cypress/add-commands'; 23 | -------------------------------------------------------------------------------- /apps/www/cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "noEmit": true, 5 | "baseUrl": "../node_modules", 6 | "types": ["cypress", "@types/testing-library__cypress"] 7 | }, 8 | "include": ["**/*.*"] 9 | } 10 | -------------------------------------------------------------------------------- /apps/www/jest-setup.ts: -------------------------------------------------------------------------------- 1 | import Dotenv from 'dotenv'; 2 | Dotenv.config({ path: '.env.development' }); 3 | 4 | process.on('unhandledRejection', (err) => { 5 | fail(err); 6 | }); 7 | -------------------------------------------------------------------------------- /apps/www/jest-utils.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import Nock from 'nock'; 3 | import type { PropsWithChildren, ReactElement } from 'react'; 4 | import React from 'react'; 5 | import { QueryClient, QueryClientProvider } from 'react-query'; 6 | 7 | import { ToastsContextProvider as AdminToastsContextProvider } from './components/admin/toasts/Toasts'; 8 | import { ToastContextProvider as KlientToastContextProvider } from './components/klient/shared/components/toast/Toast'; 9 | 10 | export const initMockServer = () => { 11 | const mockServer = Nock(process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost', { 12 | allowUnmocked: false, 13 | }); 14 | 15 | beforeEach(() => { 16 | Nock.disableNetConnect(); 17 | }); 18 | 19 | afterEach(() => { 20 | if (!mockServer.isDone()) { 21 | console.error(mockServer.pendingMocks()); 22 | } 23 | mockServer.done(); 24 | 25 | Nock.cleanAll(); 26 | Nock.enableNetConnect(); 27 | }); 28 | 29 | return mockServer; 30 | }; 31 | 32 | interface Options { 33 | readonly queryClientConfig?: ConstructorParameters[0]; 34 | } 35 | 36 | export const TestProvider = ({ 37 | children, 38 | options, 39 | }: PropsWithChildren<{ readonly options?: Options }>) => { 40 | return ( 41 | 55 | 56 | {children} 57 | 58 | 59 | ); 60 | }; 61 | 62 | export const renderWithProviders = (Component: ReactElement) => { 63 | render(Component, { wrapper: TestProvider }); 64 | }; 65 | -------------------------------------------------------------------------------- /apps/www/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'jsdom', 3 | roots: [''], 4 | moduleFileExtensions: ['js', 'ts', 'tsx', 'json'], 5 | testPathIgnorePatterns: ['[/\\\\](node_modules|.next)[/\\\\]', '/cypress/'], 6 | transformIgnorePatterns: ['[/\\\\]node_modules[/\\\\].+\\.(ts|tsx)$'], 7 | transform: { 8 | '^.+\\.(ts|tsx)$': 'babel-jest', 9 | }, 10 | watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'], 11 | moduleNameMapper: { 12 | '\\.(css|less|sass|scss)$': 'identity-obj-proxy', 13 | '\\.(gif|ttf|eot|svg|png)$': '/test/__mocks__/fileMock.js', 14 | }, 15 | setupFiles: ['./jest-setup.ts'], 16 | setupFilesAfterEnv: ['next', '@testing-library/jest-dom', 'jest-extended'], 17 | testTimeout: 10000, 18 | }; 19 | -------------------------------------------------------------------------------- /apps/www/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | import 'jest-extended'; 6 | 7 | declare namespace NodeJS { 8 | interface ProcessEnv { 9 | readonly NEXT_PUBLIC_API_URL: string; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /apps/www/next.config.js: -------------------------------------------------------------------------------- 1 | const withImages = require('next-images'); 2 | const config = withImages(); 3 | 4 | config.redirects = async () => { 5 | return [ 6 | { 7 | source: '/', 8 | destination: '/produkty', 9 | permanent: false, 10 | }, 11 | ]; 12 | }; 13 | 14 | config.reactStrictMode = true; 15 | config.poweredByHeader = false; 16 | 17 | module.exports = config; 18 | -------------------------------------------------------------------------------- /apps/www/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "www", 3 | "version": "1.0.0-alpha.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "dev": "cross-env NODE_ICU_DATA=../../node_modules/full-icu next dev", 8 | "build": "next build", 9 | "start": "cross-env NODE_ICU_DATA=../../node_modules/full-icu next start", 10 | "test": "cross-env NODE_ICU_DATA=../../node_modules/full-icu jest --detectOpenHandles --forceExit", 11 | "test_:ci": "cross-env NODE_ICU_DATA=../../node_modules/full-icu jest", 12 | "tsc": "tsc --noEmit", 13 | "eslint": "eslint . --ext .js,.jsx,.ts,.tsx --fix", 14 | "cy:open": "cypress open", 15 | "cy:run": "cypress run" 16 | }, 17 | "dependencies": { 18 | "@carbon/icons-react": "10.33.0", 19 | "@stripe/react-stripe-js": "1.4.1", 20 | "@stripe/stripe-js": "1.15.0", 21 | "carbon-components": "10.36.0", 22 | "carbon-components-react": "7.36.0", 23 | "carbon-icons": "7.0.7", 24 | "clsx": "1.1.1", 25 | "eslint-plugin-testing-library": "4.6.0", 26 | "final-form": "4.20.2", 27 | "full-icu": "1.3.4", 28 | "ms": "2.1.3", 29 | "next": "10.2.3", 30 | "next-images": "1.7.0", 31 | "ramda": "0.27.1", 32 | "react": "17.0.2", 33 | "react-dom": "17.0.2", 34 | "react-final-form": "6.5.3", 35 | "react-modal-promise": "0.7.6", 36 | "react-number-format": "4.6.0", 37 | "react-query": "3.16.0", 38 | "slugify": "1.5.3", 39 | "tailwindcss": "2.1.2", 40 | "yup": "0.32.9" 41 | }, 42 | "devDependencies": { 43 | "@testing-library/dom": "7.31.0", 44 | "@testing-library/jest-dom": "5.12.0", 45 | "@testing-library/react": "11.2.7", 46 | "@testing-library/user-event": "13.1.9", 47 | "@types/carbon-components-react": "7.33.0", 48 | "@types/carbon__icons-react": "10.31.0", 49 | "@types/jest": "26.0.23", 50 | "@types/node": "15.6.1", 51 | "@types/react": "17.0.8", 52 | "@types/react-dom": "17.0.5", 53 | "@types/testing-library__react": "10.2.0", 54 | "@types/yup": "0.29.11", 55 | "babel-eslint": "10.1.0", 56 | "babel-jest": "27.0.2", 57 | "dotenv": "10.0.0", 58 | "eslint": "7.27.0", 59 | "eslint-config-react-app": "6.0.0", 60 | "eslint-plugin-css-modules": "2.11.0", 61 | "eslint-plugin-cypress": "2.11.3", 62 | "eslint-plugin-flowtype": "5.7.2", 63 | "eslint-plugin-jsx-a11y": "6.4.1", 64 | "eslint-plugin-react": "7.23.2", 65 | "eslint-plugin-react-hooks": "4.2.0", 66 | "jest": "27.0.2", 67 | "jest-css-modules": "2.1.0", 68 | "jest-extended": "0.11.5", 69 | "jest-watch-typeahead": "0.6.4", 70 | "nock": "13.0.11", 71 | "node-sass": "6.0.0", 72 | "postcss": "8.3.0", 73 | "postcss-flexbugs-fixes": "5.0.2", 74 | "postcss-preset-env": "6.7.0", 75 | "react-query-devtools": "2.6.3", 76 | "ts-loader": "9.2.2", 77 | "typescript-plugin-css-modules": "3.2.0" 78 | }, 79 | "optionalDependencies": { 80 | "@cypress/webpack-preprocessor": "5.9.0", 81 | "@testing-library/cypress": "7.0.6", 82 | "cypress": "7.4.0" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /apps/www/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import ms from 'ms'; 2 | import type { AppProps } from 'next/app'; 3 | import React from 'react'; 4 | import '../styles/index.css'; 5 | import { QueryClient, QueryClientProvider } from 'react-query'; 6 | // eslint-disable-next-line import/no-extraneous-dependencies -- OK 7 | import { ReactQueryDevtools } from 'react-query-devtools'; 8 | import type { DehydratedState } from 'react-query/hydration'; 9 | import { Hydrate } from 'react-query/hydration'; 10 | 11 | import { ToastContextProvider } from '../components/klient/shared/components/toast/Toast'; 12 | 13 | function MyApp({ Component, pageProps }: AppProps) { 14 | const queryClientRef = React.useRef(); 15 | if (!queryClientRef.current) { 16 | queryClientRef.current = new QueryClient({ 17 | defaultOptions: { 18 | queries: { 19 | refetchOnWindowFocus: false, 20 | refetchOnReconnect: false, 21 | staleTime: ms('10 seconds'), 22 | }, 23 | }, 24 | }); 25 | } 26 | 27 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- pageProps: any; 28 | const { dehydratedState } = pageProps as { readonly dehydratedState?: DehydratedState }; 29 | return ( 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | } 40 | 41 | export default MyApp; 42 | -------------------------------------------------------------------------------- /apps/www/pages/admin/add-product.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import React from 'react'; 3 | 4 | import { AdminLayout } from '../../components/admin/adminLayout/AdminLayout'; 5 | import { ProductsForm } from '../../components/admin/productsForm/ProductsForm'; 6 | import { createProduct } from '../../utils/api/createProduct'; 7 | 8 | export default function AdminAddProductPage() { 9 | return ( 10 | <> 11 | 12 | Dodawanie produktów 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /apps/www/pages/admin/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import React from 'react'; 3 | 4 | import { AdminLayout } from '../../components/admin/adminLayout/AdminLayout'; 5 | 6 | export default function AdminHomePage() { 7 | return ( 8 | <> 9 | 10 | Panel admina 11 | 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /apps/www/pages/admin/login.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import React from 'react'; 3 | 4 | import { AdminLayout } from '../../components/admin/adminLayout/AdminLayout'; 5 | import { LoginForm } from '../../components/admin/loginForm/LoginForm'; 6 | 7 | export default function AdminLoginPage() { 8 | return ( 9 | <> 10 | 11 | Logowanie 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /apps/www/pages/admin/orders/[orderId].tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import React from 'react'; 3 | 4 | import { AdminLayout } from '../../../components/admin/adminLayout/AdminLayout'; 5 | import { AdminSingleOrder } from '../../../components/admin/adminSingleOrder/AdminSingleOrder'; 6 | 7 | export default function AdminSingleOrderPage() { 8 | return ( 9 | <> 10 | 11 | Status zamówienia 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /apps/www/pages/admin/orders/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import React from 'react'; 3 | 4 | import { AdminLayout } from '../../../components/admin/adminLayout/AdminLayout'; 5 | import { AdminOrders } from '../../../components/admin/adminOrders/AdminOrders'; 6 | 7 | export default function AdminOrdersPage() { 8 | return ( 9 | <> 10 | 11 | Zamówienia 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /apps/www/pages/admin/products/[productId].tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { AdminLayout } from '../../../components/admin/adminLayout/AdminLayout'; 4 | import { AdminSingleProduct } from '../../../components/admin/adminSingleProduct/AdminSingleProduct'; 5 | 6 | export default function AdminSingleProductPage() { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /apps/www/pages/admin/products/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import React from 'react'; 3 | 4 | import { AdminLayout } from '../../../components/admin/adminLayout/AdminLayout'; 5 | import { AdminProducts } from '../../../components/admin/adminProducts/AdminProducts'; 6 | 7 | export default function AdminProductsPage() { 8 | return ( 9 | <> 10 | 11 | Produkty 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /apps/www/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next'; 2 | import { useRouter } from 'next/router'; 3 | import { useEffect } from 'react'; 4 | 5 | const HomePage: NextPage = function HomePage() { 6 | // client-side rendering 7 | const router = useRouter(); 8 | useEffect(() => { 9 | void router.replace('/produkty'); 10 | }, [router]); 11 | return null; 12 | }; 13 | HomePage.getInitialProps = (appContext) => { 14 | // server-side rendering 15 | if (typeof window === 'undefined' && appContext.res) { 16 | appContext.res.writeHead(302, { Location: '/produkty' }); 17 | appContext.res.end(); 18 | } 19 | return {}; 20 | }; 21 | 22 | export default HomePage; 23 | -------------------------------------------------------------------------------- /apps/www/pages/koszyk/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Cart } from '../../components/klient/modules/cart/Cart'; 4 | import { Layout } from '../../components/klient/shared/components/layout/Layout'; 5 | 6 | function CartPage() { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | } 13 | 14 | export default CartPage; 15 | -------------------------------------------------------------------------------- /apps/www/pages/produkty/[productSlug].tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { QueryClient } from 'react-query'; 3 | import { dehydrate } from 'react-query/hydration'; 4 | 5 | import { FeaturedProduct } from '../../components/klient/modules/featuredProduct/FeaturedProduct'; 6 | import { Layout } from '../../components/klient/shared/components/layout/Layout'; 7 | import { useGetProducts, useGetProductBySlug } from '../../utils/api/queryHooks'; 8 | import { useParams } from '../../utils/hooks'; 9 | import type { InferGetStaticPathsType } from '../../utils/types'; 10 | 11 | function ProductPage() { 12 | const productSlug = String(useParams(['productSlug']).productSlug); 13 | const { data: productResponse } = useGetProductBySlug(productSlug); 14 | 15 | return ( 16 | productResponse?.data && ( 17 | 18 | 19 | 20 | ) 21 | ); 22 | } 23 | 24 | export const getStaticPaths = async () => { 25 | const queryClient = new QueryClient(); 26 | const response = await useGetProducts.prefetch(queryClient); 27 | 28 | return { 29 | paths: 30 | response?.data.map((p) => { 31 | return { params: { productSlug: p.slug } }; 32 | }) ?? [], 33 | fallback: false, 34 | }; 35 | }; 36 | 37 | export const getStaticProps = async ({ 38 | params: { productSlug }, 39 | }: InferGetStaticPathsType) => { 40 | const queryClient = new QueryClient(); 41 | await useGetProductBySlug.prefetch(queryClient, productSlug); 42 | 43 | return { 44 | props: { 45 | dehydratedState: dehydrate(queryClient), 46 | }, 47 | revalidate: 60, 48 | }; 49 | }; 50 | 51 | export default ProductPage; 52 | -------------------------------------------------------------------------------- /apps/www/pages/produkty/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { QueryClient } from 'react-query'; 3 | import { dehydrate } from 'react-query/hydration'; 4 | 5 | import { Hero } from '../../components/klient/modules/hero/Hero'; 6 | import { ProductCollection } from '../../components/klient/modules/productCollection/ProductCollection'; 7 | import { Layout } from '../../components/klient/shared/components/layout/Layout'; 8 | import { useGetProducts } from '../../utils/api/queryHooks'; 9 | 10 | function ProductsPage() { 11 | const { data: productsResponse } = useGetProducts(); 12 | 13 | if (!productsResponse?.data.length) { 14 | return ( 15 | 16 | 17 | Brak produktów. 18 | 19 | ); 20 | } 21 | 22 | return ( 23 | 24 | 25 | 26 | 27 | ); 28 | } 29 | 30 | export const getStaticProps = async () => { 31 | const queryClient = new QueryClient(); 32 | await useGetProducts.prefetch(queryClient); 33 | 34 | return { 35 | props: { 36 | dehydratedState: dehydrate(queryClient), 37 | }, 38 | revalidate: 60, 39 | }; 40 | }; 41 | 42 | export default ProductsPage; 43 | -------------------------------------------------------------------------------- /apps/www/pages/zamowienie/[orderId].tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { CheckoutInProgress } from '../../components/klient/modules/checkout/CheckoutInProgress'; 4 | import { Layout } from '../../components/klient/shared/components/layout/Layout'; 5 | import { useParams } from '../../utils/hooks'; 6 | 7 | export default function SingleOrderPage() { 8 | const orderId = String(useParams(['orderId']).orderId); 9 | 10 | return {orderId && }; 11 | } 12 | -------------------------------------------------------------------------------- /apps/www/pages/zamowienie/index.tsx: -------------------------------------------------------------------------------- 1 | import { Elements } from '@stripe/react-stripe-js'; 2 | import type { Stripe } from '@stripe/stripe-js'; 3 | import { loadStripe } from '@stripe/stripe-js/pure'; 4 | import { useRouter } from 'next/router'; 5 | import React, { useEffect } from 'react'; 6 | 7 | import { Checkout } from '../../components/klient/modules/checkout/Checkout'; 8 | import { Layout } from '../../components/klient/shared/components/layout/Layout'; 9 | import { useCart } from '../../components/klient/shared/utils/useCart'; 10 | 11 | const getStripe = (() => { 12 | // https://github.com/stripe/stripe-js/issues/43#issuecomment-643840075 13 | // lazy-load stripe only when checkout page is opened 14 | let mutableStripePromise: Promise | undefined; 15 | return () => { 16 | if (!mutableStripePromise) { 17 | if (!process.env.NEXT_PUBLIC_STRIPE_KEY) { 18 | throw new Error('Missing process.env.NEXT_PUBLIC_STRIPE_KEY!'); 19 | } 20 | 21 | mutableStripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_KEY); 22 | } 23 | return mutableStripePromise; 24 | }; 25 | })(); 26 | 27 | function CheckoutPage() { 28 | const { cartResponseData } = useCart(); 29 | const router = useRouter(); 30 | 31 | useEffect(() => { 32 | if (cartResponseData && !cartResponseData.totalQuantity) { 33 | void router.replace('/'); 34 | } 35 | }, [cartResponseData, router]); 36 | 37 | return ( 38 | 39 | 40 | {cartResponseData?.cartProducts && } 41 | 42 | 43 | ); 44 | } 45 | 46 | export default CheckoutPage; 47 | -------------------------------------------------------------------------------- /apps/www/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | 'tailwindcss', 4 | 'postcss-flexbugs-fixes', 5 | [ 6 | 'postcss-preset-env', 7 | { 8 | autoprefixer: { 9 | flexbox: 'no-2009', 10 | }, 11 | stage: 3, 12 | features: { 13 | 'custom-properties': false, 14 | }, 15 | }, 16 | ], 17 | ], 18 | }; 19 | -------------------------------------------------------------------------------- /apps/www/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/sklep/174cbe47d8b134140c248d8df5f77aa2adb4c30b/apps/www/public/favicon.ico -------------------------------------------------------------------------------- /apps/www/styles/components/AdminSingleProduct.module.scss: -------------------------------------------------------------------------------- 1 | .heading { 2 | font-size: 2.5rem; 3 | padding: 1rem 0; 4 | margin: 0 3.5rem 1rem; 5 | } 6 | 7 | .deleteButton { 8 | width: 100%; 9 | margin: 0 3.5rem; 10 | } 11 | 12 | .errorMessage { 13 | display: inline-block; 14 | font-size: 3.5rem; 15 | color: #e74c3c; 16 | padding: 0 3.5rem; 17 | max-width: 50rem; 18 | } 19 | -------------------------------------------------------------------------------- /apps/www/styles/components/cart.css: -------------------------------------------------------------------------------- 1 | .cart-item-close-btn { 2 | @apply absolute; 3 | top: 0.5rem; 4 | right: 0; 5 | } 6 | 7 | /* hack to remove arrows from inputs with type=number */ 8 | .cart input[type='number']::-webkit-inner-spin-button, 9 | .cart input[type='number']::-webkit-outer-spin-button { 10 | -webkit-appearance: none; 11 | margin: 0; 12 | } 13 | -------------------------------------------------------------------------------- /apps/www/styles/components/hero.css: -------------------------------------------------------------------------------- 1 | .hero { 2 | width: 100vw; 3 | height: 50vh; 4 | background-image: url('https://picsum.photos/id/21/1600/1600?grayscale'); 5 | } 6 | -------------------------------------------------------------------------------- /apps/www/styles/components/stripe.css: -------------------------------------------------------------------------------- 1 | .result-message { 2 | line-height: 22px; 3 | font-size: 16px; 4 | } 5 | .result-message a { 6 | color: rgb(89, 111, 214); 7 | font-weight: 600; 8 | text-decoration: none; 9 | } 10 | .hidden { 11 | display: none; 12 | } 13 | #card-error { 14 | color: rgb(105, 115, 134); 15 | font-size: 16px; 16 | line-height: 20px; 17 | margin-top: 12px; 18 | text-align: center; 19 | } 20 | #card-element { 21 | border-radius: 4px 4px 0 0; 22 | padding: 12px; 23 | border: 1px solid rgba(50, 50, 93, 0.1); 24 | max-height: 44px; 25 | width: 100%; 26 | background: white; 27 | box-sizing: border-box; 28 | } 29 | #payment-request-button { 30 | margin-bottom: 32px; 31 | } 32 | 33 | /* spinner/processing state, errors */ 34 | .spinner, 35 | .spinner:before, 36 | .spinner:after { 37 | border-radius: 50%; 38 | } 39 | .spinner { 40 | color: #ffffff; 41 | font-size: 22px; 42 | text-indent: -99999px; 43 | margin: 0px auto; 44 | position: relative; 45 | width: 20px; 46 | height: 20px; 47 | box-shadow: inset 0 0 0 2px; 48 | -webkit-transform: translateZ(0); 49 | -ms-transform: translateZ(0); 50 | transform: translateZ(0); 51 | } 52 | .spinner:before, 53 | .spinner:after { 54 | position: absolute; 55 | content: ''; 56 | } 57 | .spinner:before { 58 | width: 10.4px; 59 | height: 20.4px; 60 | background: #1a202c; 61 | border-radius: 20.4px 0 0 20.4px; 62 | top: -0.2px; 63 | left: -0.2px; 64 | -webkit-transform-origin: 10.4px 10.2px; 65 | transform-origin: 10.4px 10.2px; 66 | -webkit-animation: loading 2s infinite ease 1.5s; 67 | animation: loading 2s infinite ease 1.5s; 68 | } 69 | .spinner:after { 70 | width: 10.4px; 71 | height: 20.2px; 72 | background: #1a202c; 73 | border-radius: 0 10.2px 10.2px 0; 74 | top: -0.1px; 75 | left: 10.2px; 76 | -webkit-transform-origin: 0px 10.2px; 77 | transform-origin: 0px 10.2px; 78 | -webkit-animation: loading 2s infinite ease; 79 | animation: loading 2s infinite ease; 80 | } 81 | @keyframes loading { 82 | 0% { 83 | -webkit-transform: rotate(0deg); 84 | transform: rotate(0deg); 85 | } 86 | 100% { 87 | -webkit-transform: rotate(360deg); 88 | transform: rotate(360deg); 89 | } 90 | } 91 | @media only screen and (max-width: 600px) { 92 | form { 93 | width: 80vw; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /apps/www/styles/components/toast.css: -------------------------------------------------------------------------------- 1 | .slide-left { 2 | animation: slide-left 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; 3 | } 4 | 5 | @keyframes slide-left { 6 | 0% { 7 | transform: translateX(100px); 8 | } 9 | 100% { 10 | transform: translateX(0); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /apps/www/styles/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | 3 | /* Write your own custom base styles here */ 4 | 5 | /* Start purging... */ 6 | @tailwind components; 7 | /* Stop purging. */ 8 | 9 | /* Write you own custom component styles here */ 10 | @import './components/hero.css'; 11 | @import './components/cart.css'; 12 | @import './components/stripe.css'; 13 | @import './components/toast.css'; 14 | 15 | /* Start purging... */ 16 | @tailwind utilities; 17 | /* Stop purging. */ 18 | 19 | /* Your own custom utilities */ 20 | @import './utils/utils.css'; 21 | 22 | body.react-query-is-loading * { 23 | cursor: wait; 24 | } 25 | 26 | .visually-hidden { 27 | @apply overflow-hidden absolute whitespace-nowrap; 28 | clip: rect(0 0 0 0); 29 | clip-path: inset(50%); 30 | height: 1px; 31 | width: 1px; 32 | } 33 | 34 | button:disabled { 35 | opacity: 0.5; 36 | cursor: default; 37 | } 38 | 39 | body { 40 | overflow-x: hidden; 41 | } 42 | -------------------------------------------------------------------------------- /apps/www/styles/utils/utils.css: -------------------------------------------------------------------------------- 1 | .worksans { 2 | font-family: 'Work Sans', sans-serif; 3 | } 4 | 5 | .hover\:grow { 6 | transition: all 0.3s; 7 | transform: scale(1); 8 | } 9 | 10 | .hover\:grow:hover { 11 | transform: scale(1.02); 12 | } 13 | -------------------------------------------------------------------------------- /apps/www/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: [], 3 | darkMode: false, // or 'media' or 'class' 4 | theme: { 5 | extend: { 6 | colors: { 7 | 'accent-1': '#333', 8 | }, 9 | minHeight: { 10 | 14: '3rem', 11 | }, 12 | }, 13 | }, 14 | variants: { 15 | extend: {}, 16 | }, 17 | plugins: [], 18 | }; 19 | -------------------------------------------------------------------------------- /apps/www/test/__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /apps/www/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "esModuleInterop": true, 5 | "forceConsistentCasingInFileNames": true, 6 | "isolatedModules": true, 7 | "jsx": "preserve", 8 | "lib": ["dom", "dom.iterable", "esnext"], 9 | "module": "esnext", 10 | "moduleResolution": "node", 11 | "noEmit": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "noImplicitReturns": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "resolveJsonModule": true, 17 | "skipLibCheck": true, 18 | "strict": true, 19 | "target": "es2019", 20 | "allowJs": true, 21 | "plugins": [{ "name": "typescript-plugin-css-modules" }] 22 | }, 23 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "next.config.js"], 24 | "exclude": ["node_modules", "cypress"] 25 | } 26 | -------------------------------------------------------------------------------- /apps/www/types/order.ts: -------------------------------------------------------------------------------- 1 | export type Order = { 2 | readonly id: string; 3 | }; 4 | -------------------------------------------------------------------------------- /apps/www/types/product.ts: -------------------------------------------------------------------------------- 1 | // todo: correct it 2 | export type Product = { 3 | readonly id: number; 4 | readonly name: string; 5 | readonly description?: string; 6 | readonly slug?: string; 7 | readonly isPublic: boolean; 8 | readonly regularPrice: number; 9 | readonly discountPrice?: number; 10 | readonly productType?: string; 11 | }; 12 | -------------------------------------------------------------------------------- /apps/www/utils/api/createProduct.ts: -------------------------------------------------------------------------------- 1 | import type { SklepTypes } from '@sklep/types'; 2 | 3 | import { fetcher } from '../fetcher'; 4 | 5 | export const createProduct = (body: SklepTypes['postProductsRequestBody']) => { 6 | return fetcher('/products', 'POST', { 7 | body: { 8 | ...body, 9 | regularPrice: body.regularPrice * 100, 10 | discountPrice: body.discountPrice ? body.discountPrice * 100 : null, 11 | }, 12 | }); 13 | }; 14 | -------------------------------------------------------------------------------- /apps/www/utils/api/deleteProduct.ts: -------------------------------------------------------------------------------- 1 | import { fetcher } from '../fetcher'; 2 | 3 | export const deleteProduct = (productId: number) => { 4 | return fetcher('/products/{productId}', 'DELETE', { params: { productId } }); 5 | }; 6 | -------------------------------------------------------------------------------- /apps/www/utils/api/deleteProducts.ts: -------------------------------------------------------------------------------- 1 | import { fetcher } from '../fetcher'; 2 | 3 | export const deleteProducts = (productsIdsArray: readonly number[]) => { 4 | return Promise.allSettled( 5 | productsIdsArray.map((productId) => { 6 | return fetcher('/products/{productId}', 'DELETE', { params: { productId } }); 7 | }), 8 | ); 9 | }; 10 | -------------------------------------------------------------------------------- /apps/www/utils/api/getAllOrderStatuses.ts: -------------------------------------------------------------------------------- 1 | import type { QueryClient } from 'react-query'; 2 | 3 | import { fetcher, useToWQuery } from '../fetcher'; 4 | 5 | export const useGetOrderStatuses = () => { 6 | return useToWQuery(['/orders/statuses', 'GET', {}], { staleTime: Infinity, cacheTime: Infinity }); 7 | }; 8 | useGetOrderStatuses.prefetch = (queryClient: QueryClient) => { 9 | return queryClient.fetchQuery(['/orders/statuses', 'GET', {}], () => 10 | fetcher('/orders/statuses', 'GET', {}), 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /apps/www/utils/api/queryHooks.ts: -------------------------------------------------------------------------------- 1 | import type { QueryClient, UseQueryOptions } from 'react-query'; 2 | 3 | import { fetcher, useToWQuery } from '../fetcher'; 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any -- any query options 6 | type AnyUseQueryOptions = UseQueryOptions; 7 | 8 | export const useGetProducts = ( 9 | query: { readonly take: number; readonly skip: number } | Record = {}, 10 | ) => useToWQuery(['/products', 'GET', { query }] as const); 11 | useGetProducts.prefetch = (queryClient: QueryClient) => 12 | queryClient.fetchQuery(['/products', 'GET', { query: {} }], () => 13 | fetcher('/products', 'GET', { query: {} }), 14 | ); 15 | 16 | export const useGetProductBySlug = ( 17 | productIdOrSlug: string | number, 18 | queryConfig?: AnyUseQueryOptions, 19 | ) => 20 | useToWQuery( 21 | ['/products/{productIdOrSlug}', 'GET', { params: { productIdOrSlug } }] as const, 22 | queryConfig, 23 | ); 24 | useGetProductBySlug.prefetch = (queryClient: QueryClient, productIdOrSlug: string | number) => 25 | queryClient.fetchQuery( 26 | ['/products/{productIdOrSlug}', 'GET', { params: { productIdOrSlug } }], 27 | () => fetcher('/products/{productIdOrSlug}', 'GET', { params: { productIdOrSlug } }), 28 | ); 29 | 30 | export const useGetOrderById = (orderId: string, queryConfig?: AnyUseQueryOptions) => 31 | useToWQuery(['/orders/{orderId}', 'GET', { params: { orderId } }] as const, queryConfig); 32 | 33 | export const useGetOrders = ( 34 | query: { readonly take: number; readonly skip: number } | Record = {}, 35 | ) => useToWQuery(['/orders', 'GET', { query }] as const); 36 | useGetOrders.prefetch = (queryClient: QueryClient) => 37 | queryClient.fetchQuery(['/orders', 'GET', {}], () => fetcher('/orders', 'GET', { query: {} })); 38 | -------------------------------------------------------------------------------- /apps/www/utils/api/updateOrder.ts: -------------------------------------------------------------------------------- 1 | import type { SklepTypes } from '@sklep/types'; 2 | 3 | import { fetcher } from '../fetcher'; 4 | 5 | export const updateOrder = (orderId: string, body: SklepTypes['putOrdersOrderIdRequestBody']) => { 6 | return fetcher('/orders/{orderId}', 'PUT', { body, params: { orderId } }); 7 | }; 8 | -------------------------------------------------------------------------------- /apps/www/utils/api/updateProduct.ts: -------------------------------------------------------------------------------- 1 | import type { SklepTypes } from '@sklep/types'; 2 | 3 | import { fetcher } from '../fetcher'; 4 | 5 | export const updateProduct = ( 6 | productId: number, 7 | body: SklepTypes['putProductsProductIdRequestBody'], 8 | ) => { 9 | return fetcher('/products/{productId}', 'PUT', { 10 | params: { productId }, 11 | body: { 12 | ...body, 13 | regularPrice: body.regularPrice * 100, 14 | discountPrice: body.discountPrice ? body.discountPrice * 100 : null, 15 | }, 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /apps/www/utils/currency.ts: -------------------------------------------------------------------------------- 1 | export const formatCurrency = (price: number) => 2 | new Intl.NumberFormat('pl-PL', { 3 | style: 'currency', 4 | currency: 'PLN', 5 | maximumFractionDigits: 2, 6 | minimumFractionDigits: 2, 7 | }).format(price); 8 | -------------------------------------------------------------------------------- /apps/www/utils/fetcherTypes.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types -- type trickery */ 2 | export type Get = { 3 | readonly 0: Obj; 4 | readonly 1: _Get> extends infer R 5 | ? R extends object 6 | ? Get> 7 | : R 8 | : never; 9 | }[Keys['length'] extends 0 ? 0 : 1]; 10 | 11 | export type _Get = 12 | Keys extends keyof NonNullable ? NonNullable[Keys] : undefined | null; 13 | 14 | type _Head = Arr['length'] extends 0 ? never : Arr[0]; 15 | 16 | type _Tail = Arr extends readonly [infer _, ...infer Tail] 17 | ? Tail extends readonly (keyof any)[] 18 | ? Tail 19 | : never 20 | : never; 21 | /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types */ 22 | -------------------------------------------------------------------------------- /apps/www/utils/formUtils.tsx: -------------------------------------------------------------------------------- 1 | import { Form } from 'carbon-components-react'; 2 | import type { ValidationErrors } from 'final-form'; 3 | import { setIn } from 'final-form'; 4 | import React from 'react'; 5 | import type { FieldMetaState, FormProps } from 'react-final-form'; 6 | import { Form as FinalForm } from 'react-final-form'; 7 | import { ValidationError } from 'yup'; 8 | import type { ObjectSchema, InferType } from 'yup'; 9 | import type { ObjectShape } from 'yup/lib/object'; 10 | 11 | export const ToWForm: >( 12 | props: FormProps> & { readonly validate?: never } & { 13 | readonly schema: Schema; 14 | readonly className?: string; 15 | }, 16 | ) => React.ReactElement = ({ schema, onSubmit, className, children, ...props }) => { 17 | const validate = React.useMemo(() => createFormValidator(schema), [schema]); 18 | const handleSubmit = React.useCallback( 19 | async (values, ...args) => { 20 | const validatedValues = await schema.validate(values); 21 | return onSubmit(validatedValues, ...args); 22 | }, 23 | [onSubmit, schema], 24 | ); 25 | 26 | return ( 27 | 28 | {({ handleSubmit }) => { 29 | return ( 30 |
31 | {children} 32 |
33 | ); 34 | }} 35 |
36 | ); 37 | }; 38 | 39 | export const createFormValidator = 40 | >(schema: Schema) => 41 | (values: InferType): ValidationErrors | Promise => { 42 | try { 43 | schema.validateSync(values, { abortEarly: false }); 44 | } catch (err) { 45 | if (err instanceof ValidationError) { 46 | return err.inner.reduce((formError, innerError) => { 47 | return innerError.path 48 | ? setIn(formError, innerError.path, innerError.message) 49 | : formError; 50 | }, {}); 51 | } 52 | console.error(err); 53 | } 54 | return {}; 55 | }; 56 | 57 | export const getErrorProps = (meta: FieldMetaState) => { 58 | const isInvalid = 59 | (meta.error || (meta.submitError && !meta.dirtySinceLastSubmit)) && meta.touched; 60 | 61 | return { 62 | invalid: isInvalid, 63 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- it's okay 64 | invalidText: meta.error || meta.submitError, 65 | }; 66 | }; 67 | -------------------------------------------------------------------------------- /apps/www/utils/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import { useState, useEffect } from 'react'; 3 | 4 | export function useDebouncedValue(value: T, delay: number) { 5 | const [debouncedValue, setDebouncedValue] = useState(value); 6 | 7 | useEffect(() => { 8 | const handler = setTimeout(() => { 9 | setDebouncedValue(value); 10 | }, delay); 11 | 12 | return () => { 13 | clearTimeout(handler); 14 | }; 15 | }, [value, delay]); 16 | 17 | return debouncedValue; 18 | } 19 | 20 | export const useParams = (required: readonly T[] = []) => { 21 | const params = useRouter().query; 22 | 23 | const missingParams = required 24 | .map((param: string) => (params[param] == null ? param : undefined)) 25 | .filter((param): param is string => typeof param === 'string'); 26 | if (missingParams.length > 0) { 27 | throw new Error(`Missing params ${missingParams.join(', ')}!`); 28 | } 29 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- OK 30 | return params as { readonly [K in T]: string }; 31 | }; 32 | -------------------------------------------------------------------------------- /apps/www/utils/serverErrorHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { serverErrorHandler } from './serverErrorHandler'; 2 | 3 | const multipleErrors = { 4 | status: 400, 5 | name: 'test name', 6 | message: 'test message', 7 | data: { 8 | statusCode: 400, 9 | error: 'Bad Request', 10 | message: 'string.base. any.required', 11 | validation: { 12 | source: 'payload', 13 | keys: ['name', 'description', 'regularPrice'], 14 | }, 15 | details: [ 16 | { 17 | message: 'string.base', 18 | path: ['name'], 19 | type: 'string.base', 20 | }, 21 | { 22 | message: 'string.base', 23 | path: ['description'], 24 | type: 'string.base', 25 | }, 26 | { 27 | message: 'any.required', 28 | path: ['regularPrice'], 29 | type: 'any.required', 30 | }, 31 | ], 32 | }, 33 | }; 34 | 35 | const oneError = { 36 | status: 400, 37 | name: 'test nameA', 38 | message: 'test message', 39 | data: { 40 | statusCode: 400, 41 | error: 'Bad Request', 42 | message: 'string.base', 43 | validation: { 44 | source: 'payload', 45 | keys: ['name', 'description', 'regularPrice'], 46 | }, 47 | details: [ 48 | { 49 | message: 'string.base', 50 | path: ['name'], 51 | type: 'string.base', 52 | }, 53 | ], 54 | }, 55 | }; 56 | 57 | const not400Error = { 58 | status: 401, 59 | name: 'Unauthorized', 60 | message: 'test message', 61 | data: { 62 | statusCode: 401, 63 | error: 'Bad Request', 64 | message: 'string.base', 65 | validation: { 66 | source: 'payload', 67 | keys: ['name', 'description', 'regularPrice'], 68 | }, 69 | details: [ 70 | { 71 | message: 'string.base', 72 | path: ['name'], 73 | type: 'string.base', 74 | }, 75 | ], 76 | }, 77 | }; 78 | 79 | describe('Test status 400 server error handling', () => { 80 | it('should return proper error - one error ', () => { 81 | expect(serverErrorHandler(oneError)).toEqual({ name: 'Pole musi być tekstem' }); 82 | }); 83 | 84 | it('should returrn proper errors - multiple errors', () => { 85 | expect(serverErrorHandler(multipleErrors)).toEqual({ 86 | name: 'Pole musi być tekstem', 87 | description: 'Pole musi być tekstem', 88 | regularPrice: 'To pole jest wymagane', 89 | }); 90 | }); 91 | 92 | it('should throw error when status is other than 400', () => { 93 | expect(() => serverErrorHandler(not400Error)).toThrowError(not400Error); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /apps/www/utils/serverErrorHandler.ts: -------------------------------------------------------------------------------- 1 | import type { ResponseError } from './fetcher'; 2 | 3 | export interface ErrorDetail { 4 | readonly message: string; 5 | readonly path: readonly string[]; 6 | readonly type: string; 7 | } 8 | 9 | export interface Validation { 10 | readonly source: string; 11 | readonly keys: readonly string[]; 12 | } 13 | 14 | export interface My400Error { 15 | readonly statusCode?: 400; 16 | readonly error?: string; 17 | readonly message?: string; 18 | readonly validation?: Validation; 19 | readonly details?: readonly ErrorDetail[]; 20 | } 21 | 22 | interface ErrorTranslation { 23 | readonly [index: string]: string; 24 | } 25 | 26 | const badRequestErrorsTranslation: ErrorTranslation = { 27 | 'any.required': 'To pole jest wymagane', 28 | 'string.base': 'Pole musi być tekstem', 29 | 'number.base': 'Pole musi być liczbą', 30 | }; 31 | 32 | function getTranslatedErrorMessage(message: string) { 33 | if (!badRequestErrorsTranslation[message]) { 34 | console.warn(`No translation for ${message}`); 35 | return 'Popraw pole'; 36 | } 37 | return badRequestErrorsTranslation[message]; 38 | } 39 | 40 | export function serverErrorHandler(err: ResponseError) { 41 | if (err.status === 400) { 42 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- if it's our API then this assertion is okay 43 | const { details } = err.data as My400Error; 44 | return ( 45 | details 46 | ?.map((error) => { 47 | return { [error.path[0]]: getTranslatedErrorMessage(error.message) }; 48 | }) 49 | .reduce((error1, error2) => Object.assign(error1, error2), {}) ?? {} 50 | ); 51 | } 52 | throw err; 53 | } 54 | -------------------------------------------------------------------------------- /apps/www/utils/types.ts: -------------------------------------------------------------------------------- 1 | export type InferGetStaticPathsType = T extends () => Promise 2 | ? R extends { readonly paths: ReadonlyArray } 3 | ? P 4 | : never 5 | : never; 6 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-alpha.0", 3 | "npmClient": "yarn", 4 | "useWorkspaces": true, 5 | "command": { 6 | "bootstrap": { 7 | "mutex": "network:13419" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sklep.typeofweb", 3 | "private": "true", 4 | "scripts": { 5 | "dev": "yarn --frozen-lockfile && lerna bootstrap && lerna run dev --parallel", 6 | "eslint": "yarn lerna run eslint --stream", 7 | "test": "yarn lerna run test --stream", 8 | "test:ci": "yarn lerna run test_:ci --stream", 9 | "tsc": "yarn lerna run tsc --stream" 10 | }, 11 | "workspaces": { 12 | "packages": [ 13 | "apps/*" 14 | ] 15 | }, 16 | "dependencies": {}, 17 | "devDependencies": { 18 | "@typescript-eslint/eslint-plugin": "4.25.0", 19 | "@typescript-eslint/parser": "4.25.0", 20 | "concurrently": "6.2.0", 21 | "cross-env": "7.0.3", 22 | "eslint-config-prettier": "8.3.0", 23 | "eslint-plugin-eslint-comments": "3.2.0", 24 | "eslint-plugin-functional": "3.2.1", 25 | "eslint-plugin-import": "2.23.3", 26 | "husky": "6.0.0", 27 | "lerna": "4.0.0", 28 | "lint-staged": "11.0.0", 29 | "prettier": "2.3.0", 30 | "rimraf": "3.0.2", 31 | "ts-node": "10.0.0", 32 | "typescript": "4.3.2", 33 | "weak-napi": "2.0.2" 34 | }, 35 | "husky": { 36 | "hooks": { 37 | "pre-commit": "lint-staged" 38 | } 39 | }, 40 | "lint-staged": { 41 | "*.{js,jsx,ts,tsx}": [ 42 | "eslint --fix", 43 | "yarn prettier --write" 44 | ], 45 | "*.json,*.md,*.yaml,*.yml": [ 46 | "yarn prettier --write" 47 | ] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | ./apps/www/tsconfig.json --------------------------------------------------------------------------------