added login page
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import Welcome from '@/widgets/welcome';
|
||||
import Login from '@/widgets/auth/ui/login';
|
||||
|
||||
export default async function QrCode() {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-screen">
|
||||
<Welcome />
|
||||
<Login />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { setRequestLocale } from 'next-intl/server';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Script from 'next/script';
|
||||
import { ReactNode } from 'react';
|
||||
import { Toaster } from 'sonner';
|
||||
import '../globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -46,6 +47,7 @@ export default async function RootLayout({ children, params }: Props) {
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<QueryProvider>
|
||||
<Toaster />
|
||||
{/* <Navbar /> */}
|
||||
{children}
|
||||
{/* <Footer /> */}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
const GET_BOXES = '/qr';
|
||||
const AUTH_LOGIN = '/auth/token';
|
||||
|
||||
export { BASE_URL, GET_BOXES };
|
||||
export { AUTH_LOGIN, BASE_URL, GET_BOXES };
|
||||
|
||||
21
src/shared/lib/cookie.ts
Normal file
21
src/shared/lib/cookie.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import cookie from 'js-cookie';
|
||||
|
||||
const token = 'qr_token';
|
||||
|
||||
// saved token cookie
|
||||
export const saveToken = (value: string) => {
|
||||
cookie.set(token, value, {
|
||||
expires: 7,
|
||||
secure: true,
|
||||
});
|
||||
};
|
||||
|
||||
// remove token cookie
|
||||
export const removeToken = () => {
|
||||
cookie.remove(token);
|
||||
};
|
||||
|
||||
//get token cookie
|
||||
export const getToken = () => {
|
||||
return cookie.get(token);
|
||||
};
|
||||
92
src/shared/ui/card.tsx
Normal file
92
src/shared/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
167
src/shared/ui/form.tsx
Normal file
167
src/shared/ui/form.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Label } from "@/shared/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
24
src/shared/ui/label.tsx
Normal file
24
src/shared/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
40
src/shared/ui/sonner.tsx
Normal file
40
src/shared/ui/sonner.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
22
src/widgets/auth/lib/api.ts
Normal file
22
src/widgets/auth/lib/api.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import httpClient from '@/shared/config/api/httpClient';
|
||||
import { AUTH_LOGIN } from '@/shared/config/api/URLs';
|
||||
import { AxiosResponse } from 'axios';
|
||||
|
||||
interface LoginRes {
|
||||
data: {
|
||||
accessToken: string;
|
||||
roles: string[];
|
||||
username: string;
|
||||
};
|
||||
message: string;
|
||||
}
|
||||
|
||||
const login = (body: {
|
||||
username: string;
|
||||
password: string;
|
||||
}): Promise<AxiosResponse<LoginRes>> => {
|
||||
const res = httpClient.post(AUTH_LOGIN, body);
|
||||
return res;
|
||||
};
|
||||
|
||||
export { login };
|
||||
125
src/widgets/auth/ui/login.tsx
Normal file
125
src/widgets/auth/ui/login.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from '@/shared/config/i18n/navigation';
|
||||
import { saveToken } from '@/shared/lib/cookie';
|
||||
import { Button } from '@/shared/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@/shared/ui/form';
|
||||
import { Input } from '@/shared/ui/input';
|
||||
import { Label } from '@/shared/ui/label';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import * as z from 'zod';
|
||||
import { login } from '../lib/api';
|
||||
|
||||
const loginSchema = z.object({
|
||||
login: z.string().min(1, 'Login is required'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
});
|
||||
|
||||
const Login = () => {
|
||||
const navigate = useRouter();
|
||||
const form = useForm<z.infer<typeof loginSchema>>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
login: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: (body: { username: string; password: string }) => login(body),
|
||||
onSuccess: (res) => {
|
||||
saveToken(res.data.data.accessToken);
|
||||
navigate.back();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Xatolik yuz berdi', {
|
||||
richColors: true,
|
||||
position: 'top-center',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof loginSchema>) {
|
||||
mutate({
|
||||
password: values.password,
|
||||
username: values.login,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 w-full">
|
||||
<Card className="w-full max-w-md sm:max-w-lg md:max-w-xl lg:max-w-2xl shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl sm:text-3xl font-semibold text-center">
|
||||
Login
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-6 sm:space-y-8"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="login"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label className="text-base sm:text-lg">Login</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter your login"
|
||||
className="h-12 sm:h-14 text-base sm:text-lg placeholder:text-base sm:placeholder:text-lg"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label className="text-base sm:text-lg">Password</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
className="h-12 sm:h-14 text-base sm:text-lg placeholder:text-base sm:placeholder:text-lg"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 sm:h-14 text-lg sm:text-xl rounded-xl sm:rounded-2xl cursor-pointer"
|
||||
>
|
||||
{isPending ? <Loader2 className="animate-spin" /> : 'Send'}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
@@ -2,16 +2,27 @@
|
||||
|
||||
import Passport from '@/assets/passport.jpg';
|
||||
import { getBoxes } from '@/shared/config/api/testApi';
|
||||
import { useRouter } from '@/shared/config/i18n/navigation';
|
||||
import { getToken } from '@/shared/lib/cookie';
|
||||
import formatPhone from '@/shared/lib/formatPhone';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Image from 'next/image';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const Boxes = () => {
|
||||
const { id } = useParams();
|
||||
const token = getToken();
|
||||
const navigate = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
navigate.push('/boxes/qr-code');
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['boxes_detail'],
|
||||
queryKey: ['boxes_detail', id],
|
||||
queryFn: () => getBoxes.boxesDetail({ id: Number(id) }),
|
||||
select(data) {
|
||||
return data.data.data;
|
||||
|
||||
Reference in New Issue
Block a user