├── .commitlintrc ├── .editorconfig ├── .env ├── .env.local.example ├── .env.production ├── .eslintignore ├── .eslintrc ├── .gitattributes ├── .gitignore ├── .husky ├── commit-msg ├── pre-commit └── pre-push ├── .lintstagedrc ├── .prettierignore ├── .prettierrc ├── .storybook ├── i18n.ts ├── main.ts ├── preview-head.html └── preview.tsx ├── .stylelintrc ├── CHANGELOG.md ├── LICENCE.md ├── babel.config.json ├── docs └── fsd-schema.jpg ├── jest-globals.d.ts ├── jest.config.js ├── jest.extends.ts ├── jest.setup.js ├── mocks ├── auth-mock.ts ├── browser.ts ├── index.ts └── server.ts ├── next-env.d.ts ├── next-i18next.config.mjs ├── next-sitemap.config.js ├── next.config.mjs ├── orval.config.ts ├── package.json ├── process.env.d.ts ├── public ├── locales │ ├── en │ │ ├── 404.json │ │ ├── auth.json │ │ ├── common.json │ │ └── index.json │ └── ru │ │ ├── 404.json │ │ ├── auth.json │ │ ├── common.json │ │ └── index.json ├── site.webmanifest └── static │ └── images │ ├── 404.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── circle-scatter.svg │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── logo-og.png │ ├── maskable_icon.png │ ├── tools │ ├── effector.png │ ├── eslint.png │ ├── fsd.png │ ├── i18n.png │ ├── jest.png │ ├── next.png │ ├── sentry.png │ ├── storybook.png │ └── typescript.png │ └── vercel.svg ├── readme.md ├── renovate.json ├── sentry.client.config.js ├── sentry.properties ├── sentry.server.config.js ├── src ├── app │ ├── components │ │ ├── browser-page-bootstrap.tsx │ │ ├── server-page-bootstrap.tsx │ │ └── universal-app-bootstrap.tsx │ └── providers │ │ ├── index.ts │ │ ├── with-design-system.tsx │ │ ├── with-effector.tsx │ │ └── with-i18n.tsx ├── entities │ ├── auth │ │ ├── index.ts │ │ └── model │ │ │ ├── auth.ts │ │ │ ├── logout.ts │ │ │ └── refresh.ts │ ├── session │ │ ├── index.ts │ │ └── session.model.ts │ └── user │ │ ├── index.ts │ │ └── user.model.ts ├── features │ ├── auth │ │ ├── login │ │ │ ├── login-form.tsx │ │ │ ├── login.model.ts │ │ │ └── tests │ │ │ │ ├── login-form.test.tsx │ │ │ │ └── login.model.test.ts │ │ ├── model.ts │ │ └── register │ │ │ ├── register-form.tsx │ │ │ ├── register.model.ts │ │ │ └── tests │ │ │ ├── register-form.test.tsx │ │ │ └── register.model.test.ts │ ├── cookie-consent │ │ ├── cookie-consent.model.ts │ │ ├── cookie-consent.tsx │ │ ├── index.ts │ │ └── stories │ │ │ └── cookie-consent.stories.tsx │ ├── locale-toggler │ │ ├── index.ts │ │ ├── locale-toggler.tsx │ │ └── stories │ │ │ └── locale-toggler.stories.tsx │ ├── new-main-page │ │ ├── advantage │ │ │ ├── advantage.styled.ts │ │ │ ├── advantage.tsx │ │ │ ├── index.ts │ │ │ └── stories │ │ │ │ └── advantage.stories.tsx │ │ ├── change-theme │ │ │ ├── change-theme.tsx │ │ │ ├── index.tsx │ │ │ └── stories │ │ │ │ └── change-theme.stories.tsx │ │ ├── demo-item │ │ │ ├── demo-item.styled.ts │ │ │ ├── demo-item.tsx │ │ │ ├── index.ts │ │ │ └── stories │ │ │ │ └── demo-item.stories.tsx │ │ ├── demos │ │ │ ├── demos.const.ts │ │ │ ├── demos.tsx │ │ │ ├── index.ts │ │ │ └── stories │ │ │ │ └── demos.stories.tsx │ │ ├── dignity-grid │ │ │ ├── dignity-grid.styled.ts │ │ │ ├── dignity-grid.tsx │ │ │ ├── index.ts │ │ │ └── stories │ │ │ │ └── dignity-grid.stories.tsx │ │ ├── dignity │ │ │ ├── dignity.tsx │ │ │ ├── index.ts │ │ │ └── stories │ │ │ │ └── dignity.stories.tsx │ │ ├── hero │ │ │ ├── hero.tsx │ │ │ ├── index.ts │ │ │ └── stories │ │ │ │ └── header.stories.tsx │ │ ├── section │ │ │ ├── index.ts │ │ │ ├── section.tsx │ │ │ └── stories │ │ │ │ └── section.stories.tsx │ │ └── why-nextplate │ │ │ ├── index.ts │ │ │ ├── stories │ │ │ └── why-nextplate.stories.tsx │ │ │ └── why-nextplate.tsx │ └── nprogress │ │ ├── index.ts │ │ ├── nprogress.tsx │ │ └── stories │ │ └── nprogress.stories.tsx ├── i18next.d.ts ├── middleware.page.ts ├── pages │ ├── 404.page.tsx │ ├── _app.page.tsx │ ├── _document.page.tsx │ ├── _error.page.tsx │ ├── api │ │ └── status.page.ts │ ├── auth │ │ ├── login.page.tsx │ │ └── signup.page.tsx │ ├── dashboard │ │ ├── index.page.tsx │ │ └── model.ts │ └── index.page.tsx ├── shared │ ├── api │ │ ├── api.generated.ts │ │ ├── http-client.ts │ │ ├── index.ts │ │ └── request │ │ │ ├── index.ts │ │ │ ├── request-with-auth.ts │ │ │ └── request.ts │ ├── components │ │ ├── error-handling │ │ │ ├── default-error-layout.tsx │ │ │ ├── error-debug.tsx │ │ │ └── index.ts │ │ ├── github-button │ │ │ ├── github-button.styled.ts │ │ │ ├── github-button.tsx │ │ │ ├── index.ts │ │ │ └── stories │ │ │ │ └── github-button.stories.tsx │ │ ├── google-button │ │ │ ├── google-button.styled.ts │ │ │ ├── google-button.tsx │ │ │ ├── index.ts │ │ │ └── stories │ │ │ │ └── google-button.stories.tsx │ │ └── system │ │ │ ├── active-link │ │ │ ├── active-link.tsx │ │ │ ├── index.ts │ │ │ └── stories │ │ │ │ └── active-link.stories.tsx │ │ │ ├── alert-dialog │ │ │ ├── alert-dialog.styled.ts │ │ │ ├── alert-dialog.tsx │ │ │ ├── index.ts │ │ │ └── stories │ │ │ │ └── alert-dialog.stories.tsx │ │ │ ├── breadcrumbs │ │ │ ├── breadcrumbs.styled.ts │ │ │ ├── breadcrumbs.tsx │ │ │ ├── index.ts │ │ │ ├── stories │ │ │ │ └── breadcrumbs.stories.tsx │ │ │ └── tests │ │ │ │ └── breadcrumbs.test.tsx │ │ │ ├── delayed-loading │ │ │ ├── delayed-loading.tsx │ │ │ ├── index.ts │ │ │ ├── stories │ │ │ │ └── delayed-loading.stories.tsx │ │ │ └── use-delayed-loading.ts │ │ │ ├── dropdown-menu │ │ │ ├── dropdown-menu-content.tsx │ │ │ ├── dropdown-menu-item.tsx │ │ │ ├── dropdown-menu-label.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── index.ts │ │ │ └── stories │ │ │ │ └── dropdown-menu.stories.tsx │ │ │ ├── fade │ │ │ ├── fade.tsx │ │ │ ├── index.ts │ │ │ └── stories │ │ │ │ └── fade.stories.tsx │ │ │ ├── indicator │ │ │ ├── index.ts │ │ │ ├── indicator.styled.ts │ │ │ ├── indicator.tsx │ │ │ ├── indicator.types.ts │ │ │ └── stories │ │ │ │ └── indicator.stories.tsx │ │ │ ├── input │ │ │ └── input-password.tsx │ │ │ ├── loading-overlay │ │ │ ├── index.ts │ │ │ ├── loading-overlay.styled.ts │ │ │ ├── loading-overlay.tsx │ │ │ ├── stories │ │ │ │ └── loading-overlay.stories.tsx │ │ │ └── tests │ │ │ │ └── loading-overlay.test.tsx │ │ │ ├── modal │ │ │ ├── index.ts │ │ │ ├── modal.styled.ts │ │ │ ├── modal.tsx │ │ │ └── stories │ │ │ │ └── modal.stories.tsx │ │ │ ├── scroll-area │ │ │ ├── index.ts │ │ │ ├── scroll-area.tsx │ │ │ └── stories │ │ │ │ └── scroll-area.stories.tsx │ │ │ └── sheet │ │ │ ├── index.ts │ │ │ ├── sheet-content.tsx │ │ │ ├── sheet-overlay.tsx │ │ │ ├── sheet.tsx │ │ │ └── stories │ │ │ └── sheet.stories.tsx │ ├── design │ │ ├── external-styles.tsx │ │ ├── lib │ │ │ └── responsive-property.ts │ │ ├── media.ts │ │ └── tokens │ │ │ ├── transitions.ts │ │ │ └── typography.ts │ ├── hooks │ │ └── .gitkeep │ ├── lib │ │ ├── $path.ts │ │ ├── css-in-js │ │ │ └── animations.ts │ │ ├── effector │ │ │ ├── forms │ │ │ │ ├── create-errors.ts │ │ │ │ ├── create-field.ts │ │ │ │ ├── create-form.ts │ │ │ │ ├── extract-values.ts │ │ │ │ ├── index.ts │ │ │ │ ├── types.ts │ │ │ │ └── validate-with-schema.ts │ │ │ ├── index.ts │ │ │ └── router │ │ │ │ ├── effector-router.ts │ │ │ │ └── index.ts │ │ ├── i18n │ │ │ ├── i18n.config.mjs │ │ │ ├── i18n.ts │ │ │ ├── index.ts │ │ │ └── translations.ts │ │ ├── logging │ │ │ ├── create-logger.ts │ │ │ ├── logger.new.ts │ │ │ └── logger.ts │ │ ├── meta │ │ │ ├── index.ts │ │ │ ├── meta.tsx │ │ │ └── page-seo.tsx │ │ ├── mobile.ts │ │ ├── network-information │ │ │ ├── hooks │ │ │ │ ├── tests │ │ │ │ │ └── use-network-availability.test.ts │ │ │ │ ├── use-network-availability.ts │ │ │ │ └── use-network-information.ts │ │ │ ├── index.ts │ │ │ ├── network-information.ts │ │ │ └── types │ │ │ │ ├── navigator.interface.ts │ │ │ │ └── network-information.interface.ts │ │ ├── next │ │ │ ├── context.ts │ │ │ ├── middlewares │ │ │ │ ├── chain-middlewares.ts │ │ │ │ ├── index.ts │ │ │ │ ├── types.ts │ │ │ │ ├── with-headers.ts │ │ │ │ └── with-logging.ts │ │ │ └── types.ts │ │ ├── redirect.ts │ │ ├── sentry │ │ │ ├── index.ts │ │ │ ├── sentry.ts │ │ │ └── setup.ts │ │ ├── ssr │ │ │ ├── index.ts │ │ │ ├── ssg.ts │ │ │ ├── ssr-with-auth.ts │ │ │ └── ssr.ts │ │ ├── status-codes.ts │ │ ├── testing │ │ │ ├── render-with-providers.tsx │ │ │ └── test-utils.ts │ │ ├── transition.ts │ │ ├── wdyr │ │ │ └── wdyr.ts │ │ └── web-vitals │ │ │ ├── index.ts │ │ │ ├── report-web-vitals.ts │ │ │ └── types │ │ │ └── next-web-vitals-metrics-report.ts │ └── types │ │ ├── common-server-side-params.ts │ │ ├── enhanced-app-props.ts │ │ ├── enhanced-next-page.ts │ │ ├── ssg-page-props.ts │ │ ├── ssr-page-props.ts │ │ └── universal-page-props.ts ├── styled.d.ts └── widgets │ └── layouts │ ├── 404 │ ├── components │ │ └── not-found-layout │ │ │ ├── index.ts │ │ │ ├── not-found-layout.tsx │ │ │ ├── not-fount-layout.styled.ts │ │ │ ├── stories │ │ │ └── not-found-layout.stories.tsx │ │ │ └── tests │ │ │ └── not-found-layout.test.tsx │ └── index.ts │ ├── auth │ ├── components │ │ ├── auth-content │ │ │ ├── auth-content.styled.ts │ │ │ ├── auth-content.tsx │ │ │ ├── index.ts │ │ │ └── stories │ │ │ │ └── auth-content.stories.tsx │ │ ├── auth-header │ │ │ ├── auth-header.styled.ts │ │ │ ├── auth-header.tsx │ │ │ ├── index.ts │ │ │ └── stories │ │ │ │ └── auth-header.stories.tsx │ │ └── auth-layout │ │ │ ├── auth-layout.tsx │ │ │ ├── index.ts │ │ │ └── stories │ │ │ └── auth-layout.stories.tsx │ └── index.ts │ └── main │ ├── components │ ├── main-footer │ │ ├── index.ts │ │ └── main-footer.tsx │ ├── main-header │ │ ├── index.tsx │ │ └── main-header.tsx │ └── main-layout │ │ └── index.tsx │ └── index.ts ├── tsconfig.jest.json ├── tsconfig.json ├── tsconfig.server.json └── yarn.lock /.commitlintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@commitlint/config-conventional" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | end_of_line = lf 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | 9 | [{*.jhm, *.xslt, *.xul, *.rng, *.xsl, *.xsd, *.ant, *.tld, *.fxml, *.jrxml, *.xml, *.jnlp, *.wsdl}] 10 | indent_style = space 11 | indent_size = 4 12 | 13 | [{.babelrc, .prettierrc, .stylelintrc, .eslintrc, jest.config, *.json, *.js, *.js.map, *.ts, *.tsx, *.jsb3, *.jsb2, *.bowerrc, *.graphqlconfig}] 14 | indent_style = space 15 | indent_size = 2 16 | 17 | [.editorconfig] 18 | indent_style = space 19 | indent_size = 4 20 | 21 | [*.less] 22 | indent_style = space 23 | indent_size = 2 24 | 25 | [*.jshintrc] 26 | indent_style = space 27 | indent_size = 2 28 | 29 | [*.jscsrc] 30 | indent_style = space 31 | indent_size = 2 32 | 33 | [{tsconfig.lib.json, tsconfig.spec.json, tsconfig.app.json, tsconfig.json, tsconfig.e2e.json}] 34 | indent_style = space 35 | indent_size = 2 36 | 37 | [*.ejs] 38 | indent_style = space 39 | indent_size = 4 40 | 41 | [{.analysis_options, *.yml, *.yaml}] 42 | indent_style = space 43 | indent_size = 2 44 | 45 | [*.md] 46 | indent_size = 4 47 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # See https://nextjs.org/docs/basic-features/environment-variables 2 | 3 | # Note: Tips: How is this file meant to be used? 4 | # This file is tracked by git and must only contains NON-SENSITIVE information, which is usually meant to be available in the browser. 5 | # Sensitive information (server-side only) MUST be written in ".env.local" file instead (which isn't tracked by git). 6 | 7 | # Note: Tips: When is this file being used? 8 | # This file is used only when building the Next.js app locally (localhost), whether it's for running `next dev` or `next build`. 9 | # For staging/production stages, the app relies on "vercel.{NEXT_PUBLIC_CUSTOMER_REF}.{NEXT_PUBLIC_APP_STAGE}.yml:build.env". 10 | 11 | # Note: Tips: What's the difference between env vars starting with "NEXT_PUBLIC_" and the others? 12 | # All env variables that DON'T start with "NEXT_PUBLIC_" MUST be manually exposed by ./next.config.js for the project to work locally 13 | # "NEXT_PUBLIC_" has a semantic purpose. If you mean to use a variable on the browser, then you should use "NEXT_PUBLIC_". 14 | # Any non-sensitive env variable should start with "NEXT_PUBLIC_". 15 | # Sensitive information MUST NOT start with "NEXT_PUBLIC_". 16 | # You must be careful to use sensitive information only on the server-side, because if you use them on the browser or getInitialProps, they'll be leaked, even if the variable doesn't start with "NEXT_PUBLIC_". 17 | # Any change to this file needs a server restart to be applied. 18 | 19 | # The stage is "how" the application is running. 20 | # It can be either "development", "staging" or "production". 21 | # This value is also set in each "vercel.*.json" files, so that other stages use their own value. 22 | # Tip: This value must not be changed. 23 | # Tip: You may override it from ".env.local" if you want to simulate another stage, locally. 24 | NEXT_PUBLIC_APP_STAGE=development 25 | 26 | # Used by the demo to redirect to the preset branch/documentation. 27 | NEXT_PUBLIC_APP_URL=https://nextplate.dvnllrt.com 28 | 29 | NEXT_PUBLIC_API_ENDPOINT=https://api.dvnllrt.com 30 | 31 | # Tells webpack how to bundle the code for sentry.server.config.js. 32 | # See https://github.com/getsentry/sentry-docs/issues/3721#issuecomment-858987529 33 | # Will be injected automatically by "@sentry/nextjs" into ".env.local" 34 | SENTRY_SERVER_INIT_PATH=.next/server/sentry/initServerSDK.js 35 | -------------------------------------------------------------------------------- /.env.local.example: -------------------------------------------------------------------------------- 1 | # Note: Template example 2 | # This file is an example, meant to be duplicated as ".env.local" for local override 3 | 4 | # See https://nextjs.org/docs/basic-features/environment-variables 5 | 6 | # Note: Tips: How is this file meant to be used? 7 | # This file is NOT tracked by git and can contain sensitive information, or override variables from ".env". 8 | 9 | # Note: Tips: When is this file being used? 10 | # This file is used only when building the Next.js app locally (localhost), whether it's for running `next dev` or `next build`. 11 | # For staging/production stages, the app relies on "vercel.{NEXT_PUBLIC_CUSTOMER_REF}.{NEXT_PUBLIC_APP_STAGE}.yml:build.env". 12 | 13 | # Note: Tips: What's the difference between env vars starting with "NEXT_PUBLIC_" and the others? 14 | # All env variables that DON'T start with "NEXT_PUBLIC_" MUST be manually exposed by ./next.config.js for the project to work locally 15 | # "NEXT_PUBLIC_" has a semantic purpose. If you mean to use a variable on the browser, then you should use "NEXT_PUBLIC_". 16 | # Any non-sensitive env variable should start with "NEXT_PUBLIC_". 17 | # Sensitive information MUST NOT start with "NEXT_PUBLIC_". 18 | # You must be careful to use sensitive information only on the server-side, because if you use them on the browser or getInitialProps, they'll be leaked, even if the variable doesn't start with "NEXT_PUBLIC_". 19 | # Any change to this file needs a server restart to be applied. 20 | 21 | # Sentry DSN, can be found under "Your project > Client Keys (DSN)" at https://sentry.io/settings/YOUR_ORG/projects/YOUR_PROJECT/keys/ 22 | # Used to send monitoring events (errors, etc.) 23 | # Optional - If not set, the app will work anyway, it just won't send any event 24 | # Example (fake value): https://14fa1cae05079675b18cd05403ae5c48@sentry.io/1234567 25 | NEXT_PUBLIC_SENTRY_DSN= 26 | 27 | # Sentry authentication token, can be found under "Settings => Account => API => Auth Tokens" at https://sentry.io/settings/account/api/auth-tokens/ 28 | # Used to send soure maps to Sentry 29 | # See https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#environment-variables 30 | # Requires project:releases and org:read - See https://github.com/getsentry/sentry-webpack-plugin#options 31 | SENTRY_AUTH_TOKEN= 32 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | # See https://nextjs.org/docs/basic-features/environment-variables 2 | 3 | # Note: Tips: How is this file meant to be used? 4 | # This file is tracked by git and must only contains NON-SENSITIVE information, which is usually meant to be available in the browser. 5 | # Sensitive information (server-side only) MUST be written in ".env.local" file instead (which isn't tracked by git). 6 | 7 | # Note: Tips: When is this file being used? 8 | # This file is used only when building the Next.js app locally (localhost), whether it's for running `next dev` or `next build`. 9 | # For staging/production stages, the app relies on "vercel.{NEXT_PUBLIC_CUSTOMER_REF}.{NEXT_PUBLIC_APP_STAGE}.yml:build.env". 10 | 11 | # Note: Tips: What's the difference between env vars starting with "NEXT_PUBLIC_" and the others? 12 | # All env variables that DON'T start with "NEXT_PUBLIC_" MUST be manually exposed by ./next.config.js for the project to work locally 13 | # "NEXT_PUBLIC_" has a semantic purpose. If you mean to use a variable on the browser, then you should use "NEXT_PUBLIC_". 14 | # Any non-sensitive env variable should start with "NEXT_PUBLIC_". 15 | # Sensitive information MUST NOT start with "NEXT_PUBLIC_". 16 | # You must be careful to use sensitive information only on the server-side, because if you use them on the browser or getInitialProps, they'll be leaked, even if the variable doesn't start with "NEXT_PUBLIC_". 17 | # Any change to this file needs a server restart to be applied. 18 | 19 | NEXT_PUBLIC_APP_URL=https://nextplate.dvnllrt.com 20 | 21 | NEXT_PUBLIC_API_ENDPOINT=https://api.dvnllrt.com 22 | 23 | # The stage is "how" the application is running. 24 | # It can be either "development", "staging" or "production". 25 | # This value is also set in each "vercel.*.json" files, so that other stages use their own value. 26 | # Tip: This value must not be changed. 27 | # Tip: You may override it from ".env.local" if you want to simulate another stage, locally. 28 | NEXT_PUBLIC_APP_STAGE=production 29 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | cypress 4 | src/**/*.test* 5 | src/gql/** 6 | src/propTypes/** 7 | src/svg/** 8 | src/types/** 9 | src/components/svg/** 10 | public 11 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # From https://github.com/Danimoth/gitattributes/blob/master/Web.gitattributes 2 | 3 | # Handle line endings automatically for files detected as text 4 | # and leave all files detected as binary untouched. 5 | * text=auto 6 | 7 | # 8 | # The above will handle all files NOT found below 9 | # 10 | 11 | # 12 | ## These files are text and should be normalized (Convert crlf => lf) 13 | # 14 | 15 | # source code 16 | *.php text 17 | *.css text 18 | *.sass text 19 | *.scss text 20 | *.less text 21 | *.styl text 22 | *.js text eol=lf 23 | *.jsx text eol=lf 24 | *.ts text eol=lf 25 | *.tsx text eol=lf 26 | *.coffee text 27 | *.json text 28 | *.htm text 29 | *.html text 30 | *.xml text 31 | *.svg text 32 | *.txt text 33 | *.ini text 34 | *.inc text 35 | *.pl text 36 | *.rb text 37 | *.py text 38 | *.scm text 39 | *.sql text 40 | *.sh text 41 | *.bat text 42 | 43 | # templates 44 | *.ejs text 45 | *.hbt text 46 | *.jade text 47 | *.haml text 48 | *.hbs text 49 | *.dot text 50 | *.tmpl text 51 | *.phtml text 52 | 53 | # server config 54 | .htaccess text 55 | .nginx.conf text 56 | 57 | # git config 58 | .gitattributes text 59 | .gitignore text 60 | .gitconfig text 61 | 62 | # code analysis config 63 | .jshintrc text 64 | .jscsrc text 65 | .jshintignore text 66 | .csslintrc text 67 | 68 | # misc config 69 | *.yaml text 70 | *.yml text 71 | .editorconfig text 72 | 73 | # build config 74 | *.npmignore text 75 | *.bowerrc text 76 | 77 | # Heroku 78 | Procfile text 79 | .slugignore text 80 | 81 | # Documentation 82 | *.md text 83 | LICENSE text 84 | AUTHORS text 85 | 86 | 87 | # 88 | ## These files are binary and should be left untouched 89 | # 90 | 91 | # (binary is a macro for -text -diff) 92 | *.png binary 93 | *.jpg binary 94 | *.jpeg binary 95 | *.gif binary 96 | *.ico binary 97 | *.mov binary 98 | *.mp4 binary 99 | *.mp3 binary 100 | *.flv binary 101 | *.fla binary 102 | *.swf binary 103 | *.gz binary 104 | *.zip binary 105 | *.7z binary 106 | *.ttf binary 107 | *.eot binary 108 | *.woff binary 109 | *.pyc binary 110 | *.pdf binary 111 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn typecheck && yarn lint-staged 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn test 5 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.(ts|tsx)": [ 3 | "yarn lint:fix" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.yml 2 | dist 3 | logs 4 | node_modules 5 | storybook-static 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "bracketSpacing": true, 4 | "bracketSameLine": false, 5 | "arrowParens": "always", 6 | "trailingComma": "all", 7 | "tabWidth": 2, 8 | "printWidth": 120, 9 | "endOfLine": "lf", 10 | "importOrder": [ 11 | "", 12 | "^(react/(.*)$)|^(react$)", 13 | "^(next/(.*)$)|^(next$)", 14 | "", 15 | "", 16 | "^@/root/(.*)$", 17 | "", 18 | "^@/app/(.*)$", 19 | "", 20 | "^@/pages/(.*)$", 21 | "", 22 | "^@/layouts/(.*)$", 23 | "", 24 | "^@/features/(.*)$", 25 | "", 26 | "^@/entities/(.*)$", 27 | "", 28 | "^@/shared/(.*)$", 29 | "", 30 | "^[./]" 31 | ], 32 | "importOrderTypeScriptVersion": "5.0.0", 33 | "importOrderParserPlugins": [ 34 | "typescript", 35 | "jsx", 36 | "decorators-legacy" 37 | ], 38 | "plugins": [ 39 | "@ianvs/prettier-plugin-sort-imports" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /.storybook/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18n from 'i18next'; 2 | import { initReactI18next } from 'react-i18next'; 3 | 4 | i18n.use(initReactI18next).init({ 5 | fallbackLng: 'en', 6 | debug: true, 7 | }); 8 | 9 | export default i18n; 10 | -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /.storybook/preview.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Button, EffableProvider, useEffableTheme } from '@effable/react'; 4 | import { type Preview, Decorator } from '@storybook/react'; 5 | import { fork } from 'effector'; 6 | import { Provider as EffectorProvider } from 'effector-react'; 7 | import { I18nextProvider } from 'react-i18next'; 8 | 9 | import '@/shared/design/external-styles'; 10 | 11 | import i18n from './i18n'; 12 | 13 | const preview: Preview = { 14 | parameters: { 15 | actions: { argTypesRegex: '^on[A-Z].*' }, 16 | controls: { 17 | matchers: { 18 | color: /(background|color)$/i, 19 | date: /Date$/, 20 | }, 21 | }, 22 | }, 23 | }; 24 | 25 | 26 | export const decorators: Decorator[] = [ 27 | (Story, context) => { 28 | const scope = fork({}); 29 | 30 | return ( 31 | 32 | 33 | 34 | 35 | 36 |
46 | 47 |
48 |
49 |
50 |
51 | ); 52 | }, 53 | ]; 54 | 55 | const Toggler = () => { 56 | const { mode, setMode } = useEffableTheme('Toggler'); 57 | 58 | return ( 59 |
66 | 69 | 72 | 75 |
76 | ); 77 | }; 78 | 79 | export default preview; 80 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "processors": [ 3 | "stylelint-processor-styled-components" 4 | ], 5 | "extends": [ 6 | "stylelint-config-recommended", 7 | "stylelint-config-styled-components" 8 | ] 9 | } -------------------------------------------------------------------------------- /LICENCE.md: -------------------------------------------------------------------------------- 1 | 2 | MIT License 3 | 4 | Copyright (c) 2022 devianllert 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | /** 2 | * Babel configuration for Next.js 3 | * 4 | * @see https://nextjs.org/docs/advanced-features/customizing-babel-config Official doc reference v10 5 | * @see https://github.com/vercel/next.js/blob/canary/packages/next/build/babel/preset.ts You can take a look at this file to learn about the presets included by next/babel. 6 | * @example https://github.com/vercel/next.js/tree/canary/examples/with-custom-babel-config Next.js official example of customizing Babel 7 | */ 8 | { 9 | "presets": [ 10 | "next/babel" 11 | ], 12 | "plugins": [ 13 | "@emotion/babel-plugin", 14 | [ 15 | "effector/babel-plugin", 16 | { 17 | "factories": [ 18 | "@farfetched/core", 19 | "patronum", 20 | "@/shared/lib/effector/forms" 21 | ] 22 | } 23 | ] 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /docs/fsd-schema.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devianllert/nextplate/3b6319895d475b7961e4a80c2052d1d6149f380b/docs/fsd-schema.jpg -------------------------------------------------------------------------------- /jest-globals.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import 'jest-extended'; 3 | 4 | /** 5 | * Enhance the Node.js environment "global" variable to add our own types 6 | * 7 | * @see https://stackoverflow.com/a/42304473/2391795 8 | */ 9 | declare global { 10 | namespace NodeJS { 11 | interface Global { 12 | muteConsole: () => any; 13 | muteConsoleButLog: () => any; 14 | unmuteConsole: () => any; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /jest.extends.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 3 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 4 | // Note: All expect.extend() utilities loaded here will be available for all tests, they also might need to be declared in jest.d.ts 5 | import { toMatchOneOf, toMatchShapeOf } from 'jest-to-match-shape-of'; // See https://www.npmjs.com/package/jest-to-match-shape-of 6 | import { matchers } from '@emotion/jest'; 7 | import '@testing-library/jest-dom/extend-expect'; 8 | 9 | // Extend Jest "expect" function 10 | expect.extend({ 11 | toMatchOneOf, 12 | toMatchShapeOf, 13 | ...matchers, 14 | }); 15 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | // Note: Unlike what could be expected, once an ENV var is found by dotenv, it won't be overridden 2 | // So, the order must be from the most important to the less important 3 | // See https://github.com/motdotla/dotenv/issues/256#issuecomment-598676663 4 | require('@next/env').loadEnvConfig(process.cwd()); 5 | 6 | /** 7 | * Importing next during test applies automated polyfills: 8 | * - Polyfill the built-in "fetch" provided by Next.js 9 | * 10 | * @see https://github.com/vercel/next.js/discussions/13678#discussioncomment-22383 How to use built-in fetch in tests? 11 | * @see https://nextjs.org/blog/next-9-4#improved-built-in-fetch-support Next.js Blog - Improved Built-in Fetch Support 12 | * @see https://jestjs.io/docs/en/configuration#setupfilesafterenv-array About setupFilesAfterEnv usage 13 | */ 14 | require('next'); 15 | 16 | // Backup of the native console object for later re-use 17 | global._console = global.console; 18 | 19 | // Force mute console by returning a mock object that mocks the props we use 20 | global.muteConsole = () => { 21 | return { 22 | debug: jest.fn(), 23 | error: jest.fn(), 24 | info: jest.fn(), 25 | log: jest.fn(), 26 | warn: jest.fn(), 27 | }; 28 | }; 29 | 30 | // Mock __non_webpack_require__ to use the standard node.js "require" 31 | global['__non_webpack_require__'] = require; 32 | 33 | jest.mock('react-i18next', () => ({ 34 | // this mock makes sure any components using the translate hook can use it without a warning being shown 35 | useTranslation: () => { 36 | return { 37 | t: (str) => str, 38 | i18n: { 39 | changeLanguage: () => new Promise(() => {}), 40 | language: 'cimode', 41 | }, 42 | }; 43 | }, 44 | })); 45 | 46 | Object.defineProperty(window, 'matchMedia', { 47 | writable: true, 48 | value: jest.fn().mockImplementation((query) => ({ 49 | matches: false, 50 | media: query, 51 | onchange: null, 52 | addListener: jest.fn(), // Deprecated 53 | removeListener: jest.fn(), // Deprecated 54 | addEventListener: jest.fn(), 55 | removeEventListener: jest.fn(), 56 | dispatchEvent: jest.fn(), 57 | })), 58 | }); 59 | -------------------------------------------------------------------------------- /mocks/auth-mock.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw'; 2 | import { getLoginMock, getRefreshMock } from '../src/shared/api/api.generated'; 3 | 4 | export const accessToken = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2ODM5OTY0NDcsImV4cCI6MTcxNTUzMjQ1MCwiYXVkIjoid3d3LmV4YW1wbGUuY29tIiwic3ViIjoianJvY2tldEBleGFtcGxlLmNvbSIsImlkIjoiMiJ9.s6hH_wBzR8BtWzTdTCCQ-mcuDFyJz_q_4lUNKK95EyA'; 5 | export const refreshToken = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2ODM5OTY0NjMsImV4cCI6MTY4Mzk5NzY2NCwiYXVkIjoid3d3LmV4YW1wbGUuY29tIiwic3ViIjoianJvY2tldEBleGFtcGxlLmNvbSIsImlkIjoiMiJ9.1msAjPLJQAkAa2WQ5QVmkT5pPRUdT9kGy8JRFWziPHk'; 6 | 7 | export const authMocks = [ 8 | rest.post('*/api/v1/auth/login', (_req, res, ctx) => { 9 | return res(ctx.delay(1000), ctx.status(200, 'Mocked status'), ctx.json(getLoginMock()), ctx.cookie('access_token', accessToken), ctx.cookie('refresh_token', refreshToken)); 10 | }), 11 | rest.post('*/api/v1/auth/refresh', (_req, res, ctx) => { 12 | return res(ctx.delay(1000), ctx.status(200, 'Mocked status'), ctx.json(getRefreshMock()), ctx.cookie('access_token', accessToken), ctx.cookie('refresh_token', refreshToken)); 13 | }), 14 | ]; 15 | -------------------------------------------------------------------------------- /mocks/browser.ts: -------------------------------------------------------------------------------- 1 | import { setupWorker } from 'msw'; 2 | import { getNestplateAuthMSW } from '@/shared/api/api.generated'; 3 | import { authMocks } from './auth-mock'; 4 | 5 | export const worker = setupWorker(...getNestplateAuthMSW(), ...authMocks); 6 | -------------------------------------------------------------------------------- /mocks/index.ts: -------------------------------------------------------------------------------- 1 | async function initMocks() { 2 | if (typeof window === 'undefined') { 3 | const { server } = await import('./server'); 4 | server.listen({ onUnhandledRequest: 'bypass' }); 5 | } else { 6 | const { worker } = await import('./browser'); 7 | worker.start({ onUnhandledRequest: 'bypass' }); 8 | } 9 | } 10 | 11 | // eslint-disable-next-line @typescript-eslint/no-floating-promises 12 | initMocks(); 13 | 14 | export {}; 15 | -------------------------------------------------------------------------------- /mocks/server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from 'msw/node'; 2 | import { getNestplateAuthMSW } from '@/shared/api/api.generated'; 3 | import { authMocks } from './auth-mock'; 4 | 5 | export const server = setupServer(...getNestplateAuthMSW(), ...authMocks); 6 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/basic-features/typescript for more information. 7 | -------------------------------------------------------------------------------- /next-i18next.config.mjs: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | import { defaultLocale, supportedLanguages } from './src/shared/lib/i18n/i18n.config.mjs'; 4 | 5 | /** 6 | * @type {import('next-i18next').UserConfig} 7 | */ 8 | const config = { 9 | i18n: { 10 | defaultLocale, 11 | locales: supportedLanguages, 12 | }, 13 | localePath: path.resolve('./public/locales'), 14 | }; 15 | 16 | export default config; 17 | -------------------------------------------------------------------------------- /next-sitemap.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next-sitemap').IConfig} */ 2 | const config = { 3 | siteUrl: process.env.NEXT_PUBLIC_APP_URL, 4 | generateRobotsTxt: true, // (optional) 5 | // ...other options 6 | }; 7 | 8 | module.exports = config; 9 | -------------------------------------------------------------------------------- /orval.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | 3 | import { defineConfig } from 'orval'; 4 | import { loadEnvConfig } from '@next/env'; 5 | import { accessToken, refreshToken } from './mocks/auth-mock'; 6 | 7 | loadEnvConfig(process.cwd()); 8 | 9 | export default defineConfig({ 10 | api: { 11 | output: { 12 | mock: true, 13 | client: 'axios-functions', 14 | target: 'src/shared/api/api.generated.ts', 15 | mode: 'single', 16 | override: { 17 | mock: { 18 | properties: { 19 | access: accessToken, 20 | refresh: refreshToken, 21 | }, 22 | }, 23 | mutator: { 24 | path: './src/shared/api/http-client.ts', 25 | name: 'httpClient', 26 | }, 27 | }, 28 | }, 29 | input: { 30 | validation: true, 31 | target: `${process.env.NEXT_PUBLIC_API_ENDPOINT}/api/v1/swagger-json`, 32 | }, 33 | hooks: { 34 | afterAllFilesWrite: 'yarn lint:fix', 35 | }, 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /process.env.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Declare known environment variables. 3 | * Enables auto-completion when using "process.env.". 4 | * Makes it easier to find env vars, and helps avoid typo mistakes. 5 | * 6 | * Unlisted env vars will still be usable. 7 | * 8 | * @see https://stackoverflow.com/a/53981706/2391795 9 | */ 10 | declare global { 11 | namespace NodeJS { 12 | interface ProcessEnv { 13 | // Server variables 14 | NODE_ENV: 'test' | 'development' | 'production'; 15 | SENTRY_AUTH_TOKEN: string; 16 | 17 | // Public variables 18 | NEXT_PUBLIC_APP_STAGE: 'test' | 'development' | 'staging' | 'production'; 19 | NEXT_PUBLIC_APP_URL: string; 20 | NEXT_PUBLIC_APP_NAME: string; 21 | NEXT_PUBLIC_APP_VERSION: string; 22 | NEXT_PUBLIC_API_ENDPOINT: string; 23 | NEXT_PUBLIC_BUILD_TIME: string; 24 | NEXT_PUBLIC_SENTRY_DSN?: string; 25 | NEXT_PUBLIC_LOGFLARE_KEY: string; 26 | NEXT_PUBLIC_LOGFLARE_STREAM: string; 27 | } 28 | } 29 | } 30 | 31 | // Trick to make this a valid module: 32 | // If this file has no import/export statements (i.e. is a script) 33 | // convert it into a module by adding an empty export statement. 34 | export {}; 35 | -------------------------------------------------------------------------------- /public/locales/en/404.json: -------------------------------------------------------------------------------- 1 | { 2 | "SEO_TITLE": "$t(TITLE)", 3 | "SEO_DESCRIPTION": "$t(DESCRIPTION)", 4 | "TITLE": "Page not found", 5 | "SUBTITLE": "404 Error", 6 | "DESCRIPTION": "The page you are looking for could not be found.", 7 | "BUTTON": "Go Home" 8 | } 9 | -------------------------------------------------------------------------------- /public/locales/en/auth.json: -------------------------------------------------------------------------------- 1 | { 2 | "SEO_LOGIN_TITLE": "Login", 3 | "SEO_LOGIN_DESCRIPTION": "Login to your account. This is a demo app intended to demonstrate the capabilities of this boilerplate", 4 | "SEO_SIGNUP_TITLE": "Sign up", 5 | "SEO_SIGNUP_DESCRIPTION": "Sign up. This is a demo app intended to demonstrate the capabilities of this boilerplate", 6 | "SEO_IMAGE_URL": "/static/images/apps/en/auth.png", 7 | "OAUTH_GOOGLE": "Continue with Google", 8 | "OAUTH_GITHUB": "Continue with GitHub", 9 | "LOGIN": "Login", 10 | "SIGNUP": "Sign up", 11 | "LOGOUT": "Logout", 12 | "HAVE_ACCOUNT": "Already have an account?", 13 | "NEED_ACCOUNT": "Need an account?", 14 | "ERROR_LOGIN_INVALID": "Invalid username or email", 15 | "ERROR_FIELD_REQUIRED": "This field is required", 16 | "ERROR_EMAIL_ALREADY_EXISTS": "An account with this email already exists", 17 | "ERROR_EMAIL_INVALID": "Invalid email", 18 | "ERROR_PASSWORD_MIN": "Password must contain at least 6 characters", 19 | "ERROR_USERNAME_MIN": "Username must contain at least 3 characters", 20 | "EMAIL_LABEL": "Email", 21 | "EMAIL_PLACEHOLDER": "example@gmail.com", 22 | "USERNAME_LABEL": "Username", 23 | "USERNAME_PLACEHOLDER": "Enter your username", 24 | "PASSWORD_LABEL": "Password", 25 | "PASSWORD_PLACEHOLDER": "Enter your password", 26 | "CONFIRM_PASSWORD_LABEL": "Confirm password", 27 | "CONFIRM_PASSWORD_PLACEHOLDER": "Enter your password" 28 | } 29 | -------------------------------------------------------------------------------- /public/locales/en/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "LAST_UPDATE": "Updated", 3 | "COOKIE_CONSENT_TITLE": "This website uses <0>cookies to ensure you get the best experience on our website.", 4 | "COOKIE_CONSENT_ACTION": "Got it!", 5 | "ERR_NETWORK": "Unable to access the network", 6 | "ERROR_UNEXPECTED": "Something went wrong" 7 | } 8 | -------------------------------------------------------------------------------- /public/locales/en/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "SEO_TITLE": "Main", 3 | "SEO_DESCRIPTION": "Nextplate. Template with all you need. Aims for developers who really care about code quality, architecture, security and all the best practices in frontend", 4 | "HERO_TITLE": "Nextplate. Template with all you need.", 5 | "HERO_SUBTITLE": "Aims for developers who really care about code quality, architecture, security and all the best practices in frontend", 6 | "HERO_DOCUMENTATION": "Documentation", 7 | "FEATURES_TITLE": "Built-in features", 8 | "FEATURES_PERFORMANT_TITLE": "Performant", 9 | "FEATURES_PERFORMANT_DESCRIPTION": "Next.js gives you the best developer experience with all the features you need for production: hybrid static & server rendering, route pre-fetching, and more", 10 | "FEATURES_RICH_TITLE": "Full-featured", 11 | "FEATURES_RICH_DESCRIPTION": "Packed with full of useful features like i18n (<0>next-i18next), Testing (<0>Jest), Monitoring (<0>Sentry), <0>Storybook, API codegen (<0>Orval) and much more!", 12 | "FEATURES_DX_TITLE": "Best-in-class DX", 13 | "FEATURES_DX_DESCRIPTION": "This boilerplate is meant for developers with basic skills in React, who are looking for a way of building production-grade web applications.", 14 | "FEATURES_APPS_TITLE": "Built-in demos", 15 | "FEATURES_APPS_DESCRIPTION": "This boilerplate has several built-in demo apps that show an example of using the features of this template.", 16 | "DEMOS_TITLE": "Built-in demos", 17 | "DEMOS_AUTH_TITLE": "Authorization", 18 | "DEMOS_AUTH_DESCRIPTION": "This app provides an example of how to implement: authentication flow, refresh token rotation, form validation, page transitions. Source code provides an example of how to build application using Feature Sliced methodology and a state manager for managing complex state.", 19 | "DEMOS_WEATHER_TITLE": "Weather", 20 | "DEMOS_WEATHER_DESCRIPTION": "This app shows a detailed information on the current and future weather conditions in their area. Source code provides an example of how to work with getting data from a third-party api and its further display in pages.", 21 | "DEMOS_PASSWORD_TITLE": "Passkip", 22 | "DEMOS_PASSWORD_DESCRIPTION": "This app allows users to quickly and easily generate strong, secure passwords in browser for all their online accounts. Source code provides an example of how to work with styling, storybook and state management." 23 | } 24 | -------------------------------------------------------------------------------- /public/locales/ru/404.json: -------------------------------------------------------------------------------- 1 | { 2 | "SEO_TITLE": "$t(TITLE)", 3 | "SEO_DESCRIPTION": "$t(DESCRIPTION)", 4 | "TITLE": "Страница не найдена", 5 | "SUBTITLE": "Ошибка 404", 6 | "DESCRIPTION": "Такой страницы не существует.", 7 | "BUTTON": "Домой" 8 | } 9 | -------------------------------------------------------------------------------- /public/locales/ru/auth.json: -------------------------------------------------------------------------------- 1 | { 2 | "SEO_LOGIN_TITLE": "Войти", 3 | "SEO_LOGIN_DESCRIPTION": "Войдите в ваш аккаунт. Это демо приложение, созданное для демонстрации возможностей шаблона", 4 | "SEO_SIGNUP_TITLE": "Регистрация", 5 | "SEO_SIGNUP_DESCRIPTION": "Создайте новый аккаунт. Это демо приложение, созданное для демонстрации возможностей шаблона", 6 | "SEO_IMAGE_URL": "/static/images/apps/ru/auth.png", 7 | "OAUTH_GOOGLE": "Войти через Google", 8 | "OAUTH_GITHUB": "Войти через GitHub", 9 | "LOGIN": "Войти", 10 | "SIGNUP": "Регистрация", 11 | "HAVE_ACCOUNT": "Уже есть аккаунт?", 12 | "NEED_ACCOUNT": "Нужен аккаунт?", 13 | "ERROR_LOGIN_INVALID": "Неправильное имя или пароль", 14 | "ERROR_FIELD_REQUIRED": "Это поле обязательно", 15 | "ERROR_EMAIL_ALREADY_EXISTS": "Аккаунт с такой почтой уже существует", 16 | "ERROR_EMAIL_INVALID": "Некорректный адрес", 17 | "ERROR_PASSWORD_MIN": "Пароль должен быть не меньше 6 символов", 18 | "ERROR_USERNAME_MIN": "Имя пользователя должно быть не меньше 3 символов", 19 | "EMAIL_LABEL": "Почта", 20 | "EMAIL_PLACEHOLDER": "example@gmail.com", 21 | "USERNAME_LABEL": "Имя пользователя", 22 | "USERNAME_PLACEHOLDER": "Введите имя пользователя", 23 | "PASSWORD_LABEL": "Пароль", 24 | "PASSWORD_PLACEHOLDER": "Введите пароль", 25 | "CONFIRM_PASSWORD_LABEL": "Подтвердите пароль", 26 | "CONFIRM_PASSWORD_PLACEHOLDER": "Введите пароль" 27 | } 28 | -------------------------------------------------------------------------------- /public/locales/ru/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "LAST_UPDATE": "Обновлено", 3 | "COOKIE_CONSENT_TITLE": "Этот сайт использует <0>куки для улучшения пользовательского опыта.", 4 | "COOKIE_CONSENT_ACTION": "Хорошо!", 5 | "ERR_NETWORK": "Отсутствует подключение к интернету", 6 | "ERROR_UNEXPECTED": "Что-то пошло не так" 7 | } 8 | -------------------------------------------------------------------------------- /public/locales/ru/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "SEO_TITLE": "Главная", 3 | "SEO_DESCRIPTION": "Nextplate. Шаблон со всем необходимым. Для разработчиков, которые действительно заботятся о качестве кода, архитектуре, безопасности и всех лучших практиках во фронтенде", 4 | "HERO_TITLE": "Nextplate. Шаблон со всем необходимым.", 5 | "HERO_SUBTITLE": "Для разработчиков, которые действительно заботятся о качестве кода, архитектуре, безопасности и всех лучших практиках во фронтенде", 6 | "HERO_DOCUMENTATION": "Документация", 7 | "FEATURES_TITLE": "Особенности", 8 | "FEATURES_PERFORMANT_TITLE": "Производительность", 9 | "FEATURES_PERFORMANT_DESCRIPTION": "Next.js предоставляет лучшие возможности, необходимые для продакшена: гибридный статический и серверный рендеринг, префетчинг и многое другое.", 10 | "FEATURES_RICH_TITLE": "Многофункциональный", 11 | "FEATURES_RICH_DESCRIPTION": "Наполнен полезными функциями, как i18n (<0>next-i18next), Тестирование (<0>Jest), Мониторинг (<0>Sentry), <0>Storybook, кодогенерация api (<0>Orval) и другое!", 12 | "FEATURES_DX_TITLE": "Лучший DX", 13 | "FEATURES_DX_DESCRIPTION": "Предназначен для разработчиков с базовыми навыками в React, которые ищут способ создания веб-приложений продакшен класса.", 14 | "FEATURES_APPS_TITLE": "Демо приложения", 15 | "FEATURES_APPS_DESCRIPTION": "Имеет несколько встроенных демо-приложений, которые показывают пример использования возможностей этого шаблона", 16 | "DEMOS_TITLE": "Демо приложения", 17 | "DEMOS_AUTH_TITLE": "Authorization", 18 | "DEMOS_AUTH_DESCRIPTION": "Это приложение представляет собой пример того, как реализовать: аутентификация, обновление рефреш токенов, валидацию форм, анимированные переходы страниц. Все это сделано с использованием методологии Feature Sliced ​​и менеджера состояний для управления сложным состоянием.", 19 | "DEMOS_WEATHER_TITLE": "Weather", 20 | "DEMOS_WEATHER_DESCRIPTION": "Это приложение показывает подробную информацию о текущих и будущих погодных условиях. В исходном коде приведен пример работы с получением данных из стороннего API и дальнейшим их отображением на страницах.", 21 | "DEMOS_PASSWORD_TITLE": "Passkip", 22 | "DEMOS_PASSWORD_DESCRIPTION": "Это приложение позволяет пользователям быстро и легко создавать надежные и безопасные пароли прямо в браузере для всех своих онлайн-аккаунтов. Исходный код предоставляет пример того, как работать со стилизацией, storybook и менеджером состояний." 23 | 24 | } 25 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextplate", 3 | "short_name": "nextplate", 4 | "description": "nextplate -> https://nextplte.dvnllrt.com", 5 | "display": "standalone", 6 | "start_url": "/", 7 | "theme_color": "#ffffff", 8 | "background_color": "#000000", 9 | "orientation": "portrait", 10 | "icons": [ 11 | { 12 | "src": "/static/images/android-chrome-192x192.png", 13 | "sizes": "192x192", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "/static/images/android-chrome-512x512.png", 18 | "sizes": "512x512", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "/static/images/maskable_icon.png", 23 | "sizes": "192x192", 24 | "type": "image/png", 25 | "purpose": "any maskable" 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /public/static/images/404.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devianllert/nextplate/3b6319895d475b7961e4a80c2052d1d6149f380b/public/static/images/404.png -------------------------------------------------------------------------------- /public/static/images/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devianllert/nextplate/3b6319895d475b7961e4a80c2052d1d6149f380b/public/static/images/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/static/images/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devianllert/nextplate/3b6319895d475b7961e4a80c2052d1d6149f380b/public/static/images/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/static/images/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devianllert/nextplate/3b6319895d475b7961e4a80c2052d1d6149f380b/public/static/images/apple-touch-icon.png -------------------------------------------------------------------------------- /public/static/images/circle-scatter.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/static/images/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devianllert/nextplate/3b6319895d475b7961e4a80c2052d1d6149f380b/public/static/images/favicon-16x16.png -------------------------------------------------------------------------------- /public/static/images/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devianllert/nextplate/3b6319895d475b7961e4a80c2052d1d6149f380b/public/static/images/favicon-32x32.png -------------------------------------------------------------------------------- /public/static/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devianllert/nextplate/3b6319895d475b7961e4a80c2052d1d6149f380b/public/static/images/favicon.ico -------------------------------------------------------------------------------- /public/static/images/logo-og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devianllert/nextplate/3b6319895d475b7961e4a80c2052d1d6149f380b/public/static/images/logo-og.png -------------------------------------------------------------------------------- /public/static/images/maskable_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devianllert/nextplate/3b6319895d475b7961e4a80c2052d1d6149f380b/public/static/images/maskable_icon.png -------------------------------------------------------------------------------- /public/static/images/tools/effector.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devianllert/nextplate/3b6319895d475b7961e4a80c2052d1d6149f380b/public/static/images/tools/effector.png -------------------------------------------------------------------------------- /public/static/images/tools/eslint.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devianllert/nextplate/3b6319895d475b7961e4a80c2052d1d6149f380b/public/static/images/tools/eslint.png -------------------------------------------------------------------------------- /public/static/images/tools/fsd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devianllert/nextplate/3b6319895d475b7961e4a80c2052d1d6149f380b/public/static/images/tools/fsd.png -------------------------------------------------------------------------------- /public/static/images/tools/i18n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devianllert/nextplate/3b6319895d475b7961e4a80c2052d1d6149f380b/public/static/images/tools/i18n.png -------------------------------------------------------------------------------- /public/static/images/tools/jest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devianllert/nextplate/3b6319895d475b7961e4a80c2052d1d6149f380b/public/static/images/tools/jest.png -------------------------------------------------------------------------------- /public/static/images/tools/next.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devianllert/nextplate/3b6319895d475b7961e4a80c2052d1d6149f380b/public/static/images/tools/next.png -------------------------------------------------------------------------------- /public/static/images/tools/sentry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devianllert/nextplate/3b6319895d475b7961e4a80c2052d1d6149f380b/public/static/images/tools/sentry.png -------------------------------------------------------------------------------- /public/static/images/tools/storybook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devianllert/nextplate/3b6319895d475b7961e4a80c2052d1d6149f380b/public/static/images/tools/storybook.png -------------------------------------------------------------------------------- /public/static/images/tools/typescript.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devianllert/nextplate/3b6319895d475b7961e4a80c2052d1d6149f380b/public/static/images/tools/typescript.png -------------------------------------------------------------------------------- /public/static/images/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "enabled": true, 4 | "schedule": ["before 3am on Monday"], 5 | "extends": [ 6 | "config:base", 7 | ":pinVersions", 8 | ":separateMultipleMajorReleases", 9 | ":combinePatchMinorReleases", 10 | ":automergePatch", 11 | ":automergeMinor", 12 | ":pinSkipCi", 13 | "group:allNonMajor" 14 | ], 15 | "assignees": ["devianllert"], 16 | "packageRules": [ 17 | { 18 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 19 | "automerge": true 20 | }, 21 | { 22 | "matchDepTypes": ["devDependencies"], 23 | "automerge": true 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /sentry.client.config.js: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the browser. 2 | // The config you add here will be used whenever a page is visited. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | 5 | // eslint-disable-next-line import/no-unresolved 6 | import { setupSentry } from '@/shared/lib/sentry'; 7 | 8 | setupSentry(); 9 | -------------------------------------------------------------------------------- /sentry.properties: -------------------------------------------------------------------------------- 1 | defaults.url=https://sentry.io/ 2 | defaults.org=devianllert 3 | defaults.project=dvnllrt 4 | cli.executable=../../../.npm/_npx/a8388072043b4cbc/node_modules/@sentry/cli/bin/sentry-cli 5 | -------------------------------------------------------------------------------- /sentry.server.config.js: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the server. 2 | // The config you add here will be used whenever a page is visited. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | 5 | // eslint-disable-next-line import/no-unresolved 6 | import { setupSentry } from '@/shared/lib/sentry'; 7 | 8 | setupSentry(); 9 | -------------------------------------------------------------------------------- /src/app/components/browser-page-bootstrap.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useTranslation } from 'next-i18next'; 3 | 4 | import { createLogger } from '@/shared/lib/logging/logger'; 5 | import { EnhancedAppProps } from '@/shared/types/enhanced-app-props'; 6 | import { UniversalPageProps } from '@/shared/types/universal-page-props'; 7 | 8 | const logger = createLogger('BrowserPageBootstrap'); 9 | 10 | export type BrowserPageBootstrapProps = EnhancedAppProps; 11 | 12 | /** 13 | * Bootstraps the page, only when rendered on the browser 14 | * 15 | * @param props 16 | */ 17 | const BrowserPageBootstrap = (props: BrowserPageBootstrapProps) => { 18 | const { Component, err, router } = props; 19 | 20 | const { t, i18n } = useTranslation(); 21 | 22 | const LayoutComponent = Component.Layout ?? React.Fragment; 23 | 24 | // When the page is served by the browser, some browser-only properties are available 25 | // eslint-disable-next-line react/destructuring-assignment 26 | const pageProps = props.pageProps as unknown as UniversalPageProps; 27 | 28 | const injectedPageProps: UniversalPageProps = { 29 | ...pageProps, 30 | }; 31 | 32 | // In non-production stages, bind some utilities to the browser's DOM, for ease of quick testing 33 | if (process.env.NEXT_PUBLIC_APP_STAGE !== 'production') { 34 | // eslint-disable-next-line react-hooks/rules-of-hooks 35 | React.useEffect(() => { 36 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any 37 | (window as unknown as any).router = router; 38 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any 39 | (window as unknown as any).i18n = i18n; 40 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any 41 | (window as unknown as any).t = t; 42 | 43 | logger.info(`Utilities have been bound to the DOM for quick testing (only in non-production stages): 44 | - i18n 45 | - router 46 | - t 47 | `); 48 | }, []); 49 | } 50 | 51 | return ( 52 | 53 | 59 | 60 | ); 61 | }; 62 | 63 | export default BrowserPageBootstrap; 64 | -------------------------------------------------------------------------------- /src/app/components/server-page-bootstrap.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { EnhancedAppProps } from '@/shared/types/enhanced-app-props'; 4 | import { UniversalPageProps } from '@/shared/types/universal-page-props'; 5 | 6 | export type ServerPageBootstrapProps = EnhancedAppProps; 7 | 8 | /** 9 | * Bootstraps the page, only when rendered on the server 10 | * 11 | * @param props 12 | */ 13 | const ServerPageBootstrap = (props: ServerPageBootstrapProps) => { 14 | const { Component, err } = props; 15 | 16 | const LayoutComponent = Component.Layout ?? React.Fragment; 17 | 18 | // When the page is served by the server, some server-only properties are available 19 | // eslint-disable-next-line react/destructuring-assignment 20 | const pageProps = props.pageProps as unknown as UniversalPageProps; 21 | 22 | const injectedPageProps: UniversalPageProps = { 23 | ...pageProps, 24 | }; 25 | 26 | return ( 27 | 28 | 33 | 34 | ); 35 | }; 36 | 37 | export default ServerPageBootstrap; 38 | -------------------------------------------------------------------------------- /src/app/providers/index.ts: -------------------------------------------------------------------------------- 1 | import { compose } from '@effable/misc'; 2 | 3 | import { withDesignSystem } from './with-design-system'; 4 | import { withEffector } from './with-effector'; 5 | import { withI18n } from './with-i18n'; 6 | 7 | export const withProviders = compose(withDesignSystem, withEffector, withI18n); 8 | -------------------------------------------------------------------------------- /src/app/providers/with-design-system.tsx: -------------------------------------------------------------------------------- 1 | import { AppProps } from 'next/app'; 2 | import { EffableProvider } from '@effable/react'; 3 | 4 | export const withDesignSystem = (Component: AppProps['Component']) => (props: AppProps) => { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /src/app/providers/with-effector.tsx: -------------------------------------------------------------------------------- 1 | import { AppProps } from 'next/app'; 2 | import { EffectorNext } from '@effector/next'; 3 | 4 | import { EFFECTOR_STATE_KEY, EffectorState } from '@/shared/lib/effector'; 5 | 6 | type AppWithEffectorState = AppProps; 7 | 8 | export const withEffector = (Component: AppWithEffectorState['Component']) => (props: AppWithEffectorState) => { 9 | const { pageProps } = props; 10 | 11 | const values = pageProps[EFFECTOR_STATE_KEY]; 12 | 13 | return ( 14 | 15 | 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/app/providers/with-i18n.tsx: -------------------------------------------------------------------------------- 1 | import { AppProps } from 'next/app'; 2 | import { appWithTranslation } from 'next-i18next'; 3 | 4 | import nextI18nConfig from '../../../next-i18next.config.mjs'; 5 | 6 | export const withI18n = (Component: AppProps['Component']) => appWithTranslation(Component, nextI18nConfig); 7 | -------------------------------------------------------------------------------- /src/entities/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './model/auth'; 2 | export * from './model/logout'; 3 | export * from './model/refresh'; 4 | -------------------------------------------------------------------------------- /src/entities/auth/model/auth.ts: -------------------------------------------------------------------------------- 1 | import { createEffect, sample } from 'effector'; 2 | 3 | import { 4 | AuthEmailLoginDto, 5 | AuthRegisterLoginDto, 6 | LoginResult, 7 | RefreshResult, 8 | RegisterResult, 9 | } from '@/shared/api/api.generated'; 10 | import { $token, RequestError, requestFx, setToken } from '@/shared/api/request/request'; 11 | import { pushFx } from '@/shared/lib/effector/router'; 12 | 13 | export const loginFx = createEffect(async (values) => { 14 | const tokens = await requestFx({ 15 | method: 'POST', 16 | url: 'api/v1/auth/login', 17 | data: values, 18 | }); 19 | 20 | return tokens; 21 | }); 22 | 23 | export const registerFx = createEffect(async (values) => { 24 | const tokens = await requestFx({ 25 | method: 'POST', 26 | url: 'api/v1/auth/register', 27 | data: values, 28 | }); 29 | 30 | return tokens; 31 | }); 32 | 33 | export const refreshFx = createEffect(async () => { 34 | const tokens = await requestFx({ 35 | url: 'api/v1/auth/refresh', 36 | method: 'POST', 37 | withCredentials: true, 38 | }); 39 | 40 | return tokens; 41 | }); 42 | 43 | export const logoutFx = createEffect(() => { 44 | return requestFx({ 45 | method: 'POST', 46 | url: 'api/v1/auth/logout', 47 | withCredentials: true, 48 | }); 49 | }); 50 | 51 | sample({ 52 | clock: [logoutFx.done, refreshFx.failData], 53 | fn: () => ({ 54 | url: '/auth/login', 55 | }), 56 | target: pushFx, 57 | }); 58 | 59 | sample({ 60 | clock: [loginFx.doneData, refreshFx.doneData], 61 | fn: ({ data }) => data.access, 62 | target: setToken, 63 | }); 64 | 65 | sample({ 66 | clock: refreshFx.failData, 67 | fn: () => null, 68 | target: setToken, 69 | }); 70 | 71 | export const $isLoggedIn = $token.map((token) => !!token); 72 | -------------------------------------------------------------------------------- /src/entities/auth/model/logout.ts: -------------------------------------------------------------------------------- 1 | import { createEvent, sample } from 'effector'; 2 | 3 | import { logoutFx } from './auth'; 4 | 5 | export const forceLogout = createEvent(); 6 | 7 | sample({ 8 | clock: forceLogout, 9 | target: logoutFx, 10 | }); 11 | -------------------------------------------------------------------------------- /src/entities/auth/model/refresh.ts: -------------------------------------------------------------------------------- 1 | import { createDefer, Defer } from '@effable/misc'; 2 | import { createEffect, createEvent, createStore, sample } from 'effector'; 3 | import decode, { JwtPayload } from 'jwt-decode'; 4 | 5 | import { $token } from '@/shared/api/request/request'; 6 | 7 | import { refreshFx } from './auth'; 8 | 9 | const $pendingRequests = createStore[]>([]); 10 | 11 | const refreshDone = sample({ 12 | clock: refreshFx.doneData, 13 | source: $pendingRequests, 14 | fn: (requests, token): [string, Defer[]] => [token.data.access, requests], 15 | }); 16 | 17 | const refreshFail = sample({ 18 | clock: refreshFx.failData, 19 | source: $pendingRequests, 20 | fn: (requests): [null, Defer[]] => [null, requests], 21 | }); 22 | 23 | const checkTokenValidity = createEvent>(); 24 | export const authenticateFx = createEffect(() => checkTokenValidity(createDefer()).promise); 25 | 26 | const tokenValid = sample({ 27 | clock: checkTokenValidity, 28 | source: $token, 29 | filter(token) { 30 | if (!token) return false; 31 | 32 | const decodedToken = decode>(token); 33 | 34 | const isExpired = new Date(decodedToken.exp * 1000) < new Date(); 35 | 36 | return !isExpired; 37 | }, 38 | fn: (token, request): [string | null, Defer] => [token, request], 39 | }); 40 | 41 | const tokenInvalid = sample({ 42 | clock: checkTokenValidity, 43 | source: $token, 44 | filter(token) { 45 | if (!token) return true; 46 | 47 | const decodedToken = decode>(token); 48 | 49 | const isExpired = new Date(decodedToken.exp * 1000) < new Date(); 50 | 51 | return isExpired; 52 | }, 53 | fn: (token, request): [string | null, Defer] => [token, request], 54 | }); 55 | 56 | $pendingRequests.on(tokenInvalid, (queue, [_, request]) => queue.concat(request)); 57 | 58 | sample({ 59 | source: tokenInvalid, 60 | filter: refreshFx.pending.map((is) => !is), 61 | target: refreshFx, 62 | }); 63 | 64 | tokenValid.watch(([token, request]) => request.resolve(token)); 65 | refreshDone.watch(([token, requests]) => requests?.forEach((request) => request.resolve(token))); 66 | refreshFail.watch(([token, requests]) => requests?.forEach((request) => request.reject(token))); 67 | -------------------------------------------------------------------------------- /src/entities/session/index.ts: -------------------------------------------------------------------------------- 1 | export * from './session.model'; 2 | -------------------------------------------------------------------------------- /src/entities/session/session.model.ts: -------------------------------------------------------------------------------- 1 | import { createQuery } from '@farfetched/core'; 2 | import { AxiosRequestConfig, AxiosResponse } from 'axios'; 3 | import { attach, Effect } from 'effector'; 4 | 5 | import { Session } from '@/shared/api/api.generated'; 6 | import { requestWithAuthFx } from '@/shared/api/request'; 7 | 8 | export const fetchUserSessionsFx = attach, AxiosResponse>>({ 9 | mapParams: () => ({ 10 | url: '/api/v1/sessions', 11 | }), 12 | effect: requestWithAuthFx, 13 | }); 14 | 15 | export const sessionQuery = createQuery({ 16 | effect: fetchUserSessionsFx, 17 | mapData: ({ result }) => result.data, 18 | name: 'sessions', 19 | }); 20 | -------------------------------------------------------------------------------- /src/entities/user/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user.model'; 2 | -------------------------------------------------------------------------------- /src/entities/user/user.model.ts: -------------------------------------------------------------------------------- 1 | import { createQuery } from '@farfetched/core'; 2 | import { AxiosRequestConfig, AxiosResponse } from 'axios'; 3 | import { attach, createEvent, Effect } from 'effector'; 4 | 5 | import { User } from '@/shared/api/api.generated'; 6 | import { requestWithAuthFx } from '@/shared/api/request'; 7 | 8 | export const userUpdated = createEvent(); 9 | 10 | export const fetchUserFx = attach, AxiosResponse>>({ 11 | mapParams: (userId: number) => ({ 12 | url: `/api/v1/users/${userId}`, 13 | }), 14 | effect: requestWithAuthFx, 15 | }); 16 | 17 | export const userQuery = createQuery({ 18 | effect: fetchUserFx, 19 | mapData: ({ result }) => result.data, 20 | name: 'user', 21 | }); 22 | -------------------------------------------------------------------------------- /src/features/auth/login/login.model.ts: -------------------------------------------------------------------------------- 1 | import { createEvent, sample } from 'effector'; 2 | import { splitMap, spread } from 'patronum'; 3 | import { z } from 'zod'; 4 | 5 | import { loginFx } from '@/entities/auth'; 6 | 7 | import { createField, createForm } from '@/shared/lib/effector/forms'; 8 | import { pushFx } from '@/shared/lib/effector/router/effector-router'; 9 | 10 | import { email } from '../model'; 11 | 12 | export const loginButtonClicked = createEvent(); 13 | 14 | const password = createField({ 15 | initialValue: '', 16 | schema: z.string().min(1, 'ERROR_FIELD_REQUIRED'), 17 | }); 18 | 19 | export const loginForm = createForm({ 20 | fields: { 21 | email, 22 | password, 23 | }, 24 | $disabled: loginFx.pending, 25 | }); 26 | 27 | sample({ 28 | clock: loginForm.submitted, 29 | target: loginFx, 30 | }); 31 | 32 | const { 33 | fieldsError, 34 | commonError, 35 | __: unexpectedError, 36 | } = splitMap({ 37 | source: loginFx.failData, 38 | cases: { 39 | fieldsError: (error) => error.errors, 40 | commonError: (error) => error.code, 41 | }, 42 | }); 43 | 44 | spread({ 45 | source: fieldsError, 46 | targets: { 47 | email: loginForm.fields.email.addError, 48 | password: loginForm.fields.password.addError, 49 | }, 50 | }); 51 | 52 | sample({ 53 | clock: commonError, 54 | target: loginForm.addError, 55 | }); 56 | 57 | sample({ 58 | clock: unexpectedError, 59 | target: loginForm.addError.prepend(() => 'ERROR_UNEXPECTED'), 60 | }); 61 | 62 | sample({ 63 | clock: loginFx.done, 64 | fn: () => ({ url: '/dashboard' }), 65 | target: pushFx, 66 | }); 67 | -------------------------------------------------------------------------------- /src/features/auth/login/tests/login-form.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { act, screen } from '@testing-library/react'; 6 | import userEvent from '@testing-library/user-event'; 7 | import { fork, Scope } from 'effector'; 8 | 9 | import { loginFx } from '@/entities/auth'; 10 | 11 | import { renderWithProviders } from '@/shared/lib/testing/render-with-providers'; 12 | 13 | import { LoginForm } from '../login-form'; 14 | 15 | describe('Login Form', () => { 16 | test('should send request after submit', async () => { 17 | const loginMock = jest.fn(); 18 | 19 | const scope = fork({ 20 | handlers: new Map().set(loginFx, (params) => { 21 | loginMock(params); 22 | return { data: { access: 'access', refresh: 'refresh' } }; 23 | }), 24 | }); 25 | 26 | renderWithProviders(, scope); 27 | 28 | const emailField = screen.getByPlaceholderText('EMAIL_PLACEHOLDER'); 29 | const passwordField = screen.getByPlaceholderText('PASSWORD_PLACEHOLDER'); 30 | const loginButton = screen.getByRole('button', { name: 'LOGIN' }); 31 | 32 | await act(async () => { 33 | await userEvent.type(emailField, 'test@gmail.com'); 34 | await userEvent.type(passwordField, '123456'); 35 | }); 36 | 37 | expect(emailField).toHaveValue('test@gmail.com'); 38 | expect(passwordField).toHaveValue('123456'); 39 | 40 | await act(async () => { 41 | await userEvent.click(loginButton); 42 | }); 43 | 44 | expect(loginMock).toHaveBeenCalledTimes(1); 45 | expect(loginMock).toHaveBeenCalledWith({ email: 'test@gmail.com', password: '123456' }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/features/auth/model.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import { createField } from '@/shared/lib/effector/forms'; 4 | 5 | export const email = createField({ 6 | initialValue: '', 7 | schema: z.string({ required_error: 'ERROR_FIELD_REQUIRED' }).email('ERROR_EMAIL_INVALID'), 8 | }); 9 | -------------------------------------------------------------------------------- /src/features/auth/register/register.model.ts: -------------------------------------------------------------------------------- 1 | import { createEvent, sample } from 'effector'; 2 | import { splitMap, spread } from 'patronum'; 3 | import { z } from 'zod'; 4 | 5 | import { registerFx } from '@/entities/auth'; 6 | 7 | import { createField, createForm } from '@/shared/lib/effector/forms'; 8 | 9 | import { email } from '../model'; 10 | 11 | export const registerButtonClicked = createEvent(); 12 | 13 | const username = createField({ 14 | initialValue: '', 15 | schema: z.string().min(3, 'ERROR_USERNAME_MIN'), 16 | }); 17 | 18 | const password = createField({ 19 | initialValue: '', 20 | schema: z.string().min(6, 'ERROR_PASSWORD_MIN'), 21 | }); 22 | 23 | const confirmPassword = createField({ 24 | initialValue: '', 25 | schema: z.string().min(1), 26 | }); 27 | 28 | export const registerForm = createForm({ 29 | fields: { 30 | email, 31 | username, 32 | password, 33 | }, 34 | $disabled: registerFx.pending, 35 | }); 36 | 37 | sample({ 38 | clock: registerForm.submitted, 39 | target: registerFx, 40 | }); 41 | 42 | const { 43 | fieldsError, 44 | commonError, 45 | __: unexpectedError, 46 | } = splitMap({ 47 | source: registerFx.failData, 48 | cases: { 49 | fieldsError: (error) => error.errors, 50 | commonError: (error) => error.code, 51 | }, 52 | }); 53 | 54 | spread({ 55 | source: fieldsError, 56 | targets: { 57 | email: registerForm.fields.email.addError, 58 | username: registerForm.fields.username.addError, 59 | password: registerForm.fields.password.addError, 60 | }, 61 | }); 62 | 63 | sample({ 64 | clock: commonError, 65 | target: registerForm.addError, 66 | }); 67 | 68 | sample({ 69 | clock: unexpectedError, 70 | target: registerForm.addError.prepend(() => 'ERROR_UNEXPECTED'), 71 | }); 72 | -------------------------------------------------------------------------------- /src/features/auth/register/tests/register-form.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { act, screen } from '@testing-library/react'; 6 | import userEvent from '@testing-library/user-event'; 7 | import { fork, Scope } from 'effector'; 8 | 9 | import { registerFx } from '@/entities/auth'; 10 | 11 | import { renderWithProviders } from '@/shared/lib/testing/render-with-providers'; 12 | 13 | import { RegisterForm } from '../register-form'; 14 | 15 | describe('Register Form', () => { 16 | test('should send request after submit', async () => { 17 | const registerFxMock = jest.fn(); 18 | 19 | const scope = fork({ 20 | handlers: new Map().set(registerFx, registerFxMock), 21 | }); 22 | 23 | renderWithProviders(, scope); 24 | 25 | const emailField = screen.getByPlaceholderText('EMAIL_PLACEHOLDER'); 26 | const usernameField = screen.getByPlaceholderText('USERNAME_PLACEHOLDER'); 27 | const passwordField = screen.getByPlaceholderText('PASSWORD_PLACEHOLDER'); 28 | const loginButton = screen.getByRole('button', { name: 'SIGNUP' }); 29 | 30 | await act(async () => { 31 | await userEvent.type(emailField, 'test@gmail.com'); 32 | await userEvent.type(usernameField, 'test'); 33 | await userEvent.type(passwordField, '123456'); 34 | }); 35 | 36 | expect(emailField).toHaveValue('test@gmail.com'); 37 | expect(usernameField).toHaveValue('test'); 38 | expect(passwordField).toHaveValue('123456'); 39 | 40 | await act(async () => { 41 | await userEvent.click(loginButton); 42 | }); 43 | 44 | expect(registerFxMock).toHaveBeenCalledTimes(1); 45 | expect(registerFxMock).toHaveBeenCalledWith({ email: 'test@gmail.com', username: 'test', password: '123456' }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/features/cookie-consent/cookie-consent.model.ts: -------------------------------------------------------------------------------- 1 | import { createEvent, createStore } from 'effector'; 2 | import { persist } from 'effector-storage/local'; 3 | 4 | export const COOKIE_CONSENT_KEY = 'cookie-consent'; 5 | 6 | export const cookieAllowed = createEvent(); 7 | 8 | export const $isCookieAllowed = createStore(false); 9 | 10 | persist({ store: $isCookieAllowed, key: COOKIE_CONSENT_KEY }); 11 | 12 | $isCookieAllowed.on(cookieAllowed, () => true); 13 | -------------------------------------------------------------------------------- /src/features/cookie-consent/cookie-consent.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Link, Portal, Text } from '@effable/react'; 2 | import { useUnit } from 'effector-react'; 3 | import { Trans, useTranslation } from 'next-i18next'; 4 | 5 | import { $isCookieAllowed, cookieAllowed } from './cookie-consent.model'; 6 | 7 | export const CookieConsent = () => { 8 | const { t } = useTranslation('common'); 9 | 10 | const [isAllowed, allowCookies] = useUnit([$isCookieAllowed, cookieAllowed]); 11 | 12 | if (isAllowed) return null; 13 | 14 | return ( 15 | 16 | 17 | 27 | 28 | ]} 32 | /> 33 | 34 | 35 | 38 | 39 | 40 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /src/features/cookie-consent/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cookie-consent'; 2 | -------------------------------------------------------------------------------- /src/features/cookie-consent/stories/cookie-consent.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | 4 | import { CookieConsent } from '../cookie-consent'; 5 | 6 | export default { 7 | title: 'Features/CookieConsent', 8 | component: CookieConsent, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ; 12 | 13 | export const Basic = Template.bind({}); 14 | -------------------------------------------------------------------------------- /src/features/locale-toggler/index.ts: -------------------------------------------------------------------------------- 1 | export * from './locale-toggler'; 2 | -------------------------------------------------------------------------------- /src/features/locale-toggler/locale-toggler.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useRouter } from 'next/router'; 3 | import { Button } from '@effable/react'; 4 | import { useTranslation } from 'next-i18next'; 5 | import { RiGlobalLine } from 'react-icons/ri'; 6 | 7 | import * as DropdownMenu from '@/shared/components/system/dropdown-menu'; 8 | import { SUPPORTED_LOCALES } from '@/shared/lib/i18n'; 9 | 10 | export const LocaleToggler = () => { 11 | const { i18n } = useTranslation(); 12 | const router = useRouter(); 13 | 14 | const changeLocale = (locale: string) => { 15 | const { pathname, query, asPath } = router; 16 | 17 | // change just the locale and maintain all other route information including href's query 18 | router.replace({ pathname, query }, asPath, { locale }) as unknown as void; 19 | }; 20 | 21 | return ( 22 | 23 | 24 | 27 | 28 | 29 | 30 | 31 | 32 | {Object.values(SUPPORTED_LOCALES).map((key) => ( 33 | 34 | {key.toUpperCase()} 35 | 36 | ))} 37 | 38 | 39 | 40 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /src/features/locale-toggler/stories/locale-toggler.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | 4 | import { LocaleToggler } from '../locale-toggler'; 5 | 6 | export default { 7 | title: 'Features/LocaleToggler', 8 | component: LocaleToggler, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ; 12 | 13 | export const Basic = Template.bind({}); 14 | -------------------------------------------------------------------------------- /src/features/new-main-page/advantage/advantage.styled.ts: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | 3 | export const AdvantageRoot = styled.div({}); 4 | -------------------------------------------------------------------------------- /src/features/new-main-page/advantage/advantage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Box, Heading, Text } from '@effable/react'; 3 | 4 | import * as S from './advantage.styled'; 5 | 6 | export interface AdvantageProps { 7 | /** 8 | * The content 9 | */ 10 | icon: React.ReactNode; 11 | description: React.ReactNode; 12 | title: string; 13 | } 14 | 15 | export const Advantage = (props: AdvantageProps) => { 16 | const { icon, description, title } = props; 17 | 18 | return ( 19 | 33 | 48 | {icon} 49 | 50 | 51 | 60 | 61 | {title} 62 | 63 | 64 | 74 | {description} 75 | 76 | 77 | 78 | ); 79 | }; 80 | -------------------------------------------------------------------------------- /src/features/new-main-page/advantage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './advantage'; 2 | -------------------------------------------------------------------------------- /src/features/new-main-page/advantage/stories/advantage.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | 4 | import { Advantage, AdvantageProps } from '../advantage'; 5 | 6 | export default { 7 | title: 'Features/NewMainPage/Advantage', 8 | component: Advantage, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ; 12 | 13 | export const Basic = Template.bind({}); 14 | 15 | Basic.args = { 16 | title: 'DemoItem', 17 | description: 'DemoItem', 18 | icon: '', 19 | }; 20 | -------------------------------------------------------------------------------- /src/features/new-main-page/change-theme/change-theme.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { capitalize } from '@effable/misc'; 3 | import { Button, useEffableTheme } from '@effable/react'; 4 | import { RiComputerLine, RiMoonLine, RiSunLine } from 'react-icons/ri'; 5 | 6 | import * as DropdownMenu from '@/shared/components/system/dropdown-menu'; 7 | 8 | const iconMap = { 9 | light: RiSunLine, 10 | dark: RiMoonLine, 11 | system: RiComputerLine, 12 | }; 13 | 14 | export const ChangeTheme = () => { 15 | // eslint-disable-next-line @typescript-eslint/unbound-method 16 | const { mode, setMode } = useEffableTheme('ChangeTheme'); 17 | 18 | const Icon = iconMap[mode]; 19 | 20 | return ( 21 | 22 | 23 | 26 | 27 | 28 | 29 | 30 | setMode(value as typeof mode)}> 31 | 32 | 33 | 34 | 35 | Light 36 | 37 | 38 | 39 | 40 | 41 | Dark 42 | 43 | 44 | 45 | 46 | 47 | System 48 | 49 | 50 | 51 | 52 | 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /src/features/new-main-page/change-theme/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './change-theme'; 2 | -------------------------------------------------------------------------------- /src/features/new-main-page/change-theme/stories/change-theme.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | 4 | import { ChangeTheme } from '../change-theme'; 5 | 6 | export default { 7 | title: 'Features/NewMainPage/Change-theme', 8 | component: ChangeTheme, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ; 12 | 13 | export const Basic = Template.bind({}); 14 | 15 | Basic.args = { 16 | children: 'ChangeTheme', 17 | }; 18 | -------------------------------------------------------------------------------- /src/features/new-main-page/demo-item/demo-item.styled.ts: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | 3 | export const DemoImg = styled.img({ 4 | width: '100%', 5 | height: '100%', 6 | }); 7 | 8 | export const Anchor = styled.a({ 9 | backgroundColor: '#E8E8E8', 10 | maxWidth: '627px', 11 | maxHeight: '402px', 12 | width: '100%', 13 | height: '100%', 14 | }); 15 | -------------------------------------------------------------------------------- /src/features/new-main-page/demo-item/index.ts: -------------------------------------------------------------------------------- 1 | export * from './demo-item'; 2 | -------------------------------------------------------------------------------- /src/features/new-main-page/demo-item/stories/demo-item.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | 4 | import { DemoItem, DemoItemProps } from '../demo-item'; 5 | 6 | export default { 7 | title: 'Features/NewMainPage/DemoItem', 8 | component: DemoItem, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ; 12 | 13 | export const Basic = Template.bind({}); 14 | 15 | Basic.args = { 16 | title: 'DemoItem', 17 | description: 'DemoItem', 18 | preview: 'DemoItem', 19 | }; 20 | -------------------------------------------------------------------------------- /src/features/new-main-page/demos/demos.const.ts: -------------------------------------------------------------------------------- 1 | interface DemoApp { 2 | link?: string; 3 | image?: string; 4 | title: string; 5 | description: string; 6 | } 7 | 8 | export const demoApps: DemoApp[] = [ 9 | { 10 | title: 'Authorization', 11 | description: 'DEMOS_AUTH_DESCRIPTION', 12 | }, 13 | { 14 | title: 'Weather', 15 | description: 'DEMOS_WEATHER_DESCRIPTION', 16 | }, 17 | { 18 | title: 'Passkip', 19 | description: 'DEMOS_PASSWORD_DESCRIPTION', 20 | }, 21 | ]; 22 | -------------------------------------------------------------------------------- /src/features/new-main-page/demos/demos.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Box, Heading, Stack } from '@effable/react'; 3 | import { useTranslation } from 'next-i18next'; 4 | 5 | import { pagesPath } from '@/shared/lib/$path'; 6 | 7 | import { DemoItem } from '../demo-item'; 8 | import { Section } from '../section'; 9 | import { demoApps } from './demos.const'; 10 | 11 | export const Demos = () => { 12 | const { t } = useTranslation('index'); 13 | 14 | return ( 15 |
16 | 17 | 18 | 19 | 20 | {t('DEMOS_TITLE')} 21 | 22 | 23 | 24 | {demoApps.map((app) => ( 25 | 32 | ))} 33 | 34 | 35 |
36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /src/features/new-main-page/demos/index.ts: -------------------------------------------------------------------------------- 1 | export * from './demos'; 2 | -------------------------------------------------------------------------------- /src/features/new-main-page/demos/stories/demos.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | 4 | import { Demos } from '../demos'; 5 | 6 | export default { 7 | title: 'Demos', 8 | component: Demos, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ; 12 | 13 | export const Basic = Template.bind({}); 14 | 15 | Basic.args = {}; 16 | -------------------------------------------------------------------------------- /src/features/new-main-page/dignity-grid/dignity-grid.styled.ts: -------------------------------------------------------------------------------- 1 | import { keyframes } from '@emotion/react'; 2 | import styled from '@emotion/styled'; 3 | 4 | const float = keyframes` 5 | 0% { 6 | transform: translateY(0) 7 | } 8 | 9 | 30% { 10 | transform: translateY(-4px) 11 | } 12 | 13 | 50% { 14 | transform: translateY(4px) 15 | } 16 | 17 | 70% { 18 | transform: translateY(-4px) 19 | } 20 | 21 | 100% { 22 | transform: translateY(0) 23 | } 24 | `; 25 | 26 | export const AnimatedBubble = styled.div<{ delay?: string; duration: string; mode?: string }>((props) => ({ 27 | animation: `${props.duration} ${props.delay ?? '0s'} infinite ${props.mode ?? 'normal'} none running ${float}`, 28 | })); 29 | -------------------------------------------------------------------------------- /src/features/new-main-page/dignity-grid/index.ts: -------------------------------------------------------------------------------- 1 | export * from './dignity-grid'; 2 | -------------------------------------------------------------------------------- /src/features/new-main-page/dignity-grid/stories/dignity-grid.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | 4 | import { DignityGrid } from '../dignity-grid'; 5 | 6 | export default { 7 | title: 'DignityGrid', 8 | component: DignityGrid, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ; 12 | 13 | export const Basic = Template.bind({}); 14 | 15 | Basic.args = { 16 | children: 'DignityGrid', 17 | }; 18 | -------------------------------------------------------------------------------- /src/features/new-main-page/dignity/dignity.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import NextImage from 'next/image'; 3 | import { Box, Heading, Text } from '@effable/react'; 4 | 5 | export interface DignityProps { 6 | title: string; 7 | text?: string; 8 | icon: string; 9 | } 10 | 11 | export const Dignity = (props: DignityProps) => { 12 | const { title, text, icon } = props; 13 | 14 | return ( 15 | 26 | 27 | 28 | 29 | 30 | 31 | {title} 32 | 33 | 34 | {text && ( 35 | 36 | {text} 37 | 38 | )} 39 | 40 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /src/features/new-main-page/dignity/index.ts: -------------------------------------------------------------------------------- 1 | export * from './dignity'; 2 | -------------------------------------------------------------------------------- /src/features/new-main-page/dignity/stories/dignity.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | 4 | import { Dignity, DignityProps } from '../dignity'; 5 | 6 | export default { 7 | title: 'Features/NewMainPage/Dignity', 8 | component: Dignity, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ; 12 | 13 | export const Basic = Template.bind({}); 14 | 15 | Basic.args = { 16 | title: 'Husky', 17 | text: 'for tracking code quality before commits', 18 | }; 19 | -------------------------------------------------------------------------------- /src/features/new-main-page/hero/index.ts: -------------------------------------------------------------------------------- 1 | export * from './hero'; 2 | -------------------------------------------------------------------------------- /src/features/new-main-page/hero/stories/header.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | 4 | import { Hero } from '../hero'; 5 | 6 | export default { 7 | title: 'Features/NewMainPage/Header', 8 | component: Hero, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ; 12 | 13 | export const Basic = Template.bind({}); 14 | 15 | Basic.args = {}; 16 | -------------------------------------------------------------------------------- /src/features/new-main-page/section/index.ts: -------------------------------------------------------------------------------- 1 | export * from './section'; 2 | -------------------------------------------------------------------------------- /src/features/new-main-page/section/section.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Box, Container } from '@effable/react'; 3 | 4 | export interface SectionProps { 5 | /** 6 | * The content 7 | */ 8 | children: React.ReactNode; 9 | backgroundColor?: string; 10 | } 11 | 12 | export const Section = (props: SectionProps) => { 13 | const { children, backgroundColor } = props; 14 | 15 | return ( 16 | 24 | {children} 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/features/new-main-page/section/stories/section.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | 4 | import { Section, SectionProps } from '../section'; 5 | 6 | export default { 7 | title: 'Section', 8 | component: Section, 9 | } as Meta; 10 | 11 | const Template: Story = (args) =>
; 12 | 13 | export const Basic = Template.bind({}); 14 | 15 | Basic.args = { 16 | children: 'Section', 17 | }; 18 | -------------------------------------------------------------------------------- /src/features/new-main-page/why-nextplate/index.ts: -------------------------------------------------------------------------------- 1 | export * from './why-nextplate'; 2 | -------------------------------------------------------------------------------- /src/features/new-main-page/why-nextplate/stories/why-nextplate.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | 4 | import { WhyNextplate } from '../why-nextplate'; 5 | 6 | export default { 7 | title: 'WhyNextplate', 8 | component: WhyNextplate, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ; 12 | 13 | export const Basic = Template.bind({}); 14 | 15 | Basic.args = {}; 16 | -------------------------------------------------------------------------------- /src/features/new-main-page/why-nextplate/why-nextplate.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Box, Code, Container, Heading, SimpleGrid } from '@effable/react'; 3 | import { Trans, useTranslation } from 'next-i18next'; 4 | import { RiEyeFill, RiFlashlightFill, RiSettings3Fill, RiStarFill } from 'react-icons/ri'; 5 | 6 | import { Advantage } from '@/features/new-main-page/advantage'; 7 | 8 | export const WhyNextplate = () => { 9 | const { t } = useTranslation(['index', 'common']); 10 | 11 | return ( 12 | 13 | 14 | 15 | {t('FEATURES_TITLE')} 16 | 17 | 18 | 19 | } 23 | /> 24 | 25 | ]} />} 28 | icon={} 29 | /> 30 | 31 | } 35 | /> 36 | 37 | } 41 | /> 42 | 43 | 44 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /src/features/nprogress/index.ts: -------------------------------------------------------------------------------- 1 | export * from './nprogress'; 2 | -------------------------------------------------------------------------------- /src/features/nprogress/stories/nprogress.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | import NProgress from 'nprogress'; 4 | 5 | import { NProgressRoot, NProgressRootProps } from '../nprogress'; 6 | 7 | export default { 8 | title: 'Components/Nprogress', 9 | component: NProgressRoot, 10 | } as Meta; 11 | 12 | const Template: Story = (args) => { 13 | NProgress.start(); 14 | 15 | return ; 16 | }; 17 | 18 | export const Basic = Template.bind({}); 19 | -------------------------------------------------------------------------------- /src/i18next.d.ts: -------------------------------------------------------------------------------- 1 | import 'i18next'; 2 | 3 | declare module 'i18next' { 4 | interface CustomTypeOptions { 5 | returnNull: false; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/middleware.page.ts: -------------------------------------------------------------------------------- 1 | import { chainMiddlewares, withHeaders, withLogging } from '@/shared/lib/next/middlewares'; 2 | 3 | export default chainMiddlewares([withLogging, withHeaders]); 4 | 5 | export const config = { 6 | matcher: [ 7 | '/', 8 | /* 9 | * Match all request paths except for the ones starting with: 10 | * - api (API routes) 11 | * - _next/static (static files) 12 | * - _next/image (image optimization files) 13 | * - favicon.ico (favicon file) 14 | */ 15 | '/((?!api|static/images|_next/static|_next/image|site.webmanifest|robots.txt).*)', 16 | ], 17 | }; 18 | -------------------------------------------------------------------------------- /src/pages/_document.page.tsx: -------------------------------------------------------------------------------- 1 | import Document, { DocumentContext, DocumentInitialProps, Head, Html, Main, NextScript } from 'next/document'; 2 | import { InitializeColorMode } from '@effable/react'; 3 | 4 | import { CommonMetaTags } from '@/shared/lib/meta'; 5 | 6 | /** 7 | * Note: Is only rendered on the server side and not on the client side 8 | * 9 | * Used to inject tag and default meta tags 10 | * 11 | * See https://github.com/vercel/next.js/#custom-document 12 | */ 13 | class NextplateDocument extends Document { 14 | static async getInitialProps(ctx: DocumentContext): Promise { 15 | const initialProps = await Document.getInitialProps(ctx); 16 | 17 | return initialProps; 18 | } 19 | 20 | render() { 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | 31 | 32 | 33 | ); 34 | } 35 | } 36 | 37 | export default NextplateDocument; 38 | -------------------------------------------------------------------------------- /src/pages/api/status.page.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import * as Sentry from '@sentry/nextjs'; 3 | 4 | import { configureReq } from '@/shared/lib/sentry'; 5 | 6 | const fileLabel = 'api/status'; 7 | 8 | /** 9 | * Status endpoint - Prints the "status" of the deployed instance. 10 | * 11 | * Prints useful information regarding the deployment. 12 | * Meant to be used for debugging purposes. 13 | * Can also be used as "ping endpoint" to make sure the app is online. 14 | * 15 | * @param req 16 | * @param res 17 | * @method GET 18 | */ 19 | export const status = (req: NextApiRequest, res: NextApiResponse): void => { 20 | try { 21 | configureReq(req, { fileLabel }); 22 | 23 | res.json({ 24 | appStage: process.env.NEXT_PUBLIC_APP_STAGE, 25 | appName: process.env.NEXT_PUBLIC_APP_NAME, 26 | appRelease: process.env.NEXT_PUBLIC_APP_VERSION, 27 | appBuildTime: process.env.NEXT_PUBLIC_BUILD_TIME, 28 | appBuildId: process.env.NEXT_PUBLIC_BUILD_ID, 29 | nodejs: process.version, 30 | regionVERCEL: process.env.VERCEL_REGION, 31 | timezone: process.env.TZ, 32 | memory: process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE, 33 | environment: process.env.NODE_ENV, 34 | NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, 35 | }); 36 | } catch (e: unknown) { 37 | res.json({ 38 | error: true, 39 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 40 | message: process.env.NEXT_PUBLIC_APP_STAGE === 'production' ? undefined : (e as Error).message, 41 | }); 42 | } 43 | }; 44 | 45 | export default Sentry.withSentry(status); 46 | -------------------------------------------------------------------------------- /src/pages/auth/login.page.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useTranslation } from 'next-i18next'; 3 | 4 | import { AuthLayout } from '@/layouts/auth'; 5 | 6 | import { LoginForm } from '@/features/auth/login/login-form'; 7 | 8 | import { createLogger } from '@/shared/lib/logging/logger'; 9 | import { PageSEO } from '@/shared/lib/meta'; 10 | import { getTranslationsStaticProps } from '@/shared/lib/ssr'; 11 | import { EnhancedNextPage } from '@/shared/types/enhanced-next-page'; 12 | import { SSGPageProps } from '@/shared/types/ssg-page-props'; 13 | 14 | const logger = createLogger('Login'); 15 | 16 | /** 17 | * Only executed on the server side at build time. 18 | * 19 | * @return Props (as "SSGPageProps") that will be passed to the Page component, as props 20 | * 21 | * @see https://github.com/vercel/next.js/discussions/10949#discussioncomment-6884 22 | * @see https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation 23 | */ 24 | export const getStaticProps = getTranslationsStaticProps(['auth', 'common']); 25 | 26 | type LoginPageProps = SSGPageProps; 27 | 28 | const LoginPage: EnhancedNextPage = () => { 29 | const { t } = useTranslation('auth'); 30 | 31 | return ( 32 | <> 33 | 34 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | LoginPage.Layout = AuthLayout; 41 | 42 | export default LoginPage; 43 | -------------------------------------------------------------------------------- /src/pages/auth/signup.page.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'next-i18next'; 2 | 3 | import { AuthLayout } from '@/layouts/auth'; 4 | 5 | import { RegisterForm } from '@/features/auth/register/register-form'; 6 | 7 | import { createLogger } from '@/shared/lib/logging/logger'; 8 | import { PageSEO } from '@/shared/lib/meta'; 9 | import { getTranslationsStaticProps } from '@/shared/lib/ssr'; 10 | import { EnhancedNextPage } from '@/shared/types/enhanced-next-page'; 11 | import { SSGPageProps } from '@/shared/types/ssg-page-props'; 12 | 13 | const logger = createLogger('SignUp'); 14 | 15 | /** 16 | * Only executed on the server side at build time. 17 | * 18 | * @return Props (as "SSGPageProps") that will be passed to the Page component, as props 19 | * 20 | * @see https://github.com/vercel/next.js/discussions/10949#discussioncomment-6884 21 | * @see https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation 22 | */ 23 | export const getStaticProps = getTranslationsStaticProps(['auth', 'common']); 24 | 25 | type SignUpPageProps = SSGPageProps; 26 | 27 | const SignUpPage: EnhancedNextPage = () => { 28 | const { t } = useTranslation('auth'); 29 | 30 | return ( 31 | <> 32 | 33 | 34 | 35 | 36 | ); 37 | }; 38 | 39 | SignUpPage.Layout = AuthLayout; 40 | 41 | export default SignUpPage; 42 | -------------------------------------------------------------------------------- /src/pages/dashboard/model.ts: -------------------------------------------------------------------------------- 1 | import { createEvent, sample } from 'effector'; 2 | 3 | import { sessionQuery } from '@/entities/session'; 4 | import { userQuery } from '@/entities/user'; 5 | 6 | import { $tokenPayload } from '@/shared/api/request'; 7 | import { PageContext } from '@/shared/lib/next/types'; 8 | 9 | import { TokenPayload } from '../../shared/api/api.generated'; 10 | 11 | export const dashboardPageStarted = createEvent(); 12 | 13 | sample({ 14 | clock: dashboardPageStarted, 15 | source: $tokenPayload, 16 | filter: (payload: TokenPayload | null): payload is TokenPayload => !!payload?.id, 17 | fn: (payload) => payload.id, 18 | target: [userQuery.start, sessionQuery.start], 19 | }); 20 | -------------------------------------------------------------------------------- /src/pages/index.page.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Box } from '@effable/react'; 3 | import { useTranslation } from 'next-i18next'; 4 | 5 | import { MainLayout } from '@/layouts/main'; 6 | 7 | import { CookieConsent } from '@/features/cookie-consent'; 8 | import { Demos } from '@/features/new-main-page/demos'; 9 | import { Hero } from '@/features/new-main-page/hero'; 10 | import { WhyNextplate } from '@/features/new-main-page/why-nextplate'; 11 | 12 | import { PageSEO } from '@/shared/lib/meta'; 13 | import { getTranslationsStaticProps } from '@/shared/lib/ssr'; 14 | import { EnhancedNextPage } from '@/shared/types/enhanced-next-page'; 15 | import { SSGPageProps } from '@/shared/types/ssg-page-props'; 16 | 17 | type IndexPageProps = SSGPageProps; 18 | 19 | export const getStaticProps = getTranslationsStaticProps(['index', 'common']); 20 | 21 | const IndexPage: EnhancedNextPage = () => { 22 | const { t } = useTranslation(['index', 'common']); 23 | 24 | return ( 25 | <> 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | }; 40 | 41 | IndexPage.Layout = MainLayout; 42 | 43 | export default IndexPage; 44 | -------------------------------------------------------------------------------- /src/shared/api/http-client.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError, AxiosRequestConfig, AxiosResponse, isAxiosError } from 'axios'; 2 | 3 | const AXIOS_INSTANCE = axios.create({ 4 | baseURL: process.env.NEXT_PUBLIC_APP_URL, 5 | timeout: 1000 * 10, 6 | }); 7 | 8 | export const httpClient = (config: AxiosRequestConfig, options?: AxiosRequestConfig): Promise> => { 9 | const controller = new AbortController(); 10 | 11 | const promise = AXIOS_INSTANCE({ 12 | ...config, 13 | ...options, 14 | signal: controller.signal, 15 | }).catch((e) => { 16 | if (isAxiosError(e)) { 17 | throw e.response?.data; 18 | } 19 | 20 | throw e; 21 | }); 22 | 23 | // @ts-ignore 24 | promise.cancel = () => { 25 | controller.abort('Query was cancelled'); 26 | }; 27 | 28 | return promise; 29 | }; 30 | 31 | export default httpClient; 32 | 33 | // In some case with react-query and swr you want to be able to override the return error type so you can also do it here like this 34 | export type ErrorType = AxiosError; 35 | 36 | // In case you want to wrap the body type (optional) 37 | // (if the custom instance is processing data before sending it, like changing the case for example) 38 | export type BodyType = BodyData; 39 | -------------------------------------------------------------------------------- /src/shared/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './http-client'; 2 | -------------------------------------------------------------------------------- /src/shared/api/request/index.ts: -------------------------------------------------------------------------------- 1 | export * from './request'; 2 | export * from './request-with-auth'; 3 | -------------------------------------------------------------------------------- /src/shared/api/request/request-with-auth.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestConfig } from 'axios'; 2 | import { createEffect } from 'effector'; 3 | 4 | // FIXME: violates FSD boundaries 5 | import { authenticateFx } from '@/entities/auth'; 6 | 7 | import { requestFx } from './request'; 8 | 9 | export const requestWithAuthFx = createEffect(async (params: AxiosRequestConfig) => { 10 | const token = await authenticateFx(); 11 | 12 | const data = await requestFx({ 13 | ...params, 14 | headers: { 15 | ...(params.headers ?? {}), 16 | ...(token && { Authorization: `Bearer ${token}` }), 17 | }, 18 | }); 19 | 20 | return data; 21 | }); 22 | -------------------------------------------------------------------------------- /src/shared/api/request/request.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable effector/no-watch */ 2 | import { isBrowser } from '@effable/misc'; 3 | import { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'; 4 | import { attach, createEffect, createEvent, createStore, restore } from 'effector'; 5 | import decode from 'jwt-decode'; 6 | 7 | import { TokenPayload } from '../api.generated'; 8 | import { httpClient } from '../http-client'; 9 | 10 | export interface RequestError { 11 | statusCode: number; 12 | code?: string; 13 | message: string; 14 | errors: { 15 | [key: string]: string | string[]; 16 | }; 17 | } 18 | 19 | export const setCookiesForRequest = createEvent(); 20 | // WARNING: cookies should be sent only to an OUR backend 21 | // Any other can steal the access token 22 | export const $cookiesForRequest = restore(setCookiesForRequest, ''); 23 | 24 | export const requestInternalFx = createEffect>(); 25 | 26 | requestInternalFx.use(httpClient); 27 | 28 | export const requestFx = attach({ 29 | effect: requestInternalFx, 30 | source: $cookiesForRequest, 31 | mapParams: (params: AxiosRequestConfig, cookies) => ({ 32 | method: 'GET', 33 | ...params, 34 | headers: { 35 | ...params.headers, 36 | ...(!isBrowser() && { 37 | cookie: cookies, 38 | }), 39 | }, 40 | }), 41 | }); 42 | 43 | export const setToken = createEvent(); 44 | export const $token = createStore(null); 45 | export const $tokenPayload = createStore(null); 46 | 47 | $token.on(setToken, (_, newToken) => newToken); 48 | $tokenPayload.on(setToken, (_, newToken) => (newToken ? decode(newToken) : null)); 49 | 50 | if (process.env.NEXT_PUBLIC_APP_STAGE === 'development') { 51 | requestInternalFx.watch((request) => { 52 | console.log(`[request]: ${request.method} • ${request.url}`); 53 | }); 54 | 55 | requestInternalFx.done.watch((response) => { 56 | console.log(`[request.done]: ${response.params.method} • ${response.params.url} • ${response.result.status}`); 57 | }); 58 | 59 | requestInternalFx.fail.watch((response) => { 60 | console.log(`[request.fail]: ${response.params.method} • ${response.params.url} • ${response.error.status}`); 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /src/shared/components/error-handling/default-error-layout.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { ErrorDebug } from './error-debug'; 4 | 5 | export interface DefaultErrorLayoutProps { 6 | error: Error; 7 | context?: Record; 8 | } 9 | 10 | /** 11 | * Default error layout, used by DefaultLayout to display errors instead of the page's content, when an error is caught 12 | * 13 | * Displays a report dialog modal allowing end-users to provide a manual feedback about what happened. 14 | * You may want to customise this component to display different error messages to the end users, based on statusCode or other information. 15 | * 16 | * @param props 17 | */ 18 | export const DefaultErrorLayout = (props: DefaultErrorLayoutProps) => { 19 | const { error, context } = props; 20 | 21 | return ( 22 |
29 |
35 |

Service currently unavailable

36 |
Error 500.
37 |
38 | 39 |
40 |

Try to refresh the page. Please contact our support below if the issue persists.

41 | 42 |
43 | 44 | {process.env.NEXT_PUBLIC_APP_STAGE !== 'production' && } 45 |
46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /src/shared/components/error-handling/error-debug.tsx: -------------------------------------------------------------------------------- 1 | export interface ErrorDebugProps { 2 | error?: Error; 3 | context?: Record; 4 | } 5 | 6 | /** 7 | * Displays a given error on screen 8 | * 9 | * Used by DefaultErrorLayout to display error debug info. 10 | * 11 | * @param props 12 | */ 13 | export const ErrorDebug = (props: ErrorDebugProps) => { 14 | const { error, context }: ErrorDebugProps = props; 15 | const { message, stack } = error || {}; 16 | 17 | let stringifiedContext: string | null; 18 | try { 19 | stringifiedContext = JSON.stringify(context, null, 2); 20 | } catch (e) { 21 | stringifiedContext = null; 22 | } 23 | 24 | return ( 25 |
31 |
32 | 33 | 34 | The below "debug info" are only displayed on non-production stages. 35 |
36 | Note that debug information about the error are also available on the server/browser console. 37 |
38 | 39 |
40 | 41 |
55 |         Error message:
56 |         
57 | {message} 58 |
59 | {context && ( 60 | <> 61 | Error additional context:
62 | {stringifiedContext} 63 |
64 | 65 | )} 66 | Stack trace:
67 | {stack} 68 |
69 |
70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /src/shared/components/error-handling/index.ts: -------------------------------------------------------------------------------- 1 | export * from './default-error-layout'; 2 | export * from './error-debug'; 3 | -------------------------------------------------------------------------------- /src/shared/components/github-button/github-button.styled.ts: -------------------------------------------------------------------------------- 1 | import { ButtonBase, createTransition } from '@effable/react'; 2 | import styled from '@emotion/styled'; 3 | 4 | import { duration } from '@/shared/design/tokens/transitions'; 5 | import { variants } from '@/shared/design/tokens/typography'; 6 | 7 | export const GithubButtonRoot = styled(ButtonBase)((props) => ({ 8 | ...variants.button1, 9 | background: props.theme.colors.neutral.neutral3, 10 | color: props.theme.colors.text.primary, 11 | padding: 6, 12 | borderRadius: 4, 13 | minHeight: 40, 14 | width: '100%', 15 | border: '2px solid', 16 | borderColor: props.theme.colors.neutral.neutral7, 17 | transition: createTransition('background', { duration: duration.shorter }), 18 | 19 | '&:hover': { 20 | background: props.theme.colors.neutral.neutral4, 21 | }, 22 | '&:active': { 23 | background: props.theme.colors.neutral.neutral5, 24 | }, 25 | })); 26 | -------------------------------------------------------------------------------- /src/shared/components/github-button/github-button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Box } from '@effable/react'; 3 | import { useTranslation } from 'next-i18next'; 4 | import { RiGithubFill } from 'react-icons/ri'; 5 | 6 | import * as S from './github-button.styled'; 7 | 8 | export interface GithubButtonProps { 9 | redirectTo?: string; 10 | } 11 | 12 | export const GithubButton = (props: GithubButtonProps) => { 13 | const { redirectTo = `${process.env.NEXT_PUBLIC_APP_URL}/dashboard` } = props; 14 | 15 | const { t } = useTranslation('auth'); 16 | 17 | const redirectUrl = redirectTo?.startsWith('http') ? redirectTo : `${process.env.NEXT_PUBLIC_APP_URL}/${redirectTo}`; 18 | 19 | const handleClick = () => { 20 | window.location.assign(`${process.env.NEXT_PUBLIC_APP_URL}/api/v1/auth/github?from=${redirectUrl}`); 21 | }; 22 | 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 | {t('OAUTH_GITHUB')} 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/shared/components/github-button/index.ts: -------------------------------------------------------------------------------- 1 | export * from './github-button'; 2 | -------------------------------------------------------------------------------- /src/shared/components/github-button/stories/github-button.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | 4 | import { GithubButton, GithubButtonProps } from '../github-button'; 5 | 6 | export default { 7 | title: 'GithubButton', 8 | component: GithubButton, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ; 12 | 13 | export const Basic = Template.bind({}); 14 | -------------------------------------------------------------------------------- /src/shared/components/google-button/google-button.styled.ts: -------------------------------------------------------------------------------- 1 | import { ButtonBase, createTransition } from '@effable/react'; 2 | import styled from '@emotion/styled'; 3 | 4 | import { duration } from '@/shared/design/tokens/transitions'; 5 | import { variants } from '@/shared/design/tokens/typography'; 6 | 7 | export const GoogleButtonRoot = styled(ButtonBase)((props) => ({ 8 | ...variants.button1, 9 | background: props.theme.colors.neutral.neutral3, 10 | color: props.theme.colors.text.primary, 11 | padding: 6, 12 | borderRadius: 4, 13 | minHeight: 40, 14 | width: '100%', 15 | border: '2px solid', 16 | borderColor: props.theme.colors.neutral.neutral7, 17 | transition: createTransition('background', { duration: duration.shorter }), 18 | 19 | '&:hover': { 20 | background: props.theme.colors.neutral.neutral4, 21 | }, 22 | '&:active': { 23 | background: props.theme.colors.neutral.neutral5, 24 | }, 25 | })); 26 | -------------------------------------------------------------------------------- /src/shared/components/google-button/google-button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Box } from '@effable/react'; 3 | import { useTranslation } from 'next-i18next'; 4 | import { RiGoogleFill } from 'react-icons/ri'; 5 | 6 | import * as S from './google-button.styled'; 7 | 8 | export interface GoogleButtonProps { 9 | redirectTo?: string; 10 | } 11 | 12 | export const GoogleButton = (props: GoogleButtonProps) => { 13 | const { redirectTo = `${process.env.NEXT_PUBLIC_APP_URL}/dashboard` } = props; 14 | 15 | const { t } = useTranslation('auth'); 16 | 17 | const redirectUrl = redirectTo?.startsWith('http') ? redirectTo : `${process.env.NEXT_PUBLIC_APP_URL}/${redirectTo}`; 18 | 19 | const handleClick = () => { 20 | window.location.assign(`${process.env.NEXT_PUBLIC_APP_URL}/api/v1/auth/google?from=${redirectUrl}`); 21 | }; 22 | 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 | {t('OAUTH_GOOGLE')} 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/shared/components/google-button/index.ts: -------------------------------------------------------------------------------- 1 | export * from './google-button'; 2 | -------------------------------------------------------------------------------- /src/shared/components/google-button/stories/google-button.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | 4 | import { GoogleButton, GoogleButtonProps } from '../google-button'; 5 | 6 | export default { 7 | title: 'GoogleButton', 8 | component: GoogleButton, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ; 12 | 13 | export const Basic = Template.bind({}); 14 | -------------------------------------------------------------------------------- /src/shared/components/system/active-link/active-link.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Link, { LinkProps } from 'next/link'; 3 | import { useRouter } from 'next/router'; 4 | 5 | export interface ActiveLinkProps extends LinkProps { 6 | /** 7 | * The content. 8 | */ 9 | children: React.ReactElement; 10 | } 11 | 12 | /** 13 | * The `ActiveLink` component is used send the `active` property to its child 14 | * if the current pathname matches the `href` prop. 15 | */ 16 | export const ActiveLink = (props: ActiveLinkProps) => { 17 | const { children, href, ...other } = props; 18 | 19 | const { asPath } = useRouter(); 20 | 21 | const child = React.Children.only(children); 22 | const isActive = asPath === href; 23 | 24 | return ( 25 | 26 | {React.cloneElement(child, { 27 | active: isActive, 28 | })} 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/shared/components/system/active-link/index.ts: -------------------------------------------------------------------------------- 1 | export * from './active-link'; 2 | -------------------------------------------------------------------------------- /src/shared/components/system/active-link/stories/active-link.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | 4 | import { ActiveLink, ActiveLinkProps } from '../active-link'; 5 | 6 | export default { 7 | title: 'Design System/Components/ActiveLink', 8 | component: ActiveLink, 9 | } as Meta; 10 | 11 | const StyledLink = ({ active }: { active?: boolean }) => ( 12 | // eslint-disable-next-line jsx-a11y/anchor-is-valid 13 | {active ? 'active link' : 'link'} 14 | ); 15 | 16 | const Template: Story = (args) => ; 17 | 18 | export const Basic = Template.bind({}); 19 | 20 | Basic.args = { 21 | children: , 22 | href: '/', 23 | }; 24 | -------------------------------------------------------------------------------- /src/shared/components/system/alert-dialog/alert-dialog.styled.ts: -------------------------------------------------------------------------------- 1 | import { keyframes } from '@emotion/react'; 2 | import styled from '@emotion/styled'; 3 | import { blackA } from '@radix-ui/colors'; 4 | import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; 5 | 6 | const overlayShow = keyframes({ 7 | '0%': { opacity: 0 }, 8 | '100%': { opacity: 1 }, 9 | }); 10 | 11 | const overlayHide = keyframes({ 12 | '0%': { opacity: 1 }, 13 | '100%': { opacity: 0 }, 14 | }); 15 | 16 | const contentShow = keyframes({ 17 | '0%': { opacity: 0, transform: 'translate(-50%, -50%) scale(.75)' }, 18 | '100%': { opacity: 1, transform: 'translate(-50%, -50%) scale(1)' }, 19 | }); 20 | 21 | const contentHide = keyframes({ 22 | '0%': { opacity: 1, transform: 'translate(-50%, -50%) scale(1)' }, 23 | '100%': { opacity: 0, transform: 'translate(-50%, -50%) scale(.75)' }, 24 | }); 25 | 26 | export const StyledOverlay = styled(AlertDialogPrimitive.Overlay)({ 27 | backgroundColor: blackA.blackA9, 28 | position: 'fixed', 29 | inset: 0, 30 | '@media (prefers-reduced-motion: no-preference)': { 31 | '&[data-state=open]': { 32 | animation: `${overlayShow} 250ms cubic-bezier(0.16, 1, 0.3, 1) forwards`, 33 | }, 34 | '&[data-state=closed]': { 35 | animation: `${overlayHide} 250ms cubic-bezier(0.16, 1, 0.3, 1) forwards`, 36 | }, 37 | }, 38 | }); 39 | 40 | export const StyledContent = styled(AlertDialogPrimitive.Content)({ 41 | position: 'fixed', 42 | top: '50%', 43 | left: '50%', 44 | transform: 'translate(-50%, -50%)', 45 | width: '90vw', 46 | maxWidth: '450px', 47 | maxHeight: '85vh', 48 | 49 | '&:focus': { 50 | outline: 'none', 51 | }, 52 | 53 | '@media (prefers-reduced-motion: no-preference)': { 54 | '&[data-state=open]': { 55 | animation: `${contentShow} 250ms cubic-bezier(0.16, 1, 0.3, 1) forwards`, 56 | }, 57 | '&[data-state=closed]': { 58 | animation: `${contentHide} 250ms cubic-bezier(0.16, 1, 0.3, 1) forwards`, 59 | }, 60 | }, 61 | }); 62 | -------------------------------------------------------------------------------- /src/shared/components/system/alert-dialog/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as S from './alert-dialog.styled'; 2 | 3 | export * from '@radix-ui/react-alert-dialog'; 4 | 5 | export const { StyledOverlay, StyledContent } = S; 6 | -------------------------------------------------------------------------------- /src/shared/components/system/alert-dialog/index.ts: -------------------------------------------------------------------------------- 1 | export * from './alert-dialog'; 2 | -------------------------------------------------------------------------------- /src/shared/components/system/alert-dialog/stories/alert-dialog.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Box, Button, Stack } from '@effable/react'; 3 | import { Meta } from '@storybook/react'; 4 | 5 | import * as AlertDialog from '../alert-dialog'; 6 | 7 | export default { 8 | title: 'Design System/Components/AlertDialog', 9 | component: AlertDialog.Root, 10 | } as Meta; 11 | 12 | export const Basic = () => ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | Are you absolutely sure? 22 | 23 | This action cannot be undone. This will permanently delete your account and remove your data from our servers. 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | ); 40 | -------------------------------------------------------------------------------- /src/shared/components/system/breadcrumbs/breadcrumbs.styled.ts: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | 3 | export const BreadcrumbsRoot = styled.nav` 4 | display: flex; 5 | align-items: center; 6 | 7 | font-size: 14px; 8 | line-height: 20px; 9 | 10 | letter-spacing: 0.25px; 11 | `; 12 | 13 | export const BreadcrumbsList = styled.ol` 14 | display: flex; 15 | flex-wrap: wrap; 16 | align-items: center; 17 | 18 | padding: 0; 19 | margin: 0; 20 | 21 | list-style: none; 22 | `; 23 | 24 | export const BreadcrumbsSeparator = styled.li` 25 | display: flex; 26 | user-select: none; 27 | margin-left: 4px; 28 | margin-right: 4px; 29 | `; 30 | 31 | export const BreadcrumbsItem = styled.li({ 32 | display: 'inline-flex', 33 | alignItems: 'center', 34 | }); 35 | 36 | export const BreadcrumbsLink = styled.a({ 37 | textDecoration: 'none', 38 | }); 39 | -------------------------------------------------------------------------------- /src/shared/components/system/breadcrumbs/breadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import * as S from './breadcrumbs.styled'; 4 | 5 | export type BreadcrumbItemProps = React.HTMLAttributes; 6 | 7 | /** 8 | * The `BreadcrumbItem` component is used to group a breadcrumb link. 9 | * It renders a `li` element to denote it belongs to an order list of links. 10 | */ 11 | export const BreadcrumbsItem = React.forwardRef((props, ref) => { 12 | const { children, ...other } = props; 13 | 14 | return ( 15 | 16 | {children} 17 | 18 | ); 19 | }); 20 | 21 | export type BreadcrumbLinkProps = React.AnchorHTMLAttributes; 22 | 23 | export const BreadcrumbsLink = React.forwardRef((props, ref) => { 24 | const { children, ...other } = props; 25 | 26 | return ( 27 | 28 | {children} 29 | 30 | ); 31 | }); 32 | 33 | export type BreadcrumbSeparatorProps = React.HTMLAttributes; 34 | 35 | /** 36 | * The `BreadcrumbSeparator` component used to separate each breadcrumb item. 37 | */ 38 | export const BreadcrumbsSeparator = React.forwardRef((props, ref) => { 39 | const { children, ...other } = props; 40 | 41 | return ( 42 | 43 | {children} 44 | 45 | ); 46 | }); 47 | 48 | export type BreadcrumbsProps = React.HTMLAttributes; 49 | 50 | /** 51 | * Breadcrumbs, or a breadcrumb navigation, can help enhance how users navigate to previous page levels of a website, 52 | * especially if that website has many pages or products. 53 | */ 54 | export const Breadcrumbs = React.forwardRef((props, ref) => { 55 | const { children, ...other } = props; 56 | 57 | return ( 58 | 59 | {children} 60 | 61 | ); 62 | }); 63 | 64 | export { Breadcrumbs as Root, BreadcrumbsItem as Item, BreadcrumbsLink as Link, BreadcrumbsSeparator as Separator }; 65 | -------------------------------------------------------------------------------- /src/shared/components/system/breadcrumbs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './breadcrumbs'; 2 | -------------------------------------------------------------------------------- /src/shared/components/system/breadcrumbs/stories/breadcrumbs.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Meta } from '@storybook/react'; 3 | import { RiArrowRightSLine } from 'react-icons/ri'; 4 | 5 | import * as Breadcrumbs from '../breadcrumbs'; 6 | 7 | export default { 8 | title: 'Design System/Components/Breadcrumbs', 9 | component: Breadcrumbs.Root, 10 | } as Meta; 11 | 12 | export const Basic = () => ( 13 | 14 | 15 | Home 16 | 17 | 18 | / 19 | 20 | 21 | Shop 22 | 23 | 24 | / 25 | 26 | Product 27 | 28 | ); 29 | 30 | export const CustomSeparators = () => ( 31 | 32 | Home 33 | 34 | 35 | 36 | 37 | 38 | Shop 39 | 40 | 41 | 42 | 43 | 44 | Product 45 | 46 | ); 47 | -------------------------------------------------------------------------------- /src/shared/components/system/breadcrumbs/tests/breadcrumbs.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import React, { ReactChild } from 'react'; 6 | import { render, screen } from '@testing-library/react'; 7 | import userEvent from '@testing-library/user-event'; 8 | 9 | import { Breadcrumbs } from '../breadcrumbs'; 10 | 11 | describe('', () => { 12 | const children: ReactChild = 'Breadcrumbs'; 13 | 14 | it('should render a children', () => { 15 | render({children}); 16 | 17 | expect(screen.getByText(children)).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/shared/components/system/delayed-loading/delayed-loading.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { useDelayedLoading, UseDelayedLoadingOptions } from './use-delayed-loading'; 4 | 5 | export interface DelayedLoadingProps extends UseDelayedLoadingOptions { 6 | /** 7 | * The content. 8 | */ 9 | children?: (props: { loading: boolean }) => React.ReactNode; 10 | } 11 | 12 | /** 13 | * The `DelayedLoading` component is used to prevent the display of your loading component 14 | * until a certain amount of time has passed. 15 | */ 16 | export const DelayedLoading = (props: DelayedLoadingProps) => { 17 | const { delay = 200, loading = false, children, minDuration = 500, initialLoading = false } = props; 18 | 19 | const show = useDelayedLoading({ 20 | delay, 21 | loading, 22 | minDuration, 23 | initialLoading, 24 | }); 25 | 26 | return ( 27 | // eslint-disable-next-line react/jsx-no-useless-fragment 28 | <>{children?.({ loading: show })} 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /src/shared/components/system/delayed-loading/index.ts: -------------------------------------------------------------------------------- 1 | export * from './delayed-loading'; 2 | -------------------------------------------------------------------------------- /src/shared/components/system/delayed-loading/use-delayed-loading.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export interface UseDelayedLoadingOptions { 4 | /** 5 | * The amount of time in `ms` before the loading fallback is displayed. 6 | */ 7 | delay?: number; 8 | 9 | /** 10 | * The minimum amount of time in `ms` the loading fallback will be displayed for. 11 | */ 12 | minDuration?: number | null; 13 | 14 | /** 15 | * When this prop is `true` the delay will be started. 16 | */ 17 | loading?: boolean; 18 | 19 | /** 20 | * The initial loading state. 21 | */ 22 | initialLoading?: boolean; 23 | } 24 | 25 | /** 26 | * The `useDelayedLoading` hook is used to prevent the display of your loading component 27 | * until a certain amount of time has passed. 28 | */ 29 | export const useDelayedLoading = (props: UseDelayedLoadingOptions): boolean => { 30 | const { delay = 200, loading = false, minDuration = 500, initialLoading = false } = props; 31 | 32 | const [show, setShow] = React.useState(initialLoading); 33 | 34 | React.useEffect(() => { 35 | if (!show && loading) { 36 | const timer = setTimeout(() => setShow(true), delay); 37 | 38 | return () => clearTimeout(timer); 39 | } 40 | 41 | return undefined; 42 | }, [delay, loading, show]); 43 | 44 | React.useEffect(() => { 45 | if (show && loading === false) { 46 | const timer = setTimeout(() => setShow(false), minDuration ?? 0); 47 | 48 | return () => clearTimeout(timer); 49 | } 50 | 51 | return undefined; 52 | }, [minDuration, loading, show]); 53 | 54 | return show; 55 | }; 56 | -------------------------------------------------------------------------------- /src/shared/components/system/dropdown-menu/dropdown-menu-label.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; 3 | 4 | export const DropdownMenuLabel = styled(DropdownMenuPrimitive.Label)((props) => ({ 5 | fontSize: 12, 6 | color: props.theme.colors.neutral.neutral11, 7 | lineHeight: '24px', 8 | paddingLeft: 4, 9 | paddingRight: 4, 10 | })); 11 | 12 | export { DropdownMenuLabel as Label }; 13 | -------------------------------------------------------------------------------- /src/shared/components/system/dropdown-menu/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; 3 | 4 | export const DropdownMenu = DropdownMenuPrimitive.Root; 5 | export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; 6 | export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; 7 | export const DropdownMenuGroup = DropdownMenuPrimitive.Group; 8 | export const DropdownMenuSub = DropdownMenuPrimitive.Sub; 9 | export const DropdownMenuPortal = DropdownMenuPrimitive.Portal; 10 | 11 | export const DropdownMenuSeparator = styled(DropdownMenuPrimitive.Separator)((props) => ({ 12 | height: 1, 13 | marginTop: 4, 14 | marginBottom: 4, 15 | backgroundColor: props.theme.colors.neutral.neutral6, 16 | })); 17 | 18 | export const DropdownMenuArrow = styled(DropdownMenuPrimitive.Arrow)({ 19 | fill: 'white', 20 | }); 21 | 22 | export { 23 | DropdownMenu as Root, 24 | DropdownMenuTrigger as Trigger, 25 | DropdownMenuRadioGroup as RadioGroup, 26 | DropdownMenuGroup as Group, 27 | DropdownMenuSeparator as Separator, 28 | DropdownMenuArrow as Arrow, 29 | DropdownMenuSub as Sub, 30 | DropdownMenuPortal as Portal, 31 | }; 32 | -------------------------------------------------------------------------------- /src/shared/components/system/dropdown-menu/index.ts: -------------------------------------------------------------------------------- 1 | export * from './dropdown-menu'; 2 | export * from './dropdown-menu-content'; 3 | export * from './dropdown-menu-label'; 4 | export * from './dropdown-menu-item'; 5 | -------------------------------------------------------------------------------- /src/shared/components/system/fade/fade.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Variants as _Variants, AnimatePresence, HTMLMotionProps, motion } from 'framer-motion'; 3 | 4 | import { TransitionDefaults, Variants, withDelay, WithTransitionConfig } from '@/shared/lib/transition'; 5 | 6 | export type FadeProps = WithTransitionConfig>; 7 | 8 | const variants: Variants = { 9 | enter: ({ transition, transitionEnd, delay } = {}) => ({ 10 | opacity: 1, 11 | transition: transition?.enter ?? withDelay.enter(TransitionDefaults.enter, delay), 12 | transitionEnd: transitionEnd?.enter, 13 | }), 14 | exit: ({ transition, transitionEnd, delay } = {}) => ({ 15 | opacity: 0, 16 | transition: transition?.exit ?? withDelay.exit(TransitionDefaults.exit, delay), 17 | transitionEnd: transitionEnd?.exit, 18 | }), 19 | }; 20 | 21 | export const fadeConfig: HTMLMotionProps<'div'> = { 22 | initial: 'exit', 23 | animate: 'enter', 24 | exit: 'exit', 25 | variants: variants as _Variants, 26 | }; 27 | 28 | /** 29 | * The `Fade` component is used to fade in from transparent to opaque. 30 | */ 31 | export const Fade = React.forwardRef((props, ref) => { 32 | const { unmountOnExit, in: isOpen, className, transition, transitionEnd, delay, ...rest } = props; 33 | 34 | const animate = isOpen || unmountOnExit ? 'enter' : 'exit'; 35 | const show = unmountOnExit ? isOpen && unmountOnExit : true; 36 | 37 | const custom = { transition, transitionEnd, delay }; 38 | 39 | return ( 40 | 41 | {show && } 42 | 43 | ); 44 | }); 45 | -------------------------------------------------------------------------------- /src/shared/components/system/fade/index.ts: -------------------------------------------------------------------------------- 1 | export * from './fade'; 2 | -------------------------------------------------------------------------------- /src/shared/components/system/fade/stories/fade.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Box } from '@effable/react'; 3 | import { Meta, Story } from '@storybook/react'; 4 | 5 | import { Fade, FadeProps } from '../fade'; 6 | 7 | export default { 8 | title: 'Design System/Components/Fade', 9 | component: Fade, 10 | } as Meta; 11 | 12 | const Template: Story = (args) => { 13 | return ( 14 | 15 | 16 | some content 17 | 18 | 19 | ); 20 | }; 21 | 22 | export const Basic = Template.bind({}); 23 | -------------------------------------------------------------------------------- /src/shared/components/system/indicator/index.ts: -------------------------------------------------------------------------------- 1 | export * from './indicator'; 2 | -------------------------------------------------------------------------------- /src/shared/components/system/indicator/indicator.types.ts: -------------------------------------------------------------------------------- 1 | type Position = 'top' | 'middle' | 'bottom'; 2 | type Placement = 'start' | 'center' | 'end'; 3 | 4 | export type IndicatorPosition = `${Position}-${Placement}`; 5 | -------------------------------------------------------------------------------- /src/shared/components/system/indicator/stories/indicator.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { AspectRatio, Badge, Box } from '@effable/react'; 3 | import { Meta, Story } from '@storybook/react'; 4 | 5 | import { Indicator, IndicatorProps } from '../indicator'; 6 | 7 | export default { 8 | title: 'Design System/Components/Indicator', 9 | component: Indicator, 10 | } as Meta; 11 | 12 | const Template: Story = (args) => ; 13 | 14 | export const Basic = Template.bind({}); 15 | 16 | Basic.args = { 17 | children: ( 18 | 19 | 20 | Lanspace 29 | 30 | 31 | ), 32 | }; 33 | 34 | export const WithCustomElement = Template.bind({}); 35 | 36 | WithCustomElement.args = { 37 | label: New, 38 | color: null, 39 | border: true, 40 | shape: 'circle', 41 | children: ( 42 | 43 | 44 | Lanspace 53 | 54 | 55 | ), 56 | }; 57 | -------------------------------------------------------------------------------- /src/shared/components/system/input/input-password.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ActionButton, Input, InputProps, useBoolean } from '@effable/react'; 3 | import { RiEyeLine, RiEyeOffLine } from 'react-icons/ri'; 4 | 5 | export type InputPasswordProps = Omit & { 6 | passwordIconLabel: string; 7 | }; 8 | 9 | export const InputPassword = React.forwardRef((props, ref) => { 10 | const { passwordIconLabel, ...other } = props; 11 | 12 | const [show, toggleShow] = useBoolean(false); 13 | 14 | return ( 15 | toggleShow()} size="small" label={passwordIconLabel ?? ''}> 20 | {show ? : } 21 | 22 | } 23 | {...other} 24 | /> 25 | ); 26 | }); 27 | -------------------------------------------------------------------------------- /src/shared/components/system/loading-overlay/index.ts: -------------------------------------------------------------------------------- 1 | export * from './loading-overlay'; 2 | -------------------------------------------------------------------------------- /src/shared/components/system/loading-overlay/loading-overlay.styled.ts: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | 3 | export const LoadingOverlayRoot = styled.div({ 4 | position: 'absolute', 5 | backgroundColor: 'rgba(255, 255, 255, 0.75)', 6 | top: 0, 7 | bottom: 0, 8 | left: 0, 9 | right: 0, 10 | display: 'flex', 11 | alignItems: 'center', 12 | justifyContent: 'center', 13 | overflow: 'hidden', 14 | }); 15 | -------------------------------------------------------------------------------- /src/shared/components/system/loading-overlay/loading-overlay.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Spinner } from '@effable/react'; 3 | 4 | import * as S from './loading-overlay.styled'; 5 | 6 | export interface LoadingOverlayProps { 7 | /** 8 | * Provide custom loader. 9 | */ 10 | loader?: React.ReactNode; 11 | } 12 | 13 | /** 14 | * The `LoadingOverlay` component is used to show overlay over given container with centered Loader. 15 | */ 16 | export const LoadingOverlay = (props: LoadingOverlayProps) => { 17 | const { loader = } = props; 18 | 19 | return {loader}; 20 | }; 21 | -------------------------------------------------------------------------------- /src/shared/components/system/loading-overlay/stories/loading-overlay.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Box } from '@effable/react'; 3 | import { Meta, Story } from '@storybook/react'; 4 | 5 | import { LoadingOverlay, LoadingOverlayProps } from '../loading-overlay'; 6 | 7 | export default { 8 | title: 'Design System/Components/LoadingOverlay', 9 | component: LoadingOverlay, 10 | } as Meta; 11 | 12 | const Template: Story = (args) => { 13 | return ( 14 | 15 | 16 | 17 | 18 | some content 19 | 20 | 21 | ); 22 | }; 23 | 24 | export const Basic = Template.bind({}); 25 | -------------------------------------------------------------------------------- /src/shared/components/system/loading-overlay/tests/loading-overlay.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import * as React from 'react'; 6 | import { render, screen } from '@testing-library/react'; 7 | import userEvent from '@testing-library/user-event'; 8 | 9 | import { LoadingOverlay } from '../loading-overlay'; 10 | 11 | describe('', () => { 12 | const children = 'LoadingOverlay'; 13 | 14 | it('should render a children', () => { 15 | render(); 16 | 17 | expect(screen.getByText(children)).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/shared/components/system/modal/index.ts: -------------------------------------------------------------------------------- 1 | export * from './modal'; 2 | -------------------------------------------------------------------------------- /src/shared/components/system/modal/modal.styled.ts: -------------------------------------------------------------------------------- 1 | import { keyframes } from '@emotion/react'; 2 | import styled from '@emotion/styled'; 3 | import { blackA } from '@radix-ui/colors'; 4 | import * as DialogPrimitive from '@radix-ui/react-dialog'; 5 | 6 | const overlayShow = keyframes({ 7 | '0%': { opacity: 0 }, 8 | '100%': { opacity: 1 }, 9 | }); 10 | 11 | const overlayHide = keyframes({ 12 | '0%': { opacity: 1 }, 13 | '100%': { opacity: 0 }, 14 | }); 15 | 16 | const contentShow = keyframes({ 17 | '0%': { opacity: 0, transform: 'translate(-50%, -50%) scale(.75)' }, 18 | '100%': { opacity: 1, transform: 'translate(-50%, -50%) scale(1)' }, 19 | }); 20 | 21 | const contentHide = keyframes({ 22 | '0%': { opacity: 1, transform: 'translate(-50%, -50%) scale(1)' }, 23 | '100%': { opacity: 0, transform: 'translate(-50%, -50%) scale(.75)' }, 24 | }); 25 | 26 | export const StyledOverlay = styled(DialogPrimitive.Overlay)({ 27 | backgroundColor: blackA.blackA9, 28 | position: 'fixed', 29 | inset: 0, 30 | // TODO: Use value from library tokens 31 | zIndex: 1300 - 1, 32 | '@media (prefers-reduced-motion: no-preference)': { 33 | '&[data-state=open]': { 34 | animation: `${overlayShow} 250ms cubic-bezier(0.16, 1, 0.3, 1) forwards`, 35 | }, 36 | '&[data-state=closed]': { 37 | animation: `${overlayHide} 250ms cubic-bezier(0.16, 1, 0.3, 1) forwards`, 38 | }, 39 | }, 40 | }); 41 | 42 | export const StyledContent = styled(DialogPrimitive.Content)({ 43 | position: 'fixed', 44 | top: '50%', 45 | left: '50%', 46 | transform: 'translate(-50%, -50%)', 47 | width: '90vw', 48 | maxWidth: '450px', 49 | maxHeight: '85vh', 50 | // TODO: Use value from library tokens 51 | zIndex: 1300, 52 | 53 | '&:focus': { 54 | outline: 'none', 55 | }, 56 | 57 | '@media (prefers-reduced-motion: no-preference)': { 58 | '&[data-state=open]': { 59 | animation: `${contentShow} 250ms cubic-bezier(0.16, 1, 0.3, 1) forwards`, 60 | }, 61 | '&[data-state=closed]': { 62 | animation: `${contentHide} 250ms cubic-bezier(0.16, 1, 0.3, 1) forwards`, 63 | }, 64 | }, 65 | }); 66 | -------------------------------------------------------------------------------- /src/shared/components/system/modal/modal.tsx: -------------------------------------------------------------------------------- 1 | import * as S from './modal.styled'; 2 | 3 | export * from '@radix-ui/react-dialog'; 4 | 5 | export const { StyledOverlay, StyledContent } = S; 6 | -------------------------------------------------------------------------------- /src/shared/components/system/scroll-area/index.ts: -------------------------------------------------------------------------------- 1 | export * from './scroll-area'; 2 | -------------------------------------------------------------------------------- /src/shared/components/system/scroll-area/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; 3 | 4 | import { fadeIn, fadeOut } from '@/shared/lib/css-in-js/animations'; 5 | 6 | const SCROLLBAR_SIZE = 12; 7 | 8 | export const StyledViewport = styled(ScrollAreaPrimitive.Viewport)({ 9 | width: '100%', 10 | height: '100%', 11 | borderRadius: 'inherit', 12 | }); 13 | 14 | export const StyledScrollbar = styled(ScrollAreaPrimitive.Scrollbar)((props) => ({ 15 | display: 'flex', 16 | // ensures no selection 17 | userSelect: 'none', 18 | // disable browser handling of all panning and zooming gestures on touch devices 19 | touchAction: 'none', 20 | padding: 4, 21 | transition: 'background 160ms ease-out', 22 | '&:hover': { background: props.theme.colors.neutral.neutral6 }, 23 | '&[data-orientation="vertical"]': { width: SCROLLBAR_SIZE }, 24 | '&[data-orientation="horizontal"]': { 25 | flexDirection: 'column', 26 | height: SCROLLBAR_SIZE, 27 | }, 28 | '&[data-state="visible"]': { 29 | animation: `${fadeIn} 125ms ease forwards`, 30 | }, 31 | '&[data-state="hidden"]': { 32 | animation: `${fadeOut} 125ms ease forwards`, 33 | }, 34 | })); 35 | 36 | export const StyledThumb = styled(ScrollAreaPrimitive.Thumb)((props) => ({ 37 | flex: 1, 38 | background: props.theme.colors.neutral.neutral10, 39 | borderRadius: SCROLLBAR_SIZE, 40 | // increase target size for touch devices https://www.w3.org/WAI/WCAG21/Understanding/target-size.html 41 | position: 'relative', 42 | '&::before': { 43 | content: '""', 44 | position: 'absolute', 45 | top: '50%', 46 | left: '50%', 47 | transform: 'translate(-50%, -50%)', 48 | width: '100%', 49 | height: '100%', 50 | minWidth: 44, 51 | minHeight: 44, 52 | }, 53 | })); 54 | 55 | export * from '@radix-ui/react-scroll-area'; 56 | -------------------------------------------------------------------------------- /src/shared/components/system/scroll-area/stories/scroll-area.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Box, Heading, Stack, Text } from '@effable/react'; 3 | import { Meta, Story } from '@storybook/react'; 4 | 5 | import * as ScrollArea from '../scroll-area'; 6 | 7 | export default { 8 | title: 'Design System/Components/ScrollArea', 9 | component: ScrollArea.Root, 10 | } as Meta; 11 | 12 | const TAGS = Array.from({ length: 50 }).map((_, i, a) => `item ${a.length - i}`); 13 | 14 | export const Basic = () => ( 15 | 16 | 17 | 18 | 19 | Tags 20 | 21 | 22 | {TAGS.map((tag) => ( 23 | 24 | 25 | {tag} 26 | 27 | 28 | ))} 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | -------------------------------------------------------------------------------- /src/shared/components/system/sheet/index.ts: -------------------------------------------------------------------------------- 1 | export * from './sheet'; 2 | export * from './sheet-content'; 3 | export * from './sheet-overlay'; 4 | -------------------------------------------------------------------------------- /src/shared/components/system/sheet/sheet-content.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { keyframes } from '@emotion/react'; 3 | import styled from '@emotion/styled'; 4 | import * as DialogPrimitive from '@radix-ui/react-dialog'; 5 | 6 | const slideIn = keyframes({ 7 | from: { transform: 'var(--sheet-transform-value)' }, 8 | to: { transform: 'translate3d(0,0,0)' }, 9 | }); 10 | 11 | const slideOut = keyframes({ 12 | from: { transform: 'translate3d(0,0,0)' }, 13 | to: { transform: 'var(--sheet-transform-value)' }, 14 | }); 15 | 16 | const slideDirectionTransform = { 17 | top: { 18 | '--sheet-transform-value': 'translate3d(0,-100%,0)', 19 | width: '100%', 20 | bottom: 'auto', 21 | }, 22 | right: { 23 | '--sheet-transform-value': 'translate3d(100%,0,0)', 24 | right: 0, 25 | }, 26 | bottom: { 27 | '--sheet-transform-value': 'translate3d(0,100%,0)', 28 | width: '100%', 29 | bottom: 0, 30 | top: 'auto', 31 | }, 32 | left: { 33 | '--sheet-transform-value': 'translate3d(-100%,0,0)', 34 | left: 0, 35 | }, 36 | }; 37 | 38 | type SheetDirection = 'left' | 'right' | 'top' | 'bottom'; 39 | 40 | const StyledSheetContent = styled(DialogPrimitive.Content)<{ direction: SheetDirection }>((props) => ({ 41 | position: 'fixed', 42 | top: 0, 43 | bottom: 0, 44 | 45 | // Among other things, prevents text alignment inconsistencies when dialog can't be centered in the viewport evenly. 46 | // Affects animated and non-animated dialogs alike. 47 | willChange: 'transform', 48 | 49 | '&[data-state="open"]': { 50 | animation: `${slideIn} 250ms ease forwards`, 51 | }, 52 | 53 | '&[data-state="closed"]': { 54 | animation: `${slideOut} 250ms ease forwards`, 55 | }, 56 | 57 | ...slideDirectionTransform[props.direction], 58 | })); 59 | 60 | interface SheetContentProps extends DialogPrimitive.DialogContentProps { 61 | direction?: SheetDirection; 62 | } 63 | 64 | export const SheetContent = React.forwardRef((props, forwardedRef) => { 65 | const { children, direction = 'left', ...other } = props; 66 | 67 | return ( 68 | 69 | {children} 70 | 71 | ); 72 | }); 73 | 74 | export { SheetContent as Content }; 75 | -------------------------------------------------------------------------------- /src/shared/components/system/sheet/sheet-overlay.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import * as DialogPrimitive from '@radix-ui/react-dialog'; 3 | 4 | import { fadeIn, fadeOut } from '@/shared/lib/css-in-js/animations'; 5 | 6 | export const StyledOverlay = styled(DialogPrimitive.Overlay)({ 7 | position: 'fixed', 8 | top: 0, 9 | right: 0, 10 | bottom: 0, 11 | left: 0, 12 | backgroundColor: 'rgba(0, 0, 0, .35)', 13 | 14 | '&[data-state="open"]': { 15 | animation: `${fadeIn} 250ms ease forwards`, 16 | }, 17 | 18 | '&[data-state="closed"]': { 19 | animation: `${fadeOut} 250ms ease forwards`, 20 | }, 21 | }); 22 | 23 | export { StyledOverlay as Overlay }; 24 | -------------------------------------------------------------------------------- /src/shared/components/system/sheet/sheet.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as DialogPrimitive from '@radix-ui/react-dialog'; 3 | 4 | import { StyledOverlay } from './sheet-overlay'; 5 | 6 | export const Sheet = ({ children, ...props }: DialogPrimitive.DialogProps) => ( 7 | 8 | 9 | 10 | {children} 11 | 12 | ); 13 | 14 | export const SheetTrigger = DialogPrimitive.Trigger; 15 | export const SheetClose = DialogPrimitive.Close; 16 | export const SheetTitle = DialogPrimitive.Title; 17 | export const SheetPortal = DialogPrimitive.Portal; 18 | export const SheetDescription = DialogPrimitive.Description; 19 | -------------------------------------------------------------------------------- /src/shared/components/system/sheet/stories/sheet.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button } from '@effable/react'; 3 | import styled from '@emotion/styled'; 4 | import { Meta } from '@storybook/react'; 5 | 6 | import { Sheet, SheetContent, SheetPortal, SheetTrigger } from '../index'; 7 | 8 | export default { 9 | title: 'Design System/Components/Sheet', 10 | component: Sheet, 11 | } as Meta; 12 | 13 | const StyledContent = styled(SheetContent)((props) => ({ 14 | background: 'white', 15 | boxShadow: props.theme.shadows['6x'], 16 | 17 | '&[data-direction=top], &[data-direction=bottom]': { 18 | height: 300, 19 | }, 20 | 21 | '&[data-direction=left], &[data-direction=right]': { 22 | width: 300, 23 | }, 24 | })); 25 | 26 | export const Top = () => { 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | 34 | 123 35 | 36 | 37 | ); 38 | }; 39 | 40 | export const Left = () => { 41 | return ( 42 | 43 | 44 | 45 | 46 | 47 | 48 | 123 49 | 50 | 51 | ); 52 | }; 53 | 54 | export const Right = () => { 55 | return ( 56 | 57 | 58 | 59 | 60 | 61 | 62 | 123 63 | 64 | 65 | ); 66 | }; 67 | 68 | export const Bottom = () => { 69 | return ( 70 | 71 | 72 | 73 | 74 | 75 | 76 | 123 77 | 78 | 79 | ); 80 | }; 81 | -------------------------------------------------------------------------------- /src/shared/design/external-styles.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Contains all imports of external CSS libs (.css files) that must be injected in all Next.js pages. 3 | * 4 | * This approach is preferred over importing them all one by one within the _app.tsx file, because it's easier to maintain. 5 | * 6 | * Also, this file is being imported by both "src/pages/_app.tsx" and ".storybook/preview.js", 7 | * so that global 3rd party CSS are included when previewing components, too. 8 | */ 9 | 10 | import '@fontsource/inter/300.css'; 11 | import '@fontsource/inter/400.css'; 12 | import '@fontsource/inter/500.css'; 13 | 14 | export {}; 15 | -------------------------------------------------------------------------------- /src/shared/design/lib/responsive-property.ts: -------------------------------------------------------------------------------- 1 | import { breakpointsArray as breakpoints } from '../media'; 2 | 3 | interface ResponsivePropertyOptions { 4 | property: string; 5 | values: (number | string | null)[]; 6 | unit?: string; 7 | } 8 | 9 | export function responsiveProperty(options: ResponsivePropertyOptions) { 10 | const { property, values } = options; 11 | 12 | const output: Record | string> = { 13 | [property]: `${values[0]}`, 14 | }; 15 | 16 | values.slice(1).forEach((value, index) => { 17 | if (value === null) return; 18 | 19 | output[`@media (min-width:${breakpoints[index]}px)`] = { 20 | [property]: `${value}`, 21 | }; 22 | }); 23 | 24 | return output; 25 | } 26 | -------------------------------------------------------------------------------- /src/shared/design/media.ts: -------------------------------------------------------------------------------- 1 | export type Breakpoint = 'desktop' | 'laptop' | 'tablet' | 'mobile'; 2 | 3 | export const breakpoints: Record = { 4 | mobile: 0, 5 | tablet: 600, 6 | laptop: 1024, 7 | desktop: 1440, 8 | }; 9 | 10 | export const breakpointsArray = Object.values(breakpoints).slice(1); 11 | -------------------------------------------------------------------------------- /src/shared/design/tokens/transitions.ts: -------------------------------------------------------------------------------- 1 | // Follow https://material.google.com/motion/duration-easing.html#duration-easing-natural-easing-curves 2 | // to learn the context in which each easing should be used. 3 | export const easing = { 4 | // This is the most common easing curve. 5 | easeInOut: 'cubic-bezier(0.4, 0, 0.2, 1)', 6 | // Objects enter the screen at full velocity from off-screen and 7 | // slowly decelerate to a resting point. 8 | easeOut: 'cubic-bezier(0.0, 0, 0.2, 1)', 9 | // Objects leave the screen at full velocity. They do not decelerate when off-screen. 10 | easeIn: 'cubic-bezier(0.4, 0, 1, 1)', 11 | // The sharp curve is used by objects that may return to the screen at any time. 12 | sharp: 'cubic-bezier(0.4, 0, 0.6, 1)', 13 | } as const; 14 | 15 | // Follow https://material.io/guidelines/motion/duration-easing.html#duration-easing-common-durations 16 | // to learn when use what timing 17 | export const duration = { 18 | shortest: 150, 19 | shorter: 200, 20 | short: 250, 21 | // most basic recommended timing 22 | standard: 300, 23 | // this is to be used in complex animations 24 | complex: 375, 25 | // recommended when something is entering screen 26 | enteringScreen: 225, 27 | // recommended when something is leaving screen 28 | leavingScreen: 195, 29 | } as const; 30 | -------------------------------------------------------------------------------- /src/shared/hooks/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devianllert/nextplate/3b6319895d475b7961e4a80c2052d1d6149f380b/src/shared/hooks/.gitkeep -------------------------------------------------------------------------------- /src/shared/lib/css-in-js/animations.ts: -------------------------------------------------------------------------------- 1 | import { keyframes } from '@emotion/react'; 2 | 3 | export const fadeIn = keyframes({ 4 | from: { opacity: '0' }, 5 | to: { opacity: '1' }, 6 | }); 7 | 8 | export const fadeOut = keyframes({ 9 | from: { opacity: '1' }, 10 | to: { opacity: '0' }, 11 | }); 12 | 13 | export const scaleIn = keyframes({ 14 | from: { transform: 'scale(0)' }, 15 | to: { transform: 'scale(1)' }, 16 | }); 17 | 18 | export const scaleOut = keyframes({ 19 | from: { transform: 'scale(1)' }, 20 | to: { transform: 'scale(0)' }, 21 | }); 22 | -------------------------------------------------------------------------------- /src/shared/lib/effector/forms/create-errors.ts: -------------------------------------------------------------------------------- 1 | import { combine, createStore, Store } from 'effector'; 2 | import { ZodIssue } from 'zod'; 3 | 4 | export interface CreateErrorsOptions { 5 | errors: Store; 6 | } 7 | 8 | export const createErrors = (options: CreateErrorsOptions) => { 9 | const { errors } = options; 10 | 11 | const $isDirty = createStore(false); 12 | 13 | const $isValid = errors.map((errorsStore) => errorsStore.length === 0); 14 | const $hasErrors = errors.map((errorsStore) => errorsStore.length > 0); 15 | 16 | const $dirtyErrors = combine($isDirty, errors, (isDirty, errorsStore) => (isDirty ? errorsStore : [])); 17 | 18 | const $isDirtyAndValid = $dirtyErrors.map((errorsStore) => errorsStore.length === 0); 19 | const $hasDirtyErrors = $dirtyErrors.map((errorsStore) => errorsStore.length > 0); 20 | 21 | return { 22 | $isDirty, 23 | $isValid, 24 | $hasErrors, 25 | $dirtyErrors, 26 | $isDirtyAndValid, 27 | $hasDirtyErrors, 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /src/shared/lib/effector/forms/extract-values.ts: -------------------------------------------------------------------------------- 1 | import { FieldsObject } from './types'; 2 | 3 | export const extractValues = (fields: T) => { 4 | // @ts-expect-error no default values 5 | const valuesObj: Record = {}; 6 | 7 | Object.keys(fields).forEach((key: keyof T) => { 8 | valuesObj[key] = fields[key].$value; 9 | }); 10 | 11 | return valuesObj; 12 | }; 13 | 14 | export const extractErrors = (fields: T) => { 15 | // @ts-expect-error no default values 16 | const valuesObj: Record = {}; 17 | 18 | Object.keys(fields).forEach((key: keyof T) => { 19 | valuesObj[key] = fields[key].$errors; 20 | }); 21 | 22 | return valuesObj; 23 | }; 24 | -------------------------------------------------------------------------------- /src/shared/lib/effector/forms/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-field'; 2 | export * from './create-form'; 3 | -------------------------------------------------------------------------------- /src/shared/lib/effector/forms/types.ts: -------------------------------------------------------------------------------- 1 | import { Effect, Event, Store } from 'effector'; 2 | import { ZodIssue } from 'zod'; 3 | 4 | export interface Field { 5 | /** 6 | * Returns field's value. 7 | */ 8 | $value: Store; 9 | 10 | /** 11 | * Field validation errors. 12 | */ 13 | $errors: Store; 14 | 15 | /** 16 | * Returns `true` if there are no errors and `false` otherwise. 17 | */ 18 | $isValid: Store; 19 | 20 | /** 21 | * Returns true if values are not deeply equal from initial values, false otherwise. 22 | * `$isDirty` is a readonly computed property and should not be mutated directly. 23 | */ 24 | $isDirty: Store; 25 | 26 | /** 27 | * Returns `true` if field has errors and `false` otherwise. 28 | */ 29 | $hasErrors: Store; 30 | $dirtyErrors: Store; 31 | $isDirtyAndValid: Store; 32 | $hasDirtyErrors: Store; 33 | 34 | $isTouched: Store; 35 | 36 | changed: Event; 37 | blurred: Event; 38 | reset: Event; 39 | addError: Event; 40 | validate: Event; 41 | 42 | // [key: string]: Event | Effect | Store; 43 | } 44 | 45 | export type FieldsObject = { 46 | [key: string]: Field; 47 | }; 48 | -------------------------------------------------------------------------------- /src/shared/lib/effector/forms/validate-with-schema.ts: -------------------------------------------------------------------------------- 1 | import { ZodSchema } from 'zod'; 2 | 3 | export const validateWithSchema = (value: T, schema: ZodSchema) => { 4 | const result = schema.safeParse(value); 5 | 6 | if (result.success) { 7 | return []; 8 | } 9 | 10 | return result.error.errors; 11 | }; 12 | -------------------------------------------------------------------------------- /src/shared/lib/effector/index.ts: -------------------------------------------------------------------------------- 1 | export const EFFECTOR_STATE_KEY = '__EFFECTOR_STATE__'; 2 | 3 | export interface EffectorState { 4 | [EFFECTOR_STATE_KEY]?: Values; 5 | } 6 | 7 | interface Values { 8 | [sid: string]: any; 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/lib/effector/router/effector-router.ts: -------------------------------------------------------------------------------- 1 | import singletonRouter, { type NextRouter } from 'next/router'; 2 | import { createDomain } from 'effector'; 3 | 4 | export const routerDomain = createDomain('router'); 5 | 6 | type NextRouterEventWithError = [any, string]; 7 | 8 | export const routeChangeStart = routerDomain.createEvent('routeChangeStarted'); 9 | export const routeChangeComplete = routerDomain.createEvent(); 10 | export const routeChangeError = routerDomain.createEvent(); 11 | export const beforeHistoryChange = routerDomain.createEvent(); 12 | export const hashChangeStart = routerDomain.createEvent(); 13 | export const hashChangeComplete = routerDomain.createEvent(); 14 | 15 | export const pushFx = routerDomain.createEffect(); 16 | 17 | const connectRouterToEffector = (nextRouter) => { 18 | nextRouter.ready(() => { 19 | const { router }: { router: NextRouter } = nextRouter; 20 | 21 | // forward next.js router events to effector events 22 | router.events.on('routeChangeStart', routeChangeStart); 23 | router.events.on('routeChangeComplete', routeChangeComplete); 24 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 25 | router.events.on('routeChangeError', (err, url) => routeChangeError([err, url])); 26 | router.events.on('beforeHistoryChange', beforeHistoryChange); 27 | router.events.on('hashChangeStart', hashChangeStart); 28 | router.events.on('hashChangeComplete', hashChangeComplete); 29 | 30 | // @ts-expect-error fix types 31 | pushFx.use(async ({ url, as, options }) => { 32 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 33 | await router.push(url, as, options); 34 | }); 35 | }); 36 | }; 37 | 38 | connectRouterToEffector(singletonRouter); 39 | -------------------------------------------------------------------------------- /src/shared/lib/effector/router/index.ts: -------------------------------------------------------------------------------- 1 | export * from './effector-router'; 2 | -------------------------------------------------------------------------------- /src/shared/lib/i18n/i18n.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * List of all supported locales by your app. 3 | * 4 | * If a user tries to load your site using non-supported locales, the default locale is used instead. 5 | * 6 | * @type {Record} 7 | */ 8 | const supportedLocales = { 9 | ENGLISH: 'en', 10 | RUSSIAN: 'ru', 11 | ...(process.env.NEXT_PUBLIC_APP_STAGE === 'development' && { 12 | CIMODE: 'cimode', 13 | }), 14 | }; 15 | 16 | /** 17 | * Select the "supportedLocales.name" you want to use by default in your app. 18 | * This value will be used as a fallback value, when the user locale cannot be resolved. 19 | * 20 | * @example en 21 | * @example en-US 22 | * 23 | * @type {string} 24 | */ 25 | const defaultLocale = supportedLocales.ENGLISH; 26 | 27 | /** 28 | * Returns the list of all supported languages. 29 | * Basically extracts the "lang" parameter from the supported locales array. 30 | * 31 | * @type {string[]} 32 | */ 33 | const supportedLanguages = Object.values(supportedLocales); 34 | 35 | export { defaultLocale, supportedLocales, supportedLanguages }; 36 | -------------------------------------------------------------------------------- /src/shared/lib/i18n/i18n.ts: -------------------------------------------------------------------------------- 1 | import { defaultLocale, supportedLanguages, supportedLocales } from './i18n.config.mjs'; 2 | 3 | export const SUPPORTED_LOCALES: Record = supportedLocales; 4 | export const SUPPORTED_LANGUAGES: string[] = supportedLanguages; 5 | 6 | /** 7 | * Language used by default if no user language can be resolved 8 | * We use English because it's the most used languages among those supported 9 | */ 10 | export const DEFAULT_LOCALE = defaultLocale; 11 | -------------------------------------------------------------------------------- /src/shared/lib/i18n/index.ts: -------------------------------------------------------------------------------- 1 | export * from './i18n'; 2 | -------------------------------------------------------------------------------- /src/shared/lib/i18n/translations.ts: -------------------------------------------------------------------------------- 1 | import { GetServerSidePropsContext, GetStaticPropsContext } from 'next'; 2 | import { SSRConfig } from 'next-i18next'; 3 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; 4 | 5 | import nextI18nConfig from '../../../../next-i18next.config.mjs'; 6 | import { DEFAULT_LOCALE } from './i18n'; 7 | 8 | type MultiversalContext = GetServerSidePropsContext | GetStaticPropsContext; 9 | 10 | export const getTranslationsConfig = async ( 11 | context: MultiversalContext, 12 | namespaces: string[] = [], 13 | ): Promise => { 14 | const { locale = DEFAULT_LOCALE } = context; 15 | 16 | const i18n = await serverSideTranslations(locale, namespaces, nextI18nConfig); 17 | 18 | return i18n; 19 | }; 20 | -------------------------------------------------------------------------------- /src/shared/lib/logging/logger.new.ts: -------------------------------------------------------------------------------- 1 | import pino, { DestinationStream } from 'pino'; 2 | import { logflarePinoVercel } from 'pino-logflare'; 3 | 4 | const { stream, send } = logflarePinoVercel({ 5 | apiKey: process.env.NEXT_PUBLIC_LOGFLARE_KEY || 'BhPKtO8HHxMu', 6 | sourceToken: process.env.NEXT_PUBLIC_LOGFLARE_STREAM || '39f1cc63-2e2f-4287-8963-d6abd376f14f', 7 | }); 8 | 9 | const logger = pino( 10 | { 11 | browser: { 12 | transmit: { 13 | send, 14 | }, 15 | }, 16 | level: process.env.NEXT_PUBLIC_APP_STAGE === 'production' ? 'error' : 'debug', 17 | base: { 18 | env: process.env.NODE_ENV || 'ENV not set', 19 | revision: process.env.VERCEL_GITHUB_COMMIT_SHA, 20 | }, 21 | }, 22 | stream as DestinationStream, 23 | ); 24 | 25 | export { logger }; 26 | -------------------------------------------------------------------------------- /src/shared/lib/logging/logger.ts: -------------------------------------------------------------------------------- 1 | import { isBrowser } from '@effable/misc'; 2 | 3 | import { createLogger as createConsoleLogger, Logger } from './create-logger'; 4 | 5 | /** 6 | * Custom logger proxy. 7 | * 8 | * Customize the @unly/simple-logger library by providing app-wide default behavior. 9 | * 10 | * @param fileLabel 11 | */ 12 | export const createLogger = (fileLabel: string): Logger => { 13 | // Mute logger during tests, to avoid cluttering the console 14 | if (process.env.NODE_ENV === 'test') { 15 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call 16 | return global.muteConsole(); 17 | } 18 | 19 | return createConsoleLogger({ 20 | prefix: fileLabel, 21 | shouldShowTime: () => false, 22 | shouldPrint: () => { 23 | return !(process.env.NEXT_PUBLIC_APP_STAGE === 'production' && isBrowser()); 24 | }, 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /src/shared/lib/meta/index.ts: -------------------------------------------------------------------------------- 1 | export * from './meta'; 2 | export * from './page-seo'; 3 | -------------------------------------------------------------------------------- /src/shared/lib/meta/meta.tsx: -------------------------------------------------------------------------------- 1 | import { capitalize } from '@effable/misc'; 2 | import { defaultTheme } from '@effable/react'; 3 | 4 | export const APP_TITLE = 'nextplate'; 5 | 6 | export const getAppTitle = (title?: string): string => 7 | title ? `${title} - ${capitalize(APP_TITLE)}` : capitalize(APP_TITLE); 8 | 9 | export const getMetaImageUrl = (image: string): string => 10 | image.startsWith('https://') ? image : `${process.env.NEXT_PUBLIC_APP_URL}${image}`; 11 | 12 | export const CommonMetaTags = () => ( 13 | <> 14 | 20 | 26 | 27 | 28 | 29 | 30 | 31 | 38 | 39 | 40 | ); 41 | 42 | interface GetAlternateHrefLinksProps { 43 | asPath: string; 44 | locales?: string[]; 45 | } 46 | 47 | export const getAlternateHrefLinks = (props: GetAlternateHrefLinksProps) => { 48 | const { asPath, locales = [] } = props; 49 | 50 | if (!process.env.NEXT_PUBLIC_APP_URL) { 51 | return null; 52 | } 53 | 54 | return ( 55 | <> 56 | {locales 57 | .concat('x-default') 58 | .filter((locale: string) => locale !== 'cimode') 59 | .map((locale: string) => { 60 | const localePath = locale === 'x-default' ? '' : `${locale}`; 61 | const href = `${process.env.NEXT_PUBLIC_APP_URL}/${localePath}${asPath === '/' ? '' : asPath}`; 62 | 63 | return ; 64 | })} 65 | 66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /src/shared/lib/mobile.ts: -------------------------------------------------------------------------------- 1 | import { isBrowser } from '@effable/misc'; 2 | import * as Sentry from '@sentry/nextjs'; 3 | 4 | import { createLogger } from '@/shared/lib/logging/logger'; 5 | 6 | const fileLabel = 'shared/lib/mobile'; 7 | const logger = createLogger(fileLabel); 8 | 9 | /** 10 | * Returns whether running on a mobile device 11 | */ 12 | export const isMobileDevice = (): boolean => { 13 | if (isBrowser()) { 14 | try { 15 | return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); 16 | } catch (e: unknown) { 17 | logger.error((e as Error).message); 18 | Sentry.captureException(e); 19 | 20 | return false; 21 | } 22 | } else { 23 | return false; 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /src/shared/lib/network-information/hooks/tests/use-network-availability.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { fireEvent } from '@testing-library/react'; 6 | import { act, renderHook } from '@testing-library/react-hooks'; 7 | 8 | import { useNetworkAvailability } from '../use-network-availability'; 9 | 10 | describe('useNetworkAvailability', () => { 11 | it('should change network availability', () => { 12 | const { result } = renderHook(() => useNetworkAvailability()); 13 | expect(result.current).toBe(true); 14 | 15 | act(() => { 16 | fireEvent(window, new Event('offline')); 17 | }); 18 | 19 | expect(result.current).toBe(false); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/shared/lib/network-information/hooks/use-network-availability.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { isBrowser } from '@effable/misc'; 3 | 4 | /** 5 | * Tracks information about the network's availability. 6 | */ 7 | export const useNetworkAvailability = (): boolean => { 8 | const [online, setOnline] = React.useState(isBrowser() ? navigator.onLine : true); 9 | 10 | React.useEffect(() => { 11 | const updateOffile = () => setOnline(false); 12 | const updateOnline = () => setOnline(true); 13 | 14 | window.addEventListener('offline', updateOffile); 15 | window.addEventListener('online', updateOnline); 16 | 17 | return (): void => { 18 | window.removeEventListener('offline', updateOffile); 19 | window.removeEventListener('online', updateOnline); 20 | }; 21 | }, []); 22 | 23 | return online; 24 | }; 25 | -------------------------------------------------------------------------------- /src/shared/lib/network-information/hooks/use-network-information.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { getNavigatorConnection, getNetworkInformation } from '../network-information'; 4 | import { NetworkInformation } from '../types/network-information.interface'; 5 | 6 | /** 7 | * Tracks information about the device's network connection. 8 | */ 9 | export const useNetworkInformation = (): NetworkInformation | undefined => { 10 | const [networkInformation, setNetworkInformation] = React.useState(getNetworkInformation()); 11 | 12 | React.useEffect(() => { 13 | const connection = getNavigatorConnection(); 14 | 15 | const updateNetworkInfo = () => setNetworkInformation(getNavigatorConnection()); 16 | 17 | if (connection) { 18 | connection.addEventListener('change', updateNetworkInfo); 19 | 20 | return () => { 21 | connection.removeEventListener('change', updateNetworkInfo); 22 | }; 23 | } 24 | 25 | return undefined; 26 | }, []); 27 | 28 | return networkInformation; 29 | }; 30 | -------------------------------------------------------------------------------- /src/shared/lib/network-information/index.ts: -------------------------------------------------------------------------------- 1 | export * from './network-information'; 2 | export * from './hooks/use-network-information'; 3 | export * from './hooks/use-network-availability'; 4 | export * from './types/navigator.interface'; 5 | export * from './types/network-information.interface'; 6 | -------------------------------------------------------------------------------- /src/shared/lib/network-information/types/navigator.interface.ts: -------------------------------------------------------------------------------- 1 | import { NetworkInformation } from './network-information.interface'; 2 | 3 | export interface ExtendedNavigator extends Omit { 4 | connection: NetworkInformation; 5 | mozConnection?: NetworkInformation; 6 | webkitConnection?: NetworkInformation; 7 | } 8 | -------------------------------------------------------------------------------- /src/shared/lib/next/middlewares/chain-middlewares.ts: -------------------------------------------------------------------------------- 1 | import { NextMiddleware, NextResponse } from 'next/server'; 2 | 3 | import { MiddlewareFactory } from './types'; 4 | 5 | export function chainMiddlewares(functions: MiddlewareFactory[] = [], index = 0): NextMiddleware { 6 | const current = functions[index]; 7 | 8 | if (current) { 9 | const next = chainMiddlewares(functions, index + 1); 10 | 11 | return current(next); 12 | } 13 | 14 | return () => NextResponse.next(); 15 | } 16 | -------------------------------------------------------------------------------- /src/shared/lib/next/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export * from './chain-middlewares'; 2 | export * from './with-logging'; 3 | export * from './with-headers'; 4 | -------------------------------------------------------------------------------- /src/shared/lib/next/middlewares/types.ts: -------------------------------------------------------------------------------- 1 | import { NextMiddleware } from 'next/server'; 2 | 3 | export type MiddlewareFactory = (middleware: NextMiddleware) => NextMiddleware; 4 | -------------------------------------------------------------------------------- /src/shared/lib/next/middlewares/with-headers.ts: -------------------------------------------------------------------------------- 1 | import { NextFetchEvent, NextMiddleware, NextRequest } from 'next/server'; 2 | 3 | import { MiddlewareFactory } from './types'; 4 | 5 | export const withHeaders: MiddlewareFactory = (next: NextMiddleware) => { 6 | return async (request: NextRequest, _next: NextFetchEvent) => { 7 | const res = await next(request, _next); 8 | 9 | // TODO: Move CSP headers to middleware 10 | // if (res) { 11 | // res.headers.set('x-content-type-options', 'nosniff'); 12 | // res.headers.set('x-dns-prefetch-control', 'false'); 13 | // res.headers.set('x-download-options', 'noopen'); 14 | // res.headers.set('x-frame-options', 'SAMEORIGIN'); 15 | // } 16 | 17 | return res; 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /src/shared/lib/next/middlewares/with-logging.ts: -------------------------------------------------------------------------------- 1 | import { NextFetchEvent, NextRequest } from 'next/server'; 2 | import pico from 'picocolors'; 3 | 4 | import { MiddlewareFactory } from './types'; 5 | 6 | export const withLogging: MiddlewareFactory = (next) => { 7 | return async (request: NextRequest, _next: NextFetchEvent) => { 8 | console.debug(`${pico.green('[request]')}: ${request.method} • ${request.nextUrl.pathname}`); 9 | 10 | return next(request, _next); 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /src/shared/lib/next/types.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage, ServerResponse } from 'http'; 2 | import { ParsedUrlQuery } from 'querystring'; 3 | import { GetStaticPropsContext, PreviewData } from 'next'; 4 | import { Event } from 'effector'; 5 | 6 | export interface PageContextBase { 7 | route?: string; 8 | pathname: string; 9 | query: Q; 10 | params: P; 11 | asPath?: string; 12 | locale?: string; 13 | locales?: string[]; 14 | defaultLocale?: string; 15 | } 16 | 17 | export interface PageContextClientEnv { 18 | env: 'client'; 19 | } 20 | 21 | export interface PageContextServerEnv { 22 | env: 'server'; 23 | req: IncomingMessage & { 24 | cookies: { 25 | [key: string]: string; 26 | }; 27 | }; 28 | res: ServerResponse; 29 | } 30 | 31 | export type ClientPageContext< 32 | Q extends ParsedUrlQuery = ParsedUrlQuery, 33 | P extends ParsedUrlQuery = ParsedUrlQuery, 34 | > = PageContextBase & PageContextClientEnv; 35 | 36 | export type ServerPageContext< 37 | Q extends ParsedUrlQuery = ParsedUrlQuery, 38 | P extends ParsedUrlQuery = ParsedUrlQuery, 39 | > = PageContextBase & PageContextServerEnv; 40 | 41 | export type PageContext = 42 | | ClientPageContext 43 | | ServerPageContext; 44 | 45 | export type PageEvent = Event< 46 | PageContext 47 | >; 48 | 49 | export type EmptyOrPageEvent = 50 | | PageEvent 51 | | Event; 52 | 53 | export type StaticPageContext< 54 | P extends ParsedUrlQuery = ParsedUrlQuery, 55 | D extends PreviewData = PreviewData, 56 | > = GetStaticPropsContext; 57 | 58 | export type StaticPageEvent

= Event< 59 | StaticPageContext 60 | >; 61 | 62 | export type EmptyOrStaticPageEvent

= 63 | | StaticPageEvent 64 | | Event; 65 | -------------------------------------------------------------------------------- /src/shared/lib/redirect.ts: -------------------------------------------------------------------------------- 1 | import { NextApiResponse } from 'next'; 2 | 3 | /** 4 | * Server side redirection. 5 | * 6 | * Use a 302 status code by default. (Temporary redirection) 7 | * - Code: 302 8 | * - Typical use case: "The Web page is temporarily unavailable for unforeseen reasons." 9 | * 10 | * Note: If you don't want to perform the redirection, use a non 3XX status code (e.g: 200) 11 | * Useful to disable a redirection conditionally. 12 | * 13 | * @param res 14 | * @param location 15 | * @param statusCode 16 | * 17 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections 18 | */ 19 | const redirect = ( 20 | res: NextApiResponse, 21 | location: string, 22 | statusCode: 200 | 300 | 301 | 302 | 303 | 304 | 307 | 308 = 302, 23 | ): void => { 24 | if (!res) { 25 | throw new Error('Response object required'); 26 | } 27 | 28 | if (!statusCode) { 29 | throw new Error('Status code required'); 30 | } 31 | 32 | if (!location) { 33 | throw new Error('Location required'); 34 | } 35 | 36 | res.statusCode = statusCode; 37 | res.setHeader('Location', location); 38 | res.end(); 39 | }; 40 | 41 | export default redirect; 42 | -------------------------------------------------------------------------------- /src/shared/lib/sentry/index.ts: -------------------------------------------------------------------------------- 1 | export * from './sentry'; 2 | export * from './setup'; 3 | -------------------------------------------------------------------------------- /src/shared/lib/sentry/setup.ts: -------------------------------------------------------------------------------- 1 | import { isBrowser } from '@effable/misc'; 2 | import * as Sentry from '@sentry/nextjs'; 3 | 4 | import { createLogger } from '@/shared/lib/logging/logger'; 5 | 6 | import { configureSentry } from './sentry'; 7 | 8 | const logger = createLogger('modules/sentry/setup.ts'); 9 | 10 | /** 11 | * Configure Sentry default scope. 12 | * 13 | * Used by both sentry config files (client/server). 14 | * Also configures the default scope, subsequent calls to "configureScope" will enrich the scope. 15 | * Must only contain tags/contexts/extras that are universal (not server or browser specific). 16 | * 17 | * The Sentry scope will be enriched by: 18 | * - BrowserPageBootstrap, for browser-specific metadata. 19 | * - ServerPageBootstrap, for server-specific metadata. 20 | * - API endpoints, for per-API additional metadata. 21 | * - React components, for per-component additional metadata. 22 | * 23 | * Doesn't initialize Sentry if NEXT_PUBLIC_SENTRY_DSN isn't defined. 24 | * 25 | * Automatically applied on the browser, thanks to @sentry/nextjs. 26 | * Automatically applied on the server, thanks to @sentry/nextjs, when "withSentry" HOC is used. 27 | * 28 | * @see https://www.npmjs.com/package/@sentry/nextjs 29 | * @see https://docs.sentry.io/platforms/javascript/guides/nextjs/ 30 | * @see https://docs.sentry.io/platforms/javascript/guides/nextjs/usage 31 | */ 32 | export const setupSentry = () => { 33 | if (process.env.NEXT_PUBLIC_SENTRY_DSN) { 34 | Sentry.init({ 35 | dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, 36 | enabled: !isBrowser() && process.env.NODE_ENV !== 'test', 37 | environment: process.env.NEXT_PUBLIC_APP_STAGE, 38 | debug: false, 39 | tracesSampleRate: 1.0, 40 | }); 41 | 42 | configureSentry(); 43 | 44 | logger.log('Sentry initialized'); 45 | } else if (!isBrowser() && process.env.NODE_ENV !== 'test') { 46 | logger.error("Sentry DSN not defined, events (exceptions, messages, etc.) won't be sent to Sentry."); 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /src/shared/lib/ssr/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ssg'; 2 | export * from './ssr'; 3 | export * from './ssr-with-auth'; 4 | -------------------------------------------------------------------------------- /src/shared/lib/ssr/ssg.ts: -------------------------------------------------------------------------------- 1 | import { GetStaticProps, GetStaticPropsResult } from 'next'; 2 | 3 | import { getTranslationsConfig } from '@/shared/lib/i18n/translations'; 4 | import { SSGPageProps } from '@/shared/types/ssg-page-props'; 5 | 6 | /** 7 | * Returns a "getStaticProps" function. 8 | * 9 | * Meant to be avoid duplication of the `serverSideTranslations` for static pages that need translations. 10 | * 11 | * @param namespaces 12 | */ 13 | export const getTranslationsStaticProps = (namespaces: string[] = []): GetStaticProps => { 14 | const getStaticProps: GetStaticProps = async (props) => { 15 | const i18n = await getTranslationsConfig(props, namespaces); 16 | 17 | return { 18 | props: { 19 | isStaticRendering: true, 20 | _nextI18Next: i18n._nextI18Next, 21 | }, 22 | }; 23 | }; 24 | 25 | return getStaticProps; 26 | }; 27 | -------------------------------------------------------------------------------- /src/shared/lib/ssr/ssr.ts: -------------------------------------------------------------------------------- 1 | import { GetServerSideProps, GetServerSidePropsResult } from 'next'; 2 | 3 | import { getTranslationsConfig } from '@/shared/lib/i18n/translations'; 4 | import { CommonServerSideParams } from '@/shared/types/common-server-side-params'; 5 | import { SSRPageProps } from '@/shared/types/ssr-page-props'; 6 | 7 | /** 8 | * getServerSideProps returns only part of the props expected in SSRPageProps 9 | * To avoid TS issue, we omit those that we don't return, and add those necessary to the getServerSideProps function 10 | */ 11 | export type GetCoreServerSidePropsResults = Omit; 12 | 13 | /** 14 | * Returns a "getServerSideProps" function. 15 | * 16 | * @param namespaces 17 | */ 18 | export const getCoreServerSideProps = ( 19 | namespaces: string[] = [], 20 | ): GetServerSideProps => { 21 | const getServerSideProps: GetServerSideProps = async ( 22 | context, 23 | ): Promise> => { 24 | const { req } = context; 25 | 26 | const i18n = await getTranslationsConfig(context, namespaces); 27 | 28 | return { 29 | props: { 30 | isServerRendering: true, 31 | _nextI18Next: i18n._nextI18Next, 32 | }, 33 | }; 34 | }; 35 | 36 | return getServerSideProps; 37 | }; 38 | -------------------------------------------------------------------------------- /src/shared/lib/testing/render-with-providers.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import * as React from 'react'; 3 | import { EffableProvider } from '@effable/react'; 4 | import { render, RenderResult } from '@testing-library/react'; 5 | import { fork, Scope } from 'effector'; 6 | import { Provider as EffectorProvider } from 'effector-react'; 7 | 8 | export const renderWithProviders = (ui: React.ReactNode, scope?: Scope): RenderResult => 9 | render( 10 | 11 | {ui} 12 | , 13 | ); 14 | -------------------------------------------------------------------------------- /src/shared/lib/testing/test-utils.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import * as React from 'react'; 3 | import { render, RenderOptions } from '@testing-library/react'; 4 | import { RunOptions } from 'axe-core'; 5 | import { axe, toHaveNoViolations } from 'jest-axe'; 6 | 7 | expect.extend(toHaveNoViolations); 8 | 9 | type UI = Parameters[0]; 10 | 11 | type TestA11YOptions = RenderOptions & { axeOptions?: RunOptions }; 12 | 13 | export const testA11y = async ( 14 | ui: UI | HTMLElement, 15 | { axeOptions, ...options }: TestA11YOptions = {}, 16 | ): Promise => { 17 | const container = React.isValidElement(ui) ? render(ui, options).container : ui; 18 | 19 | // @ts-ignore 20 | const results = await axe(container as Element, axeOptions); 21 | 22 | expect(results).toHaveNoViolations(); 23 | }; 24 | -------------------------------------------------------------------------------- /src/shared/lib/wdyr/wdyr.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /* eslint-disable import/no-extraneous-dependencies */ 3 | /* eslint-disable global-require */ 4 | 5 | /** 6 | * WDYR (why-did-you-render) helps locate unnecessary re-renders. 7 | * Applied in development environment, on the frontend only. 8 | * 9 | * It will only log unnecessary re-renders, not expected re-renders. 10 | * 11 | * @see https://github.com/welldone-software/why-did-you-render 12 | * @see https://github.com/vercel/next.js/tree/canary/examples/with-why-did-you-render 13 | */ 14 | import * as React from 'react'; 15 | import { isBrowser } from '@effable/misc'; 16 | 17 | if (isBrowser() && process.env.NEXT_PUBLIC_APP_STAGE === 'development') { 18 | // eslint-disable-next-line @typescript-eslint/no-var-requires 19 | const whyDidYouRender = require('@welldone-software/why-did-you-render'); 20 | 21 | // eslint-disable-next-line no-console 22 | console.debug( 23 | 'Applying whyDidYouRender, to help you locate unnecessary re-renders during development. See https://github.com/welldone-software/why-did-you-render', 24 | ); 25 | 26 | // See https://github.com/welldone-software/why-did-you-render#options 27 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 28 | whyDidYouRender(React, { 29 | trackAllPureComponents: true, 30 | trackHooks: true, 31 | logOwnerReasons: true, 32 | collapseGroups: true, 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /src/shared/lib/web-vitals/index.ts: -------------------------------------------------------------------------------- 1 | export { reportWebVitals } from './report-web-vitals'; 2 | export type { NextWebVitalsMetricsReport } from './types/next-web-vitals-metrics-report'; 3 | -------------------------------------------------------------------------------- /src/shared/lib/web-vitals/report-web-vitals.ts: -------------------------------------------------------------------------------- 1 | // Note: Use v1 for uniqueness - See https://www.sohamkamani.com/blog/2016/10/05/uuid1-vs-uuid4/ 2 | 3 | import type { NextWebVitalsMetric } from 'next/app'; 4 | import { v1 as uuid } from 'uuid'; 5 | 6 | import { NextWebVitalsMetricsReport } from './types/next-web-vitals-metrics-report'; 7 | 8 | /** 9 | * Global variable meant to keep all metrics together, until there are enough to send them in batch as a single report 10 | */ 11 | const globalWebVitalsMetric: NextWebVitalsMetricsReport = { 12 | reportId: uuid(), 13 | metrics: {}, 14 | reportedCount: 0, 15 | }; 16 | 17 | /** 18 | * Will be called once for every metric that has to be reported. 19 | * 20 | * There are, at minimum, 3 metrics being received (Next.js-hydration, FCP and TTFB) 21 | * Then, 2 other metrics can be received optionally (FID, LCP) 22 | * 23 | * @param metrics 24 | * @see https://web.dev/vitals/ Essential metrics for a healthy site 25 | * @see https://nextjs.org/blog/next-9-4#integrated-web-vitals-reporting Initial release notes 26 | */ 27 | export function reportWebVitals(metrics: NextWebVitalsMetric): void { 28 | if (process.env.NEXT_PUBLIC_APP_STAGE !== 'production') { 29 | console.debug(metrics); 30 | } 31 | 32 | const { name } = metrics; 33 | const count = globalWebVitalsMetric.reportedCount; 34 | globalWebVitalsMetric.metrics[name] = metrics; 35 | const keysLength = Object.keys(globalWebVitalsMetric.metrics).length; 36 | 37 | // Temporise analytics API calls by waiting for at least 5 metrics to be received before sending the first report 38 | // (because 3 metrics will be received upon initial page load, and then 2 more upon first click) 39 | // Then, send report every 2 metrics (because each client-side redirection will generate 2 metrics) 40 | if ((count === 0 && keysLength === 5) || (count > 0 && keysLength === 2)) { 41 | // send report to analytics service 42 | // sendWebVitals(globalWebVitalsMetric); 43 | 44 | // Reset and prepare next metrics to be reported 45 | globalWebVitalsMetric.metrics = {}; 46 | globalWebVitalsMetric.reportedCount += 1; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/shared/lib/web-vitals/types/next-web-vitals-metrics-report.ts: -------------------------------------------------------------------------------- 1 | import { NextWebVitalsMetric } from 'next/app'; 2 | 3 | /** 4 | * Group all vital metrics together, using their own "name" property as key. 5 | * 6 | * Meant to help regroup multiple reports together to send them all at once, to reduce API calls. 7 | * 8 | * @see https://web.dev/vitals/ Essential metrics for a healthy site 9 | * @see https://nextjs.org/blog/next-9-4#integrated-web-vitals-reporting 10 | */ 11 | export type NextWebVitalsMetricsReport = { 12 | // Number of times a report has been sent, kinda help to trace how long a same client-side session was 13 | reportedCount: number; 14 | // ID of the "report", helps grouping reports with different data but same reportId together when analysing data 15 | reportId: string; 16 | metrics: { 17 | // First contentful paint, triggers on page load 18 | FCP?: NextWebVitalsMetric; 19 | // First input delay, trigger on first end-user interaction (click) 20 | FID?: NextWebVitalsMetric; 21 | // Largest contentful paint, triggers on first end-user interaction (sometimes doesn't trigger) 22 | LCP?: NextWebVitalsMetric; 23 | // Triggers on page load 24 | 'Next.js-hydration'?: NextWebVitalsMetric; 25 | // Triggers on client-side redirection () 26 | 'Next.js-render'?: NextWebVitalsMetric; 27 | // Triggers on client-side redirection () 28 | 'Next.js-route-change-to-render'?: NextWebVitalsMetric; 29 | // Time to first byte, triggers on page load 30 | TTFB?: NextWebVitalsMetric; 31 | }; 32 | }; 33 | -------------------------------------------------------------------------------- /src/shared/types/common-server-side-params.ts: -------------------------------------------------------------------------------- 1 | import { ParsedUrlQuery } from 'querystring'; 2 | 3 | /** 4 | * Server side params provided to any page (SSG or SSR) 5 | * - Static params provided to getStaticProps and getStaticPaths for static pages (when building SSG pages) 6 | * - Dynamic params provided to getServerSideProps (when using SSR) 7 | * 8 | * Those params come from the route (url) being used, they are affected by "redirects" and the route name (e.g: "/folder/[id].tsx" 9 | * 10 | * @see next.config.js "redirects" section for url params 11 | */ 12 | export type CommonServerSideParams = E; 13 | -------------------------------------------------------------------------------- /src/shared/types/enhanced-app-props.ts: -------------------------------------------------------------------------------- 1 | import { AppProps } from 'next/app'; 2 | 3 | import { EnhancedNextPage } from '@/shared/types/enhanced-next-page'; 4 | import { UniversalPageProps } from '@/shared/types/universal-page-props'; 5 | 6 | /** 7 | * Props that are provided to the render function of the application (in _app) 8 | * Those props can be consolidated by either getInitialProps, getServerProps or getStaticProps, depending on the page and its configuration 9 | */ 10 | export type EnhancedAppProps = AppProps & { 11 | Component: EnhancedNextPage; 12 | err?: Error; 13 | }; 14 | -------------------------------------------------------------------------------- /src/shared/types/enhanced-next-page.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { NextPage } from 'next'; 3 | 4 | // eslint-disable-next-line @typescript-eslint/ban-types 5 | export type EnhancedNextPage

= NextPage & { 6 | /** 7 | * The injected layout component 8 | * @see https://adamwathan.me/2019/10/17/persistent-layout-patterns-in-nextjs/ 9 | */ 10 | Layout?: React.FC; 11 | }; 12 | -------------------------------------------------------------------------------- /src/shared/types/ssg-page-props.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/indent */ 2 | 3 | import { UniversalPageProps } from './universal-page-props'; 4 | 5 | /** 6 | * Static properties returned by getStaticProps for static pages (using SSG) 7 | * Mind that those properties are generated from the server, when building the static bundle 8 | * 9 | * Multiversal page props are listed in MultiversalPageProps 10 | * Server-side page props are listed in SSRPageProps 11 | * Client-side page props are listed in SSGPageProps 12 | * 13 | * XXX SSGPageProps doesn't extend from OnlyBrowserPageProps (like SSRPageProps does with OnlyServerPageProps) because SSG properties are actually generated by the server and don't have access to browser variables 14 | */ 15 | // eslint-disable-next-line @typescript-eslint/ban-types 16 | export type SSGPageProps = { 17 | // Props that are specific to SSG 18 | isStaticRendering: boolean; 19 | } & UniversalPageProps & // Generic props that are provided immediately, no matter what 20 | E; 21 | -------------------------------------------------------------------------------- /src/shared/types/ssr-page-props.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/indent */ 2 | 3 | import { UniversalPageProps } from './universal-page-props'; 4 | 5 | /** 6 | * Dynamic (server) properties returned by getInitialProps or getServerProps for server-side rendered pages (using SSR) 7 | * Mind that those properties are generated by the server, for each request 8 | * 9 | * Multiversal page props are listed in MultiversalPageProps 10 | * Server-side page props are listed in SSRPageProps 11 | * Client-side page props are listed in SSGPageProps 12 | */ 13 | export type SSRPageProps = { 14 | // Props that are specific to SSR 15 | isServerRendering: boolean; 16 | } & UniversalPageProps & // Generic props that are provided immediately, no matter what 17 | E; 18 | -------------------------------------------------------------------------------- /src/shared/types/universal-page-props.ts: -------------------------------------------------------------------------------- 1 | import { SSRConfig } from 'next-i18next'; 2 | 3 | import { EffectorState } from '../lib/effector'; 4 | 5 | /** 6 | * Page properties available on all pages, whether they're rendered statically, dynamically, from the server or the client 7 | * 8 | * Multiversal page props are listed in MultiversalPageProps 9 | * Server-side page props are listed in SSRPageProps 10 | * Client-side page props are listed in SSGPageProps 11 | */ 12 | // eslint-disable-next-line @typescript-eslint/ban-types 13 | export type UniversalPageProps = { 14 | error?: Error; // Only defined if there was an error 15 | statusCode?: number; // Provided by Next.js framework, sometimes 16 | _nextI18Next: SSRConfig['_nextI18Next']; 17 | } & EffectorState & 18 | E; 19 | -------------------------------------------------------------------------------- /src/styled.d.ts: -------------------------------------------------------------------------------- 1 | import '@emotion/react'; 2 | 3 | import { EffableTheme } from '@effable/react'; 4 | 5 | declare module '@emotion/react' { 6 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 7 | export interface Theme extends EffableTheme {} 8 | } 9 | -------------------------------------------------------------------------------- /src/widgets/layouts/404/components/not-found-layout/index.ts: -------------------------------------------------------------------------------- 1 | export * from './not-found-layout'; 2 | -------------------------------------------------------------------------------- /src/widgets/layouts/404/components/not-found-layout/not-found-layout.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Box, Container } from '@effable/react'; 3 | 4 | import * as S from './not-fount-layout.styled'; 5 | 6 | export interface NotFound404LayoutProps { 7 | /** 8 | * The content 9 | */ 10 | children?: React.ReactNode; 11 | } 12 | 13 | export const NotFoundLayout = (props: NotFound404LayoutProps) => { 14 | const { children } = props; 15 | 16 | return ( 17 | 18 | 19 | 20 | {children} 21 | 22 | 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/widgets/layouts/404/components/not-found-layout/not-fount-layout.styled.ts: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | 3 | export const LayoutRoot = styled.div({ 4 | display: 'flex', 5 | flexDirection: 'column', 6 | alignItems: 'center', 7 | justifyContent: 'center', 8 | minHeight: '100vh', 9 | width: '100%', 10 | }); 11 | -------------------------------------------------------------------------------- /src/widgets/layouts/404/components/not-found-layout/stories/not-found-layout.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | 4 | import { NotFoundLayout } from '../not-found-layout'; 5 | 6 | export default { 7 | title: 'Layouts/404/NotFound404Layout', 8 | component: NotFoundLayout, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ; 12 | 13 | export const Basic = Template.bind({}); 14 | -------------------------------------------------------------------------------- /src/widgets/layouts/404/components/not-found-layout/tests/not-found-layout.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import React, { ReactChild } from 'react'; 6 | import { screen } from '@testing-library/react'; 7 | 8 | import { renderWithProviders } from '@/shared/lib/testing/render-with-providers'; 9 | 10 | import { NotFoundLayout } from '../not-found-layout'; 11 | 12 | describe('', () => { 13 | const children: ReactChild = 'NotFound404Layout'; 14 | 15 | it('should render a children', () => { 16 | renderWithProviders({children}); 17 | 18 | expect(screen.getByText(children)).toBeDefined(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/widgets/layouts/404/index.ts: -------------------------------------------------------------------------------- 1 | export * from './components/not-found-layout'; 2 | -------------------------------------------------------------------------------- /src/widgets/layouts/auth/components/auth-content/auth-content.styled.ts: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | 3 | export const Content = styled.main({ 4 | position: 'relative', 5 | overflow: 'hidden', 6 | display: 'flex', 7 | flexDirection: 'column', 8 | alignItems: 'center', 9 | justifyContent: 'center', 10 | width: '100%', 11 | flex: 1, 12 | }); 13 | -------------------------------------------------------------------------------- /src/widgets/layouts/auth/components/auth-content/auth-content.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useRouter } from 'next/router'; 3 | import { Box, Container } from '@effable/react'; 4 | import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; 5 | 6 | import { duration } from '@/shared/design/tokens/transitions'; 7 | 8 | import * as S from './auth-content.styled'; 9 | 10 | interface AuthContentProps { 11 | /** 12 | * The content 13 | */ 14 | children: React.ReactNode; 15 | } 16 | 17 | const variants = { 18 | hidden: { opacity: 0, scale: 0.5 }, 19 | enter: { opacity: 1, scale: 1 }, 20 | exit: { opacity: 0, scale: 0.5 }, 21 | transition: { type: 'spring', bounce: 0, duration: duration.short / 1000 }, 22 | }; 23 | 24 | export const AuthContent = (props: AuthContentProps) => { 25 | const { children } = props; 26 | 27 | const router = useRouter(); 28 | 29 | const shouldReduceMotion = useReducedMotion() ?? false; 30 | 31 | return ( 32 | 33 | 34 | 35 | 43 | 44 | {children} 45 | 46 | 47 | 48 | 49 | 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /src/widgets/layouts/auth/components/auth-content/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth-content'; 2 | -------------------------------------------------------------------------------- /src/widgets/layouts/auth/components/auth-content/stories/auth-content.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | 3 | import { AuthContent } from '../auth-content'; 4 | 5 | export default { 6 | title: 'Layouts/Auth/AuthContent', 7 | component: AuthContent, 8 | }; 9 | 10 | export const Basic = (): ReactElement => Basic; 11 | -------------------------------------------------------------------------------- /src/widgets/layouts/auth/components/auth-header/auth-header.styled.ts: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | 3 | export const AuthHeaderRoot = styled.header({ 4 | display: 'flex', 5 | alignItems: 'center', 6 | justifyContent: 'space-between', 7 | width: '100%', 8 | height: 64, 9 | }); 10 | -------------------------------------------------------------------------------- /src/widgets/layouts/auth/components/auth-header/auth-header.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Link from 'next/link'; 3 | import { Box, Container, Heading, Stack } from '@effable/react'; 4 | 5 | import { LocaleToggler } from '@/features/locale-toggler'; 6 | 7 | import { APP_TITLE } from '@/shared/lib/meta'; 8 | 9 | import * as S from './auth-header.styled'; 10 | 11 | export const AuthHeader = () => { 12 | return ( 13 | 14 | 15 | 16 | 17 | {APP_TITLE} 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/widgets/layouts/auth/components/auth-header/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth-header'; 2 | -------------------------------------------------------------------------------- /src/widgets/layouts/auth/components/auth-header/stories/auth-header.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { AuthHeader } from '../auth-header'; 4 | 5 | export default { 6 | title: 'Layouts/Auth/AuthHeader', 7 | component: AuthHeader, 8 | }; 9 | 10 | export const Basic = () => ; 11 | -------------------------------------------------------------------------------- /src/widgets/layouts/auth/components/auth-layout/auth-layout.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Image from 'next/image'; 3 | import { Box } from '@effable/react'; 4 | import { motion, useMotionValue, useReducedMotion, useTransform } from 'framer-motion'; 5 | 6 | import { AuthContent } from '../auth-content'; 7 | import { AuthHeader } from '../auth-header'; 8 | 9 | interface AuthLayoutProps { 10 | /** 11 | * The content 12 | */ 13 | children?: React.ReactNode; 14 | } 15 | 16 | export const AuthLayout = (props: AuthLayoutProps) => { 17 | const { children } = props; 18 | 19 | const shouldReduceMotion = useReducedMotion() ?? false; 20 | 21 | const x = useMotionValue(0); 22 | const y = useMotionValue(0); 23 | 24 | const rotateX = useTransform(y, (value) => value / 100); 25 | const rotateY = useTransform(x, (value) => value / 100); 26 | 27 | const handleMouse = (event: React.MouseEvent) => { 28 | const rect = event.currentTarget.getBoundingClientRect(); 29 | 30 | x.set(rect.width / 2 - event.clientX); 31 | y.set(rect.height / 2 - event.clientY); 32 | }; 33 | 34 | return ( 35 | 42 | 43 | 44 | 45 | {children} 46 | 47 | 48 | 49 | 59 | 70 | 71 | 72 | 73 | ); 74 | }; 75 | -------------------------------------------------------------------------------- /src/widgets/layouts/auth/components/auth-layout/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth-layout'; 2 | -------------------------------------------------------------------------------- /src/widgets/layouts/auth/components/auth-layout/stories/auth-layout.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { AuthLayout } from '../auth-layout'; 4 | 5 | export default { 6 | title: 'Layouts/Auth/AuthLayout', 7 | component: AuthLayout, 8 | }; 9 | 10 | export const Basic = () => ; 11 | -------------------------------------------------------------------------------- /src/widgets/layouts/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './components/auth-layout'; 2 | -------------------------------------------------------------------------------- /src/widgets/layouts/main/components/main-footer/index.ts: -------------------------------------------------------------------------------- 1 | export * from './main-footer'; 2 | -------------------------------------------------------------------------------- /src/widgets/layouts/main/components/main-footer/main-footer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Box, Container, DisplayOnBrowserMount, Text } from '@effable/react'; 3 | import { useTranslation } from 'next-i18next'; 4 | import Timeago from 'timeago-react'; 5 | import { register } from 'timeago.js'; 6 | import ru from 'timeago.js/lib/lang/ru'; 7 | 8 | register('ru', ru); 9 | 10 | export const MainFooter = () => { 11 | const { t, i18n } = useTranslation('common'); 12 | 13 | return ( 14 | 23 | 24 | 33 | 34 | Copyright © {new Date().getFullYear()} devianllert 35 | 36 | 37 | 38 | 39 | v{process.env.NEXT_PUBLIC_APP_VERSION} 40 | {' • '} 41 | {t('LAST_UPDATE')} 42 | 43 | 44 | 45 | 46 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /src/widgets/layouts/main/components/main-header/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './main-header'; 2 | -------------------------------------------------------------------------------- /src/widgets/layouts/main/components/main-header/main-header.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { capitalize } from '@effable/misc'; 3 | import { Box, Container, DisplayOnBrowserMount, SkipNavLink, Stack, Text } from '@effable/react'; 4 | 5 | import { LocaleToggler } from '@/features/locale-toggler'; 6 | import { ChangeTheme } from '@/features/new-main-page/change-theme'; 7 | 8 | import { APP_TITLE } from '@/shared/lib/meta'; 9 | 10 | export const MainHeader = () => { 11 | return ( 12 | <> 13 | 14 | 24 | 25 | 26 | 27 | {capitalize(APP_TITLE)} 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /src/widgets/layouts/main/components/main-layout/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Box, SkipNavContent } from '@effable/react'; 3 | 4 | import { MainFooter } from '../main-footer'; 5 | import { MainHeader } from '../main-header'; 6 | 7 | interface MainLayoutProps { 8 | /** 9 | * The content 10 | */ 11 | children?: React.ReactNode; 12 | } 13 | 14 | export const MainLayout = (props: MainLayoutProps) => { 15 | const { children } = props; 16 | 17 | return ( 18 | 19 | 20 | 21 | 22 | {children} 23 | 24 | 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/widgets/layouts/main/index.ts: -------------------------------------------------------------------------------- 1 | export * from './components/main-header'; 2 | export * from './components/main-layout'; 3 | -------------------------------------------------------------------------------- /tsconfig.jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "react" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "allowJs": true, 5 | "allowSyntheticDefaultImports": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "isolatedModules": true, 9 | "jsx": "preserve", 10 | "lib": [ 11 | "dom", 12 | "dom.iterable", 13 | "esnext" 14 | ], 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "noEmit": true, 18 | "noUnusedLocals": false, 19 | "noUnusedParameters": false, 20 | "preserveConstEnums": true, 21 | "removeComments": false, 22 | "resolveJsonModule": true, 23 | "skipLibCheck": true, 24 | "sourceMap": true, 25 | "alwaysStrict": true, 26 | "strictNullChecks": true, 27 | "strict": true, 28 | "noImplicitAny": false, 29 | "strictBindCallApply": true, 30 | // TODO: Enable later for more type-safety 31 | // "noUncheckedIndexedAccess": true, 32 | "target": "ES2017", 33 | "incremental": true, 34 | "paths": { 35 | "@/root/*": [ 36 | "*" 37 | ], 38 | "@/app/*": [ 39 | "src/app/*" 40 | ], 41 | "@/public/*": [ 42 | "public/*" 43 | ], 44 | "@/shared/*": [ 45 | "src/shared/*" 46 | ], 47 | "@/components/*": [ 48 | "src/shared/components/*" 49 | ], 50 | "@/lib/*": [ 51 | "src/shared/lib/*" 52 | ], 53 | "@/layouts/*": [ 54 | "src/widgets/layouts/*" 55 | ], 56 | "@/widgets/*": [ 57 | "src/widgets/*" 58 | ], 59 | "@/entities/*": [ 60 | "src/entities/*" 61 | ], 62 | "@/features/*": [ 63 | "src/features/*" 64 | ], 65 | "@/pages/*": [ 66 | "src/pages/*" 67 | ] 68 | }, 69 | "plugins": [ 70 | { 71 | "name": "next" 72 | } 73 | ] 74 | }, 75 | "exclude": [ 76 | ".github", 77 | ".next", 78 | "_site", 79 | "coverage", 80 | "cypress", 81 | "node_modules", 82 | "public" 83 | ], 84 | "include": [ 85 | "next-env.d.ts", 86 | "jest.config.js", 87 | "jest.setup.js", 88 | "./sentry.client.config.js", 89 | "./sentry.server.config.js", 90 | "next-sitemap.config.js", 91 | "**/*.cjs", 92 | "**/*.mjs", 93 | "**/*.ts", 94 | "**/*.tsx", 95 | ".next/types/**/*.ts" 96 | ] 97 | } 98 | -------------------------------------------------------------------------------- /tsconfig.server.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "dist", 6 | "target": "es2017", 7 | "isolatedModules": false, 8 | "noEmit": false 9 | }, 10 | "include": ["server/**/*.ts"] 11 | } 12 | --------------------------------------------------------------------------------