diff --git a/apps/frontend/components.json b/apps/frontend/components.json index 2406a85..3252184 100644 --- a/apps/frontend/components.json +++ b/apps/frontend/components.json @@ -12,7 +12,7 @@ }, "aliases": { "components": "shared/ui", - "utils": "shared/lib", + "utils": "shared/lib/utils", "ui": "shared/ui", "lib": "shared/lib", "hooks": "shared/hooks/shadcn" diff --git a/apps/frontend/src/pages/login/ui/LoginForm.tsx b/apps/frontend/src/pages/login/ui/LoginForm.tsx index 1b903b6..f03c4dd 100644 --- a/apps/frontend/src/pages/login/ui/LoginForm.tsx +++ b/apps/frontend/src/pages/login/ui/LoginForm.tsx @@ -8,12 +8,13 @@ import { FieldDescription, FieldLabel, Button, - Input, FieldGroup, FieldError, Link, + InputPassword, + InputEmail, } from 'shared/ui'; -import { cn } from 'shared/lib'; +import { cn } from 'shared/lib/utils'; import * as z from 'zod'; export function LoginForm({ className, ...props }: Omit, 'children'>) { @@ -42,12 +43,11 @@ export function LoginForm({ className, ...props }: Omit ( Email - {fieldState.invalid && } @@ -64,13 +64,7 @@ export function LoginForm({ className, ...props }: Omit - + {fieldState.invalid && } )} diff --git a/apps/frontend/src/pages/register/model/registerSchema.ts b/apps/frontend/src/pages/register/model/registerSchema.ts index 7bed4e8..c2324f4 100644 --- a/apps/frontend/src/pages/register/model/registerSchema.ts +++ b/apps/frontend/src/pages/register/model/registerSchema.ts @@ -1,9 +1,24 @@ import { z } from 'zod'; -export const formSchema = z.object({ - email: z.email('Неверный формат email'), - name: z.string().min(2, 'Слишком короткое имя').max(15, 'Слишком длинное имя'), - password: z.string().min(6, 'Минимум 6 символов').max(32, 'Слишком длинный пароль'), -}); +export const formSchema = z + .object({ + name: z + .string() + .trim() + .min(1, 'Обязательное поле') + .min(2, 'Слишком короткое имя') + .max(15, 'Слишком длинное имя'), + email: z.string().min(1, 'Обязательное поле').check(z.email('Неверный формат email')), + password: z + .string() + .min(1, 'Обязательное поле') + .min(6, 'Минимум 6 символов') + .max(32, 'Слишком длинный пароль'), + confirmPassword: z.string().min(1, 'Обязательное поле'), + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Пароли не совпадают', + path: ['confirmPassword'], + }); export type FormState = z.infer; diff --git a/apps/frontend/src/pages/register/ui/RegisterForm.tsx b/apps/frontend/src/pages/register/ui/RegisterForm.tsx index 612438b..4ca69f3 100644 --- a/apps/frontend/src/pages/register/ui/RegisterForm.tsx +++ b/apps/frontend/src/pages/register/ui/RegisterForm.tsx @@ -1,54 +1,121 @@ 'use client'; -import { useForm } from 'react-hook-form'; +import { Controller, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Input } from 'shared/ui/input'; +import { InputPassword, Input, InputEmail } from 'shared/ui'; import { formSchema, FormState } from '../model/registerSchema'; -import { Field, FieldDescription, FieldLabel } from 'shared/ui/field'; -import { Button } from 'shared/ui'; +import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from 'shared/ui'; +import { Button, Link } from 'shared/ui'; +import { cn } from 'shared/lib/utils'; +import * as z from 'zod'; +import { useState } from 'react'; -export function RegisterForm() { - const { - register, - handleSubmit, - reset, - formState: { errors }, - } = useForm({ +export function RegisterForm({ + className, + ...props +}: Omit, 'children'>) { + const [showPassword, setShowPassword] = useState(false); + + const form = useForm({ resolver: zodResolver(formSchema), - mode: 'onChange', + defaultValues: { + name: '', + email: '', + password: '', + confirmPassword: '', + }, }); - const onSubmit = (data: FormState): void => { - console.log(data); - reset(); + + const onSubmit = (data: z.infer) => { + alert(JSON.stringify(data)); }; + return ( -
-
- - Почта - - {errors.email && ( - {errors.email.message} + + + ( + + Имя + + {fieldState.invalid && } + )} - - - Имя - - {errors.name && ( - - {errors.name.message} - + /> + ( + + Email + + {fieldState.invalid && } + + )} + /> + ( + + Пароль + + {fieldState.invalid && } + + )} + /> + ( + + Повторите пароль + + {fieldState.invalid && } + )} + /> + + - Пароль - - {errors.password && ( - {errors.password.message} - )} + + Уже есть аккаунт? Войти + -
- +
); } diff --git a/apps/frontend/src/pages/register/ui/RegisterPage.tsx b/apps/frontend/src/pages/register/ui/RegisterPage.tsx index 8d3a162..ea7db5a 100644 --- a/apps/frontend/src/pages/register/ui/RegisterPage.tsx +++ b/apps/frontend/src/pages/register/ui/RegisterPage.tsx @@ -1,12 +1,45 @@ -'use client'; +import { AppCopyright, Logo, ThemedImage } from 'shared/ui'; +import { RegisterImageDark, RegisterImageLight } from 'shared/assests'; +import * as React from 'react'; import { RegisterForm } from './RegisterForm'; export default function RegisterPage() { return ( -
-

# Task-tracker

-

С возвращением

- +
+
+
+
+

Создать аккаунт

+

+ Заполните форму ниже, чтобы начать работу. +

+
+ +
+
+
); } diff --git a/apps/frontend/src/shared/assests/images/registerPreviewDark.png b/apps/frontend/src/shared/assests/images/registerPreviewDark.png new file mode 100644 index 0000000..3ca976e Binary files /dev/null and b/apps/frontend/src/shared/assests/images/registerPreviewDark.png differ diff --git a/apps/frontend/src/shared/assests/images/registerPreviewLight.png b/apps/frontend/src/shared/assests/images/registerPreviewLight.png new file mode 100644 index 0000000..3ca976e Binary files /dev/null and b/apps/frontend/src/shared/assests/images/registerPreviewLight.png differ diff --git a/apps/frontend/src/shared/assests/index.ts b/apps/frontend/src/shared/assests/index.ts index 7e23fae..a348604 100644 --- a/apps/frontend/src/shared/assests/index.ts +++ b/apps/frontend/src/shared/assests/index.ts @@ -1,3 +1,5 @@ export { default as LogoImage } from './images/logo.svg'; export { default as LoginImageLight } from './images/loginPreviewDark.png'; export { default as LoginImageDark } from './images/loginPreviewLight.png'; +export { default as RegisterImageLight } from './images/registerPreviewDark.png'; +export { default as RegisterImageDark } from './images/registerPreviewLight.png'; diff --git a/apps/frontend/src/shared/lib/hooks/index.ts b/apps/frontend/src/shared/lib/hooks/index.ts new file mode 100644 index 0000000..5619525 --- /dev/null +++ b/apps/frontend/src/shared/lib/hooks/index.ts @@ -0,0 +1 @@ +export { useControllableState, type UseControllableStateProps } from './useControllableState'; diff --git a/apps/frontend/src/shared/lib/hooks/useControllableState.ts b/apps/frontend/src/shared/lib/hooks/useControllableState.ts new file mode 100644 index 0000000..976d260 --- /dev/null +++ b/apps/frontend/src/shared/lib/hooks/useControllableState.ts @@ -0,0 +1,34 @@ +'use client'; + +import { useState, useCallback, useMemo, Dispatch, SetStateAction } from 'react'; + +export interface UseControllableStateProps { + value?: T; + defaultValue?: T; + onChange?: (val: T) => void; +} + +export function useControllableState({ + value, + defaultValue, + onChange, +}: UseControllableStateProps) { + const [internal, setInternal] = useState(defaultValue); + const isControlled = value !== undefined; + const current = isControlled ? value : internal; + + const setValue = useCallback>>( + (next) => { + const resolved: T = + typeof next === 'function' ? (next as (prevState: T | undefined) => T)(current) : next; + + if (!isControlled) { + setInternal(resolved); + } + onChange?.(resolved); + }, + [current, isControlled, onChange] + ); + + return useMemo(() => [current, setValue] as const, [current, setValue]); +} diff --git a/apps/frontend/src/shared/lib/index.ts b/apps/frontend/src/shared/lib/index.ts deleted file mode 100644 index 40b37c4..0000000 --- a/apps/frontend/src/shared/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { cn } from './utils/cn'; diff --git a/apps/frontend/src/shared/lib/sum/sum.test.ts b/apps/frontend/src/shared/lib/sum/sum.test.ts deleted file mode 100644 index 465d6ac..0000000 --- a/apps/frontend/src/shared/lib/sum/sum.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { expect, test } from 'vitest'; -import { sum } from './sum'; - -//todo example test, remove later -test('adds 1 + 2 to equal 3', () => { - expect(sum(1, 2)).toBe(3); -}); diff --git a/apps/frontend/src/shared/lib/sum/sum.ts b/apps/frontend/src/shared/lib/sum/sum.ts deleted file mode 100644 index 508f35f..0000000 --- a/apps/frontend/src/shared/lib/sum/sum.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function sum(a: number, b: number) { - return a + b; -} diff --git a/apps/frontend/src/shared/lib/utils/index.ts b/apps/frontend/src/shared/lib/utils/index.ts new file mode 100644 index 0000000..414c24d --- /dev/null +++ b/apps/frontend/src/shared/lib/utils/index.ts @@ -0,0 +1 @@ +export { cn } from './cn'; diff --git a/apps/frontend/src/shared/ui/AppCopyright/app-copyright.tsx b/apps/frontend/src/shared/ui/AppCopyright/app-copyright.tsx index b3abd74..ab523b8 100644 --- a/apps/frontend/src/shared/ui/AppCopyright/app-copyright.tsx +++ b/apps/frontend/src/shared/ui/AppCopyright/app-copyright.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { cn } from 'shared/lib'; +import { cn } from 'shared/lib/utils'; function AppCopyright({ className, ...props }: Omit, 'children'>) { return ( diff --git a/apps/frontend/src/shared/ui/Button/button.tsx b/apps/frontend/src/shared/ui/Button/button.tsx index ed3ac1d..e68ea80 100644 --- a/apps/frontend/src/shared/ui/Button/button.tsx +++ b/apps/frontend/src/shared/ui/Button/button.tsx @@ -2,10 +2,10 @@ import * as React from 'react'; import { cva, type VariantProps } from 'class-variance-authority'; import { Slot } from 'radix-ui'; -import { cn } from 'shared/lib'; +import { cn } from 'shared/lib/utils'; const buttonVariants = cva( - "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none", + "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none hover:cursor-pointer", { variants: { variant: { diff --git a/apps/frontend/src/shared/ui/InputEmail/input-email.stories.ts b/apps/frontend/src/shared/ui/InputEmail/input-email.stories.ts new file mode 100644 index 0000000..626a8cb --- /dev/null +++ b/apps/frontend/src/shared/ui/InputEmail/input-email.stories.ts @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { InputEmail } from './input-email'; + +const meta = { + title: 'Shared/InputEmail', + component: InputEmail, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/frontend/src/shared/ui/InputEmail/input-email.tsx b/apps/frontend/src/shared/ui/InputEmail/input-email.tsx new file mode 100644 index 0000000..97b227b --- /dev/null +++ b/apps/frontend/src/shared/ui/InputEmail/input-email.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { Input } from 'shared/ui'; + +function InputEmail(props: React.ComponentProps) { + return ( + + ); +} + +export { InputEmail }; diff --git a/apps/frontend/src/shared/ui/InputPassword/input-password.stories.ts b/apps/frontend/src/shared/ui/InputPassword/input-password.stories.ts new file mode 100644 index 0000000..cdf9b24 --- /dev/null +++ b/apps/frontend/src/shared/ui/InputPassword/input-password.stories.ts @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { InputPassword } from './input-password'; + +const meta = { + title: 'Shared/InputPassword', + component: InputPassword, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + showEyeIcon: true, + value: 'secret_password', + }, +}; + +export const Placeholder: Story = { + args: { + showEyeIcon: true, + }, +}; + +export const WithoutEyeIcon: Story = { + args: { + showEyeIcon: false, + }, +}; diff --git a/apps/frontend/src/shared/ui/InputPassword/input-password.tsx b/apps/frontend/src/shared/ui/InputPassword/input-password.tsx new file mode 100644 index 0000000..ab11f9e --- /dev/null +++ b/apps/frontend/src/shared/ui/InputPassword/input-password.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +import { EyeOffIcon, EyeIcon } from 'lucide-react'; +import { Button, InputGroup, InputGroupAddon, InputGroupInput } from 'shared/ui'; +import { useControllableState } from 'shared/lib/hooks'; +import { HTMLInputTypeAttribute } from 'react'; + +interface InputPasswordProps extends Omit< + React.ComponentProps, + 'children' +> { + showEyeIcon?: boolean; + visible?: boolean; + defaultVisibleValue?: boolean; + onVisibleChange?: (val: boolean) => void; +} + +function InputPassword(props: InputPasswordProps) { + const { + showEyeIcon = true, + visible, + defaultVisibleValue = false, + onVisibleChange, + ...rest + } = props; + + const [show, setShow] = useControllableState({ + value: visible, + defaultValue: defaultVisibleValue, + onChange: onVisibleChange, + }); + + const passwordInputType: HTMLInputTypeAttribute = show ? 'text' : 'password'; + + return ( + + + {showEyeIcon ? ( + + + + ) : null} + + ); +} + +export { InputPassword }; diff --git a/apps/frontend/src/shared/ui/Link/link.tsx b/apps/frontend/src/shared/ui/Link/link.tsx index 238e3bf..e685d55 100644 --- a/apps/frontend/src/shared/ui/Link/link.tsx +++ b/apps/frontend/src/shared/ui/Link/link.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { cn } from 'shared/lib'; +import { cn } from 'shared/lib/utils'; import NextLink from 'next/link'; import { cva, type VariantProps } from 'class-variance-authority'; diff --git a/apps/frontend/src/shared/ui/Logo/logo.tsx b/apps/frontend/src/shared/ui/Logo/logo.tsx index 1f47cc1..9af6bc2 100644 --- a/apps/frontend/src/shared/ui/Logo/logo.tsx +++ b/apps/frontend/src/shared/ui/Logo/logo.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { cva, type VariantProps } from 'class-variance-authority'; -import { cn } from 'shared/lib'; +import { cn } from 'shared/lib/utils'; import Image from 'next/image'; import { LogoImage } from 'shared/assests'; diff --git a/apps/frontend/src/shared/ui/ThemedImage/themed-image.tsx b/apps/frontend/src/shared/ui/ThemedImage/themed-image.tsx index 0f9a22c..67487ee 100644 --- a/apps/frontend/src/shared/ui/ThemedImage/themed-image.tsx +++ b/apps/frontend/src/shared/ui/ThemedImage/themed-image.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { cn } from 'shared/lib'; +import { cn } from 'shared/lib/utils'; import Image from 'next/image'; interface ThemedImageProps extends Omit< diff --git a/apps/frontend/src/shared/ui/alert-dialog.tsx b/apps/frontend/src/shared/ui/alert-dialog.tsx index 46ef693..cd2cc35 100644 --- a/apps/frontend/src/shared/ui/alert-dialog.tsx +++ b/apps/frontend/src/shared/ui/alert-dialog.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { AlertDialog as AlertDialogPrimitive } from 'radix-ui'; -import { cn } from 'shared/lib'; +import { cn } from 'shared/lib/utils'; import { Button } from 'shared/ui'; function AlertDialog({ ...props }: React.ComponentProps) { diff --git a/apps/frontend/src/shared/ui/card.tsx b/apps/frontend/src/shared/ui/card.tsx index 3eb3a69..b0311b1 100644 --- a/apps/frontend/src/shared/ui/card.tsx +++ b/apps/frontend/src/shared/ui/card.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { cn } from 'shared/lib'; +import { cn } from 'shared/lib/utils'; function Card({ className, diff --git a/apps/frontend/src/shared/ui/field.tsx b/apps/frontend/src/shared/ui/field.tsx index 4bef5e0..25ec005 100644 --- a/apps/frontend/src/shared/ui/field.tsx +++ b/apps/frontend/src/shared/ui/field.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { cva, type VariantProps } from 'class-variance-authority'; -import { cn } from 'shared/lib'; +import { cn } from 'shared/lib/utils'; import { Label } from 'shared/ui/label'; import { Separator } from 'shared/ui/separator'; diff --git a/apps/frontend/src/shared/ui/index.ts b/apps/frontend/src/shared/ui/index.ts index 62633bb..48b37fa 100644 --- a/apps/frontend/src/shared/ui/index.ts +++ b/apps/frontend/src/shared/ui/index.ts @@ -9,3 +9,7 @@ export * from './Logo/logo'; export * from './AppCopyright/app-copyright'; export * from './ThemedImage/themed-image'; export * from './Link/link'; +export * from './input-group'; +export * from './textarea'; +export * from './InputPassword/input-password'; +export * from './InputEmail/input-email'; diff --git a/apps/frontend/src/shared/ui/input-group.tsx b/apps/frontend/src/shared/ui/input-group.tsx new file mode 100644 index 0000000..6237844 --- /dev/null +++ b/apps/frontend/src/shared/ui/input-group.tsx @@ -0,0 +1,142 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from 'shared/lib/utils'; +import { Button } from 'shared/ui'; +import { Input } from 'shared/ui/input'; +import { Textarea } from 'shared/ui/textarea'; + +function InputGroup({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5', + className + )} + {...props} + /> + ); +} + +const inputGroupAddonVariants = cva( + "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4", + { + variants: { + align: { + 'inline-start': 'order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]', + 'inline-end': 'order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]', + 'block-start': + 'order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2', + 'block-end': + 'order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2', + }, + }, + defaultVariants: { + align: 'inline-start', + }, + } +); + +function InputGroupAddon({ + className, + align = 'inline-start', + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( +
{ + if ((e.target as HTMLElement).closest('button')) { + return; + } + e.currentTarget.parentElement?.querySelector('input')?.focus(); + }} + {...props} + /> + ); +} + +const inputGroupButtonVariants = cva('flex items-center gap-2 text-sm shadow-none', { + variants: { + size: { + xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5", + sm: '', + 'icon-xs': 'size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0', + 'icon-sm': 'size-8 p-0 has-[>svg]:p-0', + }, + }, + defaultVariants: { + size: 'xs', + }, +}); + +function InputGroupButton({ + className, + type = 'button', + variant = 'ghost', + size = 'xs', + ...props +}: Omit, 'size'> & + VariantProps) { + return ( +