create-vite-redux 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -0
- package/index.js +169 -0
- package/package.json +30 -0
- package/templates/react-redux-tw/.env.example +1 -0
- package/templates/react-redux-tw/.prettierignore +2 -0
- package/templates/react-redux-tw/.prettierrc +8 -0
- package/templates/react-redux-tw/README.md +81 -0
- package/templates/react-redux-tw/_gitignore +31 -0
- package/templates/react-redux-tw/_package.json +49 -0
- package/templates/react-redux-tw/eslint.config.js +25 -0
- package/templates/react-redux-tw/index.html +13 -0
- package/templates/react-redux-tw/src/App.tsx +15 -0
- package/templates/react-redux-tw/src/components/layout/Footer.tsx +11 -0
- package/templates/react-redux-tw/src/components/layout/Header.tsx +16 -0
- package/templates/react-redux-tw/src/components/ui/button.tsx +54 -0
- package/templates/react-redux-tw/src/components/ui/card.tsx +73 -0
- package/templates/react-redux-tw/src/components/ui/dialog.tsx +118 -0
- package/templates/react-redux-tw/src/components/ui/form.tsx +138 -0
- package/templates/react-redux-tw/src/components/ui/input.tsx +18 -0
- package/templates/react-redux-tw/src/components/ui/label.tsx +18 -0
- package/templates/react-redux-tw/src/components/ui/sonner.tsx +19 -0
- package/templates/react-redux-tw/src/features/auth/LoginForm.tsx +92 -0
- package/templates/react-redux-tw/src/features/auth/authApi.ts +40 -0
- package/templates/react-redux-tw/src/features/auth/authSchema.ts +19 -0
- package/templates/react-redux-tw/src/features/auth/authSlice.ts +31 -0
- package/templates/react-redux-tw/src/features/counter/Counter.tsx +34 -0
- package/templates/react-redux-tw/src/features/counter/counterSlice.ts +32 -0
- package/templates/react-redux-tw/src/hooks/redux.ts +5 -0
- package/templates/react-redux-tw/src/index.css +123 -0
- package/templates/react-redux-tw/src/lib/utils.ts +6 -0
- package/templates/react-redux-tw/src/main.tsx +17 -0
- package/templates/react-redux-tw/src/router/index.tsx +26 -0
- package/templates/react-redux-tw/src/services/api.ts +10 -0
- package/templates/react-redux-tw/src/store/index.ts +16 -0
- package/templates/react-redux-tw/tsconfig.app.json +32 -0
- package/templates/react-redux-tw/tsconfig.json +7 -0
- package/templates/react-redux-tw/tsconfig.node.json +24 -0
- package/templates/react-redux-tw/vite.config.ts +17 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '@/lib/utils'
|
|
3
|
+
|
|
4
|
+
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
|
5
|
+
return (
|
|
6
|
+
<div
|
|
7
|
+
data-slot="card"
|
|
8
|
+
className={cn('bg-card text-card-foreground rounded-xl border shadow-sm', className)}
|
|
9
|
+
{...props}
|
|
10
|
+
/>
|
|
11
|
+
)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
|
15
|
+
return (
|
|
16
|
+
<div
|
|
17
|
+
data-slot="card-header"
|
|
18
|
+
className={cn(
|
|
19
|
+
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 pt-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
|
20
|
+
className
|
|
21
|
+
)}
|
|
22
|
+
{...props}
|
|
23
|
+
/>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
data-slot="card-title"
|
|
31
|
+
className={cn('leading-none font-semibold', className)}
|
|
32
|
+
{...props}
|
|
33
|
+
/>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
|
38
|
+
return (
|
|
39
|
+
<div
|
|
40
|
+
data-slot="card-description"
|
|
41
|
+
className={cn('text-muted-foreground text-sm', className)}
|
|
42
|
+
{...props}
|
|
43
|
+
/>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
data-slot="card-action"
|
|
51
|
+
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
|
|
52
|
+
{...props}
|
|
53
|
+
/>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
|
58
|
+
return (
|
|
59
|
+
<div data-slot="card-content" className={cn('px-6 pb-6 pt-0', className)} {...props} />
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
|
64
|
+
return (
|
|
65
|
+
<div
|
|
66
|
+
data-slot="card-footer"
|
|
67
|
+
className={cn('flex items-center px-6 pb-6 pt-0', className)}
|
|
68
|
+
{...props}
|
|
69
|
+
/>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
|
3
|
+
import { XIcon } from 'lucide-react'
|
|
4
|
+
import { cn } from '@/lib/utils'
|
|
5
|
+
|
|
6
|
+
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
|
7
|
+
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
|
11
|
+
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
|
15
|
+
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
|
19
|
+
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function DialogOverlay({
|
|
23
|
+
className,
|
|
24
|
+
...props
|
|
25
|
+
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
|
26
|
+
return (
|
|
27
|
+
<DialogPrimitive.Overlay
|
|
28
|
+
data-slot="dialog-overlay"
|
|
29
|
+
className={cn(
|
|
30
|
+
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
|
31
|
+
className
|
|
32
|
+
)}
|
|
33
|
+
{...props}
|
|
34
|
+
/>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function DialogContent({
|
|
39
|
+
className,
|
|
40
|
+
children,
|
|
41
|
+
...props
|
|
42
|
+
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
|
43
|
+
return (
|
|
44
|
+
<DialogPortal>
|
|
45
|
+
<DialogOverlay />
|
|
46
|
+
<DialogPrimitive.Content
|
|
47
|
+
data-slot="dialog-content"
|
|
48
|
+
className={cn(
|
|
49
|
+
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border p-6 shadow-lg duration-200 sm:max-w-lg',
|
|
50
|
+
className
|
|
51
|
+
)}
|
|
52
|
+
{...props}
|
|
53
|
+
>
|
|
54
|
+
{children}
|
|
55
|
+
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-4">
|
|
56
|
+
<XIcon />
|
|
57
|
+
<span className="sr-only">Close</span>
|
|
58
|
+
</DialogPrimitive.Close>
|
|
59
|
+
</DialogPrimitive.Content>
|
|
60
|
+
</DialogPortal>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
|
65
|
+
return (
|
|
66
|
+
<div
|
|
67
|
+
data-slot="dialog-header"
|
|
68
|
+
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
|
69
|
+
{...props}
|
|
70
|
+
/>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
|
75
|
+
return (
|
|
76
|
+
<div
|
|
77
|
+
data-slot="dialog-footer"
|
|
78
|
+
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
|
79
|
+
{...props}
|
|
80
|
+
/>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
|
85
|
+
return (
|
|
86
|
+
<DialogPrimitive.Title
|
|
87
|
+
data-slot="dialog-title"
|
|
88
|
+
className={cn('text-lg leading-none font-semibold', className)}
|
|
89
|
+
{...props}
|
|
90
|
+
/>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function DialogDescription({
|
|
95
|
+
className,
|
|
96
|
+
...props
|
|
97
|
+
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
|
98
|
+
return (
|
|
99
|
+
<DialogPrimitive.Description
|
|
100
|
+
data-slot="dialog-description"
|
|
101
|
+
className={cn('text-muted-foreground text-sm', className)}
|
|
102
|
+
{...props}
|
|
103
|
+
/>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export {
|
|
108
|
+
Dialog,
|
|
109
|
+
DialogClose,
|
|
110
|
+
DialogContent,
|
|
111
|
+
DialogDescription,
|
|
112
|
+
DialogFooter,
|
|
113
|
+
DialogHeader,
|
|
114
|
+
DialogOverlay,
|
|
115
|
+
DialogPortal,
|
|
116
|
+
DialogTitle,
|
|
117
|
+
DialogTrigger,
|
|
118
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import * as LabelPrimitive from '@radix-ui/react-label'
|
|
3
|
+
import { Slot } from '@radix-ui/react-slot'
|
|
4
|
+
import { Controller, FormProvider, useFormContext, useFormState } from 'react-hook-form'
|
|
5
|
+
import type {
|
|
6
|
+
ControllerProps,
|
|
7
|
+
FieldPath,
|
|
8
|
+
FieldValues,
|
|
9
|
+
} from 'react-hook-form'
|
|
10
|
+
import { cn } from '@/lib/utils'
|
|
11
|
+
import { Label } from '@/components/ui/label'
|
|
12
|
+
|
|
13
|
+
const Form = FormProvider
|
|
14
|
+
|
|
15
|
+
type FormFieldContextValue<
|
|
16
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
17
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
18
|
+
> = {
|
|
19
|
+
name: TName
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue)
|
|
23
|
+
|
|
24
|
+
function FormField<
|
|
25
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
26
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
27
|
+
>({ ...props }: ControllerProps<TFieldValues, TName>) {
|
|
28
|
+
return (
|
|
29
|
+
<FormFieldContext.Provider value={{ name: props.name }}>
|
|
30
|
+
<Controller {...props} />
|
|
31
|
+
</FormFieldContext.Provider>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function useFormField() {
|
|
36
|
+
const fieldContext = React.useContext(FormFieldContext)
|
|
37
|
+
const itemContext = React.useContext(FormItemContext)
|
|
38
|
+
const { getFieldState } = useFormContext()
|
|
39
|
+
const formState = useFormState({ name: fieldContext.name })
|
|
40
|
+
const fieldState = getFieldState(fieldContext.name, formState)
|
|
41
|
+
|
|
42
|
+
if (!fieldContext) {
|
|
43
|
+
throw new Error('useFormField should be used within <FormField>')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { id } = itemContext
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
id,
|
|
50
|
+
name: fieldContext.name,
|
|
51
|
+
formItemId: `${id}-form-item`,
|
|
52
|
+
formDescriptionId: `${id}-form-item-description`,
|
|
53
|
+
formMessageId: `${id}-form-item-message`,
|
|
54
|
+
...fieldState,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type FormItemContextValue = {
|
|
59
|
+
id: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue)
|
|
63
|
+
|
|
64
|
+
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
|
|
65
|
+
const id = React.useId()
|
|
66
|
+
return (
|
|
67
|
+
<FormItemContext.Provider value={{ id }}>
|
|
68
|
+
<div data-slot="form-item" className={cn('grid gap-2', className)} {...props} />
|
|
69
|
+
</FormItemContext.Provider>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function FormLabel({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
|
74
|
+
const { error, formItemId } = useFormField()
|
|
75
|
+
return (
|
|
76
|
+
<Label
|
|
77
|
+
data-slot="form-label"
|
|
78
|
+
data-error={!!error}
|
|
79
|
+
className={cn('data-[error=true]:text-destructive', className)}
|
|
80
|
+
htmlFor={formItemId}
|
|
81
|
+
{...props}
|
|
82
|
+
/>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
|
87
|
+
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
|
88
|
+
return (
|
|
89
|
+
<Slot
|
|
90
|
+
data-slot="form-control"
|
|
91
|
+
id={formItemId}
|
|
92
|
+
aria-describedby={!error ? formDescriptionId : `${formDescriptionId} ${formMessageId}`}
|
|
93
|
+
aria-invalid={!!error}
|
|
94
|
+
{...props}
|
|
95
|
+
/>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
|
100
|
+
const { formDescriptionId } = useFormField()
|
|
101
|
+
return (
|
|
102
|
+
<p
|
|
103
|
+
data-slot="form-description"
|
|
104
|
+
id={formDescriptionId}
|
|
105
|
+
className={cn('text-muted-foreground text-sm', className)}
|
|
106
|
+
{...props}
|
|
107
|
+
/>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function FormMessage({ className, children, ...props }: React.ComponentProps<'p'>) {
|
|
112
|
+
const { error, formMessageId } = useFormField()
|
|
113
|
+
const body = error ? String(error?.message ?? '') : children
|
|
114
|
+
|
|
115
|
+
if (!body) return null
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<p
|
|
119
|
+
data-slot="form-message"
|
|
120
|
+
id={formMessageId}
|
|
121
|
+
className={cn('text-destructive text-sm', className)}
|
|
122
|
+
{...props}
|
|
123
|
+
>
|
|
124
|
+
{body}
|
|
125
|
+
</p>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export {
|
|
130
|
+
useFormField,
|
|
131
|
+
Form,
|
|
132
|
+
FormItem,
|
|
133
|
+
FormLabel,
|
|
134
|
+
FormControl,
|
|
135
|
+
FormDescription,
|
|
136
|
+
FormMessage,
|
|
137
|
+
FormField,
|
|
138
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '@/lib/utils'
|
|
3
|
+
|
|
4
|
+
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
|
5
|
+
return (
|
|
6
|
+
<input
|
|
7
|
+
type={type}
|
|
8
|
+
data-slot="input"
|
|
9
|
+
className={cn(
|
|
10
|
+
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 aria-invalid:border-destructive',
|
|
11
|
+
className
|
|
12
|
+
)}
|
|
13
|
+
{...props}
|
|
14
|
+
/>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export { Input }
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import * as LabelPrimitive from '@radix-ui/react-label'
|
|
3
|
+
import { cn } from '@/lib/utils'
|
|
4
|
+
|
|
5
|
+
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
|
6
|
+
return (
|
|
7
|
+
<LabelPrimitive.Root
|
|
8
|
+
data-slot="label"
|
|
9
|
+
className={cn(
|
|
10
|
+
'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',
|
|
11
|
+
className
|
|
12
|
+
)}
|
|
13
|
+
{...props}
|
|
14
|
+
/>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export { Label }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Toaster as Sonner, type ToasterProps } from 'sonner'
|
|
2
|
+
|
|
3
|
+
function Toaster({ ...props }: ToasterProps) {
|
|
4
|
+
return (
|
|
5
|
+
<Sonner
|
|
6
|
+
className="toaster group"
|
|
7
|
+
style={
|
|
8
|
+
{
|
|
9
|
+
'--normal-bg': 'var(--popover)',
|
|
10
|
+
'--normal-text': 'var(--popover-foreground)',
|
|
11
|
+
'--normal-border': 'var(--border)',
|
|
12
|
+
} as React.CSSProperties
|
|
13
|
+
}
|
|
14
|
+
{...props}
|
|
15
|
+
/>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { Toaster }
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { useForm } from 'react-hook-form'
|
|
2
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
3
|
+
import { toast } from 'sonner'
|
|
4
|
+
import { loginSchema } from './authSchema'
|
|
5
|
+
import type { LoginInput } from './authSchema'
|
|
6
|
+
import { useLoginMutation } from './authApi'
|
|
7
|
+
import { useAppDispatch } from '@/hooks/redux'
|
|
8
|
+
import { setCredentials } from './authSlice'
|
|
9
|
+
import { Button } from '@/components/ui/button'
|
|
10
|
+
import { Input } from '@/components/ui/input'
|
|
11
|
+
import {
|
|
12
|
+
Card,
|
|
13
|
+
CardContent,
|
|
14
|
+
CardDescription,
|
|
15
|
+
CardFooter,
|
|
16
|
+
CardHeader,
|
|
17
|
+
CardTitle,
|
|
18
|
+
} from '@/components/ui/card'
|
|
19
|
+
import {
|
|
20
|
+
Form,
|
|
21
|
+
FormControl,
|
|
22
|
+
FormField,
|
|
23
|
+
FormItem,
|
|
24
|
+
FormLabel,
|
|
25
|
+
FormMessage,
|
|
26
|
+
} from '@/components/ui/form'
|
|
27
|
+
|
|
28
|
+
export function LoginForm() {
|
|
29
|
+
const dispatch = useAppDispatch()
|
|
30
|
+
const [login, { isLoading }] = useLoginMutation()
|
|
31
|
+
|
|
32
|
+
const form = useForm<LoginInput>({
|
|
33
|
+
resolver: zodResolver(loginSchema),
|
|
34
|
+
defaultValues: { email: '', password: '' },
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
async function onSubmit(values: LoginInput) {
|
|
38
|
+
try {
|
|
39
|
+
const result = await login(values).unwrap()
|
|
40
|
+
dispatch(setCredentials(result))
|
|
41
|
+
toast.success('Logged in successfully')
|
|
42
|
+
} catch {
|
|
43
|
+
toast.error('Invalid email or password')
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Card className="w-full max-w-sm">
|
|
49
|
+
<CardHeader>
|
|
50
|
+
<CardTitle>Sign in</CardTitle>
|
|
51
|
+
<CardDescription>Enter your credentials to access your account.</CardDescription>
|
|
52
|
+
</CardHeader>
|
|
53
|
+
<Form {...form}>
|
|
54
|
+
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
55
|
+
<CardContent className="flex flex-col gap-4">
|
|
56
|
+
<FormField
|
|
57
|
+
control={form.control}
|
|
58
|
+
name="email"
|
|
59
|
+
render={({ field }) => (
|
|
60
|
+
<FormItem>
|
|
61
|
+
<FormLabel>Email</FormLabel>
|
|
62
|
+
<FormControl>
|
|
63
|
+
<Input type="email" placeholder="you@example.com" {...field} />
|
|
64
|
+
</FormControl>
|
|
65
|
+
<FormMessage />
|
|
66
|
+
</FormItem>
|
|
67
|
+
)}
|
|
68
|
+
/>
|
|
69
|
+
<FormField
|
|
70
|
+
control={form.control}
|
|
71
|
+
name="password"
|
|
72
|
+
render={({ field }) => (
|
|
73
|
+
<FormItem>
|
|
74
|
+
<FormLabel>Password</FormLabel>
|
|
75
|
+
<FormControl>
|
|
76
|
+
<Input type="password" placeholder="••••••••" {...field} />
|
|
77
|
+
</FormControl>
|
|
78
|
+
<FormMessage />
|
|
79
|
+
</FormItem>
|
|
80
|
+
)}
|
|
81
|
+
/>
|
|
82
|
+
</CardContent>
|
|
83
|
+
<CardFooter>
|
|
84
|
+
<Button type="submit" className="w-full" disabled={isLoading}>
|
|
85
|
+
{isLoading ? 'Signing in…' : 'Sign in'}
|
|
86
|
+
</Button>
|
|
87
|
+
</CardFooter>
|
|
88
|
+
</form>
|
|
89
|
+
</Form>
|
|
90
|
+
</Card>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { api } from '@/services/api'
|
|
2
|
+
import { loginSchema, registerSchema } from './authSchema'
|
|
3
|
+
import type { LoginInput, RegisterInput } from './authSchema'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
|
|
6
|
+
const userSchema = z.object({
|
|
7
|
+
id: z.string(),
|
|
8
|
+
name: z.string(),
|
|
9
|
+
email: z.string().email(),
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const authResponseSchema = z.object({
|
|
13
|
+
user: userSchema,
|
|
14
|
+
token: z.string(),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export type AuthResponse = z.infer<typeof authResponseSchema>
|
|
18
|
+
|
|
19
|
+
export const authApi = api.injectEndpoints({
|
|
20
|
+
endpoints: (builder) => ({
|
|
21
|
+
login: builder.mutation<AuthResponse, LoginInput>({
|
|
22
|
+
query: (credentials) => ({
|
|
23
|
+
url: '/auth/login',
|
|
24
|
+
method: 'POST',
|
|
25
|
+
body: loginSchema.parse(credentials),
|
|
26
|
+
}),
|
|
27
|
+
transformResponse: (response) => authResponseSchema.parse(response),
|
|
28
|
+
}),
|
|
29
|
+
register: builder.mutation<AuthResponse, RegisterInput>({
|
|
30
|
+
query: (data) => ({
|
|
31
|
+
url: '/auth/register',
|
|
32
|
+
method: 'POST',
|
|
33
|
+
body: registerSchema.parse(data),
|
|
34
|
+
}),
|
|
35
|
+
transformResponse: (response) => authResponseSchema.parse(response),
|
|
36
|
+
}),
|
|
37
|
+
}),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
export const { useLoginMutation, useRegisterMutation } = authApi
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const loginSchema = z.object({
|
|
4
|
+
email: z.string().email('Please enter a valid email address'),
|
|
5
|
+
password: z.string().min(8, 'Password must be at least 8 characters'),
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
export const registerSchema = loginSchema
|
|
9
|
+
.extend({
|
|
10
|
+
name: z.string().min(1, 'Name is required'),
|
|
11
|
+
confirmPassword: z.string(),
|
|
12
|
+
})
|
|
13
|
+
.refine((data) => data.password === data.confirmPassword, {
|
|
14
|
+
message: "Passwords don't match",
|
|
15
|
+
path: ['confirmPassword'],
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
export type LoginInput = z.infer<typeof loginSchema>
|
|
19
|
+
export type RegisterInput = z.infer<typeof registerSchema>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createSlice } from '@reduxjs/toolkit'
|
|
2
|
+
import type { PayloadAction } from '@reduxjs/toolkit'
|
|
3
|
+
import type { AuthResponse } from './authApi'
|
|
4
|
+
|
|
5
|
+
interface AuthState {
|
|
6
|
+
user: AuthResponse['user'] | null
|
|
7
|
+
token: string | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const initialState: AuthState = {
|
|
11
|
+
user: null,
|
|
12
|
+
token: null,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const authSlice = createSlice({
|
|
16
|
+
name: 'auth',
|
|
17
|
+
initialState,
|
|
18
|
+
reducers: {
|
|
19
|
+
setCredentials: (state, action: PayloadAction<AuthResponse>) => {
|
|
20
|
+
state.user = action.payload.user
|
|
21
|
+
state.token = action.payload.token
|
|
22
|
+
},
|
|
23
|
+
logout: (state) => {
|
|
24
|
+
state.user = null
|
|
25
|
+
state.token = null
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export const { setCredentials, logout } = authSlice.actions
|
|
31
|
+
export default authSlice.reducer
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useAppDispatch, useAppSelector } from '@/hooks/redux'
|
|
2
|
+
import { increment, decrement, reset } from './counterSlice'
|
|
3
|
+
import { Button } from '@/components/ui/button'
|
|
4
|
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
|
5
|
+
|
|
6
|
+
export function Counter() {
|
|
7
|
+
const count = useAppSelector((state) => state.counter.value)
|
|
8
|
+
const dispatch = useAppDispatch()
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<Card className="w-full max-w-sm">
|
|
12
|
+
<CardHeader>
|
|
13
|
+
<CardTitle>Redux Counter</CardTitle>
|
|
14
|
+
<CardDescription>
|
|
15
|
+
A simple demo showing Redux Toolkit state management.
|
|
16
|
+
</CardDescription>
|
|
17
|
+
</CardHeader>
|
|
18
|
+
<CardContent className="flex flex-col items-center gap-4">
|
|
19
|
+
<span className="text-5xl font-bold tabular-nums">{count}</span>
|
|
20
|
+
<div className="flex gap-2">
|
|
21
|
+
<Button variant="outline" size="icon" onClick={() => dispatch(decrement())}>
|
|
22
|
+
−
|
|
23
|
+
</Button>
|
|
24
|
+
<Button variant="outline" size="icon" onClick={() => dispatch(increment())}>
|
|
25
|
+
+
|
|
26
|
+
</Button>
|
|
27
|
+
</div>
|
|
28
|
+
<Button variant="ghost" size="sm" onClick={() => dispatch(reset())}>
|
|
29
|
+
Reset
|
|
30
|
+
</Button>
|
|
31
|
+
</CardContent>
|
|
32
|
+
</Card>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createSlice } from '@reduxjs/toolkit'
|
|
2
|
+
import type { PayloadAction } from '@reduxjs/toolkit'
|
|
3
|
+
|
|
4
|
+
interface CounterState {
|
|
5
|
+
value: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const initialState: CounterState = {
|
|
9
|
+
value: 0,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const counterSlice = createSlice({
|
|
13
|
+
name: 'counter',
|
|
14
|
+
initialState,
|
|
15
|
+
reducers: {
|
|
16
|
+
increment: (state) => {
|
|
17
|
+
state.value += 1
|
|
18
|
+
},
|
|
19
|
+
decrement: (state) => {
|
|
20
|
+
state.value -= 1
|
|
21
|
+
},
|
|
22
|
+
incrementByAmount: (state, action: PayloadAction<number>) => {
|
|
23
|
+
state.value += action.payload
|
|
24
|
+
},
|
|
25
|
+
reset: (state) => {
|
|
26
|
+
state.value = 0
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
export const { increment, decrement, incrementByAmount, reset } = counterSlice.actions
|
|
32
|
+
export default counterSlice.reducer
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { useDispatch, useSelector } from 'react-redux'
|
|
2
|
+
import type { RootState, AppDispatch } from '@/store'
|
|
3
|
+
|
|
4
|
+
export const useAppDispatch = () => useDispatch<AppDispatch>()
|
|
5
|
+
export const useAppSelector = <T>(selector: (state: RootState) => T): T => useSelector(selector)
|