feat: login form as a client component, theme toggle

This commit is contained in:
2025-07-17 16:49:46 +04:00
parent 0de352c323
commit 39b4d36a82
44 changed files with 14445 additions and 1 deletions

129
.gitignore vendored Normal file
View 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
.nvmrc Normal file
View File

@ -0,0 +1 @@
v24.3.0

1
.prettierignore Normal file
View File

@ -0,0 +1 @@
.next

39
.prettierrc.json Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,2 @@
.next
out

28
.stylelintrc.json Normal file
View 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
View File

@ -0,0 +1,3 @@
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.9.2.cjs

View File

@ -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
View 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
View File

@ -0,0 +1,5 @@
body {
@mixin light {
background-color: whitesmoke;
}
}

50
app/layout.tsx Normal file
View 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
View 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>
);
}

View 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;
}

View 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>
);
}

View 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;

View 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
View 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;

View 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;
}

View 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;

View File

@ -0,0 +1,7 @@
.container {
display: flex;
flex-direction: column;
gap: rem(10);
min-height: 70vh;
background-color: transparent;
}

View 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;

View File

@ -0,0 +1,9 @@
.country-select {
font-size: medium;
@mixin light {
color: black;
}
@mixin dark {
color: white;
}
}

View 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;

View 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;

View File

@ -0,0 +1,8 @@
import type { CountryCallingCode, CountryCode } from "libphonenumber-js";
export type Country = {
code: CountryCode;
name: string;
emoji: string;
callingCode: CountryCallingCode;
};

View 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;

View 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);
}

View 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 };
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]
}

13173
yarn.lock Normal file

File diff suppressed because it is too large Load Diff