shortcut-next 0.2.2 → 0.2.6

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.
Files changed (35) hide show
  1. package/package.json +5 -2
  2. package/templates/base/@core/configs/clientConfig.ts +1 -1
  3. package/templates/base/@core/context/AuthContext.tsx +21 -28
  4. package/templates/base/@core/hooks/useAbility.ts +58 -0
  5. package/templates/base/app/(dashboard)/dashboard/page.tsx +104 -0
  6. package/templates/base/app/(dashboard)/layout.tsx +97 -0
  7. package/templates/base/app/home/page.tsx +112 -0
  8. package/templates/base/app/login/page.tsx +296 -0
  9. package/templates/base/app/unauthorized/page.tsx +120 -0
  10. package/templates/base/components/MSWProvider.tsx +54 -0
  11. package/templates/base/components/auth/LoginForm.tsx +279 -0
  12. package/templates/base/components/auth/SignupForm.tsx +348 -0
  13. package/templates/base/components/loaders/Spinner.tsx +5 -24
  14. package/templates/base/components/ui/ErrorMessage.tsx +17 -0
  15. package/templates/base/components/ui/FormFieldWrapper.tsx +27 -0
  16. package/templates/base/docs/AuthorizationDocumentation.md +348 -0
  17. package/templates/base/lib/abilities/checkAuthorization.ts +74 -0
  18. package/templates/base/lib/abilities/index.ts +27 -0
  19. package/templates/base/lib/abilities/roles.ts +75 -0
  20. package/templates/base/lib/abilities/routeMap.ts +35 -0
  21. package/templates/base/lib/abilities/routeMatcher.ts +117 -0
  22. package/templates/base/lib/abilities/types.ts +68 -0
  23. package/templates/base/lib/mocks/browser.ts +11 -0
  24. package/templates/base/lib/mocks/db.ts +124 -0
  25. package/templates/base/lib/mocks/handlers/auth.ts +203 -0
  26. package/templates/base/lib/mocks/handlers/index.ts +16 -0
  27. package/templates/base/lib/mocks/index.ts +34 -0
  28. package/templates/base/lib/mocks/jwt.ts +99 -0
  29. package/templates/base/middleware.ts +147 -0
  30. package/templates/base/package-lock.json +725 -2
  31. package/templates/base/package.json +13 -2
  32. package/templates/base/providers/AppProviders.tsx +8 -5
  33. package/templates/base/public/locales/ar.json +73 -0
  34. package/templates/base/public/locales/en.json +73 -0
  35. package/templates/base/public/mockServiceWorker.js +349 -0
@@ -0,0 +1,279 @@
1
+ 'use client'
2
+
3
+ import { Stack, Typography, TextField, Button, Divider, IconButton, InputAdornment } from '@mui/material'
4
+ import { LoadingButton } from '@mui/lab'
5
+ import { useState } from 'react'
6
+ import { useForm, Controller } from 'react-hook-form'
7
+ import { yupResolver } from '@hookform/resolvers/yup'
8
+ import * as yup from 'yup'
9
+ import { motion } from 'framer-motion'
10
+ import { useTranslation } from 'react-i18next'
11
+ import { Eye, EyeOff, ArrowLeft, Mail } from 'lucide-react'
12
+ import { Icon } from '@iconify/react'
13
+ import FormFieldWrapper from '@/components/ui/FormFieldWrapper'
14
+ import ErrorMessage from '@/components/ui/ErrorMessage'
15
+ import { useAuth } from '@/@core/context/AuthContext'
16
+ import useLanguage from '@/@core/hooks/useLanguage'
17
+
18
+ interface LoginFormData {
19
+ email: string
20
+ password: string
21
+ [key: string]: unknown
22
+ }
23
+
24
+ interface LoginFormProps {
25
+ showEmailForm: boolean
26
+ setShowEmailForm: (show: boolean) => void
27
+ onSwitchToSignup: () => void
28
+ onSocialLogin: (provider: 'google' | 'microsoft') => void
29
+ }
30
+
31
+ const MotionStack = motion.create(Stack)
32
+
33
+ const staggerContainer = {
34
+ hidden: {},
35
+ show: {
36
+ transition: {
37
+ staggerChildren: 0.08,
38
+ delayChildren: 0.08
39
+ }
40
+ }
41
+ }
42
+
43
+ const fadeInUp = {
44
+ hidden: { opacity: 0, y: 12 },
45
+ show: {
46
+ opacity: 1,
47
+ y: 0,
48
+ transition: { duration: 0.25, ease: 'easeOut' as const }
49
+ }
50
+ }
51
+
52
+ const LoginForm: React.FC<LoginFormProps> = ({ showEmailForm, setShowEmailForm, onSwitchToSignup, onSocialLogin }) => {
53
+ const { t } = useTranslation()
54
+ const { login, isLoading } = useAuth()
55
+ const [showPassword, setShowPassword] = useState(false)
56
+ const [errorMessage, setErrorMessage] = useState('')
57
+ const { language } = useLanguage()
58
+
59
+ const handleTogglePasswordVisibility = () => {
60
+ setShowPassword(!showPassword)
61
+ }
62
+
63
+ const loginSchema = yup.object().shape({
64
+ email: yup
65
+ .string()
66
+ .email(t('validation.invalidEmail', 'Invalid email address'))
67
+ .required(t('validation.emailRequired', 'Email is required')),
68
+ password: yup
69
+ .string()
70
+ .min(6, t('validation.passwordMin6', 'Password must be at least 6 characters'))
71
+ .required(t('validation.passwordRequired', 'Password is required'))
72
+ })
73
+
74
+ const {
75
+ control,
76
+ handleSubmit,
77
+ formState: { errors }
78
+ } = useForm<LoginFormData>({
79
+ defaultValues: {
80
+ email: '',
81
+ password: ''
82
+ },
83
+ resolver: yupResolver(loginSchema)
84
+ })
85
+
86
+ const onSubmit = async (data: LoginFormData) => {
87
+ setErrorMessage('')
88
+ await login(data, error => {
89
+ setErrorMessage(typeof error === 'string' ? error : 'Login failed')
90
+ })
91
+ }
92
+
93
+ return (
94
+ <MotionStack
95
+ spacing={showEmailForm ? 3 : 4}
96
+ variants={staggerContainer}
97
+ initial='hidden'
98
+ animate='show'
99
+ exit='hidden'
100
+ >
101
+ <motion.div variants={fadeInUp}>
102
+ <Typography variant='h4' textAlign='center' mb={1}>
103
+ {t('login.brand', 'Welcome Back')}
104
+ </Typography>
105
+ </motion.div>
106
+
107
+ <motion.div variants={fadeInUp}>
108
+ <Typography variant='body2' textAlign='center' color='text.secondary' mb={showEmailForm ? 1 : 0}>
109
+ {t('login.subtitle', 'Sign in to continue to your account')}
110
+ </Typography>
111
+ </motion.div>
112
+
113
+ <motion.div variants={fadeInUp}>
114
+ {!showEmailForm ? (
115
+ <MotionStack spacing={3} variants={staggerContainer} initial='hidden' animate='show' exit='hidden'>
116
+ <motion.div variants={fadeInUp}>
117
+ <Button
118
+ variant='outlined'
119
+ size='large'
120
+ fullWidth
121
+ startIcon={<Icon icon='mdi:google' />}
122
+ onClick={() => onSocialLogin('google')}
123
+ sx={{
124
+ borderColor: 'divider',
125
+ color: 'text.primary',
126
+ '&:hover': { bgcolor: 'action.hover' }
127
+ }}
128
+ >
129
+ {t('login.signInWithGoogle', 'Sign in with Google')}
130
+ </Button>
131
+ </motion.div>
132
+
133
+ <motion.div variants={fadeInUp}>
134
+ <Button
135
+ variant='outlined'
136
+ size='large'
137
+ fullWidth
138
+ startIcon={<Icon icon='mdi:microsoft' />}
139
+ onClick={() => onSocialLogin('microsoft')}
140
+ sx={{
141
+ borderColor: 'divider',
142
+ color: 'text.primary',
143
+ '&:hover': { bgcolor: 'action.hover' }
144
+ }}
145
+ >
146
+ {t('login.signInWithMicrosoft', 'Sign in with Microsoft')}
147
+ </Button>
148
+ </motion.div>
149
+
150
+ <motion.div variants={fadeInUp}>
151
+ <Divider sx={{ my: 3 }}>
152
+ <Typography variant='body2' textAlign='center' color='text.secondary'>
153
+ {t('login.or', 'or')}
154
+ </Typography>
155
+ </Divider>
156
+ </motion.div>
157
+
158
+ <motion.div variants={fadeInUp}>
159
+ <Button
160
+ variant='contained'
161
+ size='large'
162
+ fullWidth
163
+ startIcon={<Mail size={18} />}
164
+ onClick={() => setShowEmailForm(true)}
165
+ >
166
+ {t('login.signInWithEmail', 'Sign in with Email')}
167
+ </Button>
168
+ </motion.div>
169
+
170
+ <motion.div variants={fadeInUp}>
171
+ <Typography variant='body2' textAlign='center' color='text.secondary' sx={{ mt: 3 }}>
172
+ {t('login.dontHaveAccount', "Don't have an account?")}{' '}
173
+ <Button
174
+ variant='text'
175
+ color='primary'
176
+ onClick={() => {
177
+ setShowEmailForm(false)
178
+ onSwitchToSignup()
179
+ }}
180
+ >
181
+ {t('login.signUp', 'Sign Up')}
182
+ </Button>
183
+ </Typography>
184
+ </motion.div>
185
+ </MotionStack>
186
+ ) : (
187
+ <form onSubmit={handleSubmit(onSubmit)}>
188
+ <MotionStack spacing={3} variants={staggerContainer} initial='hidden' animate='show' exit='hidden'>
189
+ <ErrorMessage message={errorMessage} />
190
+
191
+ <motion.div variants={fadeInUp}>
192
+ <FormFieldWrapper title={t('login.email', 'Email')}>
193
+ <Controller
194
+ name='email'
195
+ control={control}
196
+ render={({ field }) => (
197
+ <TextField
198
+ {...field}
199
+ fullWidth
200
+ size='small'
201
+ placeholder={t('login.emailPlaceholder', 'your@email.com')}
202
+ error={!!errors.email}
203
+ helperText={errors.email?.message}
204
+ />
205
+ )}
206
+ />
207
+ </FormFieldWrapper>
208
+ </motion.div>
209
+
210
+ <motion.div variants={fadeInUp}>
211
+ <FormFieldWrapper title={t('login.password', 'Password')}>
212
+ <Controller
213
+ name='password'
214
+ control={control}
215
+ render={({ field }) => (
216
+ <TextField
217
+ {...field}
218
+ fullWidth
219
+ size='small'
220
+ type={showPassword ? 'text' : 'password'}
221
+ placeholder='••••••••'
222
+ error={!!errors.password}
223
+ helperText={errors.password?.message}
224
+ slotProps={{
225
+ input: {
226
+ endAdornment: (
227
+ <InputAdornment position='end'>
228
+ <IconButton
229
+ edge='end'
230
+ onClick={handleTogglePasswordVisibility}
231
+ onMouseDown={e => e.preventDefault()}
232
+ size='small'
233
+ >
234
+ {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
235
+ </IconButton>
236
+ </InputAdornment>
237
+ )
238
+ }
239
+ }}
240
+ />
241
+ )}
242
+ />
243
+ </FormFieldWrapper>
244
+ </motion.div>
245
+
246
+ <motion.div variants={fadeInUp}>
247
+ <LoadingButton
248
+ loading={isLoading}
249
+ type='submit'
250
+ variant='contained'
251
+ size='large'
252
+ fullWidth
253
+ sx={{ mt: 2 }}
254
+ >
255
+ {!isLoading && t('login.signIn', 'Sign In')}
256
+ </LoadingButton>
257
+ </motion.div>
258
+
259
+ <motion.div variants={fadeInUp}>
260
+ <Button
261
+ variant='text'
262
+ onClick={() => setShowEmailForm(false)}
263
+ sx={{ color: 'text.secondary' }}
264
+ startIcon={
265
+ <ArrowLeft size={16} style={{ transform: language === 'ar' ? 'rotate(180deg)' : 'none' }} />
266
+ }
267
+ >
268
+ {t('login.backToSocial', 'Back to social login')}
269
+ </Button>
270
+ </motion.div>
271
+ </MotionStack>
272
+ </form>
273
+ )}
274
+ </motion.div>
275
+ </MotionStack>
276
+ )
277
+ }
278
+
279
+ export default LoginForm
@@ -0,0 +1,348 @@
1
+ 'use client'
2
+
3
+ import { Stack, Typography, TextField, Button, InputAdornment, IconButton, MenuItem } from '@mui/material'
4
+ import { LoadingButton } from '@mui/lab'
5
+ import { useState } from 'react'
6
+ import { useForm, Controller } from 'react-hook-form'
7
+ import { yupResolver } from '@hookform/resolvers/yup'
8
+ import * as yup from 'yup'
9
+ import { motion } from 'framer-motion'
10
+ import { useTranslation } from 'react-i18next'
11
+ import { Eye, EyeOff } from 'lucide-react'
12
+ import FormFieldWrapper from '@/components/ui/FormFieldWrapper'
13
+ import ErrorMessage from '@/components/ui/ErrorMessage'
14
+ import { useAuth } from '@/@core/context/AuthContext'
15
+ import type { UserRole } from '@/lib/abilities'
16
+
17
+ interface SignupFormData {
18
+ firstName: string
19
+ lastName: string
20
+ email: string
21
+ password: string
22
+ confirmPassword: string
23
+ phone: string
24
+ role: UserRole
25
+ [key: string]: unknown
26
+ }
27
+
28
+ interface SignupFormProps {
29
+ onSwitchToLogin: () => void
30
+ }
31
+
32
+ const MotionStack = motion.create(Stack)
33
+
34
+ const staggerContainer = {
35
+ hidden: {},
36
+ show: {
37
+ transition: {
38
+ staggerChildren: 0.08,
39
+ delayChildren: 0.08
40
+ }
41
+ }
42
+ }
43
+
44
+ const fadeInUp = {
45
+ hidden: { opacity: 0, y: 32 },
46
+ show: {
47
+ opacity: 1,
48
+ y: 0,
49
+ transition: { duration: 0.25, ease: 'easeOut' as const }
50
+ }
51
+ }
52
+
53
+ const ROLES: { value: UserRole; label: string; description: string }[] = [
54
+ { value: 'admin', label: 'Admin', description: 'Full access to everything' },
55
+ { value: 'manager', label: 'Manager', description: 'Manage users, tickets, and reports' },
56
+ { value: 'agent', label: 'Agent', description: 'Handle tickets and view reports' },
57
+ { value: 'viewer', label: 'Viewer', description: 'View-only access' }
58
+ ]
59
+
60
+ const SignupForm: React.FC<SignupFormProps> = ({ onSwitchToLogin }) => {
61
+ const { t } = useTranslation()
62
+ const { signup, isLoading } = useAuth()
63
+ const [showPassword, setShowPassword] = useState(false)
64
+ const [errorMessage, setErrorMessage] = useState('')
65
+
66
+ const handleTogglePasswordVisibility = () => {
67
+ setShowPassword(!showPassword)
68
+ }
69
+
70
+ const signupSchema = yup.object().shape({
71
+ firstName: yup.string().required(t('validation.firstNameRequired', 'First name is required')),
72
+ lastName: yup.string().required(t('validation.lastNameRequired', 'Last name is required')),
73
+ email: yup
74
+ .string()
75
+ .email(t('validation.invalidEmail', 'Invalid email address'))
76
+ .required(t('validation.emailRequired', 'Email is required')),
77
+ phone: yup.string().required(t('validation.phoneRequired', 'Phone is required')),
78
+ role: yup
79
+ .string()
80
+ .oneOf(['admin', 'manager', 'agent', 'viewer'])
81
+ .required(t('validation.roleRequired', 'Role is required')),
82
+ password: yup
83
+ .string()
84
+ .min(8, t('validation.passwordMin8', 'Password must be at least 8 characters'))
85
+ .required(t('validation.passwordRequired', 'Password is required')),
86
+ confirmPassword: yup
87
+ .string()
88
+ .oneOf([yup.ref('password')], t('validation.passwordsDoNotMatch', 'Passwords do not match'))
89
+ .required(t('validation.confirmPasswordRequired', 'Please confirm your password'))
90
+ })
91
+
92
+ const {
93
+ control,
94
+ handleSubmit,
95
+ formState: { errors, isSubmitting }
96
+ } = useForm<SignupFormData>({
97
+ defaultValues: {
98
+ firstName: '',
99
+ lastName: '',
100
+ email: '',
101
+ phone: '',
102
+ password: '',
103
+ confirmPassword: '',
104
+ role: 'viewer'
105
+ },
106
+ resolver: yupResolver(signupSchema)
107
+ })
108
+
109
+ const onSubmit = async (data: SignupFormData) => {
110
+ setErrorMessage('')
111
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
112
+ const { confirmPassword, firstName, lastName, ...rest } = data
113
+
114
+ await signup(
115
+ {
116
+ ...rest,
117
+ name: `${firstName} ${lastName}`
118
+ },
119
+ error => {
120
+ setErrorMessage(typeof error === 'string' ? error : 'Signup failed')
121
+ }
122
+ )
123
+ }
124
+
125
+ return (
126
+ <MotionStack spacing={3} variants={staggerContainer} initial='hidden' animate='show' exit='hidden'>
127
+ <motion.div variants={fadeInUp}>
128
+ <Typography variant='h4' textAlign='center' mb={1}>
129
+ {t('signup.title', 'Create Account')}
130
+ </Typography>
131
+ </motion.div>
132
+
133
+ <motion.div variants={fadeInUp}>
134
+ <Typography variant='body2' textAlign='center' color='text.secondary' mb={1}>
135
+ {t('signup.subtitle', 'Sign up to get started')}
136
+ </Typography>
137
+ </motion.div>
138
+
139
+ <motion.div variants={fadeInUp}>
140
+ <form onSubmit={handleSubmit(onSubmit)}>
141
+ <MotionStack spacing={3} variants={staggerContainer} initial='hidden' animate='show' exit='hidden'>
142
+ <ErrorMessage message={errorMessage} />
143
+
144
+ <MotionStack
145
+ direction={{ xs: 'column', sm: 'row' }}
146
+ gap={2}
147
+ variants={staggerContainer}
148
+ initial='hidden'
149
+ animate='show'
150
+ exit='hidden'
151
+ >
152
+ <motion.div variants={fadeInUp} style={{ width: '100%' }}>
153
+ <FormFieldWrapper title={t('signup.firstName', 'First Name')}>
154
+ <Controller
155
+ name='firstName'
156
+ control={control}
157
+ render={({ field }) => (
158
+ <TextField
159
+ {...field}
160
+ fullWidth
161
+ size='small'
162
+ placeholder={t('signup.firstNamePlaceholder', 'John')}
163
+ error={!!errors.firstName}
164
+ helperText={errors.firstName?.message}
165
+ />
166
+ )}
167
+ />
168
+ </FormFieldWrapper>
169
+ </motion.div>
170
+
171
+ <motion.div variants={fadeInUp} style={{ width: '100%' }}>
172
+ <FormFieldWrapper title={t('signup.lastName', 'Last Name')}>
173
+ <Controller
174
+ name='lastName'
175
+ control={control}
176
+ render={({ field }) => (
177
+ <TextField
178
+ {...field}
179
+ fullWidth
180
+ size='small'
181
+ placeholder={t('signup.lastNamePlaceholder', 'Doe')}
182
+ error={!!errors.lastName}
183
+ helperText={errors.lastName?.message}
184
+ />
185
+ )}
186
+ />
187
+ </FormFieldWrapper>
188
+ </motion.div>
189
+ </MotionStack>
190
+
191
+ <motion.div variants={fadeInUp}>
192
+ <FormFieldWrapper title={t('signup.email', 'Email')}>
193
+ <Controller
194
+ name='email'
195
+ control={control}
196
+ render={({ field }) => (
197
+ <TextField
198
+ {...field}
199
+ fullWidth
200
+ size='small'
201
+ placeholder={t('signup.emailPlaceholder', 'your@email.com')}
202
+ error={!!errors.email}
203
+ helperText={errors.email?.message}
204
+ />
205
+ )}
206
+ />
207
+ </FormFieldWrapper>
208
+ </motion.div>
209
+
210
+ <motion.div variants={fadeInUp}>
211
+ <FormFieldWrapper title={t('signup.phone', 'Phone')}>
212
+ <Controller
213
+ name='phone'
214
+ control={control}
215
+ render={({ field }) => (
216
+ <TextField
217
+ {...field}
218
+ fullWidth
219
+ size='small'
220
+ placeholder={t('signup.phonePlaceholder', '+1 234 567 8900')}
221
+ error={!!errors.phone}
222
+ helperText={errors.phone?.message}
223
+ />
224
+ )}
225
+ />
226
+ </FormFieldWrapper>
227
+ </motion.div>
228
+
229
+ <motion.div variants={fadeInUp}>
230
+ <FormFieldWrapper title={t('signup.role', 'Role')}>
231
+ <Controller
232
+ name='role'
233
+ control={control}
234
+ render={({ field }) => (
235
+ <TextField
236
+ {...field}
237
+ select
238
+ fullWidth
239
+ size='small'
240
+ error={!!errors.role}
241
+ helperText={
242
+ errors.role?.message || t('signup.roleHelp', 'Select a role for testing authorization')
243
+ }
244
+ >
245
+ {ROLES.map(role => (
246
+ <MenuItem key={role.value} value={role.value}>
247
+ <Stack>
248
+ <Typography variant='body2' fontWeight={500}>
249
+ {role.label}
250
+ </Typography>
251
+ <Typography variant='caption' color='text.secondary'>
252
+ {role.description}
253
+ </Typography>
254
+ </Stack>
255
+ </MenuItem>
256
+ ))}
257
+ </TextField>
258
+ )}
259
+ />
260
+ </FormFieldWrapper>
261
+ </motion.div>
262
+
263
+ <motion.div variants={fadeInUp}>
264
+ <FormFieldWrapper title={t('signup.password', 'Password')}>
265
+ <Controller
266
+ name='password'
267
+ control={control}
268
+ render={({ field }) => (
269
+ <TextField
270
+ {...field}
271
+ fullWidth
272
+ size='small'
273
+ type={showPassword ? 'text' : 'password'}
274
+ placeholder='••••••••'
275
+ error={!!errors.password}
276
+ helperText={errors.password?.message}
277
+ slotProps={{
278
+ input: {
279
+ endAdornment: (
280
+ <InputAdornment position='end'>
281
+ <IconButton
282
+ edge='end'
283
+ onClick={handleTogglePasswordVisibility}
284
+ onMouseDown={e => e.preventDefault()}
285
+ size='small'
286
+ >
287
+ {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
288
+ </IconButton>
289
+ </InputAdornment>
290
+ )
291
+ }
292
+ }}
293
+ />
294
+ )}
295
+ />
296
+ </FormFieldWrapper>
297
+ </motion.div>
298
+
299
+ <motion.div variants={fadeInUp}>
300
+ <FormFieldWrapper title={t('signup.confirmPassword', 'Confirm Password')}>
301
+ <Controller
302
+ name='confirmPassword'
303
+ control={control}
304
+ render={({ field }) => (
305
+ <TextField
306
+ {...field}
307
+ fullWidth
308
+ size='small'
309
+ type={showPassword ? 'text' : 'password'}
310
+ placeholder='••••••••'
311
+ error={!!errors.confirmPassword}
312
+ helperText={errors.confirmPassword?.message}
313
+ />
314
+ )}
315
+ />
316
+ </FormFieldWrapper>
317
+ </motion.div>
318
+
319
+ <motion.div variants={fadeInUp}>
320
+ <LoadingButton
321
+ loading={isLoading}
322
+ type='submit'
323
+ variant='contained'
324
+ size='large'
325
+ fullWidth
326
+ disabled={isSubmitting}
327
+ sx={{ mt: 2 }}
328
+ >
329
+ {!isLoading && t('signup.create', 'Create Account')}
330
+ </LoadingButton>
331
+ </motion.div>
332
+
333
+ <motion.div variants={fadeInUp}>
334
+ <Typography variant='body2' textAlign='center' color='text.secondary' sx={{ mt: 3 }}>
335
+ {t('signup.alreadyHaveAccount', 'Already have an account?')}{' '}
336
+ <Button variant='text' onClick={onSwitchToLogin} color='primary'>
337
+ {t('signup.signIn', 'Sign In')}
338
+ </Button>
339
+ </Typography>
340
+ </motion.div>
341
+ </MotionStack>
342
+ </form>
343
+ </motion.div>
344
+ </MotionStack>
345
+ )
346
+ }
347
+
348
+ export default SignupForm
@@ -1,30 +1,11 @@
1
1
  'use client'
2
- import './spinner.css'
2
+
3
+ import { CircularProgress, Stack } from '@mui/material'
3
4
 
4
5
  export default function Spinner() {
5
6
  return (
6
- <div className='container'>
7
- <div
8
- style={{
9
- position: 'absolute',
10
- inset: 0,
11
- zIndex: 0,
12
- pointerEvents: 'none',
13
- background: `radial-gradient(circle at center,
14
- rgba(59,130,246,.12) 0%,
15
- rgba(59,130,246,.06) 20%,
16
- rgba(0,0,0,0) 60%)`
17
- }}
18
- />
19
- <div className='spinner'>
20
- {Array.from({ length: 6 }).map((_, i) => (
21
- <div key={i} />
22
- ))}
23
- </div>
24
- <div className='loading-text'>
25
- <span>Loading</span>
26
- <span className='animated-dots' />
27
- </div>
28
- </div>
7
+ <Stack height='100vh' alignItems='center' justifyContent='center'>
8
+ <CircularProgress disableShrink size={16} />
9
+ </Stack>
29
10
  )
30
11
  }
@@ -0,0 +1,17 @@
1
+ 'use client'
2
+
3
+ import { Alert } from '@mui/material'
4
+
5
+ interface ErrorMessageProps {
6
+ message?: string | null
7
+ }
8
+
9
+ export default function ErrorMessage({ message }: ErrorMessageProps) {
10
+ if (!message) return null
11
+
12
+ return (
13
+ <Alert severity='error' sx={{ mb: 2 }}>
14
+ {message}
15
+ </Alert>
16
+ )
17
+ }