feat: login form as a client component, theme toggle
This commit is contained in:
129
.gitignore
vendored
Normal file
129
.gitignore
vendored
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
.yarn
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
.idea
|
||||||
1
.prettierignore
Normal file
1
.prettierignore
Normal file
@ -0,0 +1 @@
|
|||||||
|
.next
|
||||||
39
.prettierrc.json
Normal file
39
.prettierrc.json
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"singleAttributePerLine": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"semi": true,
|
||||||
|
"quoteProps": "consistent",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"tabWidth": 4,
|
||||||
|
"bracketSameLine": true,
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"plugins": [
|
||||||
|
"@ianvs/prettier-plugin-sort-imports"
|
||||||
|
],
|
||||||
|
"importOrder": [
|
||||||
|
".*styles.css$",
|
||||||
|
"dayjs",
|
||||||
|
"^react$",
|
||||||
|
"^next$",
|
||||||
|
"^next/.*$",
|
||||||
|
"<BUILTIN_MODULES>",
|
||||||
|
"<THIRD_PARTY_MODULES>",
|
||||||
|
"^@mantine/(.*)$",
|
||||||
|
"^@mantinex/(.*)$",
|
||||||
|
"^@mantine-tests/(.*)$",
|
||||||
|
"^@docs/(.*)$",
|
||||||
|
"^@/.*$",
|
||||||
|
"^../(?!.*.css$).*$",
|
||||||
|
"^./(?!.*.css$).*$",
|
||||||
|
"\\.css$"
|
||||||
|
],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.mdx",
|
||||||
|
"options": {
|
||||||
|
"printWidth": 70
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
16
.storybook/main.ts
Normal file
16
.storybook/main.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import type { StorybookConfig } from '@storybook/nextjs';
|
||||||
|
|
||||||
|
const config: StorybookConfig = {
|
||||||
|
core: {
|
||||||
|
disableWhatsNewNotifications: true,
|
||||||
|
disableTelemetry: true,
|
||||||
|
enableCrashReports: false,
|
||||||
|
},
|
||||||
|
stories: ['../components/**/*.(stories|story).@(js|jsx|ts|tsx)'],
|
||||||
|
addons: ['storybook-dark-mode'],
|
||||||
|
framework: {
|
||||||
|
name: '@storybook/nextjs',
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
36
.storybook/preview.tsx
Normal file
36
.storybook/preview.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import '@mantine/core/styles.css';
|
||||||
|
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { addons } from '@storybook/preview-api';
|
||||||
|
import { DARK_MODE_EVENT_NAME } from 'storybook-dark-mode';
|
||||||
|
import { MantineProvider, useMantineColorScheme } from '@mantine/core';
|
||||||
|
import { darkTheme } from '../theme';
|
||||||
|
|
||||||
|
export const parameters = {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
options: {
|
||||||
|
showPanel: false,
|
||||||
|
storySort: (a, b) => {
|
||||||
|
return a.title.localeCompare(b.title, undefined, { numeric: true });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const channel = addons.getChannel();
|
||||||
|
|
||||||
|
function ColorSchemeWrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
const { setColorScheme } = useMantineColorScheme();
|
||||||
|
const handleColorScheme = (value: boolean) => setColorScheme(value ? 'dark' : 'light');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
channel.on(DARK_MODE_EVENT_NAME, handleColorScheme);
|
||||||
|
return () => channel.off(DARK_MODE_EVENT_NAME, handleColorScheme);
|
||||||
|
}, [channel]);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const decorators = [
|
||||||
|
(renderStory: any) => <ColorSchemeWrapper>{renderStory()}</ColorSchemeWrapper>,
|
||||||
|
(renderStory: any) => <MantineProvider theme={darkTheme}>{renderStory()}</MantineProvider>,
|
||||||
|
];
|
||||||
2
.stylelintignore
Normal file
2
.stylelintignore
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
.next
|
||||||
|
out
|
||||||
28
.stylelintrc.json
Normal file
28
.stylelintrc.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"extends": ["stylelint-config-standard-scss"],
|
||||||
|
"rules": {
|
||||||
|
"custom-property-pattern": null,
|
||||||
|
"selector-class-pattern": null,
|
||||||
|
"scss/no-duplicate-mixins": null,
|
||||||
|
"declaration-empty-line-before": null,
|
||||||
|
"declaration-block-no-redundant-longhand-properties": null,
|
||||||
|
"alpha-value-notation": null,
|
||||||
|
"custom-property-empty-line-before": null,
|
||||||
|
"property-no-vendor-prefix": null,
|
||||||
|
"color-function-notation": null,
|
||||||
|
"length-zero-no-unit": null,
|
||||||
|
"selector-not-notation": null,
|
||||||
|
"no-descending-specificity": null,
|
||||||
|
"comment-empty-line-before": null,
|
||||||
|
"scss/at-mixin-pattern": null,
|
||||||
|
"scss/at-rule-no-unknown": null,
|
||||||
|
"value-keyword-case": null,
|
||||||
|
"media-feature-range-notation": null,
|
||||||
|
"selector-pseudo-class-no-unknown": [
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
"ignorePseudoClasses": ["global"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
3
.yarnrc.yml
Normal file
3
.yarnrc.yml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
nodeLinker: node-modules
|
||||||
|
|
||||||
|
yarnPath: .yarn/releases/yarn-4.9.2.cjs
|
||||||
37
README.md
37
README.md
@ -1,2 +1,37 @@
|
|||||||
# LogiDex-ID-Frontend
|
# Mantine Next.js template
|
||||||
|
|
||||||
|
This is a template for [Next.js](https://nextjs.org/) app router + [Mantine](https://mantine.dev/).
|
||||||
|
If you want to use pages router instead, see [next-pages-template](https://github.com/mantinedev/next-pages-template).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
This template comes with the following features:
|
||||||
|
|
||||||
|
- [PostCSS](https://postcss.org/) with [mantine-postcss-preset](https://mantine.dev/styles/postcss-preset)
|
||||||
|
- [TypeScript](https://www.typescriptlang.org/)
|
||||||
|
- [Storybook](https://storybook.js.org/)
|
||||||
|
- [Jest](https://jestjs.io/) setup with [React Testing Library](https://testing-library.com/docs/react-testing-library/intro)
|
||||||
|
- ESLint setup with [eslint-config-mantine](https://github.com/mantinedev/eslint-config-mantine)
|
||||||
|
|
||||||
|
## npm scripts
|
||||||
|
|
||||||
|
### Build and dev scripts
|
||||||
|
|
||||||
|
- `dev` – start dev server
|
||||||
|
- `build` – bundle application for production
|
||||||
|
- `analyze` – analyzes application bundle with [@next/bundle-analyzer](https://www.npmjs.com/package/@next/bundle-analyzer)
|
||||||
|
|
||||||
|
### Testing scripts
|
||||||
|
|
||||||
|
- `typecheck` – checks TypeScript types
|
||||||
|
- `lint` – runs ESLint
|
||||||
|
- `prettier:check` – checks files with Prettier
|
||||||
|
- `jest` – runs jest tests
|
||||||
|
- `jest:watch` – starts jest watch
|
||||||
|
- `test` – runs `jest`, `prettier:check`, `lint` and `typecheck` scripts
|
||||||
|
|
||||||
|
### Other scripts
|
||||||
|
|
||||||
|
- `storybook` – starts storybook dev server
|
||||||
|
- `storybook:build` – build production storybook bundle to `storybook-static`
|
||||||
|
- `prettier:write` – formats all files with Prettier
|
||||||
|
|||||||
15
app/create-id/page.tsx
Normal file
15
app/create-id/page.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import LoginForm from "@/components/LoginForm/LoginForm";
|
||||||
|
import Logo from "@/components/Logo/Logo";
|
||||||
|
import PageItem from "@/components/PageBlock/PageItem";
|
||||||
|
import PageContainer from "@/components/PageContainer/PageContainer";
|
||||||
|
|
||||||
|
export default function CreateIdPage() {
|
||||||
|
return (
|
||||||
|
<PageContainer center>
|
||||||
|
<PageItem>
|
||||||
|
<Logo title={"Создание аккаунта"} />
|
||||||
|
<LoginForm isCreatingId />
|
||||||
|
</PageItem>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/global.css
Normal file
5
app/global.css
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
body {
|
||||||
|
@mixin light {
|
||||||
|
background-color: whitesmoke;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/layout.tsx
Normal file
50
app/layout.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import "@mantine/core/styles.css";
|
||||||
|
import React, { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
ColorSchemeScript,
|
||||||
|
mantineHtmlProps,
|
||||||
|
MantineProvider,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import Header from "@/components/Header/Header";
|
||||||
|
import { theme } from "@/theme";
|
||||||
|
import "@/app/global.css";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "LogiDex ID",
|
||||||
|
description: "LogiDex ID",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: Props) {
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang="en"
|
||||||
|
{...mantineHtmlProps}>
|
||||||
|
<head>
|
||||||
|
<ColorSchemeScript />
|
||||||
|
<link
|
||||||
|
rel="shortcut icon"
|
||||||
|
href="/favicon.svg"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="global.css"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="minimum-scale=1, initial-scale=1, width=device-width, user-scalable=no"
|
||||||
|
/>
|
||||||
|
<title />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<MantineProvider theme={theme}>
|
||||||
|
<Header />
|
||||||
|
{children}
|
||||||
|
</MantineProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
app/page.tsx
Normal file
15
app/page.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import LoginForm from "@/components/LoginForm/LoginForm";
|
||||||
|
import Logo from "@/components/Logo/Logo";
|
||||||
|
import PageItem from "@/components/PageBlock/PageItem";
|
||||||
|
import PageContainer from "@/components/PageContainer/PageContainer";
|
||||||
|
|
||||||
|
export default function MainPage() {
|
||||||
|
return (
|
||||||
|
<PageContainer center>
|
||||||
|
<PageItem>
|
||||||
|
<Logo title={"Вход"} />
|
||||||
|
<LoginForm />
|
||||||
|
</PageItem>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
components/ColorSchemeToggle/ActionToggle.module.css
Normal file
32
components/ColorSchemeToggle/ActionToggle.module.css
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
.container {
|
||||||
|
@mixin dark {
|
||||||
|
box-shadow: 0 2px 4px var(--mantine-color-dark-6),
|
||||||
|
0 4px 24px var(--mantine-color-dark-6);
|
||||||
|
}
|
||||||
|
@mixin light {
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.16),
|
||||||
|
0 4px 24px rgba(0, 0, 0, 0.16);
|
||||||
|
}
|
||||||
|
padding: rem(25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: rem(18px);
|
||||||
|
height: rem(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-mantine-color-scheme='dark']) .light {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-mantine-color-scheme='dark']) .dark {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
40
components/ColorSchemeToggle/ColorSchemeToggle.tsx
Normal file
40
components/ColorSchemeToggle/ColorSchemeToggle.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { IconMoon, IconSun } from "@tabler/icons-react";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
useComputedColorScheme,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import style from "./ActionToggle.module.css";
|
||||||
|
|
||||||
|
export function ColorSchemeToggle() {
|
||||||
|
const { setColorScheme } = useMantineColorScheme();
|
||||||
|
const computedColorScheme = useComputedColorScheme("light", {
|
||||||
|
getInitialValueInEffect: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionIcon
|
||||||
|
onClick={() =>
|
||||||
|
setColorScheme(
|
||||||
|
computedColorScheme === "light" ? "dark" : "light"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
variant="default"
|
||||||
|
size="xl"
|
||||||
|
radius="md"
|
||||||
|
aria-label="Toggle color scheme"
|
||||||
|
className={style.container}>
|
||||||
|
<IconSun
|
||||||
|
className={classNames(style.icon, style.light)}
|
||||||
|
stroke={1.5}
|
||||||
|
/>
|
||||||
|
<IconMoon
|
||||||
|
className={classNames(style.icon, style.dark)}
|
||||||
|
stroke={1.5}
|
||||||
|
/>
|
||||||
|
</ActionIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
components/Header/Header.tsx
Normal file
15
components/Header/Header.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { Group } from "@mantine/core";
|
||||||
|
import { ColorSchemeToggle } from "@/components/ColorSchemeToggle/ColorSchemeToggle";
|
||||||
|
|
||||||
|
const Header = () => {
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
justify="flex-end"
|
||||||
|
h="7vh"
|
||||||
|
px="md">
|
||||||
|
<ColorSchemeToggle />
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
||||||
66
components/LoginForm/LoginForm.tsx
Normal file
66
components/LoginForm/LoginForm.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FC } from "react";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { Button, Stack } from "@mantine/core";
|
||||||
|
import { useForm } from "@mantine/form";
|
||||||
|
import PhoneInput from "@/components/PhoneInput/PhoneInput";
|
||||||
|
|
||||||
|
type LoginForm = {
|
||||||
|
phoneNumber: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isCreatingId?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoginForm: FC<Props> = ({ isCreatingId = false }) => {
|
||||||
|
const form = useForm<LoginForm>({
|
||||||
|
initialValues: {
|
||||||
|
phoneNumber: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (values: LoginForm) => {};
|
||||||
|
|
||||||
|
const navigateToCreateId = () => redirect("/create-id");
|
||||||
|
|
||||||
|
const navigateToLogin = () => redirect("/");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<Stack w={350}>
|
||||||
|
<PhoneInput {...form.getInputProps("phoneNumber")} />
|
||||||
|
{isCreatingId ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant={"filled"}
|
||||||
|
type={"submit"}>
|
||||||
|
Создать аккаунт
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={"outline"}
|
||||||
|
onClick={() => navigateToLogin()}>
|
||||||
|
Уже есть LogiDex ID
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant={"filled"}
|
||||||
|
type={"submit"}>
|
||||||
|
Войти
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={"outline"}
|
||||||
|
onClick={() => navigateToCreateId()}>
|
||||||
|
Создать LogiDex ID
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginForm;
|
||||||
49
components/Logo/Logo.tsx
Normal file
49
components/Logo/Logo.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { Center, Divider, Image, Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Logo = ({ title }: Props) => {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
align="center"
|
||||||
|
gap={0}>
|
||||||
|
<Image
|
||||||
|
src="/favicon.svg"
|
||||||
|
alt="LogiDex Logo"
|
||||||
|
w={80}
|
||||||
|
/>
|
||||||
|
<Title
|
||||||
|
ta={"center"}
|
||||||
|
order={2}
|
||||||
|
mt={"md"}>
|
||||||
|
LogiDex
|
||||||
|
</Title>
|
||||||
|
<Title
|
||||||
|
ta={"center"}
|
||||||
|
order={5}
|
||||||
|
mt={"sm"}
|
||||||
|
style={{ color: "#4AAAC7" }}>
|
||||||
|
Fulfillment & Delivery
|
||||||
|
</Title>
|
||||||
|
{title && (
|
||||||
|
<Divider
|
||||||
|
w={"100%"}
|
||||||
|
mt={"md"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{title && (
|
||||||
|
<Center>
|
||||||
|
<Title
|
||||||
|
order={4}
|
||||||
|
my={"md"}>
|
||||||
|
{title}
|
||||||
|
</Title>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Logo;
|
||||||
26
components/PageBlock/PageItem.module.css
Normal file
26
components/PageBlock/PageItem.module.css
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
.container {
|
||||||
|
border-radius: rem(20);
|
||||||
|
background-color: white;
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-dark-8);
|
||||||
|
box-shadow: 0 8px 12px var(--mantine-color-dark-6),
|
||||||
|
0 8px 32px var(--mantine-color-dark-6);
|
||||||
|
}
|
||||||
|
@mixin light {
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.16),
|
||||||
|
0 4px 24px rgba(0, 0, 0, 0.16);
|
||||||
|
}
|
||||||
|
padding: rem(25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-full-height {
|
||||||
|
min-height: calc(100vh - (rem(20) * 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-full-height-fixed {
|
||||||
|
height: calc(100vh - (rem(20) * 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-no-border-radius {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
33
components/PageBlock/PageItem.tsx
Normal file
33
components/PageBlock/PageItem.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { CSSProperties, FC, ReactNode } from "react";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import styles from "./PageItem.module.css";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: ReactNode;
|
||||||
|
style?: CSSProperties;
|
||||||
|
fullHeight?: boolean;
|
||||||
|
fullHeightFixed?: boolean;
|
||||||
|
noBorderRadius?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PageItem: FC<Props> = ({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
fullHeight = false,
|
||||||
|
fullHeightFixed = false,
|
||||||
|
noBorderRadius = false,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={style}
|
||||||
|
className={classNames(
|
||||||
|
styles.container,
|
||||||
|
fullHeight && styles["container-full-height"],
|
||||||
|
fullHeightFixed && styles["container-full-height-fixed"],
|
||||||
|
noBorderRadius && styles["container-no-border-radius"]
|
||||||
|
)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default PageItem;
|
||||||
7
components/PageContainer/PageContainer.module.css
Normal file
7
components/PageContainer/PageContainer.module.css
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: rem(10);
|
||||||
|
min-height: 70vh;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
24
components/PageContainer/PageContainer.tsx
Normal file
24
components/PageContainer/PageContainer.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { CSSProperties, FC, ReactNode } from "react";
|
||||||
|
import styles from "./PageContainer.module.css";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: ReactNode;
|
||||||
|
style?: CSSProperties;
|
||||||
|
center?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PageContainer: FC<Props> = ({ children, style, center }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.container}
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
alignItems: center ? "center" : "",
|
||||||
|
justifyContent: center ? "center" : "",
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PageContainer;
|
||||||
9
components/PhoneInput/PhoneInput.module.css
Normal file
9
components/PhoneInput/PhoneInput.module.css
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
.country-select {
|
||||||
|
font-size: medium;
|
||||||
|
@mixin light {
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
@mixin dark {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
114
components/PhoneInput/PhoneInput.tsx
Normal file
114
components/PhoneInput/PhoneInput.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { IMaskInput } from "react-imask";
|
||||||
|
import {
|
||||||
|
InputBase,
|
||||||
|
type InputBaseProps,
|
||||||
|
type PolymorphicComponentProps,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useUncontrolled } from "@mantine/hooks";
|
||||||
|
import CountrySelect from "@/components/PhoneInput/components/CountrySelect";
|
||||||
|
import getFormat from "@/components/PhoneInput/utils/getFormat";
|
||||||
|
import getInitialDataFromValue from "@/components/PhoneInput/utils/getInitialDataFromValue";
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
initialCountryCode?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
} & Omit<
|
||||||
|
PolymorphicComponentProps<typeof IMaskInput, InputBaseProps>,
|
||||||
|
"onChange" | "defaultValue"
|
||||||
|
> & { onChange: (value: string | null) => void };
|
||||||
|
|
||||||
|
const PhoneInput = ({
|
||||||
|
initialCountryCode = "RU",
|
||||||
|
value: _value,
|
||||||
|
onChange: _onChange,
|
||||||
|
defaultValue,
|
||||||
|
...props
|
||||||
|
}: Props) => {
|
||||||
|
const [value, onChange] = useUncontrolled({
|
||||||
|
value: _value,
|
||||||
|
defaultValue,
|
||||||
|
onChange: _onChange,
|
||||||
|
});
|
||||||
|
const initialData = useRef(
|
||||||
|
getInitialDataFromValue(value, initialCountryCode)
|
||||||
|
);
|
||||||
|
const [country, setCountry] = useState(initialData.current.country);
|
||||||
|
const [format, setFormat] = useState(initialData.current.format);
|
||||||
|
const [localValue, setLocalValue] = useState(
|
||||||
|
initialData.current.localValue
|
||||||
|
);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const lastNotifiedValue = useRef<string | null>(value ?? "");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const value = localValue.trim();
|
||||||
|
if (value !== lastNotifiedValue.current) {
|
||||||
|
lastNotifiedValue.current = value;
|
||||||
|
onChange(value);
|
||||||
|
}
|
||||||
|
}, [country.code, localValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
typeof value !== "undefined" &&
|
||||||
|
value !== lastNotifiedValue.current
|
||||||
|
) {
|
||||||
|
const initialData = getInitialDataFromValue(
|
||||||
|
value,
|
||||||
|
initialCountryCode
|
||||||
|
);
|
||||||
|
lastNotifiedValue.current = value;
|
||||||
|
setCountry(initialData.country);
|
||||||
|
setFormat(initialData.format);
|
||||||
|
setLocalValue(initialData.localValue);
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const { readOnly, disabled } = props;
|
||||||
|
const leftSectionWidth = 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InputBase
|
||||||
|
{...props}
|
||||||
|
component={IMaskInput}
|
||||||
|
inputRef={inputRef}
|
||||||
|
leftSection={
|
||||||
|
<CountrySelect
|
||||||
|
disabled={disabled || readOnly}
|
||||||
|
country={country}
|
||||||
|
setCountry={country => {
|
||||||
|
setCountry(country);
|
||||||
|
setFormat(getFormat(country.code));
|
||||||
|
setLocalValue("");
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
leftSectionWidth={leftSectionWidth}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
leftSectionWidth={leftSectionWidth}
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
fontSize: 17,
|
||||||
|
color: "primaryColor.1",
|
||||||
|
paddingLeft: `calc(${leftSectionWidth}px + var(--mantine-spacing-sm))`,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
borderRight:
|
||||||
|
"1px solid var(--mantine-color-default-border)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
inputMode={"numeric"}
|
||||||
|
mask={format.mask}
|
||||||
|
value={localValue}
|
||||||
|
onAccept={value => setLocalValue(value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PhoneInput;
|
||||||
137
components/PhoneInput/components/CountrySelect.tsx
Normal file
137
components/PhoneInput/components/CountrySelect.tsx
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Box,
|
||||||
|
CheckIcon,
|
||||||
|
Combobox,
|
||||||
|
Flex,
|
||||||
|
Group,
|
||||||
|
ScrollArea,
|
||||||
|
Text,
|
||||||
|
useCombobox,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import style from "@/components/PhoneInput/PhoneInput.module.css";
|
||||||
|
import { Country } from "@/components/PhoneInput/types";
|
||||||
|
import countryOptionsDataMap from "@/components/PhoneInput/utils/countryOptionsDataMap";
|
||||||
|
|
||||||
|
const countryOptionsData = Object.values(countryOptionsDataMap);
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
country: Country;
|
||||||
|
setCountry: (country: Country) => void;
|
||||||
|
disabled: boolean | undefined;
|
||||||
|
leftSectionWidth: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CountrySelect = ({
|
||||||
|
country,
|
||||||
|
setCountry,
|
||||||
|
disabled,
|
||||||
|
leftSectionWidth,
|
||||||
|
}: Props) => {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const selectedRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const combobox = useCombobox({
|
||||||
|
onDropdownClose: () => {
|
||||||
|
combobox.resetSelectedOption();
|
||||||
|
setSearch("");
|
||||||
|
},
|
||||||
|
|
||||||
|
onDropdownOpen: () => {
|
||||||
|
combobox.focusSearchInput();
|
||||||
|
setTimeout(() => {
|
||||||
|
selectedRef.current?.scrollIntoView({
|
||||||
|
behavior: "instant",
|
||||||
|
block: "center",
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const options = countryOptionsData
|
||||||
|
.filter(item =>
|
||||||
|
item.name.toLowerCase().includes(search.toLowerCase().trim())
|
||||||
|
)
|
||||||
|
.map(item => (
|
||||||
|
<Combobox.Option
|
||||||
|
ref={item.code === country.code ? selectedRef : undefined}
|
||||||
|
value={item.code}
|
||||||
|
key={item.code}>
|
||||||
|
<Group
|
||||||
|
justify={"space-between"}
|
||||||
|
wrap={"nowrap"}
|
||||||
|
align={"center"}>
|
||||||
|
<Text>
|
||||||
|
{item.emoji} +{item.callingCode} | {item.name}
|
||||||
|
</Text>
|
||||||
|
{item.code === country.code && (
|
||||||
|
<Box>
|
||||||
|
<CheckIcon size={12} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Combobox.Option>
|
||||||
|
));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (search) {
|
||||||
|
combobox.selectFirstOption();
|
||||||
|
}
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Combobox
|
||||||
|
store={combobox}
|
||||||
|
width={350}
|
||||||
|
position={"bottom-start"}
|
||||||
|
withArrow
|
||||||
|
onOptionSubmit={val => {
|
||||||
|
setCountry(countryOptionsDataMap[val]);
|
||||||
|
combobox.closeDropdown();
|
||||||
|
}}>
|
||||||
|
<Combobox.Target withAriaAttributes={false}>
|
||||||
|
<ActionIcon
|
||||||
|
variant={"transparent"}
|
||||||
|
onClick={() => combobox.toggleDropdown()}
|
||||||
|
size={"lg"}
|
||||||
|
tabIndex={-1}
|
||||||
|
disabled={disabled}
|
||||||
|
w={leftSectionWidth}>
|
||||||
|
<Flex
|
||||||
|
align={"center"}
|
||||||
|
px={5}
|
||||||
|
ml={"md"}
|
||||||
|
w={"100%"}
|
||||||
|
className={style["country-select"]}>
|
||||||
|
<span>
|
||||||
|
{country.emoji} +{country.callingCode}
|
||||||
|
</span>
|
||||||
|
</Flex>
|
||||||
|
</ActionIcon>
|
||||||
|
</Combobox.Target>
|
||||||
|
|
||||||
|
<Combobox.Dropdown>
|
||||||
|
<Combobox.Search
|
||||||
|
value={search}
|
||||||
|
onChange={event => setSearch(event.currentTarget.value)}
|
||||||
|
placeholder="Поиск..."
|
||||||
|
/>
|
||||||
|
<Combobox.Options>
|
||||||
|
<ScrollArea.Autosize
|
||||||
|
mah={200}
|
||||||
|
type="scroll">
|
||||||
|
{options.length > 0 ? (
|
||||||
|
options
|
||||||
|
) : (
|
||||||
|
<Combobox.Empty>Нет данных</Combobox.Empty>
|
||||||
|
)}
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</Combobox.Options>
|
||||||
|
</Combobox.Dropdown>
|
||||||
|
</Combobox>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CountrySelect;
|
||||||
8
components/PhoneInput/types.ts
Normal file
8
components/PhoneInput/types.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import type { CountryCallingCode, CountryCode } from "libphonenumber-js";
|
||||||
|
|
||||||
|
export type Country = {
|
||||||
|
code: CountryCode;
|
||||||
|
name: string;
|
||||||
|
emoji: string;
|
||||||
|
callingCode: CountryCallingCode;
|
||||||
|
};
|
||||||
33
components/PhoneInput/utils/countryOptionsDataMap.ts
Normal file
33
components/PhoneInput/utils/countryOptionsDataMap.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import countries from "i18n-iso-countries";
|
||||||
|
import ru from "i18n-iso-countries/langs/ru.json";
|
||||||
|
import { type CountryCode, getCountries } from "libphonenumber-js";
|
||||||
|
import getFlagEmoji from "@/components/PhoneInput/utils/getFlagEmoji";
|
||||||
|
import { getCountryCallingCode } from "libphonenumber-js/max";
|
||||||
|
import { Country } from "@/components/PhoneInput/types";
|
||||||
|
|
||||||
|
countries.registerLocale(ru);
|
||||||
|
|
||||||
|
const libIsoCountries = countries.getNames("ru", { select: "official" });
|
||||||
|
const libPhoneNumberCountries = getCountries();
|
||||||
|
|
||||||
|
const countryOptionsDataMap = Object.fromEntries(
|
||||||
|
libPhoneNumberCountries
|
||||||
|
.map(code => {
|
||||||
|
const name = libIsoCountries[code];
|
||||||
|
const emoji = getFlagEmoji(code);
|
||||||
|
if (!name || !emoji) return null;
|
||||||
|
const callingCode = getCountryCallingCode(code);
|
||||||
|
return [
|
||||||
|
code,
|
||||||
|
{
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
emoji,
|
||||||
|
callingCode,
|
||||||
|
},
|
||||||
|
] as [CountryCode, Country];
|
||||||
|
})
|
||||||
|
.filter(o => !!o)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default countryOptionsDataMap;
|
||||||
8
components/PhoneInput/utils/getFlagEmoji.ts
Normal file
8
components/PhoneInput/utils/getFlagEmoji.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export default function getFlagEmoji(countryCode: string) {
|
||||||
|
const FLAG_CODE_OFFSET = 127397;
|
||||||
|
const codePoints = countryCode
|
||||||
|
.toUpperCase()
|
||||||
|
.split("")
|
||||||
|
.map(char => FLAG_CODE_OFFSET + char.charCodeAt(0));
|
||||||
|
return String.fromCodePoint(...codePoints);
|
||||||
|
}
|
||||||
15
components/PhoneInput/utils/getFormat.ts
Normal file
15
components/PhoneInput/utils/getFormat.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { CountryCode, getExampleNumber } from "libphonenumber-js";
|
||||||
|
import examples from "libphonenumber-js/examples.mobile.json";
|
||||||
|
import { getCountryCallingCode } from "libphonenumber-js/max";
|
||||||
|
|
||||||
|
export default function getFormat(countryCode: CountryCode) {
|
||||||
|
let example = getExampleNumber(
|
||||||
|
countryCode,
|
||||||
|
examples
|
||||||
|
)!.formatInternational();
|
||||||
|
const callingCode = getCountryCallingCode(countryCode);
|
||||||
|
const callingCodeLen = callingCode.length + 1;
|
||||||
|
example = example.slice(callingCodeLen);
|
||||||
|
const mask = example.replace(/\d/g, "0");
|
||||||
|
return { example, mask };
|
||||||
|
}
|
||||||
33
components/PhoneInput/utils/getInitialDataFromValue.ts
Normal file
33
components/PhoneInput/utils/getInitialDataFromValue.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { Country } from "@/components/PhoneInput/types";
|
||||||
|
import getFormat from "@/components/PhoneInput/utils/getFormat";
|
||||||
|
import countryOptionsDataMap from "@/components/PhoneInput/utils/countryOptionsDataMap";
|
||||||
|
import { CountryCode, parsePhoneNumberFromString } from "libphonenumber-js";
|
||||||
|
|
||||||
|
type InitialDataFromValue = {
|
||||||
|
country: Country;
|
||||||
|
format: ReturnType<typeof getFormat>;
|
||||||
|
localValue: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInitialDataFromValue = (
|
||||||
|
value: string | undefined,
|
||||||
|
initialCountryCode: string
|
||||||
|
): InitialDataFromValue => {
|
||||||
|
const defaultValue = {
|
||||||
|
country: countryOptionsDataMap[initialCountryCode],
|
||||||
|
format: getFormat(initialCountryCode as CountryCode),
|
||||||
|
localValue: "",
|
||||||
|
};
|
||||||
|
if (!value) return defaultValue;
|
||||||
|
const phoneNumber = parsePhoneNumberFromString(value);
|
||||||
|
if (!phoneNumber) return defaultValue;
|
||||||
|
if (!phoneNumber.country) return defaultValue;
|
||||||
|
|
||||||
|
return {
|
||||||
|
country: countryOptionsDataMap[phoneNumber.country],
|
||||||
|
localValue: phoneNumber.formatNational(),
|
||||||
|
format: getFormat(phoneNumber.country),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default getInitialDataFromValue;
|
||||||
21
eslint.config.mjs
Normal file
21
eslint.config.mjs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import mantine from "eslint-config-mantine";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
...mantine,
|
||||||
|
{ ignores: ["**/*.{mjs,cjs,js,d.ts,d.mts}"] },
|
||||||
|
{
|
||||||
|
files: ["**/*.story.tsx"],
|
||||||
|
rules: {
|
||||||
|
"no-console": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
rules: {
|
||||||
|
"no-console": "off",
|
||||||
|
"react/jsx-curly-brace-presence": "off",
|
||||||
|
"curly": "off",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
16
jest.config.cjs
Normal file
16
jest.config.cjs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
const nextJest = require('next/jest');
|
||||||
|
|
||||||
|
const createJestConfig = nextJest({
|
||||||
|
dir: './',
|
||||||
|
});
|
||||||
|
|
||||||
|
const customJestConfig = {
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/jest.setup.cjs'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^@/components/(.*)$': '<rootDir>/components/$1',
|
||||||
|
'^@/pages/(.*)$': '<rootDir>/pages/$1',
|
||||||
|
},
|
||||||
|
testEnvironment: 'jest-environment-jsdom',
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = createJestConfig(customJestConfig);
|
||||||
27
jest.setup.cjs
Normal file
27
jest.setup.cjs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
require('@testing-library/jest-dom');
|
||||||
|
|
||||||
|
const { getComputedStyle } = window;
|
||||||
|
window.getComputedStyle = (elt) => getComputedStyle(elt);
|
||||||
|
window.HTMLElement.prototype.scrollIntoView = () => {};
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: jest.fn().mockImplementation((query) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: jest.fn(),
|
||||||
|
removeListener: jest.fn(),
|
||||||
|
addEventListener: jest.fn(),
|
||||||
|
removeEventListener: jest.fn(),
|
||||||
|
dispatchEvent: jest.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
class ResizeObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.ResizeObserver = ResizeObserver;
|
||||||
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
15
next.config.mjs
Normal file
15
next.config.mjs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import bundleAnalyzer from '@next/bundle-analyzer';
|
||||||
|
|
||||||
|
const withBundleAnalyzer = bundleAnalyzer({
|
||||||
|
enabled: process.env.ANALYZE === 'true',
|
||||||
|
});
|
||||||
|
|
||||||
|
export default withBundleAnalyzer({
|
||||||
|
reactStrictMode: false,
|
||||||
|
eslint: {
|
||||||
|
ignoreDuringBuilds: true,
|
||||||
|
},
|
||||||
|
experimental: {
|
||||||
|
optimizePackageImports: ['@mantine/core', '@mantine/hooks'],
|
||||||
|
},
|
||||||
|
});
|
||||||
70
package.json
Normal file
70
package.json
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"name": "mantine-next-template",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"analyze": "ANALYZE=true next build",
|
||||||
|
"start": "next start",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "npm run eslint && npm run stylelint",
|
||||||
|
"eslint": "next lint",
|
||||||
|
"stylelint": "stylelint '**/*.css' --cache",
|
||||||
|
"jest": "jest",
|
||||||
|
"jest:watch": "jest --watch",
|
||||||
|
"prettier:check": "prettier --check \"**/*.{ts,tsx}\"",
|
||||||
|
"prettier:write": "prettier --write \"**/*.{ts,tsx}\"",
|
||||||
|
"test": "npm run prettier:check && npm run lint && npm run typecheck && npm run jest",
|
||||||
|
"storybook": "storybook dev -p 6006",
|
||||||
|
"storybook:build": "storybook build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mantine/core": "8.1.2",
|
||||||
|
"@mantine/form": "^8.1.3",
|
||||||
|
"@mantine/hooks": "8.1.2",
|
||||||
|
"@next/bundle-analyzer": "^15.3.3",
|
||||||
|
"@tabler/icons-react": "^3.34.0",
|
||||||
|
"classnames": "^2.5.1",
|
||||||
|
"i18n-iso-countries": "^7.14.0",
|
||||||
|
"libphonenumber-js": "^1.12.10",
|
||||||
|
"next": "15.3.3",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
|
"react-imask": "^7.6.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.27.4",
|
||||||
|
"@eslint/js": "^9.29.0",
|
||||||
|
"@ianvs/prettier-plugin-sort-imports": "^4.4.2",
|
||||||
|
"@storybook/nextjs": "^8.6.8",
|
||||||
|
"@storybook/react": "^8.6.8",
|
||||||
|
"@testing-library/dom": "^10.4.0",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/eslint-plugin-jsx-a11y": "^6",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/node": "^22.13.11",
|
||||||
|
"@types/react": "19.1.8",
|
||||||
|
"babel-loader": "^10.0.0",
|
||||||
|
"eslint": "^9.29.0",
|
||||||
|
"eslint-config-mantine": "^4.0.3",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"jest": "^30.0.0",
|
||||||
|
"jest-environment-jsdom": "^30.0.0",
|
||||||
|
"postcss": "^8.5.5",
|
||||||
|
"postcss-preset-mantine": "1.17.0",
|
||||||
|
"postcss-simple-vars": "^7.0.1",
|
||||||
|
"prettier": "^3.5.3",
|
||||||
|
"storybook": "^8.6.8",
|
||||||
|
"storybook-dark-mode": "^4.0.2",
|
||||||
|
"stylelint": "^16.20.0",
|
||||||
|
"stylelint-config-standard-scss": "^15.0.1",
|
||||||
|
"ts-jest": "^29.4.0",
|
||||||
|
"typescript": "5.8.3",
|
||||||
|
"typescript-eslint": "^8.34.0"
|
||||||
|
},
|
||||||
|
"packageManager": "yarn@4.9.2"
|
||||||
|
}
|
||||||
14
postcss.config.cjs
Normal file
14
postcss.config.cjs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
'postcss-preset-mantine': {},
|
||||||
|
'postcss-simple-vars': {
|
||||||
|
variables: {
|
||||||
|
'mantine-breakpoint-xs': '36em',
|
||||||
|
'mantine-breakpoint-sm': '48em',
|
||||||
|
'mantine-breakpoint-md': '62em',
|
||||||
|
'mantine-breakpoint-lg': '75em',
|
||||||
|
'mantine-breakpoint-xl': '88em',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
12
public/favicon.svg
Normal file
12
public/favicon.svg
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<svg width="41" height="47" viewBox="0 0 41 47" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_431_24446)">
|
||||||
|
<path opacity="0.958" fill-rule="evenodd" clip-rule="evenodd" d="M20.2179 -0.0939941C20.406 -0.0939941 20.5941 -0.0939941 20.7822 -0.0939941C27.0194 3.59767 33.2885 7.26367 39.5895 10.904C33.2187 14.6831 26.8242 18.4118 20.406 22.09C13.9877 18.4118 7.59324 14.6831 1.22253 10.904C7.56616 7.23297 13.898 3.56697 20.2179 -0.0939941ZM19.6537 3.85401C19.9938 3.85239 20.3073 3.94639 20.5941 4.13601C24.2301 6.39201 27.8663 8.64801 31.5024 10.904C23.6659 11.0293 15.8296 11.0293 7.99318 10.904C11.9233 8.59642 15.8101 6.24642 19.6537 3.85401Z" fill="#44A8C6"/>
|
||||||
|
<path opacity="0.962" fill-rule="evenodd" clip-rule="evenodd" d="M-0.0939941 13.442C6.3424 16.991 12.7369 20.6257 19.0895 24.346C19.2776 31.8649 19.3402 39.3849 19.2776 46.906C19.0895 46.906 18.9014 46.906 18.7133 46.906C12.4971 43.203 6.22796 39.5684 -0.0939941 36.002C-0.0939941 28.482 -0.0939941 20.962 -0.0939941 13.442ZM2.91518 19.646C6.9531 26.4762 10.9653 33.3382 14.9519 40.232C10.9005 38.3163 6.91964 36.2169 3.00922 33.934C2.91518 29.1718 2.88385 24.4092 2.91518 19.646Z" fill="#334B63"/>
|
||||||
|
<path opacity="0.972" fill-rule="evenodd" clip-rule="evenodd" d="M40.906 13.442C40.906 21.0246 40.906 28.6074 40.906 36.19C34.5741 39.6675 28.305 43.2395 22.0986 46.906C21.9732 46.906 21.8479 46.906 21.7225 46.906C21.6911 39.3858 21.7225 31.8658 21.8165 24.346C28.1747 20.6832 34.5378 17.0485 40.906 13.442ZM25.8601 40.326C29.6787 33.4443 33.5969 26.6137 37.6147 19.834C37.7401 24.534 37.7401 29.234 37.6147 33.934C33.7364 36.1387 29.8183 38.2693 25.8601 40.326Z" fill="#3C83B4"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_431_24446">
|
||||||
|
<rect width="41" height="47" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
5
test-utils/index.ts
Normal file
5
test-utils/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
|
export * from "@testing-library/react";
|
||||||
|
export { render } from "./render";
|
||||||
|
export { userEvent };
|
||||||
16
test-utils/render.tsx
Normal file
16
test-utils/render.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { render as testingLibraryRender } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { darkTheme } from "@/theme";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export function render(ui: React.ReactNode) {
|
||||||
|
return testingLibraryRender(<>{ui}</>, {
|
||||||
|
wrapper: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MantineProvider
|
||||||
|
theme={darkTheme}
|
||||||
|
env="test">
|
||||||
|
{children}
|
||||||
|
</MantineProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
21
theme.ts
Normal file
21
theme.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { createTheme, MantineColorsTuple } from "@mantine/core";
|
||||||
|
|
||||||
|
const myColor: MantineColorsTuple = [
|
||||||
|
"#e2faff",
|
||||||
|
"#d4eff8",
|
||||||
|
"#afdce9",
|
||||||
|
"#87c8db",
|
||||||
|
"#65b7cf",
|
||||||
|
"#4aaac7",
|
||||||
|
"#3fa7c6",
|
||||||
|
"#2c92af",
|
||||||
|
"#1b829e",
|
||||||
|
"#00718c",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const theme = createTheme({
|
||||||
|
colors: {
|
||||||
|
myColor,
|
||||||
|
},
|
||||||
|
primaryColor: "myColor",
|
||||||
|
});
|
||||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["node", "jest", "@testing-library/jest-dom"],
|
||||||
|
"target": "es5",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
},
|
||||||
|
"plugins": [{ "name": "next" }]
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user