130 changed files with 7790 additions and 5308 deletions
-
15web/.env.example
-
119web/app/(app)/(auth)/(components)/login-form.tsx
-
47web/app/(app)/(auth)/(components)/login-with-google.tsx
-
179web/app/(app)/(auth)/(components)/register-form.tsx
-
134web/app/(app)/(auth)/(components)/request-password-reset-form.tsx
-
199web/app/(app)/(auth)/(components)/reset-password-form.tsx
-
7web/app/(app)/(auth)/layout.tsx
-
66web/app/(app)/(auth)/login/page.tsx
-
16web/app/(app)/(auth)/logout/page.tsx
-
49web/app/(app)/(auth)/register/page.tsx
-
18web/app/(app)/(auth)/reset-password/page.tsx
-
509web/app/(app)/dashboard/(components)/account-settings.tsx
-
347web/app/(app)/dashboard/(components)/api-keys.tsx
-
37web/app/(app)/dashboard/(components)/community-alert.tsx
-
62web/app/(app)/dashboard/(components)/community-links.tsx
-
83web/app/(app)/dashboard/(components)/dashboard-layout.tsx
-
118web/app/(app)/dashboard/(components)/device-list.tsx
-
186web/app/(app)/dashboard/(components)/generate-api-key.tsx
-
71web/app/(app)/dashboard/(components)/get-started.tsx
-
75web/app/(app)/dashboard/(components)/main-dashboard.tsx
-
64web/app/(app)/dashboard/(components)/messaging.tsx
-
66web/app/(app)/dashboard/(components)/overview.tsx
-
146web/app/(app)/dashboard/(components)/received-sms.tsx
-
206web/app/(app)/dashboard/(components)/send-sms.tsx
-
9web/app/(app)/dashboard/layout.tsx
-
3web/app/(app)/dashboard/page.tsx
-
49web/app/(app)/layout-wrapper.tsx
-
19web/app/(app)/layout.tsx
-
91web/app/(landing-page)/(components)/code-snippet-section.tsx
-
35web/app/(landing-page)/(components)/customization-section.tsx
-
36web/app/(landing-page)/(components)/download-app-section.tsx
-
48web/app/(landing-page)/(components)/features-section.tsx
-
75web/app/(landing-page)/(components)/hero-section.tsx
-
57web/app/(landing-page)/(components)/how-it-works-section.tsx
-
50web/app/(landing-page)/(components)/landing-page-header.tsx
-
114web/app/(landing-page)/(components)/support-project-section.tsx
-
11web/app/(landing-page)/layout.tsx
-
23web/app/(landing-page)/page.tsx
-
82web/app/(landing-page)/privacy-policy/page.tsx
-
9web/app/(todo-migrate-pages-to-app-router)/v2/page.tsx
-
6web/app/api/auth/[...nextauth]/route.ts
-
59web/app/api/customer-support/route.ts
-
86web/app/api/request-account-deletion/route.ts
-
25web/app/customer-support/page.tsx
-
63web/app/layout.tsx
-
30web/components/AnimatedScrollWrapper.tsx
-
82web/components/Footer.tsx
-
151web/components/Navbar.tsx
-
29web/components/dashboard/APIKeyAndDevices.tsx
-
94web/components/dashboard/ApiKeyList.tsx
-
116web/components/dashboard/DeviceList.tsx
-
179web/components/dashboard/GenerateApiKey.tsx
-
170web/components/dashboard/ReceiveSMS.tsx
-
139web/components/dashboard/SendSMS.tsx
-
78web/components/dashboard/UserStats.tsx
-
32web/components/dashboard/UserStatsCard.tsx
-
90web/components/landing/CodeSnippetSection.tsx
-
54web/components/landing/Customization.tsx
-
91web/components/landing/DownloadAppSection.tsx
-
64web/components/landing/FeaturesSection.tsx
-
60web/components/landing/HowItWorksSection.tsx
-
153web/components/landing/IntroSection.tsx
-
56web/components/landing/SupportTheProject.tsx
-
19web/components/landing/featuresContent.ts
-
23web/components/landing/howItWorksContent.ts
-
30web/components/livechat/LiveChat.tsx
-
24web/components/meta/Meta.tsx
-
11web/components/shared/analytics.tsx
-
174web/components/shared/app-header.tsx
-
227web/components/shared/customer-support.tsx
-
51web/components/shared/footer.tsx
-
57web/components/ui/accordion.tsx
-
59web/components/ui/alert.tsx
-
50web/components/ui/avatar.tsx
-
36web/components/ui/badge.tsx
-
57web/components/ui/button.tsx
-
76web/components/ui/card.tsx
-
29web/components/ui/checkbox.tsx
-
122web/components/ui/dialog.tsx
-
200web/components/ui/dropdown-menu.tsx
-
178web/components/ui/form.tsx
-
25web/components/ui/input.tsx
-
26web/components/ui/label.tsx
-
127web/components/ui/navigation-menu.tsx
-
48web/components/ui/scroll-area.tsx
-
164web/components/ui/select.tsx
-
139web/components/ui/sheet.tsx
-
35web/components/ui/spinner.tsx
-
29web/components/ui/switch.tsx
-
55web/components/ui/tabs.tsx
-
24web/components/ui/textarea.tsx
-
128web/components/ui/toast.tsx
-
35web/components/ui/toaster.tsx
-
27web/config/api.ts
-
5web/config/external-links.ts
-
12web/config/routes.ts
-
194web/hooks/use-toast.ts
-
153web/lib/auth.ts
-
17web/lib/httpBrowserClient.ts
-
16web/lib/httpClient.ts
@ -1,3 +1,16 @@ |
|||
NEXT_PUBLIC_API_BASE_URL=https://api.textbee.dev/api/v1 |
|||
NEXT_PUBLIC_GOOGLE_CLIENT_ID= |
|||
NEXT_PUBLIC_TAWKTO_EMBED_URL= |
|||
NEXT_PUBLIC_TAWKTO_EMBED_URL= |
|||
|
|||
NEXTAUTH_URL=http://localhost:3000 |
|||
AUTH_SECRET= # https://generate-secret.vercel.app/32 |
|||
|
|||
DATABASE_URL= |
|||
|
|||
MAIL_HOST= |
|||
MAIL_PORT= |
|||
MAIL_USER= |
|||
MAIL_PASS= |
|||
MAIL_FROM= |
|||
|
|||
ADMIN_EMAIL= |
|||
@ -0,0 +1,119 @@ |
|||
'use client' |
|||
|
|||
import { signIn } from 'next-auth/react' |
|||
import { useRouter } from 'next/navigation' |
|||
import { useForm } from 'react-hook-form' |
|||
import { zodResolver } from '@hookform/resolvers/zod' |
|||
import * as z from 'zod' |
|||
import { Button } from '@/components/ui/button' |
|||
import { Input } from '@/components/ui/input' |
|||
|
|||
import { |
|||
Form, |
|||
FormControl, |
|||
FormField, |
|||
FormItem, |
|||
FormLabel, |
|||
FormMessage, |
|||
} from '@/components/ui/form' |
|||
import { Routes } from '@/config/routes' |
|||
|
|||
const loginSchema = z.object({ |
|||
email: z.string().email({ message: 'Invalid email address' }), |
|||
password: z.string().min(1, { message: 'Password is required' }), |
|||
}) |
|||
|
|||
type LoginFormValues = z.infer<typeof loginSchema> |
|||
|
|||
export default function LoginForm() { |
|||
const router = useRouter() |
|||
|
|||
const form = useForm<LoginFormValues>({ |
|||
resolver: zodResolver(loginSchema), |
|||
defaultValues: { |
|||
email: '', |
|||
password: '', |
|||
}, |
|||
}) |
|||
|
|||
const onSubmit = async (data: LoginFormValues) => { |
|||
try { |
|||
const result = await signIn('email-password-login', { |
|||
redirect: false, |
|||
email: data.email, |
|||
password: data.password, |
|||
}) |
|||
|
|||
if (result?.error) { |
|||
form.setError('root', { |
|||
type: 'manual', |
|||
message: 'Invalid email or password', |
|||
}) |
|||
} else { |
|||
router.push(Routes.dashboard) |
|||
} |
|||
} catch (error) { |
|||
console.error('login error:', error) |
|||
form.setError('root', { |
|||
type: 'manual', |
|||
message: 'An unexpected error occurred. Please try again.', |
|||
}) |
|||
} |
|||
} |
|||
|
|||
return ( |
|||
<Form {...form}> |
|||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'> |
|||
<FormField |
|||
control={form.control} |
|||
name='email' |
|||
render={({ field }) => ( |
|||
<FormItem> |
|||
<FormLabel>Email</FormLabel> |
|||
<FormControl> |
|||
<Input |
|||
placeholder='m@example.com' |
|||
{...field} |
|||
className='bg-white' |
|||
/> |
|||
</FormControl> |
|||
<FormMessage /> |
|||
</FormItem> |
|||
)} |
|||
/> |
|||
<FormField |
|||
control={form.control} |
|||
name='password' |
|||
render={({ field }) => ( |
|||
<FormItem> |
|||
<FormLabel>Password</FormLabel> |
|||
<FormControl> |
|||
<Input type='password' {...field} className='bg-white' /> |
|||
</FormControl> |
|||
<FormMessage /> |
|||
</FormItem> |
|||
)} |
|||
/> |
|||
{form.formState.errors.root && ( |
|||
<p className='text-sm font-medium text-red-500'> |
|||
{form.formState.errors.root.message} |
|||
</p> |
|||
)} |
|||
<Button |
|||
className='w-full' |
|||
type='submit' |
|||
disabled={form.formState.isSubmitting} |
|||
> |
|||
{form.formState.isSubmitting ? ( |
|||
<> |
|||
{/* <Icons.spinner className="mr-2 h-4 w-4 animate-spin" /> */} |
|||
Signing in... |
|||
</> |
|||
) : ( |
|||
'Sign In' |
|||
)} |
|||
</Button> |
|||
</form> |
|||
</Form> |
|||
) |
|||
} |
|||
@ -0,0 +1,47 @@ |
|||
'use client' |
|||
|
|||
import { Routes } from '@/config/routes' |
|||
import { toast } from '@/hooks/use-toast' |
|||
import { CredentialResponse, GoogleLogin } from '@react-oauth/google' |
|||
import { signIn } from 'next-auth/react' |
|||
import { useRouter } from 'next/navigation' |
|||
|
|||
export default function LoginWithGoogle() { |
|||
const router = useRouter() |
|||
|
|||
const onGoogleLoginSuccess = async ( |
|||
credentialResponse: CredentialResponse |
|||
) => { |
|||
toast({ |
|||
title: 'Success', |
|||
description: 'You are logged in with Google', |
|||
variant: 'default', |
|||
}) |
|||
await signIn('google-id-token-login', { |
|||
redirect: false, |
|||
idToken: credentialResponse.credential, |
|||
}) |
|||
router.push(Routes.dashboard) |
|||
} |
|||
|
|||
const onGoogleLoginError = () => { |
|||
toast({ |
|||
title: 'Error', |
|||
description: 'Something went wrong', |
|||
variant: 'destructive', |
|||
}) |
|||
} |
|||
return ( |
|||
<GoogleLogin |
|||
onSuccess={onGoogleLoginSuccess} |
|||
onError={onGoogleLoginError} |
|||
useOneTap={true} |
|||
width={'100%'} |
|||
size='large' |
|||
shape='pill' |
|||
locale='en' |
|||
theme='outline' |
|||
text='continue_with' |
|||
/> |
|||
) |
|||
} |
|||
@ -0,0 +1,179 @@ |
|||
'use client' |
|||
|
|||
import { useRouter } from 'next/navigation' |
|||
import { useForm } from 'react-hook-form' |
|||
import { zodResolver } from '@hookform/resolvers/zod' |
|||
import * as z from 'zod' |
|||
import { Button } from '@/components/ui/button' |
|||
import { Input } from '@/components/ui/input' |
|||
|
|||
import { |
|||
Form, |
|||
FormControl, |
|||
FormField, |
|||
FormItem, |
|||
FormLabel, |
|||
FormMessage, |
|||
} from '@/components/ui/form' |
|||
import { signIn } from 'next-auth/react' |
|||
import { Checkbox } from '@/components/ui/checkbox' |
|||
import { Routes } from '@/config/routes' |
|||
|
|||
const registerSchema = z.object({ |
|||
name: z |
|||
.string() |
|||
.min(2, { message: 'Name must be at least 2 characters long' }), |
|||
email: z.string().email({ message: 'Invalid email address' }), |
|||
password: z |
|||
.string() |
|||
.min(8, { message: 'Password must be at least 8 characters long' }), |
|||
phone: z.string().optional(), |
|||
marketingOptIn: z.boolean().optional().default(true), |
|||
}) |
|||
|
|||
type RegisterFormValues = z.infer<typeof registerSchema> |
|||
|
|||
export default function RegisterForm() { |
|||
const router = useRouter() |
|||
|
|||
const form = useForm<RegisterFormValues>({ |
|||
resolver: zodResolver(registerSchema), |
|||
defaultValues: { |
|||
name: '', |
|||
email: '', |
|||
password: '', |
|||
phone: '', |
|||
marketingOptIn: true, |
|||
}, |
|||
}) |
|||
|
|||
const onSubmit = async (data: RegisterFormValues) => { |
|||
form.clearErrors() |
|||
|
|||
try { |
|||
const result = await signIn('email-password-register', { |
|||
redirect: false, |
|||
email: data.email, |
|||
password: data.password, |
|||
name: data.name, |
|||
phone: data.phone, |
|||
marketingOptIn: data.marketingOptIn, |
|||
}) |
|||
|
|||
if (result?.error) { |
|||
console.log(result.error) |
|||
form.setError('root', { |
|||
type: 'manual', |
|||
message: 'Failed to create account', |
|||
}) |
|||
} else { |
|||
router.push(Routes.dashboard) |
|||
} |
|||
} catch (error) { |
|||
console.error('register error:', error) |
|||
form.setError('root', { |
|||
type: 'manual', |
|||
message: 'An unexpected error occurred. Please try again.', |
|||
}) |
|||
} |
|||
} |
|||
|
|||
return ( |
|||
<Form {...form}> |
|||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'> |
|||
<FormField |
|||
control={form.control} |
|||
name='name' |
|||
render={({ field }) => ( |
|||
<FormItem> |
|||
<FormLabel>Full Name</FormLabel> |
|||
<FormControl> |
|||
<Input placeholder='John Doe' {...field} /> |
|||
</FormControl> |
|||
<FormMessage /> |
|||
</FormItem> |
|||
)} |
|||
/> |
|||
<FormField |
|||
control={form.control} |
|||
name='email' |
|||
render={({ field }) => ( |
|||
<FormItem> |
|||
<FormLabel>Email</FormLabel> |
|||
<FormControl> |
|||
<Input placeholder='m@example.com' {...field} /> |
|||
</FormControl> |
|||
<FormMessage /> |
|||
</FormItem> |
|||
)} |
|||
/> |
|||
<FormField |
|||
control={form.control} |
|||
name='password' |
|||
render={({ field }) => ( |
|||
<FormItem> |
|||
<FormLabel>Password</FormLabel> |
|||
<FormControl> |
|||
<Input type='password' {...field} /> |
|||
</FormControl> |
|||
<FormMessage /> |
|||
</FormItem> |
|||
)} |
|||
/> |
|||
<FormField |
|||
control={form.control} |
|||
name='phone' |
|||
render={({ field }) => ( |
|||
<FormItem> |
|||
<FormLabel>Phone (optional)</FormLabel> |
|||
<FormControl> |
|||
<Input placeholder='+1 (555) 000-0000' {...field} /> |
|||
</FormControl> |
|||
<FormMessage /> |
|||
</FormItem> |
|||
)} |
|||
/> |
|||
{form.formState.errors.root && ( |
|||
<p className='text-sm font-medium text-red-500'> |
|||
{form.formState.errors.root.message} |
|||
</p> |
|||
)} |
|||
|
|||
<FormField |
|||
control={form.control} |
|||
name='marketingOptIn' |
|||
render={({ field }) => ( |
|||
<FormItem> |
|||
<div className='flex items-center space-x-3 space-y-0'> |
|||
<FormControl> |
|||
<Checkbox |
|||
checked={field.value} |
|||
onCheckedChange={field.onChange} |
|||
/> |
|||
</FormControl> |
|||
<FormLabel className='text-sm'> |
|||
I want to receive updates about new features and promotions |
|||
</FormLabel> |
|||
</div> |
|||
<FormMessage /> |
|||
</FormItem> |
|||
)} |
|||
/> |
|||
<Button |
|||
className='w-full' |
|||
type='submit' |
|||
disabled={form.formState.isSubmitting} |
|||
> |
|||
{form.formState.isSubmitting ? ( |
|||
<> |
|||
{/* <Icons.spinner className="mr-2 h-4 w-4 animate-spin" /> */} |
|||
Creating account... |
|||
</> |
|||
) : ( |
|||
'Sign Up' |
|||
)} |
|||
</Button> |
|||
</form> |
|||
</Form> |
|||
) |
|||
} |
|||
@ -0,0 +1,134 @@ |
|||
'use client' |
|||
|
|||
import Link from 'next/link' |
|||
import { useForm } from 'react-hook-form' |
|||
import { zodResolver } from '@hookform/resolvers/zod' |
|||
import * as z from 'zod' |
|||
import { Button } from '@/components/ui/button' |
|||
import { Input } from '@/components/ui/input' |
|||
import { |
|||
Card, |
|||
CardContent, |
|||
CardDescription, |
|||
CardFooter, |
|||
CardHeader, |
|||
CardTitle, |
|||
} from '@/components/ui/card' |
|||
// import { Icons } from "@/components/ui/icons"
|
|||
import { |
|||
Form, |
|||
FormControl, |
|||
FormField, |
|||
FormItem, |
|||
FormLabel, |
|||
FormMessage, |
|||
} from '@/components/ui/form' |
|||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' |
|||
|
|||
import httpBrowserClient from '@/lib/httpBrowserClient' |
|||
import { ApiEndpoints } from '@/config/api' |
|||
import { Routes } from '@/config/routes' |
|||
|
|||
const requestPasswordResetSchema = z.object({ |
|||
email: z.string().email({ message: 'Invalid email address' }), |
|||
}) |
|||
|
|||
type RequestPasswordResetFormValues = z.infer<typeof requestPasswordResetSchema> |
|||
|
|||
export default function RequestPasswordResetForm() { |
|||
const form = useForm<RequestPasswordResetFormValues>({ |
|||
resolver: zodResolver(requestPasswordResetSchema), |
|||
defaultValues: { |
|||
email: '', |
|||
}, |
|||
}) |
|||
|
|||
const onRequestPasswordReset = async ( |
|||
data: RequestPasswordResetFormValues |
|||
) => { |
|||
form.clearErrors() |
|||
try { |
|||
await httpBrowserClient.post( |
|||
ApiEndpoints.auth.requestPasswordReset(), |
|||
data |
|||
) |
|||
} catch (error) { |
|||
form.setError('email', { message: 'Invalid email address' }) |
|||
} |
|||
} |
|||
|
|||
return ( |
|||
<div className='flex items-center justify-center min-h-screen bg-gray-100'> |
|||
<Card className='w-[400px] shadow-lg'> |
|||
<CardHeader className='space-y-1'> |
|||
<CardTitle className='text-2xl font-bold text-center'> |
|||
Reset your password |
|||
</CardTitle> |
|||
<CardDescription className='text-center'> |
|||
Enter your email address and we'll send you a link to reset |
|||
your password |
|||
</CardDescription> |
|||
</CardHeader> |
|||
<CardContent> |
|||
{!form.formState.isSubmitted ? ( |
|||
<Form {...form}> |
|||
<form |
|||
onSubmit={form.handleSubmit(onRequestPasswordReset)} |
|||
className='space-y-4' |
|||
> |
|||
<FormField |
|||
control={form.control} |
|||
name='email' |
|||
render={({ field }) => ( |
|||
<FormItem> |
|||
<FormLabel>Email</FormLabel> |
|||
<FormControl> |
|||
<Input placeholder='m@example.com' {...field} /> |
|||
</FormControl> |
|||
<FormMessage /> |
|||
</FormItem> |
|||
)} |
|||
/> |
|||
<Button |
|||
className='w-full' |
|||
type='submit' |
|||
disabled={form.formState.isSubmitting} |
|||
> |
|||
{form.formState.isSubmitting ? ( |
|||
<> |
|||
{/* <Icons.spinner className="mr-2 h-4 w-4 animate-spin" /> */} |
|||
Sending reset link... |
|||
</> |
|||
) : ( |
|||
'Send reset link' |
|||
)} |
|||
</Button> |
|||
</form> |
|||
</Form> |
|||
) : ( |
|||
<Alert> |
|||
{/* <Icons.checkCircle className="h-4 w-4" /> */} |
|||
<AlertTitle>Check your email</AlertTitle> |
|||
<AlertDescription> |
|||
If an account exists for {form.getValues().email}, you will |
|||
receive a password reset link shortly. |
|||
</AlertDescription> |
|||
<AlertDescription className='mt-4 text-sm text-muted-foreground italic'> |
|||
If you don't receive an email, please check your spam |
|||
folder or contact support. |
|||
</AlertDescription> |
|||
</Alert> |
|||
)} |
|||
</CardContent> |
|||
<CardFooter className='text-center'> |
|||
<Link |
|||
href={Routes.login} |
|||
className='text-sm text-blue-600 hover:underline' |
|||
> |
|||
Back to login |
|||
</Link> |
|||
</CardFooter> |
|||
</Card> |
|||
</div> |
|||
) |
|||
} |
|||
@ -0,0 +1,199 @@ |
|||
'use client' |
|||
|
|||
import { useState } from 'react' |
|||
import Link from 'next/link' |
|||
import { useForm } from 'react-hook-form' |
|||
import { zodResolver } from '@hookform/resolvers/zod' |
|||
import * as z from 'zod' |
|||
import { Button } from '@/components/ui/button' |
|||
import { Input } from '@/components/ui/input' |
|||
import { |
|||
Card, |
|||
CardContent, |
|||
CardDescription, |
|||
CardFooter, |
|||
CardHeader, |
|||
CardTitle, |
|||
} from '@/components/ui/card' |
|||
// import { Icons } from "@/components/ui/icons"
|
|||
import { |
|||
Form, |
|||
FormControl, |
|||
FormField, |
|||
FormItem, |
|||
FormLabel, |
|||
FormMessage, |
|||
} from '@/components/ui/form' |
|||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' |
|||
import httpBrowserClient from '@/lib/httpBrowserClient' |
|||
import { ApiEndpoints } from '@/config/api' |
|||
import { Routes } from '@/config/routes' |
|||
|
|||
const resetPasswordSchema = z |
|||
.object({ |
|||
email: z.string().email({ message: 'Invalid email address' }), |
|||
otp: z.string().min(4, { message: 'OTP is required' }), |
|||
newPassword: z |
|||
.string() |
|||
.min(8, { message: 'Password must be at least 8 characters long' }), |
|||
confirmPassword: z |
|||
.string() |
|||
.min(4, { message: 'Please confirm your password' }), |
|||
}) |
|||
.superRefine((data, ctx) => { |
|||
if (data.newPassword !== data.confirmPassword) { |
|||
ctx.addIssue({ |
|||
code: z.ZodIssueCode.custom, |
|||
message: 'Passwords must match', |
|||
path: ['confirmPassword'], |
|||
}) |
|||
} |
|||
}) |
|||
|
|||
type ResetPasswordFormValues = z.infer<typeof resetPasswordSchema> |
|||
|
|||
export default function ResetPasswordForm({ |
|||
email, |
|||
otp, |
|||
}: { |
|||
email: string |
|||
otp: string |
|||
}) { |
|||
const form = useForm<ResetPasswordFormValues>({ |
|||
resolver: zodResolver(resetPasswordSchema), |
|||
defaultValues: { |
|||
email: email, |
|||
otp: otp, |
|||
newPassword: '', |
|||
confirmPassword: '', |
|||
}, |
|||
}) |
|||
|
|||
const onResetPassword = async (data: ResetPasswordFormValues) => { |
|||
try { |
|||
await httpBrowserClient.post(ApiEndpoints.auth.resetPassword(), data) |
|||
} catch (error) { |
|||
console.error(error) |
|||
form.setError('root.serverError', { |
|||
message: 'Failed to reset password', |
|||
}) |
|||
} |
|||
} |
|||
|
|||
return ( |
|||
<div className='flex items-center justify-center min-h-screen bg-gray-100'> |
|||
<Card className='w-[400px] shadow-lg'> |
|||
<CardHeader className='space-y-1'> |
|||
<CardTitle className='text-2xl font-bold text-center'> |
|||
Reset your password |
|||
</CardTitle> |
|||
<CardDescription className='text-center'> |
|||
Enter your new password below |
|||
</CardDescription> |
|||
</CardHeader> |
|||
<CardContent> |
|||
<Form {...form}> |
|||
<form |
|||
onSubmit={form.handleSubmit(onResetPassword)} |
|||
className='space-y-4' |
|||
> |
|||
<FormField |
|||
control={form.control} |
|||
name='email' |
|||
render={({ field }) => ( |
|||
<FormItem> |
|||
<FormLabel>Email</FormLabel> |
|||
<FormControl> |
|||
<Input placeholder='m@example.com' {...field} /> |
|||
</FormControl> |
|||
<FormMessage /> |
|||
</FormItem> |
|||
)} |
|||
/> |
|||
<FormField |
|||
control={form.control} |
|||
name='otp' |
|||
render={({ field }) => ( |
|||
<FormItem> |
|||
<FormLabel>OTP</FormLabel> |
|||
<FormControl> |
|||
<Input placeholder='1234' {...field} /> |
|||
</FormControl> |
|||
<FormMessage /> |
|||
</FormItem> |
|||
)} |
|||
/> |
|||
|
|||
<FormField |
|||
control={form.control} |
|||
name='newPassword' |
|||
render={({ field }) => ( |
|||
<FormItem> |
|||
<FormLabel>New Password</FormLabel> |
|||
<FormControl> |
|||
<Input type='password' {...field} /> |
|||
</FormControl> |
|||
<FormMessage /> |
|||
</FormItem> |
|||
)} |
|||
/> |
|||
|
|||
<FormField |
|||
control={form.control} |
|||
name='confirmPassword' |
|||
render={({ field }) => ( |
|||
<FormItem> |
|||
<FormLabel>Confirm Password</FormLabel> |
|||
<FormControl> |
|||
<Input type='password' {...field} /> |
|||
</FormControl> |
|||
<FormMessage /> |
|||
</FormItem> |
|||
)} |
|||
/> |
|||
|
|||
{form.formState.errors.root && ( |
|||
<p className='text-sm font-medium text-red-500'> |
|||
{form.formState.errors.root.message} |
|||
</p> |
|||
)} |
|||
|
|||
<Button |
|||
className='w-full' |
|||
type='submit' |
|||
disabled={form.formState.isSubmitting} |
|||
> |
|||
{form.formState.isSubmitting ? ( |
|||
<> |
|||
{/* <Icons.spinner className="mr-2 h-4 w-4 animate-spin" /> */} |
|||
Resetting password... |
|||
</> |
|||
) : ( |
|||
'Reset password' |
|||
)} |
|||
</Button> |
|||
</form> |
|||
</Form> |
|||
{form.formState.isSubmitted && form.formState.isSubmitSuccessful && ( |
|||
<Alert className='mt-4' variant='default'> |
|||
{/* <Icons.checkCircle className="h-4 w-4" /> */} |
|||
<AlertTitle>Password reset successful</AlertTitle> |
|||
<AlertDescription> |
|||
Your password has been reset successfully. You can now login |
|||
with your new password. |
|||
</AlertDescription> |
|||
</Alert> |
|||
)} |
|||
</CardContent> |
|||
<CardFooter className='text-center'> |
|||
<Link |
|||
href={Routes.login} |
|||
className='text-sm text-blue-600 hover:underline' |
|||
> |
|||
Back to login |
|||
</Link> |
|||
</CardFooter> |
|||
</Card> |
|||
</div> |
|||
) |
|||
} |
|||
@ -0,0 +1,7 @@ |
|||
export default function AuthLayout({ |
|||
children, |
|||
}: { |
|||
children: React.ReactNode |
|||
}) { |
|||
return <>{children}</> |
|||
} |
|||
@ -0,0 +1,66 @@ |
|||
'use client' |
|||
|
|||
import Link from 'next/link' |
|||
|
|||
import { |
|||
Card, |
|||
CardContent, |
|||
CardDescription, |
|||
CardFooter, |
|||
CardHeader, |
|||
CardTitle, |
|||
} from '@/components/ui/card' |
|||
|
|||
import LoginWithGoogle from '../(components)/login-with-google' |
|||
import LoginForm from '../(components)/login-form' |
|||
import { Routes } from '@/config/routes' |
|||
|
|||
export default function LoginPage() { |
|||
return ( |
|||
<div className='flex items-center justify-center min-h-screen bg-gray-100'> |
|||
<Card className='w-[400px] shadow-lg'> |
|||
<CardHeader className='space-y-1'> |
|||
<CardTitle className='text-2xl font-bold text-center'> |
|||
Welcome back |
|||
</CardTitle> |
|||
<CardDescription className='text-center'> |
|||
Enter your credentials to access your account |
|||
</CardDescription> |
|||
</CardHeader> |
|||
<CardContent> |
|||
<LoginForm /> |
|||
<div className='relative mt-4'> |
|||
<div className='absolute inset-0 flex items-center'> |
|||
<span className='w-full border-t' /> |
|||
</div> |
|||
<div className='relative flex justify-center text-xs uppercase'> |
|||
<span className='bg-background px-2 text-muted-foreground'> |
|||
Or |
|||
</span> |
|||
</div> |
|||
</div> |
|||
<div className='mt-4 flex justify-center'> |
|||
<LoginWithGoogle /> |
|||
</div> |
|||
</CardContent> |
|||
<CardFooter className='flex flex-col space-y-2 text-center'> |
|||
<Link |
|||
href={Routes.resetPassword} |
|||
className='text-sm text-blue-600 hover:underline' |
|||
> |
|||
Forgot your password? |
|||
</Link> |
|||
<p className='text-sm text-gray-600'> |
|||
Don't have an account?{' '} |
|||
<Link |
|||
href={Routes.register} |
|||
className='font-medium text-blue-600 hover:underline' |
|||
> |
|||
Sign up |
|||
</Link> |
|||
</p> |
|||
</CardFooter> |
|||
</Card> |
|||
</div> |
|||
) |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
'use client' |
|||
|
|||
import { signOut } from 'next-auth/react' |
|||
import { useEffect } from 'react' |
|||
|
|||
export default function Logout() { |
|||
useEffect(() => { |
|||
signOut() |
|||
}, []) |
|||
|
|||
return ( |
|||
<div className='text-center min-h-screen flex items-center justify-center'> |
|||
Logging out... |
|||
</div> |
|||
) |
|||
} |
|||
@ -0,0 +1,49 @@ |
|||
'use client' |
|||
|
|||
import Link from 'next/link' |
|||
import { |
|||
Card, |
|||
CardContent, |
|||
CardDescription, |
|||
CardFooter, |
|||
CardHeader, |
|||
CardTitle, |
|||
} from '@/components/ui/card' |
|||
|
|||
import LoginWithGoogle from '../(components)/login-with-google' |
|||
import RegisterForm from '../(components)/register-form' |
|||
import { Routes } from '@/config/routes' |
|||
|
|||
export default function RegisterPage() { |
|||
return ( |
|||
<div className='flex items-center justify-center min-h-screen bg-gray-100'> |
|||
<Card className='w-[450px] shadow-lg'> |
|||
<CardHeader className='space-y-1'> |
|||
<CardTitle className='text-2xl font-bold text-center'> |
|||
Create an account |
|||
</CardTitle> |
|||
<CardDescription className='text-center'> |
|||
Enter your details to get started |
|||
</CardDescription> |
|||
</CardHeader> |
|||
<CardContent> |
|||
<RegisterForm /> |
|||
<div className='mt-4 flex justify-center'> |
|||
<LoginWithGoogle /> |
|||
</div> |
|||
</CardContent> |
|||
<CardFooter className='text-center'> |
|||
<p className='text-sm text-gray-600'> |
|||
Already have an account?{' '} |
|||
<Link |
|||
href={Routes.login} |
|||
className='font-medium text-blue-600 hover:underline' |
|||
> |
|||
Sign in |
|||
</Link> |
|||
</p> |
|||
</CardFooter> |
|||
</Card> |
|||
</div> |
|||
) |
|||
} |
|||
@ -0,0 +1,18 @@ |
|||
'use client' |
|||
|
|||
import { useSearchParams } from 'next/navigation' |
|||
import ResetPasswordForm from '../(components)/reset-password-form' |
|||
|
|||
import RequestPasswordResetForm from '../(components)/request-password-reset-form' |
|||
|
|||
export default function ResetPasswordPage() { |
|||
const searchParams = useSearchParams() |
|||
const otp = searchParams.get('otp') |
|||
const email = searchParams.get('email') |
|||
|
|||
if (otp && email) { |
|||
return <ResetPasswordForm email={decodeURIComponent(email)} otp={otp} /> |
|||
} |
|||
|
|||
return <RequestPasswordResetForm /> |
|||
} |
|||
@ -0,0 +1,509 @@ |
|||
'use client' |
|||
|
|||
import { useState } from 'react' |
|||
import { |
|||
Card, |
|||
CardContent, |
|||
CardHeader, |
|||
CardTitle, |
|||
CardDescription, |
|||
} from '@/components/ui/card' |
|||
import { Button } from '@/components/ui/button' |
|||
import { Input } from '@/components/ui/input' |
|||
import { Label } from '@/components/ui/label' |
|||
import { |
|||
Dialog, |
|||
DialogContent, |
|||
DialogDescription, |
|||
DialogFooter, |
|||
DialogHeader, |
|||
DialogTitle, |
|||
} from '@/components/ui/dialog' |
|||
import { Badge } from '@/components/ui/badge' |
|||
import { |
|||
AlertTriangle, |
|||
Mail, |
|||
Shield, |
|||
UserCircle, |
|||
Loader2, |
|||
Check, |
|||
} from 'lucide-react' |
|||
import { useForm } from 'react-hook-form' |
|||
import { z } from 'zod' |
|||
import { zodResolver } from '@hookform/resolvers/zod' |
|||
import { useToast } from '@/hooks/use-toast' |
|||
import httpBrowserClient from '@/lib/httpBrowserClient' |
|||
import { ApiEndpoints } from '@/config/api' |
|||
import { useMutation, useQuery } from '@tanstack/react-query' |
|||
import { Spinner } from '@/components/ui/spinner' |
|||
import Link from 'next/link' |
|||
import { Textarea } from '@/components/ui/textarea' |
|||
import axios from 'axios' |
|||
import { useSession } from 'next-auth/react' |
|||
import { Routes } from '@/config/routes' |
|||
|
|||
const updateProfileSchema = z.object({ |
|||
name: z.string().min(1, 'Name is required'), |
|||
email: z.string().email().optional(), |
|||
phone: z |
|||
.string() |
|||
.regex(/^\+?\d{0,14}$/, 'Invalid phone number') |
|||
.optional(), |
|||
}) |
|||
|
|||
type UpdateProfileFormData = z.infer<typeof updateProfileSchema> |
|||
|
|||
const changePasswordSchema = z |
|||
.object({ |
|||
oldPassword: z.string().min(1, 'Old password is required'), |
|||
newPassword: z |
|||
.string() |
|||
.min(8, { message: 'Password must be at least 8 characters long' }), |
|||
confirmPassword: z |
|||
.string() |
|||
.min(4, { message: 'Please confirm your password' }), |
|||
}) |
|||
.superRefine((data, ctx) => { |
|||
if (data.newPassword !== data.confirmPassword) { |
|||
ctx.addIssue({ |
|||
code: z.ZodIssueCode.custom, |
|||
message: 'Passwords must match', |
|||
path: ['confirmPassword'], |
|||
}) |
|||
} |
|||
}) |
|||
|
|||
type ChangePasswordFormData = z.infer<typeof changePasswordSchema> |
|||
|
|||
export default function AccountSettings() { |
|||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) |
|||
const [deleteConfirmEmail, setDeleteConfirmEmail] = useState('') |
|||
const { update: updateSession } = useSession() |
|||
|
|||
const { toast } = useToast() |
|||
|
|||
const { |
|||
data: currentUser, |
|||
isLoading: isLoadingUser, |
|||
refetch: refetchCurrentUser, |
|||
} = useQuery({ |
|||
queryKey: ['currentUser'], |
|||
queryFn: () => |
|||
httpBrowserClient |
|||
.get(ApiEndpoints.auth.whoAmI()) |
|||
.then((res) => res.data?.data), |
|||
}) |
|||
|
|||
const updateProfileForm = useForm<UpdateProfileFormData>({ |
|||
resolver: zodResolver(updateProfileSchema), |
|||
defaultValues: { |
|||
name: currentUser?.name, |
|||
email: currentUser?.email, |
|||
phone: currentUser?.phone, |
|||
}, |
|||
}) |
|||
|
|||
const changePasswordForm = useForm<ChangePasswordFormData>({ |
|||
resolver: zodResolver(changePasswordSchema), |
|||
}) |
|||
|
|||
const handleDeleteAccount = () => { |
|||
if (deleteConfirmEmail !== currentUser?.email) { |
|||
toast({ |
|||
title: 'Please enter your correct email address', |
|||
}) |
|||
return |
|||
} |
|||
requestAccountDeletion() |
|||
} |
|||
|
|||
const handleVerifyEmail = () => { |
|||
// TODO: Implement email verification
|
|||
} |
|||
|
|||
const { |
|||
mutate: updateProfile, |
|||
isPending: isUpdatingProfile, |
|||
error: updateProfileError, |
|||
isSuccess: isUpdateProfileSuccess, |
|||
} = useMutation({ |
|||
mutationFn: (data: UpdateProfileFormData) => |
|||
httpBrowserClient.patch(ApiEndpoints.auth.updateProfile(), data), |
|||
onSuccess: () => { |
|||
refetchCurrentUser() |
|||
toast({ |
|||
title: 'Profile updated successfully!', |
|||
}) |
|||
updateSession({ |
|||
name: updateProfileForm.getValues().name, |
|||
phone: updateProfileForm.getValues().phone, |
|||
}) |
|||
}, |
|||
onError: () => { |
|||
toast({ |
|||
title: 'Failed to update profile', |
|||
}) |
|||
}, |
|||
}) |
|||
|
|||
const { |
|||
mutate: changePassword, |
|||
isPending: isChangingPassword, |
|||
error: changePasswordError, |
|||
isSuccess: isChangePasswordSuccess, |
|||
} = useMutation({ |
|||
mutationFn: (data: ChangePasswordFormData) => |
|||
httpBrowserClient.post(ApiEndpoints.auth.changePassword(), data), |
|||
onSuccess: () => { |
|||
toast({ |
|||
title: 'Password changed successfully!', |
|||
}) |
|||
changePasswordForm.reset() |
|||
}, |
|||
onError: (error) => { |
|||
const errorMessage = (error as any).response?.data?.error |
|||
changePasswordForm.setError('root.serverError', { |
|||
message: errorMessage || 'Failed to change password', |
|||
}) |
|||
toast({ |
|||
title: 'Failed to change password', |
|||
}) |
|||
}, |
|||
}) |
|||
|
|||
const [deleteReason, setDeleteReason] = useState('') |
|||
|
|||
const { |
|||
mutate: requestAccountDeletion, |
|||
isPending: isRequestingAccountDeletion, |
|||
error: requestAccountDeletionError, |
|||
isSuccess: isRequestAccountDeletionSuccess, |
|||
} = useMutation({ |
|||
mutationFn: () => |
|||
axios.post('/api/request-account-deletion', { |
|||
message: deleteReason, |
|||
}), |
|||
onSuccess: () => { |
|||
toast({ |
|||
title: 'Account deletion request submitted', |
|||
}) |
|||
}, |
|||
onError: () => { |
|||
toast({ |
|||
title: 'Failed to submit account deletion request', |
|||
}) |
|||
}, |
|||
}) |
|||
|
|||
if (isLoadingUser) |
|||
return ( |
|||
<div className='flex justify-center items-center h-full'> |
|||
<Spinner size='sm' /> |
|||
</div> |
|||
) |
|||
|
|||
return ( |
|||
<div className='grid gap-6 max-w-2xl mx-auto'> |
|||
<Card> |
|||
<CardHeader> |
|||
<div className='flex items-center gap-2'> |
|||
<UserCircle className='h-5 w-5' /> |
|||
<CardTitle>Profile Information</CardTitle> |
|||
</div> |
|||
<CardDescription>Update your profile information</CardDescription> |
|||
</CardHeader> |
|||
<CardContent> |
|||
<form |
|||
onSubmit={updateProfileForm.handleSubmit((data) => |
|||
updateProfile(data) |
|||
)} |
|||
className='space-y-4' |
|||
> |
|||
<div className='space-y-2'> |
|||
<Label htmlFor='name'>Full Name</Label> |
|||
<Input |
|||
id='name' |
|||
{...updateProfileForm.register('name')} |
|||
placeholder='Enter your full name' |
|||
defaultValue={currentUser?.name} |
|||
/> |
|||
{updateProfileForm.formState.errors.name && ( |
|||
<p className='text-sm text-destructive'> |
|||
{updateProfileForm.formState.errors.name.message} |
|||
</p> |
|||
)} |
|||
</div> |
|||
|
|||
<div className='space-y-2'> |
|||
<Label htmlFor='email' className='flex items-center gap-2'> |
|||
Email Address |
|||
{currentUser?.emailVerifiedAt && ( |
|||
<Badge variant='secondary' className='ml-2'> |
|||
<Shield className='h-3 w-3 mr-1' /> |
|||
Verified |
|||
</Badge> |
|||
)} |
|||
</Label> |
|||
<div className='flex gap-2'> |
|||
<Input |
|||
id='email' |
|||
type='email' |
|||
{...updateProfileForm.register('email')} |
|||
placeholder='Enter your email' |
|||
defaultValue={currentUser?.email} |
|||
disabled |
|||
/> |
|||
{!currentUser?.emailVerifiedAt ? ( |
|||
<Button |
|||
type='button' |
|||
variant='outline' |
|||
onClick={handleVerifyEmail} |
|||
disabled={true} |
|||
> |
|||
{isUpdatingProfile ? ( |
|||
<Loader2 className='h-4 w-4 animate-spin' /> |
|||
) : ( |
|||
<Mail className='h-4 w-4 mr-2' /> |
|||
)} |
|||
Verify |
|||
</Button> |
|||
) : ( |
|||
<Button variant='outline' disabled> |
|||
<Check className='h-4 w-4 mr-2' /> |
|||
Verified |
|||
</Button> |
|||
)} |
|||
</div> |
|||
{updateProfileForm.formState.errors.email && ( |
|||
<p className='text-sm text-destructive'> |
|||
{updateProfileForm.formState.errors.email.message} |
|||
</p> |
|||
)} |
|||
</div> |
|||
|
|||
<div className='space-y-2'> |
|||
<Label htmlFor='phone'>Phone Number</Label> |
|||
<Input |
|||
id='phone' |
|||
type='tel' |
|||
{...updateProfileForm.register('phone')} |
|||
placeholder='Enter your phone number' |
|||
defaultValue={currentUser?.phone} |
|||
/> |
|||
{updateProfileForm.formState.errors.phone && ( |
|||
<p className='text-sm text-destructive'> |
|||
{updateProfileForm.formState.errors.phone.message} |
|||
</p> |
|||
)} |
|||
</div> |
|||
|
|||
{isUpdateProfileSuccess && ( |
|||
<p className='text-sm text-green-500'> |
|||
Profile updated successfully! |
|||
</p> |
|||
)} |
|||
|
|||
<Button |
|||
type='submit' |
|||
className='w-full mt-6' |
|||
disabled={isUpdatingProfile} |
|||
> |
|||
{isUpdatingProfile ? ( |
|||
<Loader2 className='h-4 w-4 animate-spin mr-2' /> |
|||
) : null} |
|||
Save Changes |
|||
</Button> |
|||
</form> |
|||
</CardContent> |
|||
</Card> |
|||
|
|||
<Card> |
|||
<CardHeader> |
|||
<div className='flex items-center gap-2'> |
|||
<CardTitle>Change Password</CardTitle> |
|||
</div> |
|||
<CardDescription> |
|||
If you signed in with google, your can reset your password{' '} |
|||
<Link href={Routes.resetPassword} className='underline'> |
|||
here |
|||
</Link> |
|||
. |
|||
</CardDescription> |
|||
</CardHeader> |
|||
<CardContent> |
|||
<form |
|||
onSubmit={changePasswordForm.handleSubmit((data) => |
|||
changePassword(data) |
|||
)} |
|||
className='space-y-4' |
|||
> |
|||
<div className='space-y-2'> |
|||
<Label htmlFor='oldPassword'>Old Password</Label> |
|||
<Input |
|||
id='oldPassword' |
|||
type='password' |
|||
{...changePasswordForm.register('oldPassword')} |
|||
placeholder='Enter your old password' |
|||
/> |
|||
{changePasswordForm.formState.errors.oldPassword && ( |
|||
<p className='text-sm text-destructive'> |
|||
{changePasswordForm.formState.errors.oldPassword.message} |
|||
</p> |
|||
)} |
|||
</div> |
|||
|
|||
<div className='space-y-2'> |
|||
<Label htmlFor='oldPassword'>Old Password</Label> |
|||
<Input |
|||
id='newPassword' |
|||
type='password' |
|||
{...changePasswordForm.register('newPassword')} |
|||
placeholder='Enter your new password' |
|||
/> |
|||
{changePasswordForm.formState.errors.newPassword && ( |
|||
<p className='text-sm text-destructive'> |
|||
{changePasswordForm.formState.errors.newPassword.message} |
|||
</p> |
|||
)} |
|||
</div> |
|||
|
|||
<div className='space-y-2'> |
|||
<Label htmlFor='confirmPassword'>Confirm Password</Label> |
|||
<Input |
|||
id='confirmPassword' |
|||
type='password' |
|||
{...changePasswordForm.register('confirmPassword')} |
|||
placeholder='Enter your confirm password' |
|||
/> |
|||
{changePasswordForm.formState.errors.confirmPassword && ( |
|||
<p className='text-sm text-destructive'> |
|||
{changePasswordForm.formState.errors.confirmPassword.message} |
|||
</p> |
|||
)} |
|||
</div> |
|||
|
|||
{changePasswordForm.formState.errors.root?.serverError && ( |
|||
<p className='text-sm text-destructive'> |
|||
{changePasswordForm.formState.errors.root.serverError.message} |
|||
</p> |
|||
)} |
|||
|
|||
{isChangePasswordSuccess && ( |
|||
<p className='text-sm text-green-500'> |
|||
Password changed successfully! |
|||
</p> |
|||
)} |
|||
|
|||
<Button |
|||
type='submit' |
|||
className='w-full mt-6' |
|||
disabled={isChangingPassword} |
|||
> |
|||
{isChangingPassword ? ( |
|||
<Loader2 className='h-4 w-4 animate-spin mr-2' /> |
|||
) : null} |
|||
Change Password |
|||
</Button> |
|||
</form> |
|||
</CardContent> |
|||
</Card> |
|||
|
|||
<Card className='border-destructive/50'> |
|||
<CardHeader> |
|||
<div className='flex items-center gap-2 text-destructive'> |
|||
<AlertTriangle className='h-5 w-5' /> |
|||
<CardTitle>Danger Zone</CardTitle> |
|||
</div> |
|||
<CardDescription> |
|||
Permanently delete your account and all associated data |
|||
</CardDescription> |
|||
</CardHeader> |
|||
<CardContent> |
|||
<Dialog |
|||
open={isDeleteDialogOpen} |
|||
onOpenChange={setIsDeleteDialogOpen} |
|||
> |
|||
<Button |
|||
variant='destructive' |
|||
className='w-full' |
|||
onClick={() => setIsDeleteDialogOpen(true)} |
|||
> |
|||
<AlertTriangle className='mr-2 h-4 w-4' /> |
|||
Delete Account |
|||
</Button> |
|||
<DialogContent> |
|||
<DialogHeader> |
|||
<DialogTitle className='flex items-center gap-2'> |
|||
<AlertTriangle className='h-5 w-5 text-destructive' /> |
|||
Delete Account |
|||
</DialogTitle> |
|||
<DialogDescription className='pt-4'> |
|||
<p className='mb-4'> |
|||
Are you sure you want to delete your account? This action: |
|||
</p> |
|||
<ul className='list-disc list-inside space-y-2 mb-4'> |
|||
<li>Cannot be undone</li> |
|||
<li>Will permanently delete all your data</li> |
|||
<li>Will cancel all active subscriptions</li> |
|||
<li>Will remove access to all services</li> |
|||
</ul> |
|||
|
|||
{/* enter reason for deletion text area */} |
|||
<Label htmlFor='deleteReason'>Reason for deletion</Label> |
|||
<Textarea |
|||
className='my-2' |
|||
placeholder='Enter your reason for deletion' |
|||
value={deleteReason} |
|||
onChange={(e) => setDeleteReason(e.target.value)} |
|||
/> |
|||
|
|||
<p>Please type your email address to confirm:</p> |
|||
|
|||
<Input |
|||
className='mt-2' |
|||
placeholder='Enter your email address' |
|||
value={deleteConfirmEmail} |
|||
onChange={(e) => setDeleteConfirmEmail(e.target.value)} |
|||
/> |
|||
|
|||
{requestAccountDeletionError && ( |
|||
<p className='text-sm text-destructive'> |
|||
{requestAccountDeletionError.message || |
|||
'Failed to submit account deletion request'} |
|||
</p> |
|||
)} |
|||
|
|||
{isRequestAccountDeletionSuccess && ( |
|||
<p className='text-sm text-green-500'> |
|||
Account deletion request submitted |
|||
</p> |
|||
)} |
|||
</DialogDescription> |
|||
</DialogHeader> |
|||
<DialogFooter className='gap-2 sm:gap-0'> |
|||
<Button |
|||
variant='outline' |
|||
onClick={() => setIsDeleteDialogOpen(false)} |
|||
> |
|||
Cancel |
|||
</Button> |
|||
<Button |
|||
variant='destructive' |
|||
onClick={handleDeleteAccount} |
|||
disabled={isRequestingAccountDeletion || !deleteConfirmEmail} |
|||
> |
|||
{isRequestingAccountDeletion ? ( |
|||
<Loader2 className='h-4 w-4 animate-spin mr-2' /> |
|||
) : ( |
|||
<AlertTriangle className='h-4 w-4 mr-2' /> |
|||
)} |
|||
Delete Account |
|||
</Button> |
|||
</DialogFooter> |
|||
</DialogContent> |
|||
</Dialog> |
|||
</CardContent> |
|||
</Card> |
|||
</div> |
|||
) |
|||
} |
|||
@ -0,0 +1,347 @@ |
|||
'use client' |
|||
|
|||
import { useState } from 'react' |
|||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' |
|||
import { Button } from '@/components/ui/button' |
|||
import { Badge } from '@/components/ui/badge' |
|||
import { ScrollArea } from '@/components/ui/scroll-area' |
|||
import { Copy, Key, MoreVertical, Loader2 } from 'lucide-react' |
|||
import { |
|||
Dialog, |
|||
DialogContent, |
|||
DialogDescription, |
|||
DialogFooter, |
|||
DialogHeader, |
|||
DialogTitle, |
|||
} from '@/components/ui/dialog' |
|||
import { |
|||
DropdownMenu, |
|||
DropdownMenuContent, |
|||
DropdownMenuItem, |
|||
DropdownMenuTrigger, |
|||
} from '@/components/ui/dropdown-menu' |
|||
import { Input } from '@/components/ui/input' |
|||
import { useToast } from '@/hooks/use-toast' |
|||
import { useMutation, useQuery } from '@tanstack/react-query' |
|||
import httpBrowserClient from '@/lib/httpBrowserClient' |
|||
import { ApiEndpoints } from '@/config/api' |
|||
import { Spinner } from '@/components/ui/spinner' |
|||
|
|||
export default function ApiKeys() { |
|||
const { |
|||
isPending, |
|||
error, |
|||
data: apiKeys, |
|||
refetch: refetchApiKeys, |
|||
} = useQuery({ |
|||
queryKey: ['apiKeys'], |
|||
queryFn: () => |
|||
httpBrowserClient |
|||
.get(ApiEndpoints.auth.listApiKeys()) |
|||
.then((res) => res.data), |
|||
// select: (res) => res.data,
|
|||
}) |
|||
|
|||
const { toast } = useToast() |
|||
|
|||
const [selectedKey, setSelectedKey] = useState<(typeof apiKeys)[0] | null>( |
|||
null |
|||
) |
|||
const [isRevokeDialogOpen, setIsRevokeDialogOpen] = useState(false) |
|||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false) |
|||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) |
|||
const [newKeyName, setNewKeyName] = useState('') |
|||
|
|||
const { |
|||
mutate: revokeApiKey, |
|||
isPending: isRevokingApiKey, |
|||
error: revokeApiKeyError, |
|||
isSuccess: isRevokeApiKeySuccess, |
|||
} = useMutation({ |
|||
mutationFn: (id: string) => |
|||
httpBrowserClient.post(ApiEndpoints.auth.revokeApiKey(id)), |
|||
onSuccess: () => { |
|||
setIsRevokeDialogOpen(false) |
|||
toast({ |
|||
title: `API key "${selectedKey.apiKey}" has been revoked`, |
|||
}) |
|||
refetchApiKeys() |
|||
}, |
|||
onError: () => { |
|||
toast({ |
|||
variant: 'destructive', |
|||
title: 'Error revoking API key', |
|||
description: revokeApiKeyError?.message, |
|||
}) |
|||
}, |
|||
}) |
|||
|
|||
const { |
|||
mutate: deleteApiKey, |
|||
isPending: isDeletingApiKey, |
|||
error: deleteApiKeyError, |
|||
isSuccess: isDeleteApiKeySuccess, |
|||
} = useMutation({ |
|||
mutationFn: (id: string) => |
|||
httpBrowserClient.delete(ApiEndpoints.auth.deleteApiKey(id)), |
|||
onSuccess: () => { |
|||
setIsDeleteDialogOpen(false) |
|||
toast({ |
|||
title: `API key deleted`, |
|||
}) |
|||
refetchApiKeys() |
|||
}, |
|||
onError: () => { |
|||
toast({ |
|||
variant: 'destructive', |
|||
title: 'Error deleting API key', |
|||
description: deleteApiKeyError?.message, |
|||
}) |
|||
}, |
|||
}) |
|||
const { |
|||
mutate: renameApiKey, |
|||
isPending: isRenamingApiKey, |
|||
error: renameApiKeyError, |
|||
isSuccess: isRenameApiKeySuccess, |
|||
} = useMutation({ |
|||
mutationFn: ({ id, name }: { id: string; name: string }) => |
|||
httpBrowserClient.patch(ApiEndpoints.auth.renameApiKey(id), { name }), |
|||
onSuccess: () => { |
|||
setIsRenameDialogOpen(false) |
|||
toast({ |
|||
title: `API key renamed to "${newKeyName}"`, |
|||
}) |
|||
refetchApiKeys() |
|||
}, |
|||
onError: () => { |
|||
toast({ |
|||
variant: 'destructive', |
|||
title: 'Error renaming API key', |
|||
description: renameApiKeyError?.message, |
|||
}) |
|||
}, |
|||
}) |
|||
|
|||
return ( |
|||
<Card> |
|||
<CardHeader className='pb-2'> |
|||
<CardTitle className='text-lg'>API Keys</CardTitle> |
|||
</CardHeader> |
|||
<CardContent> |
|||
<ScrollArea className='h-[400px] pr-4'> |
|||
<div className='space-y-2'> |
|||
{isPending && ( |
|||
<div className='flex justify-center items-center h-full'> |
|||
<Spinner size='sm' /> |
|||
</div> |
|||
)} |
|||
|
|||
{error && ( |
|||
<div className='flex justify-center items-center h-full'> |
|||
<div>Error: {error.message}</div> |
|||
</div> |
|||
)} |
|||
|
|||
{!isPending && !error && apiKeys?.data?.length === 0 && ( |
|||
<div className='flex justify-center items-center h-full'> |
|||
<div>No API keys found</div> |
|||
</div> |
|||
)} |
|||
|
|||
{apiKeys?.data?.map((apiKey) => ( |
|||
<Card key={apiKey.id} className='border-0 shadow-none'> |
|||
<CardContent className='flex items-center p-3'> |
|||
<Key className='h-6 w-6 mr-3' /> |
|||
<div className='flex-1'> |
|||
<div className='flex items-center justify-between'> |
|||
<h3 className='font-semibold text-sm'> |
|||
{apiKey.name || 'API Key'} |
|||
</h3> |
|||
<Badge |
|||
variant={apiKey.revokedAt ? 'secondary' : 'default'} |
|||
className='text-xs' |
|||
> |
|||
{apiKey.revokedAt ? 'Revoked' : 'Active'} |
|||
</Badge> |
|||
</div> |
|||
<div className='flex items-center space-x-2 mt-1'> |
|||
<code className='relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-xs'> |
|||
{apiKey.apiKey} |
|||
</code> |
|||
</div> |
|||
<div className='flex items-center mt-1 space-x-3 text-xs text-muted-foreground'> |
|||
<div> |
|||
Created at:{' '} |
|||
{new Date(apiKey.createdAt).toLocaleString('en-US', { |
|||
dateStyle: 'medium', |
|||
timeStyle: 'short', |
|||
})} |
|||
</div> |
|||
<div> |
|||
Last used: {/* if usage count is 0, show never */} |
|||
{apiKey?.lastUsedAt && apiKey.usageCount > 0 |
|||
? new Date(apiKey.lastUsedAt).toLocaleString( |
|||
'en-US', |
|||
{ |
|||
dateStyle: 'medium', |
|||
timeStyle: 'short', |
|||
} |
|||
) |
|||
: 'Never'} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div className=''> |
|||
<DropdownMenu> |
|||
<DropdownMenuTrigger asChild> |
|||
<Button variant='ghost' size='icon' className='h-6 w-6'> |
|||
<MoreVertical className='h-3 w-3' /> |
|||
</Button> |
|||
</DropdownMenuTrigger> |
|||
<DropdownMenuContent align='end'> |
|||
<DropdownMenuItem |
|||
onClick={() => { |
|||
setSelectedKey(apiKey) |
|||
setNewKeyName(apiKey.name || 'API Key') |
|||
setIsRenameDialogOpen(true) |
|||
}} |
|||
> |
|||
Rename |
|||
</DropdownMenuItem> |
|||
<DropdownMenuItem |
|||
className='text-destructive' |
|||
onClick={() => { |
|||
setSelectedKey(apiKey) |
|||
setIsRevokeDialogOpen(true) |
|||
}} |
|||
disabled={!!apiKey.revokedAt} |
|||
> |
|||
Revoke |
|||
</DropdownMenuItem> |
|||
<DropdownMenuItem |
|||
className='text-destructive' |
|||
onClick={() => { |
|||
setSelectedKey(apiKey) |
|||
setIsDeleteDialogOpen(true) |
|||
}} |
|||
> |
|||
Delete |
|||
</DropdownMenuItem> |
|||
</DropdownMenuContent> |
|||
</DropdownMenu> |
|||
</div> |
|||
</CardContent> |
|||
</Card> |
|||
))} |
|||
</div> |
|||
</ScrollArea> |
|||
|
|||
{/* Revoke Dialog */} |
|||
<Dialog open={isRevokeDialogOpen} onOpenChange={setIsRevokeDialogOpen}> |
|||
<DialogContent> |
|||
<DialogHeader> |
|||
<DialogTitle>Revoke API Key</DialogTitle> |
|||
<DialogDescription> |
|||
Are you sure you want to revoke this API key? This action cannot |
|||
be undone, and any applications using this key will stop working |
|||
immediately. |
|||
</DialogDescription> |
|||
</DialogHeader> |
|||
<DialogFooter> |
|||
<Button |
|||
variant='outline' |
|||
onClick={() => setIsRevokeDialogOpen(false)} |
|||
disabled={isRevokingApiKey} |
|||
> |
|||
Cancel |
|||
</Button> |
|||
<Button |
|||
variant='destructive' |
|||
onClick={() => revokeApiKey(selectedKey?._id)} |
|||
disabled={isRevokingApiKey} |
|||
> |
|||
{isRevokingApiKey ? ( |
|||
<Loader2 className='h-4 w-4 animate-spin mr-2' /> |
|||
) : null} |
|||
Revoke Key |
|||
</Button> |
|||
</DialogFooter> |
|||
</DialogContent> |
|||
</Dialog> |
|||
|
|||
{/* Delete Dialog */} |
|||
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}> |
|||
<DialogContent> |
|||
<DialogHeader> |
|||
<DialogTitle>Delete API Key</DialogTitle> |
|||
<DialogDescription> |
|||
Are you sure you want to delete this API key? This action cannot |
|||
be undone. |
|||
</DialogDescription> |
|||
</DialogHeader> |
|||
<DialogFooter> |
|||
<Button |
|||
variant='outline' |
|||
onClick={() => setIsDeleteDialogOpen(false)} |
|||
disabled={isDeletingApiKey} |
|||
> |
|||
Cancel |
|||
</Button> |
|||
<Button |
|||
variant='destructive' |
|||
onClick={() => deleteApiKey(selectedKey?._id)} |
|||
disabled={isDeletingApiKey} |
|||
> |
|||
{isDeletingApiKey ? ( |
|||
<Loader2 className='h-4 w-4 animate-spin mr-2' /> |
|||
) : null} |
|||
Delete |
|||
</Button> |
|||
</DialogFooter> |
|||
</DialogContent> |
|||
</Dialog> |
|||
|
|||
{/* Rename Dialog */} |
|||
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}> |
|||
<DialogContent> |
|||
<DialogHeader> |
|||
<DialogTitle>Rename API Key</DialogTitle> |
|||
<DialogDescription> |
|||
Enter a new name for your API key. |
|||
</DialogDescription> |
|||
</DialogHeader> |
|||
<Input |
|||
value={newKeyName} |
|||
onChange={(e) => setNewKeyName(e.target.value)} |
|||
placeholder='Enter new name' |
|||
/> |
|||
<DialogFooter> |
|||
<Button |
|||
variant='outline' |
|||
onClick={() => setIsRenameDialogOpen(false)} |
|||
disabled={isRenamingApiKey} |
|||
> |
|||
Cancel |
|||
</Button> |
|||
<Button |
|||
onClick={() => |
|||
renameApiKey({ |
|||
id: selectedKey?._id, |
|||
name: newKeyName?.trim(), |
|||
}) |
|||
} |
|||
disabled={isRenamingApiKey || !newKeyName?.trim()} |
|||
> |
|||
{isRenamingApiKey ? ( |
|||
<Loader2 className='h-4 w-4 animate-spin mr-2' /> |
|||
) : null} |
|||
Save |
|||
</Button> |
|||
</DialogFooter> |
|||
</DialogContent> |
|||
</Dialog> |
|||
</CardContent> |
|||
</Card> |
|||
) |
|||
} |
|||
@ -0,0 +1,37 @@ |
|||
import { Alert, AlertDescription } from '@/components/ui/alert' |
|||
import { Button } from '@/components/ui/button' |
|||
import { ExternalLinks } from '@/config/external-links' |
|||
import { Github, Heart, MessageSquare } from 'lucide-react' |
|||
import Link from 'next/link' |
|||
|
|||
export default function CommunityAlert() { |
|||
return ( |
|||
<Alert> |
|||
<AlertDescription className='flex flex-wrap items-center gap-2 md:gap-4'> |
|||
<span className='flex-1'> |
|||
Join our community and support the development! |
|||
</span> |
|||
<div className='flex flex-wrap gap-1 md:gap-2'> |
|||
<Button variant='outline' size='sm' asChild> |
|||
<Link href={ExternalLinks.github} target='_blank' prefetch={false}> |
|||
<Github className='mr-1 h-4 w-4' /> |
|||
GitHub |
|||
</Link> |
|||
</Button> |
|||
<Button variant='outline' size='sm' asChild> |
|||
<Link href={ExternalLinks.patreon} target='_blank' prefetch={false}> |
|||
<Heart className='mr-1 h-4 w-4' /> |
|||
Patreon |
|||
</Link> |
|||
</Button> |
|||
<Button variant='outline' size='sm' asChild> |
|||
<Link href={ExternalLinks.discord} target='_blank' prefetch={false}> |
|||
<MessageSquare className='mr-1 h-4 w-4' /> |
|||
Discord |
|||
</Link> |
|||
</Button> |
|||
</div> |
|||
</AlertDescription> |
|||
</Alert> |
|||
) |
|||
} |
|||
@ -0,0 +1,62 @@ |
|||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' |
|||
import { Button } from '@/components/ui/button' |
|||
import { Github, Heart, MessageSquare } from 'lucide-react' |
|||
import Link from 'next/link' |
|||
import { ExternalLinks } from '@/config/external-links' |
|||
|
|||
export default function CommunityLinks() { |
|||
return ( |
|||
<div className='grid gap-4 md:grid-cols-3'> |
|||
<Card> |
|||
<CardHeader> |
|||
<CardTitle>GitHub</CardTitle> |
|||
</CardHeader> |
|||
<CardContent> |
|||
<p className='text-sm text-muted-foreground mb-4'> |
|||
Check out our source code and contribute to the project. |
|||
</p> |
|||
<Link href={ExternalLinks.github} prefetch={false} target='_blank'> |
|||
<Button className='w-full'> |
|||
<Github className='mr-2 h-4 w-4' /> |
|||
View Source |
|||
</Button> |
|||
</Link> |
|||
</CardContent> |
|||
</Card> |
|||
|
|||
<Card> |
|||
<CardHeader> |
|||
<CardTitle>Support Us</CardTitle> |
|||
</CardHeader> |
|||
<CardContent> |
|||
<p className='text-sm text-muted-foreground mb-4'> |
|||
Support the development by becoming a patron. |
|||
</p> |
|||
<Link href={ExternalLinks.patreon} prefetch={false} target='_blank'> |
|||
<Button className='w-full' variant='secondary'> |
|||
<Heart className='mr-2 h-4 w-4' /> |
|||
Become a Patron |
|||
</Button> |
|||
</Link> |
|||
</CardContent> |
|||
</Card> |
|||
|
|||
<Card> |
|||
<CardHeader> |
|||
<CardTitle>Discord</CardTitle> |
|||
</CardHeader> |
|||
<CardContent> |
|||
<p className='text-sm text-muted-foreground mb-4'> |
|||
Join our community for support and updates. |
|||
</p> |
|||
<Link href={ExternalLinks.discord} prefetch={false} target='_blank'> |
|||
<Button className='w-full' variant='outline'> |
|||
<MessageSquare className='mr-2 h-4 w-4' /> |
|||
Join Discord |
|||
</Button> |
|||
</Link> |
|||
</CardContent> |
|||
</Card> |
|||
</div> |
|||
) |
|||
} |
|||
@ -0,0 +1,83 @@ |
|||
'use client' |
|||
|
|||
import { useState } from 'react' |
|||
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' |
|||
|
|||
import { Button } from '@/components/ui/button' |
|||
import { QrCode, Heart, UserCircle } from 'lucide-react' |
|||
import CommunityAlert from './community-alert' |
|||
import MainDashboard from './main-dashboard' |
|||
import CommunityLinks from './community-links' |
|||
import AccountSettings from './account-settings' |
|||
import GenerateApiKey from './generate-api-key' |
|||
import { useSession } from 'next-auth/react' |
|||
|
|||
export default function Dashboard({ |
|||
children, |
|||
}: { |
|||
children?: React.ReactNode |
|||
}) { |
|||
const [currentTab, setCurrentTab] = useState('dashboard') |
|||
|
|||
const handleTabChange = (value: string) => { |
|||
setCurrentTab(value) |
|||
} |
|||
|
|||
const { data: session } = useSession() |
|||
|
|||
const welcomeMessage = |
|||
new Date().getHours() < 12 |
|||
? 'Good morning' |
|||
: new Date().getHours() < 18 |
|||
? 'Good afternoon' |
|||
: 'Good evening' |
|||
|
|||
return ( |
|||
<div className='flex-1 space-y-4 p-4 pt-6 md:p-8'> |
|||
<div className='flex items-center justify-between space-y-2 flex-col md:flex-row'> |
|||
<h2 className='text-3xl font-bold tracking-tight'> |
|||
{welcomeMessage}, {session?.user?.name} |
|||
</h2> |
|||
<div className='flex items-center space-x-2 py-4'> |
|||
<GenerateApiKey /> |
|||
</div> |
|||
</div> |
|||
|
|||
<Tabs |
|||
value={currentTab} |
|||
onValueChange={handleTabChange} |
|||
className='space-y-4' |
|||
> |
|||
<TabsList className='flex'> |
|||
<TabsTrigger value='dashboard' className='flex-1'> |
|||
Dashboard |
|||
</TabsTrigger> |
|||
<TabsTrigger value='community' className='flex-1'> |
|||
<Heart className='mr-2 h-4 w-4' /> |
|||
Community |
|||
</TabsTrigger> |
|||
<TabsTrigger value='account' className='flex-1'> |
|||
<UserCircle className='mr-2 h-4 w-4' /> |
|||
Account |
|||
</TabsTrigger> |
|||
</TabsList> |
|||
|
|||
<TabsContent value='dashboard' className='space-y-4'> |
|||
<CommunityAlert /> |
|||
<MainDashboard /> |
|||
</TabsContent> |
|||
|
|||
<TabsContent value='community' className='space-y-4'> |
|||
<CommunityAlert /> |
|||
<CommunityLinks /> |
|||
</TabsContent> |
|||
|
|||
<TabsContent value='account' className='space-y-4'> |
|||
<CommunityAlert /> |
|||
<AccountSettings /> |
|||
</TabsContent> |
|||
</Tabs> |
|||
</div> |
|||
) |
|||
} |
|||
@ -0,0 +1,118 @@ |
|||
'use client' |
|||
|
|||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' |
|||
import { Badge } from '@/components/ui/badge' |
|||
import { Button } from '@/components/ui/button' |
|||
import { ScrollArea } from '@/components/ui/scroll-area' |
|||
import { Smartphone, Battery, Signal, Copy } from 'lucide-react' |
|||
import { useToast } from '@/hooks/use-toast' |
|||
import httpBrowserClient from '@/lib/httpBrowserClient' |
|||
import { ApiEndpoints } from '@/config/api' |
|||
import { useQuery } from '@tanstack/react-query' |
|||
import { Spinner } from '@/components/ui/spinner' |
|||
|
|||
export default function DeviceList() { |
|||
const { toast } = useToast() |
|||
const { |
|||
isPending, |
|||
error, |
|||
data: devices, |
|||
} = useQuery({ |
|||
queryKey: ['devices'], |
|||
queryFn: () => |
|||
httpBrowserClient |
|||
.get(ApiEndpoints.gateway.listDevices()) |
|||
.then((res) => res.data), |
|||
// select: (res) => res.data,
|
|||
}) |
|||
|
|||
const handleCopyId = (id: string) => { |
|||
navigator.clipboard.writeText(id) |
|||
toast({ |
|||
title: 'Device ID copied to clipboard', |
|||
}) |
|||
} |
|||
|
|||
return ( |
|||
<Card> |
|||
<CardHeader className='pb-2'> |
|||
<CardTitle className='text-lg'>Registered Devices</CardTitle> |
|||
</CardHeader> |
|||
<CardContent> |
|||
<ScrollArea className='h-[400px] pr-4'> |
|||
<div className='space-y-2'> |
|||
{isPending && ( |
|||
<div className='flex justify-center items-center h-full'> |
|||
<Spinner size='sm' /> |
|||
</div> |
|||
)} |
|||
|
|||
{error && ( |
|||
<div className='flex justify-center items-center h-full'> |
|||
<div>Error: {error.message}</div> |
|||
</div> |
|||
)} |
|||
|
|||
{!isPending && !error && devices?.data?.length === 0 && ( |
|||
<div className='flex justify-center items-center h-full'> |
|||
<div>No devices found</div> |
|||
</div> |
|||
)} |
|||
|
|||
{devices?.data?.map((device) => ( |
|||
<Card key={device.id} className='border-0 shadow-none'> |
|||
<CardContent className='flex items-center p-3'> |
|||
<Smartphone className='h-6 w-6 mr-3' /> |
|||
<div className='flex-1'> |
|||
<div className='flex items-center justify-between'> |
|||
<h3 className='font-semibold text-sm'> |
|||
{device.brand} {device.model} |
|||
</h3> |
|||
<Badge |
|||
variant={ |
|||
device.status === 'online' ? 'default' : 'secondary' |
|||
} |
|||
className='text-xs' |
|||
> |
|||
{device.enabled ? 'Enabled' : 'Disabled'} |
|||
</Badge> |
|||
</div> |
|||
<div className='flex items-center space-x-2 mt-1'> |
|||
<code className='relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-xs'> |
|||
{device._id} |
|||
</code> |
|||
<Button |
|||
variant='ghost' |
|||
size='icon' |
|||
className='h-6 w-6' |
|||
onClick={() => handleCopyId(device._id)} |
|||
> |
|||
<Copy className='h-3 w-3' /> |
|||
</Button> |
|||
</div> |
|||
<div className='flex items-center mt-1 space-x-3 text-xs text-muted-foreground'> |
|||
<div className='flex items-center'> |
|||
<Battery className='h-3 w-3 mr-1' /> |
|||
unknown |
|||
</div> |
|||
<div className='flex items-center'> |
|||
<Signal className='h-3 w-3 mr-1' />- |
|||
</div> |
|||
<div> |
|||
Registered at:{' '} |
|||
{new Date(device.createdAt).toLocaleString('en-US', { |
|||
dateStyle: 'medium', |
|||
timeStyle: 'short', |
|||
})} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</CardContent> |
|||
</Card> |
|||
))} |
|||
</div> |
|||
</ScrollArea> |
|||
</CardContent> |
|||
</Card> |
|||
) |
|||
} |
|||
@ -0,0 +1,186 @@ |
|||
import { Button } from '@/components/ui/button' |
|||
import { |
|||
Dialog, |
|||
DialogContent, |
|||
DialogDescription, |
|||
DialogHeader, |
|||
DialogTitle, |
|||
} from '@/components/ui/dialog' |
|||
import { Spinner } from '@/components/ui/spinner' |
|||
import { ApiEndpoints } from '@/config/api' |
|||
import { Routes } from '@/config/routes' |
|||
import { useToast } from '@/hooks/use-toast' |
|||
import httpBrowserClient from '@/lib/httpBrowserClient' |
|||
import { useMutation, useQueryClient } from '@tanstack/react-query' |
|||
import { QrCode, Copy, Smartphone, Download, AlertTriangle } from 'lucide-react' |
|||
import React, { useState } from 'react' |
|||
import QRCode from 'react-qr-code' |
|||
|
|||
export default function GenerateApiKey() { |
|||
const [isGenerateKeyModalOpen, setIsGenerateKeyModalOpen] = useState(false) |
|||
const [isConfirmGenerateKeyModalOpen, setIsConfirmGenerateKeyModalOpen] = |
|||
useState(false) |
|||
|
|||
const handleConfirmGenerateKey = () => { |
|||
setIsConfirmGenerateKeyModalOpen(true) |
|||
} |
|||
|
|||
const queryClient = useQueryClient() |
|||
|
|||
// invalidate devices query after successful api key generation
|
|||
const { |
|||
isPending: isGeneratingApiKey, |
|||
error: generateApiKeyError, |
|||
mutateAsync: generateApiKey, |
|||
data: generatedApiKey, |
|||
} = useMutation({ |
|||
mutationKey: ['generate-api-key'], |
|||
onSuccess: (data) => { |
|||
setIsConfirmGenerateKeyModalOpen(false) |
|||
setIsGenerateKeyModalOpen(true) |
|||
queryClient.invalidateQueries({ queryKey: ['apiKeys', 'stats'] }) |
|||
queryClient.refetchQueries({ queryKey: ['apiKeys', 'stats'] }) |
|||
}, |
|||
mutationFn: () => |
|||
httpBrowserClient |
|||
.post(ApiEndpoints.auth.generateApiKey()) |
|||
.then((res) => res.data), |
|||
}) |
|||
|
|||
const { toast } = useToast() |
|||
|
|||
const handleCopyKey = () => { |
|||
navigator.clipboard.writeText(generatedApiKey?.data) |
|||
toast({ |
|||
title: 'API key copied to clipboard', |
|||
}) |
|||
} |
|||
|
|||
return ( |
|||
<> |
|||
<Button onClick={handleConfirmGenerateKey}> |
|||
<QrCode className='mr-2 h-4 w-4' /> |
|||
Generate API Key |
|||
</Button> |
|||
|
|||
<Dialog |
|||
open={isConfirmGenerateKeyModalOpen} |
|||
onOpenChange={setIsConfirmGenerateKeyModalOpen} |
|||
> |
|||
<DialogContent className='sm:max-w-md'> |
|||
<DialogHeader> |
|||
<DialogTitle>Create new API Key</DialogTitle> |
|||
<DialogDescription> |
|||
<div className='space-y-2 text-sm text-muted-foreground'> |
|||
<p> |
|||
By clicking generate, you will be able to view your API key. |
|||
Make sure to save it before closing the modal as you will not |
|||
be able to view it again. |
|||
</p> |
|||
</div> |
|||
</DialogDescription> |
|||
</DialogHeader> |
|||
<div className='flex flex-col space-y-4'> |
|||
<Button |
|||
onClick={() => generateApiKey()} |
|||
disabled={isGeneratingApiKey} |
|||
> |
|||
{isGeneratingApiKey ? ( |
|||
<div className='flex justify-center items-center h-full'> |
|||
<Spinner size='sm' className='text-white' /> |
|||
</div> |
|||
) : ( |
|||
'Generate API Key' |
|||
)} |
|||
</Button> |
|||
</div> |
|||
</DialogContent> |
|||
</Dialog> |
|||
|
|||
<Dialog |
|||
open={isGenerateKeyModalOpen} |
|||
onOpenChange={setIsGenerateKeyModalOpen} |
|||
> |
|||
<DialogContent className='sm:max-w-lg'> |
|||
<DialogHeader> |
|||
<DialogTitle>Your API Key</DialogTitle> |
|||
<DialogDescription> |
|||
Use this API key to connect your device or authenticate your |
|||
service. |
|||
</DialogDescription> |
|||
</DialogHeader> |
|||
|
|||
<div className='space-y-6'> |
|||
<div className='flex justify-center p-4 bg-muted rounded-lg '> |
|||
{generatedApiKey?.data && ( |
|||
<QRCode value={generatedApiKey?.data} size={120} /> |
|||
)} |
|||
</div> |
|||
|
|||
<div className='space-y-2'> |
|||
<div className='flex items-center gap-2'> |
|||
<code className='relative rounded bg-muted px-[0.5rem] py-[0.3rem] font-mono text-sm flex-1'> |
|||
{generatedApiKey?.data} |
|||
</code> |
|||
<Button variant='outline' size='icon' onClick={handleCopyKey}> |
|||
<Copy className='h-4 w-4' /> |
|||
</Button> |
|||
</div> |
|||
</div> |
|||
|
|||
<div className='space-y-4 text-sm'> |
|||
<div className='space-y-2'> |
|||
<h4 className='font-medium flex items-center gap-2'> |
|||
<Smartphone className='h-4 w-4' /> |
|||
For Device Registration |
|||
</h4> |
|||
<p className='text-muted-foreground'> |
|||
Open the TextBee app and scan the QR code, or manually enter |
|||
the API key in the app and click register/update. |
|||
</p> |
|||
</div> |
|||
|
|||
<div className='space-y-2'> |
|||
<h4 className='font-medium flex items-center gap-2'> |
|||
<Download className='h-4 w-4' /> |
|||
Don't have the app? |
|||
</h4> |
|||
<p className='text-muted-foreground'> |
|||
Download the APK from{' '} |
|||
<a |
|||
href={Routes.downloadAndroidApp} |
|||
target='_blank' |
|||
rel='noopener noreferrer' |
|||
className='text-primary hover:underline' |
|||
> |
|||
{Routes.downloadAndroidApp} |
|||
</a>{' '} |
|||
and install it. |
|||
</p> |
|||
</div> |
|||
|
|||
<div className='space-y-2'> |
|||
<h4 className='font-medium'>For External Services</h4> |
|||
<p className='text-muted-foreground'> |
|||
Copy the API key and store it securely for authenticating your |
|||
external service with TextBee. |
|||
</p> |
|||
</div> |
|||
|
|||
<div className='rounded-md bg-yellow-50 dark:bg-yellow-900/30 p-3 mt-4'> |
|||
<div className='flex items-center gap-2 text-yellow-800 dark:text-yellow-200'> |
|||
<AlertTriangle className='h-4 w-4' /> |
|||
<p className='text-sm font-medium'>Important</p> |
|||
</div> |
|||
<p className='mt-2 text-sm text-yellow-700 dark:text-yellow-300'> |
|||
Once you close this modal, you will not be able to view your |
|||
API key again. Make sure to save it before closing. |
|||
</p> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</DialogContent> |
|||
</Dialog> |
|||
</> |
|||
) |
|||
} |
|||
@ -0,0 +1,71 @@ |
|||
'use client' |
|||
|
|||
import { useState } from 'react' |
|||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' |
|||
import { Button } from '@/components/ui/button' |
|||
import { QrCode, Copy, AlertTriangle, Download, Smartphone } from 'lucide-react' |
|||
import { |
|||
Dialog, |
|||
DialogContent, |
|||
DialogDescription, |
|||
DialogFooter, |
|||
DialogHeader, |
|||
DialogTitle, |
|||
} from '@/components/ui/dialog' |
|||
import { useToast } from '@/hooks/use-toast' |
|||
import QRCode from 'react-qr-code' |
|||
import GenerateApiKey from './generate-api-key' |
|||
|
|||
export default function GetStartedCard() { |
|||
const [isGenerateKeyModalOpen, setIsGenerateKeyModalOpen] = useState(false) |
|||
const [isConfirmGenerateKeyModalOpen, setIsConfirmGenerateKeyModalOpen] = |
|||
useState(false) |
|||
const [apiKey, setApiKey] = useState('') |
|||
|
|||
const handleConfirmGenerateKey = () => { |
|||
|
|||
setIsConfirmGenerateKeyModalOpen(true) |
|||
} |
|||
|
|||
const handleGenerateKey = () => { |
|||
setIsConfirmGenerateKeyModalOpen(false) |
|||
// Simulate API key generation
|
|||
const newKey = 'sk_live_' + Math.random().toString(36).substring(2, 15) |
|||
setApiKey(newKey) |
|||
setIsGenerateKeyModalOpen(true) |
|||
} |
|||
const { toast } = useToast() |
|||
|
|||
const handleCopyKey = () => { |
|||
navigator.clipboard.writeText(apiKey) |
|||
toast({ |
|||
title: 'API key copied to clipboard', |
|||
}) |
|||
} |
|||
|
|||
const copyApiKey = () => { |
|||
navigator.clipboard.writeText(apiKey) |
|||
toast({ |
|||
title: 'API key copied to clipboard', |
|||
}) |
|||
} |
|||
|
|||
return ( |
|||
<> |
|||
<Card> |
|||
<CardHeader> |
|||
<CardTitle>Get Started</CardTitle> |
|||
</CardHeader> |
|||
<CardContent className='space-y-4'> |
|||
<p className='text-muted-foreground'> |
|||
To start using TextBee, you need to generate an API key and connect |
|||
your device. |
|||
</p> |
|||
<GenerateApiKey/> |
|||
</CardContent> |
|||
</Card> |
|||
|
|||
|
|||
</> |
|||
) |
|||
} |
|||
@ -0,0 +1,75 @@ |
|||
'use client' |
|||
|
|||
import { useRouter, usePathname } from 'next/navigation' |
|||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' |
|||
import { Alert, AlertDescription } from '@/components/ui/alert' |
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' |
|||
// import Overview from "@/components/overview";
|
|||
// import DeviceList from "@/components/device-list";
|
|||
// import ApiKeys from "@/components/api-keys";
|
|||
// import MessagingPanel from "@/components/messaging-panel";
|
|||
import { Webhook, MessageSquare } from 'lucide-react' |
|||
import { useState } from 'react' |
|||
import Overview from './overview' |
|||
import DeviceList from './device-list' |
|||
import ApiKeys from './api-keys' |
|||
import Messaging from './messaging' |
|||
|
|||
export default function DashboardOverview() { |
|||
const router = useRouter() |
|||
const pathname = usePathname() |
|||
|
|||
const [currentTab, setCurrentTab] = useState('overview') |
|||
|
|||
const handleTabChange = (value: string) => { |
|||
setCurrentTab(value) |
|||
} |
|||
|
|||
return ( |
|||
<Tabs |
|||
value={currentTab} |
|||
onValueChange={handleTabChange} |
|||
className='space-y-4' |
|||
> |
|||
<TabsList className='sticky top-[3.5rem] z-10 flex mx-auto max-w-md'> |
|||
<TabsTrigger value='overview' className='flex-1'> |
|||
Overview |
|||
</TabsTrigger> |
|||
<TabsTrigger value='messaging' className='relative flex-1'> |
|||
<MessageSquare className='ml-2 h-4 w-4' /> |
|||
<span className='mx-2'>Messaging</span> |
|||
<span className='absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground ml-8'> |
|||
3 |
|||
</span> |
|||
</TabsTrigger> |
|||
</TabsList> |
|||
|
|||
<TabsContent value='overview' className='space-y-4'> |
|||
<Overview /> |
|||
|
|||
<div className='grid gap-4 md:grid-cols-2'> |
|||
<DeviceList /> |
|||
<ApiKeys /> |
|||
</div> |
|||
|
|||
<Card> |
|||
<CardHeader> |
|||
<CardTitle>Webhooks (Coming Soon)</CardTitle> |
|||
</CardHeader> |
|||
<CardContent> |
|||
<Alert> |
|||
<AlertDescription> |
|||
Webhook support is coming soon! You'll be able to configure |
|||
endpoints to receive SMS notifications in real-time. |
|||
</AlertDescription> |
|||
</Alert> |
|||
</CardContent> |
|||
</Card> |
|||
</TabsContent> |
|||
|
|||
<TabsContent value='messaging'> |
|||
<Messaging /> |
|||
</TabsContent> |
|||
</Tabs> |
|||
) |
|||
} |
|||
@ -0,0 +1,64 @@ |
|||
'use client' |
|||
|
|||
import { useState } from 'react' |
|||
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' |
|||
import SendSms from './send-sms' |
|||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' |
|||
import ReceivedSms from './received-sms' |
|||
|
|||
export default function Messaging() { |
|||
const [currentTab, setCurrentTab] = useState('send') |
|||
|
|||
const handleTabChange = (value: string) => { |
|||
setCurrentTab(value) |
|||
} |
|||
|
|||
return ( |
|||
<div className='grid gap-6 max-w-sm md:max-w-xl mx-auto mt-10'> |
|||
<Tabs |
|||
value={currentTab} |
|||
onValueChange={handleTabChange} |
|||
className='space-y-4' |
|||
> |
|||
<TabsList className='flex'> |
|||
<TabsTrigger value='send' className='flex-1'> |
|||
Send |
|||
</TabsTrigger> |
|||
<TabsTrigger value='bulk-send' className='flex-1'> |
|||
Bulk Send |
|||
</TabsTrigger> |
|||
<TabsTrigger value='received' className='flex-1'> |
|||
Received |
|||
</TabsTrigger> |
|||
</TabsList> |
|||
|
|||
<TabsContent value='send' className='space-y-4'> |
|||
<SendSms /> |
|||
</TabsContent> |
|||
|
|||
<TabsContent value='bulk-send' className='space-y-4'> |
|||
{/* comming soon section */} |
|||
<div className='grid gap-6 max-w-xl mx-auto mt-10'> |
|||
<Card> |
|||
<CardHeader> |
|||
<CardTitle>Bulk Send</CardTitle> |
|||
</CardHeader> |
|||
<CardContent> |
|||
<div className='flex items-center gap-2'> |
|||
<div className='flex items-center gap-2'> |
|||
<p>Coming soon...</p> |
|||
</div> |
|||
</div> |
|||
</CardContent> |
|||
</Card> |
|||
</div> |
|||
</TabsContent> |
|||
|
|||
<TabsContent value='received' className='space-y-4'> |
|||
<ReceivedSms /> |
|||
</TabsContent> |
|||
</Tabs> |
|||
</div> |
|||
) |
|||
} |
|||
@ -0,0 +1,66 @@ |
|||
'use client' |
|||
|
|||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' |
|||
import { BarChart3, Smartphone, Key, MessageSquare } from 'lucide-react' |
|||
import GetStartedCard from './get-started' |
|||
import { ApiEndpoints } from '@/config/api' |
|||
import httpBrowserClient from '@/lib/httpBrowserClient' |
|||
import { useQuery } from '@tanstack/react-query' |
|||
// import GetStartedCard from "@/components/get-started-card";
|
|||
|
|||
export const StatCard = ({ title, value, icon: Icon, description }) => { |
|||
return ( |
|||
<Card> |
|||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'> |
|||
<CardTitle className='text-sm font-medium'>{title}</CardTitle> |
|||
<Icon className='h-4 w-4 text-muted-foreground' /> |
|||
</CardHeader> |
|||
<CardContent> |
|||
<div className='text-2xl font-bold'>{value}</div> |
|||
<p className='text-xs text-muted-foreground'>{description}</p> |
|||
</CardContent> |
|||
</Card> |
|||
) |
|||
} |
|||
|
|||
export default function Overview() { |
|||
const { data: stats } = useQuery({ |
|||
queryKey: ['stats'], |
|||
queryFn: () => |
|||
httpBrowserClient |
|||
.get(ApiEndpoints.gateway.getStats()) |
|||
.then((res) => res.data?.data), |
|||
}) |
|||
|
|||
return ( |
|||
<div className='space-y-4'> |
|||
<GetStartedCard /> |
|||
<div className='grid gap-4 grid-cols-2 lg:grid-cols-4'> |
|||
<StatCard |
|||
title='Total SMS Sent' |
|||
value={stats?.totalSentSMSCount?.toLocaleString()} |
|||
icon={MessageSquare} |
|||
description='Since last year' |
|||
/> |
|||
<StatCard |
|||
title='Active Devices' |
|||
value={stats?.totalDeviceCount} |
|||
icon={Smartphone} |
|||
description='Connected now' |
|||
/> |
|||
<StatCard |
|||
title='API Keys' |
|||
value={stats?.totalApiKeyCount} |
|||
icon={Key} |
|||
description='Active keys' |
|||
/> |
|||
<StatCard |
|||
title='SMS Received' |
|||
value={stats?.totalReceivedSMSCount?.toLocaleString()} |
|||
icon={BarChart3} |
|||
description='Since last year' |
|||
/> |
|||
</div> |
|||
</div> |
|||
) |
|||
} |
|||
@ -0,0 +1,146 @@ |
|||
import { Tabs, TabsList, TabsContent, TabsTrigger } from '@/components/ui/tabs' |
|||
import { ApiEndpoints } from '@/config/api' |
|||
import httpBrowserClient from '@/lib/httpBrowserClient' |
|||
import { useQuery } from '@tanstack/react-query' |
|||
import { Heart } from 'lucide-react' |
|||
import React, { useEffect, useState } from 'react' |
|||
import CommunityAlert from './community-alert' |
|||
import { Card, CardContent } from '@/components/ui/card' |
|||
import { Phone, Clock, MessageSquare } from 'lucide-react' |
|||
import { Spinner } from '@/components/ui/spinner' |
|||
|
|||
export function ReceivedSmsCard({ sms }) { |
|||
const formattedDate = new Date(sms.receivedAt).toLocaleString('en-US', { |
|||
hour: '2-digit', |
|||
minute: '2-digit', |
|||
day: 'numeric', |
|||
month: 'short', |
|||
year: 'numeric', |
|||
}) |
|||
|
|||
return ( |
|||
<Card className='hover:bg-muted/50 transition-colors max-w-sm md:max-w-none'> |
|||
<CardContent className='p-4'> |
|||
<div className='space-y-3'> |
|||
<div className='flex justify-between items-start'> |
|||
<div className='flex items-center gap-2'> |
|||
<span className='font-medium'>{sms.sender}</span> |
|||
</div> |
|||
<div className='flex items-center gap-1 text-sm text-muted-foreground'> |
|||
<Clock className='h-3 w-3' /> |
|||
<span>{formattedDate}</span> |
|||
</div> |
|||
</div> |
|||
|
|||
<div className='flex gap-2'> |
|||
<p className='text-sm max-w-sm md:max-w-none'>{sms.message}</p> |
|||
</div> |
|||
</div> |
|||
</CardContent> |
|||
</Card> |
|||
) |
|||
} |
|||
|
|||
export default function ReceivedSms() { |
|||
const { |
|||
data: devices, |
|||
isLoading: isLoadingDevices, |
|||
error: devicesError, |
|||
} = useQuery({ |
|||
queryKey: ['devices'], |
|||
queryFn: () => |
|||
httpBrowserClient |
|||
.get(ApiEndpoints.gateway.listDevices()) |
|||
.then((res) => res.data), |
|||
}) |
|||
|
|||
const handleTabChange = (tab: string) => { |
|||
setCurrentTab(tab) |
|||
} |
|||
|
|||
const [currentTab, setCurrentTab] = useState('') |
|||
|
|||
useEffect(() => { |
|||
if (devices?.data?.length) { |
|||
setCurrentTab(devices?.data?.[0]?._id) |
|||
} |
|||
}, [devices]) |
|||
|
|||
const { |
|||
data: receivedSms, |
|||
isLoading: isLoadingReceivedSms, |
|||
error: receivedSmsError, |
|||
} = useQuery({ |
|||
queryKey: ['received-sms', currentTab], |
|||
enabled: !!currentTab, |
|||
queryFn: () => |
|||
httpBrowserClient |
|||
.get(ApiEndpoints.gateway.getReceivedSMS(currentTab)) |
|||
.then((res) => res.data), |
|||
}) |
|||
|
|||
if (isLoadingDevices) |
|||
return ( |
|||
<div className='flex justify-center items-center h-full'> |
|||
<Spinner size='sm' /> |
|||
</div> |
|||
) |
|||
if (devicesError) |
|||
return ( |
|||
<div className='flex justify-center items-center h-full'> |
|||
Error: {devicesError.message} |
|||
</div> |
|||
) |
|||
if (!devices?.data?.length) |
|||
return ( |
|||
<div className='flex justify-center items-center h-full'> |
|||
No devices found |
|||
</div> |
|||
) |
|||
|
|||
return ( |
|||
<div> |
|||
<Tabs |
|||
value={currentTab} |
|||
onValueChange={handleTabChange} |
|||
className='space-y-4' |
|||
> |
|||
<TabsList className='flex'> |
|||
{devices?.data?.map((device) => ( |
|||
<TabsTrigger key={device._id} value={device._id} className='flex-1'> |
|||
{device.brand} {device.model} |
|||
</TabsTrigger> |
|||
))} |
|||
</TabsList> |
|||
|
|||
{devices?.data?.map((device) => ( |
|||
<TabsContent |
|||
key={device._id} |
|||
value={device._id} |
|||
className='space-y-4' |
|||
> |
|||
{isLoadingReceivedSms && ( |
|||
<div className='flex justify-center items-center h-full'> |
|||
<Spinner size='sm' /> |
|||
</div> |
|||
)} |
|||
{receivedSmsError && ( |
|||
<div className='flex justify-center items-center h-full'> |
|||
Error: {receivedSmsError.message} |
|||
</div> |
|||
)} |
|||
{!isLoadingReceivedSms && !receivedSms?.data?.length && ( |
|||
<div className='flex justify-center items-center h-full'> |
|||
No messages found |
|||
</div> |
|||
)} |
|||
|
|||
{receivedSms?.data?.map((sms) => ( |
|||
<ReceivedSmsCard key={sms._id} sms={sms} /> |
|||
))} |
|||
</TabsContent> |
|||
))} |
|||
</Tabs> |
|||
</div> |
|||
) |
|||
} |
|||
@ -0,0 +1,206 @@ |
|||
'use client' |
|||
|
|||
import { |
|||
Card, |
|||
CardContent, |
|||
CardDescription, |
|||
CardHeader, |
|||
CardTitle, |
|||
} from '@/components/ui/card' |
|||
import { Button } from '@/components/ui/button' |
|||
import { Input } from '@/components/ui/input' |
|||
import { Textarea } from '@/components/ui/textarea' |
|||
import { |
|||
Select, |
|||
SelectContent, |
|||
SelectItem, |
|||
SelectTrigger, |
|||
SelectValue, |
|||
} from '@/components/ui/select' |
|||
import { useForm, useFieldArray, Controller } from 'react-hook-form' |
|||
import { zodResolver } from '@hookform/resolvers/zod' |
|||
import { sendSmsSchema } from '@/lib/schemas' |
|||
import type { SendSmsFormData } from '@/lib/schemas' |
|||
import { MessageSquare, Send, Plus, X, UserCircle, Check } from 'lucide-react' |
|||
import { useToast } from '@/hooks/use-toast' |
|||
import httpBrowserClient from '@/lib/httpBrowserClient' |
|||
import { ApiEndpoints } from '@/config/api' |
|||
import { useMutation, useQuery } from '@tanstack/react-query' |
|||
import { Spinner } from '@/components/ui/spinner' |
|||
|
|||
export default function SendSms() { |
|||
const { data: devices, isLoading: isLoadingDevices } = useQuery({ |
|||
queryKey: ['devices'], |
|||
queryFn: () => |
|||
httpBrowserClient |
|||
.get(ApiEndpoints.gateway.listDevices()) |
|||
.then((res) => res.data), |
|||
}) |
|||
|
|||
const { |
|||
mutate: sendSms, |
|||
isPending: isSendingSms, |
|||
error: sendSmsError, |
|||
isSuccess: isSendSmsSuccess, |
|||
} = useMutation({ |
|||
mutationKey: ['send-sms'], |
|||
mutationFn: (data: SendSmsFormData) => |
|||
httpBrowserClient.post(ApiEndpoints.gateway.sendSMS(data.deviceId), data), |
|||
}) |
|||
|
|||
const { toast } = useToast() |
|||
const { |
|||
register, |
|||
control, |
|||
handleSubmit, |
|||
formState: { errors }, |
|||
} = useForm<SendSmsFormData>({ |
|||
resolver: zodResolver(sendSmsSchema), |
|||
defaultValues: { |
|||
recipients: [''], |
|||
message: '', |
|||
}, |
|||
}) |
|||
|
|||
const { fields, append, remove } = useFieldArray({ |
|||
control, |
|||
// @ts-expect-error
|
|||
name: 'recipients', |
|||
}) |
|||
|
|||
return ( |
|||
<div className='grid gap-6 max-w-xl mx-auto mt-10'> |
|||
<Card> |
|||
<CardHeader> |
|||
<div className='flex items-center gap-2'> |
|||
<MessageSquare className='h-5 w-5' /> |
|||
<CardTitle>Send SMS</CardTitle> |
|||
</div> |
|||
<CardDescription>Send a message to any recipient(s)</CardDescription> |
|||
</CardHeader> |
|||
<CardContent> |
|||
<form |
|||
onSubmit={(e) => handleSubmit((data) => sendSms(data))(e)} |
|||
className='space-y-4' |
|||
> |
|||
<div className='space-y-4'> |
|||
<div> |
|||
<Controller |
|||
name='deviceId' |
|||
control={control} |
|||
render={({ field }) => ( |
|||
<Select onValueChange={field.onChange} value={field.value}> |
|||
<SelectTrigger> |
|||
<SelectValue placeholder='Select a device' /> |
|||
</SelectTrigger> |
|||
<SelectContent> |
|||
{devices?.data?.map((device) => ( |
|||
<SelectItem |
|||
key={device._id} |
|||
value={device._id} |
|||
// disabled={!device.enabled}
|
|||
> |
|||
{device.brand} {device.model}{' '} |
|||
{device.enabled ? '' : '(disabled)'} |
|||
</SelectItem> |
|||
))} |
|||
</SelectContent> |
|||
</Select> |
|||
)} |
|||
/> |
|||
{errors.deviceId && ( |
|||
<p className='text-sm text-destructive mt-1'> |
|||
{errors.deviceId.message} |
|||
</p> |
|||
)} |
|||
</div> |
|||
|
|||
<div className='space-y-2'> |
|||
{fields.map((field, index) => ( |
|||
<div key={field.id}> |
|||
<div className='flex gap-2'> |
|||
<Input |
|||
type='tel' |
|||
placeholder='Phone Number' |
|||
{...register(`recipients.${index}`)} |
|||
/> |
|||
<Button |
|||
type='button' |
|||
variant='ghost' |
|||
size='icon' |
|||
onClick={() => remove(index)} |
|||
disabled={index === 0 && fields?.length === 1} |
|||
> |
|||
<X className='h-4 w-4' /> |
|||
</Button> |
|||
</div> |
|||
{errors.recipients?.[index] && ( |
|||
<p className='text-sm text-destructive'> |
|||
{errors.recipients[index].message} |
|||
</p> |
|||
)} |
|||
</div> |
|||
))} |
|||
<Button |
|||
type='button' |
|||
variant='outline' |
|||
size='sm' |
|||
onClick={() => append('')} |
|||
className='w-full' |
|||
> |
|||
<Plus className='h-4 w-4 mr-2' /> |
|||
Add Recipient |
|||
</Button> |
|||
|
|||
{errors.recipients && ( |
|||
<p className='text-sm text-destructive'> |
|||
{errors.recipients.message} |
|||
</p> |
|||
)} |
|||
|
|||
{errors.recipients?.root && ( |
|||
<p className='text-sm text-destructive'> |
|||
{errors.recipients.root.message} |
|||
</p> |
|||
)} |
|||
</div> |
|||
|
|||
<div> |
|||
<Textarea |
|||
placeholder='Message' |
|||
{...register('message')} |
|||
rows={4} |
|||
/> |
|||
{errors.message && ( |
|||
<p className='text-sm text-destructive mt-1'> |
|||
{errors.message.message} |
|||
</p> |
|||
)} |
|||
</div> |
|||
</div> |
|||
{sendSmsError && ( |
|||
<div className='flex items-center gap-2 text-destructive'> |
|||
<p>Error sending SMS: {sendSmsError.message}</p> |
|||
<X className='h-5 w-5' /> |
|||
</div> |
|||
)} |
|||
|
|||
{isSendSmsSuccess && ( |
|||
<div className='flex items-center gap-2'> |
|||
<p>SMS sent successfully!</p> |
|||
<Check className='h-5 w-5' /> |
|||
</div> |
|||
)} |
|||
|
|||
<Button type='submit' disabled={isSendingSms} className='w-full'> |
|||
{isSendingSms && ( |
|||
<Spinner size='sm' className='mr-2' color='white' /> |
|||
)} |
|||
{isSendingSms ? 'Sending...' : 'Send Message'} |
|||
</Button> |
|||
</form> |
|||
</CardContent> |
|||
</Card> |
|||
</div> |
|||
) |
|||
} |
|||
@ -0,0 +1,9 @@ |
|||
import Dashboard from "./(components)/dashboard-layout"; |
|||
|
|||
export default function DashboardLayout({ |
|||
children, |
|||
}: { |
|||
children: React.ReactNode; |
|||
}) { |
|||
return <Dashboard>{children}</Dashboard>; |
|||
} |
|||
@ -0,0 +1,3 @@ |
|||
export default function DashboardPage() { |
|||
return <div>DashboardPage</div> |
|||
} |
|||
@ -0,0 +1,49 @@ |
|||
'use client' |
|||
|
|||
import { GoogleOAuthProvider } from '@react-oauth/google' |
|||
import { SessionProvider } from 'next-auth/react' |
|||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' |
|||
import httpBrowserClient from '@/lib/httpBrowserClient' |
|||
import { ApiEndpoints } from '@/config/api' |
|||
import { useEffect } from 'react' |
|||
import { usePathname, useRouter } from 'next/navigation' |
|||
import { Routes } from '@/config/routes' |
|||
|
|||
export default function LayoutWrapper({ session, children }) { |
|||
const router = useRouter() |
|||
const pathname = usePathname() |
|||
|
|||
// log the user out if token has expired
|
|||
useEffect(() => { |
|||
if (session && session.user && !pathname.includes(Routes.logout)) { |
|||
httpBrowserClient |
|||
.get(ApiEndpoints.auth.whoAmI()) |
|||
.then((response) => { |
|||
// token is still valid
|
|||
// TODO: if name has changed, update session
|
|||
}) |
|||
.catch((error) => { |
|||
if (error.response?.status === 401) { |
|||
// token has expired
|
|||
router.push(Routes.logout) |
|||
} |
|||
}) |
|||
} |
|||
}, [pathname, router, session]) |
|||
|
|||
const queryClient = new QueryClient() |
|||
|
|||
return ( |
|||
<> |
|||
<SessionProvider session={session}> |
|||
<QueryClientProvider client={queryClient}> |
|||
<GoogleOAuthProvider |
|||
clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID} |
|||
> |
|||
{children} |
|||
</GoogleOAuthProvider> |
|||
</QueryClientProvider> |
|||
</SessionProvider> |
|||
</> |
|||
) |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
import { PropsWithChildren } from 'react' |
|||
import '@/styles/main.css' |
|||
import { authOptions } from '@/lib/auth' |
|||
import { getServerSession, Session } from 'next-auth' |
|||
import AppHeader from '@/components/shared/app-header' |
|||
import LayoutWrapper from './layout-wrapper' |
|||
|
|||
export default async function RootLayout({ children }: PropsWithChildren) { |
|||
const session: Session | null = await getServerSession(authOptions as any) |
|||
|
|||
return ( |
|||
<> |
|||
<LayoutWrapper session={session}> |
|||
<AppHeader /> |
|||
{children} |
|||
</LayoutWrapper> |
|||
</> |
|||
) |
|||
} |
|||
@ -0,0 +1,91 @@ |
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../../components/ui/tabs' |
|||
import SyntaxHighlighter from 'react-syntax-highlighter' |
|||
|
|||
const codeSnippets = [ |
|||
{ |
|||
tech: 'NodeJs', |
|||
language: 'javascript', |
|||
snippet: `import axios from 'axios'
|
|||
|
|||
const BASE_URL = 'https://api.textbee.dev/api/v1' |
|||
const API_KEY = 'YOUR_API_KEY' |
|||
const DEVICE_ID = 'YOUR_DEVICE_ID' |
|||
|
|||
const response = await axios.post(\`\$\{BASE_URL\}/gateway/devices/\$\{DEVICE_ID}/sendSMS\`, {
|
|||
recipients: [ '+1234567890' ], |
|||
message: 'Hello World!', |
|||
}, { |
|||
headers: { |
|||
'x-api-key': API_KEY, |
|||
}, |
|||
}) |
|||
|
|||
console.log(response.data)`,
|
|||
}, |
|||
{ |
|||
tech: 'Python', |
|||
language: 'python', |
|||
snippet: `import requests
|
|||
|
|||
BASE_URL = 'https://api.textbee.dev/api/v1' |
|||
API_KEY = 'YOUR_API_KEY' |
|||
DEVICE_ID = 'YOUR_DEVICE_ID' |
|||
|
|||
response = requests.post( |
|||
f'{BASE_URL}/api/device/{DEVICE_ID}/sendSMS', |
|||
json={ |
|||
'recipients': ['+1234567890'], |
|||
'message': 'Hello World!' |
|||
}, |
|||
headers={'x-api-key': API_KEY}) |
|||
|
|||
print(response.json())`,
|
|||
}, |
|||
{ |
|||
tech: 'cURL', |
|||
language: 'bash', |
|||
snippet: `curl -X POST "https://api.textbee.dev/api/v1/gateway/devices/YOUR_DEVICE_ID/sendSMS" \\
|
|||
-H 'x-api-key: YOUR_API_KEY' \\ |
|||
-H 'Content-Type: application/json' \\ |
|||
-d '{ |
|||
"recipients": [ "+1234567890" ], |
|||
"message": "Hello from textbee.dev" |
|||
}'`,
|
|||
}, |
|||
] |
|||
|
|||
export default function CodeSnippetSection() { |
|||
return ( |
|||
<section className='container mx-auto py-24 px-4 sm:px-6 lg:px-8 max-w-7xl bg-gray-50'> |
|||
<div className='mx-auto max-w-[58rem]'> |
|||
<h3 className='text-3xl font-bold mb-8'>Code Snippet</h3> |
|||
<div className='bg-white p-6 rounded-xl shadow-sm'> |
|||
<Tabs defaultValue={codeSnippets[0].tech} className='w-full'> |
|||
<TabsList className='grid w-full grid-cols-3'> |
|||
{codeSnippets.map((snippet) => { |
|||
return ( |
|||
<TabsTrigger key={snippet.tech} value={snippet.tech}> |
|||
{snippet.tech} |
|||
</TabsTrigger> |
|||
) |
|||
})} |
|||
</TabsList> |
|||
{codeSnippets.map((snippet) => { |
|||
return ( |
|||
<TabsContent key={snippet.tech} value={snippet.tech}> |
|||
<SyntaxHighlighter |
|||
language={snippet.language} |
|||
showLineNumbers={snippet.language !== 'bash'} |
|||
// className='min-h-[200px]'
|
|||
> |
|||
{snippet.snippet} |
|||
</SyntaxHighlighter> |
|||
</TabsContent> |
|||
) |
|||
})} |
|||
</Tabs> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
) |
|||
} |
|||
@ -0,0 +1,35 @@ |
|||
import { ArrowRight } from 'lucide-react' |
|||
import { Button } from '../../../components/ui/button' |
|||
import Link from 'next/link' |
|||
|
|||
export default function CustomizationSection() { |
|||
return ( |
|||
<section className='py-24 bg-gradient-to-b from-blue-50 to-white'> |
|||
<div className='container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl'> |
|||
<div className='mx-auto max-w-3xl text-center mb-12'> |
|||
<h2 className='text-4xl font-bold mb-4 text-blue-600'> |
|||
Request Custom Solutions |
|||
</h2> |
|||
<p className='text-xl text-gray-600 mb-8'> |
|||
Let's explore how we can customize TextBee to align perfectly |
|||
with your business requirements. Whether you're looking for new |
|||
features or need assistance in deploying the platform on your own |
|||
server, or need dedicated support we're here to help. |
|||
</p> |
|||
<Link |
|||
href={`mailto:contact@textbee.dev?subject=Customization Request&body=I am interested in discussing paid solutions for TextBee.`} |
|||
> |
|||
<Button |
|||
size='lg' |
|||
className='bg-blue-600 hover:bg-blue-700 text-white font-bold py-4 px-8 rounded-full shadow-lg transition-all duration-300 ease-in-out transform hover:scale-105' |
|||
// onClick={() => setCustomizationOpen(true)}
|
|||
> |
|||
Discuss Custom Solutions |
|||
<ArrowRight className='ml-2 h-5 w-5' /> |
|||
</Button> |
|||
</Link> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
) |
|||
} |
|||
@ -0,0 +1,36 @@ |
|||
import Image from 'next/image' |
|||
import { Button } from '../../../components/ui/button' |
|||
import Link from 'next/link' |
|||
import { Routes } from '@/config/routes' |
|||
|
|||
export default function DownloadAppSection() { |
|||
return ( |
|||
<section className='container mx-auto py-24 px-4 sm:px-6 lg:px-8 max-w-7xl'> |
|||
<div className='mx-auto max-w-[58rem] text-center'> |
|||
<div className='rounded-xl bg-gradient-to-r from-blue-50 to-indigo-50 p-8'> |
|||
<div className='mx-auto max-w-sm'> |
|||
<Image |
|||
alt='App preview' |
|||
className='mx-auto mb-8 rounded-xl shadow-lg' |
|||
height='400' |
|||
src='/images/smsgatewayandroid.png' |
|||
width='200' |
|||
/> |
|||
<h3 className='text-xl font-bold mb-2'> |
|||
Download the App to get started! |
|||
</h3> |
|||
<p className='text-gray-500 mb-4'> |
|||
Unlock the power of messaging with our open-source Android SMS |
|||
Gateway. |
|||
</p> |
|||
<Link href={Routes.downloadAndroidApp} prefetch={false}> |
|||
<Button className='bg-blue-500 hover:bg-blue-600'> |
|||
Download App |
|||
</Button> |
|||
</Link> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
) |
|||
} |
|||
@ -0,0 +1,48 @@ |
|||
import { Card } from '@/components/ui/card' |
|||
import { Code, Send, Zap, Users } from 'lucide-react' |
|||
|
|||
|
|||
export default function FeaturesSection() { |
|||
return ( |
|||
<section className='container mx-auto py-24 px-4 sm:px-6 lg:px-8 max-w-7xl'> |
|||
<div className='mx-auto flex max-w-[58rem] flex-col items-center justify-center gap-4 text-center'> |
|||
<h2 className='text-3xl font-bold'>Features</h2> |
|||
<p className='max-w-[85%] text-gray-500'> |
|||
The ultimate solution for your messaging needs! Our free open-source |
|||
Android-based SMS Gateway provides you with all the features you need |
|||
to effectively manage your SMS communications. |
|||
</p> |
|||
</div> |
|||
<div className='mx-auto grid justify-center gap-6 sm:grid-cols-2 md:max-w-[64rem] md:grid-cols-4 mt-12'> |
|||
<Card className='flex flex-col items-center justify-center p-6 text-center'> |
|||
<Send className='h-12 w-12 mb-4 text-blue-500' /> |
|||
<h3 className='font-bold'>Send SMS</h3> |
|||
<p className='text-sm text-gray-500'> |
|||
Send SMS to any number from your dashboard or via REST API |
|||
</p> |
|||
</Card> |
|||
<Card className='flex flex-col items-center justify-center p-6 text-center'> |
|||
<Users className='h-12 w-12 mb-4 text-blue-500' /> |
|||
<h3 className='font-bold'>Bulk SMS</h3> |
|||
<p className='text-sm text-gray-500'> |
|||
Send SMS to multiple numbers at once |
|||
</p> |
|||
</Card> |
|||
<Card className='flex flex-col items-center justify-center p-6 text-center'> |
|||
<Zap className='h-12 w-12 mb-4 text-blue-500' /> |
|||
<h3 className='font-bold'>100% Free</h3> |
|||
<p className='text-sm text-gray-500'> |
|||
No credit card required. No hidden fees. No strings attached. |
|||
</p> |
|||
</Card> |
|||
<Card className='flex flex-col items-center justify-center p-6 text-center'> |
|||
<Code className='h-12 w-12 mb-4 text-blue-500' /> |
|||
<h3 className='font-bold'>Open Source</h3> |
|||
<p className='text-sm text-gray-500'> |
|||
The entire codebase is open source and available on GitHub. |
|||
</p> |
|||
</Card> |
|||
</div> |
|||
</section> |
|||
) |
|||
} |
|||
@ -0,0 +1,75 @@ |
|||
import { Button } from '@/components/ui/button' |
|||
import { Routes } from '@/config/routes' |
|||
import { Smartphone, Code, Zap } from 'lucide-react' |
|||
import Image from 'next/image' |
|||
import Link from 'next/link' |
|||
|
|||
export default function HeroSection() { |
|||
return ( |
|||
<section className='relative overflow-hidden bg-gradient-to-b from-blue-50 to-white py-16 sm:py-24'> |
|||
<div className='absolute inset-0 bg-[url(/grid.svg)] bg-center [mask-image:linear-gradient(180deg,white,rgba(255,255,255,0))]'></div> |
|||
<div className='container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl relative'> |
|||
<div className='grid gap-8 lg:grid-cols-2 lg:gap-16'> |
|||
<div className='flex flex-col justify-center space-y-8'> |
|||
<div className='space-y-4'> |
|||
<h1 className='text-4xl font-bold tracking-tighter sm:text-5xl xl:text-6xl/none'> |
|||
Transform Your Android into a |
|||
<span className='text-blue-500 block'> |
|||
{' '} |
|||
Powerful SMS Gateway |
|||
</span> |
|||
</h1> |
|||
<p className='max-w-[600px] text-gray-500 md:text-xl'> |
|||
Unlock the potential of your device with our open-source |
|||
solution. Send SMS effortlessly through your applications. |
|||
</p> |
|||
</div> |
|||
<div className='flex flex-cdol gap-4 flex-row'> |
|||
<Link href={Routes.register} prefetch={false}> |
|||
<Button className='bg-blue-500 hover:bg-blue-600' size='lg'> |
|||
Get Started |
|||
</Button> |
|||
</Link> |
|||
<Link href='#how-it-works' prefetch={false}> |
|||
<Button variant='outline' size='lg'> |
|||
How It Works |
|||
</Button> |
|||
</Link> |
|||
</div> |
|||
<div className='flex items-center space-x-4 text-sm'> |
|||
<div className='flex items-center'> |
|||
<Smartphone className='mr-2 h-4 w-4 text-blue-500' /> |
|||
Android Compatible |
|||
</div> |
|||
<div className='flex items-center'> |
|||
<Code className='mr-2 h-4 w-4 text-blue-500' /> |
|||
Open Source |
|||
</div> |
|||
<div className='flex items-center'> |
|||
<Zap className='mr-2 h-4 w-4 text-blue-500' /> |
|||
Easy Setup |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div className='relative mx-auto w-full max-w-lg lg:max-w-none'> |
|||
<div className='absolute -top-4 -right-4 h-72 w-72 bg-blue-100 rounded-full blur-3xl'></div> |
|||
<div className='absolute -bottom-4 -left-4 h-72 w-72 bg-blue-100 rounded-full blur-3xl'></div> |
|||
<div className='relative'> |
|||
<Image |
|||
alt='TextBee App' |
|||
className='relative mx-auto w-full max-w-lg rounded-2xl shadow-xl' |
|||
height='600' |
|||
src='/images/smsgatewayandroid.png' |
|||
style={{ |
|||
objectFit: 'contain', |
|||
}} |
|||
width='500' |
|||
/> |
|||
<div className='absolute inset-0 rounded-2xl bg-gradient-to-tr from-blue-400 to-blue-300 opacity-20'></div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
) |
|||
} |
|||
@ -0,0 +1,57 @@ |
|||
import { Routes } from '@/config/routes' |
|||
import { |
|||
Accordion, |
|||
AccordionContent, |
|||
AccordionItem, |
|||
AccordionTrigger, |
|||
} from '../../../components/ui/accordion' |
|||
|
|||
export default function HowItWorksSection() { |
|||
return ( |
|||
<section |
|||
id='how-it-works' |
|||
className='container mx-auto py-24 px-4 sm:px-6 lg:px-8 max-w-7xl bg-gray-50' |
|||
> |
|||
<div className='mx-auto max-w-[58rem]'> |
|||
<h2 className='text-3xl font-bold text-center mb-8'>How It Works</h2> |
|||
<p className='text-center mb-12 text-gray-500'> |
|||
How it works is simple. You install the app on your Android device, |
|||
and it will turn your device into a SMS Gateway. You can then use the |
|||
API to send SMS messages from your web applications. |
|||
</p> |
|||
<Accordion type='single' collapsible className='w-full'> |
|||
<AccordionItem value='step-1'> |
|||
<AccordionTrigger> |
|||
Step 1: Download The Android App |
|||
</AccordionTrigger> |
|||
<AccordionContent> |
|||
Download the Android App from{' '} |
|||
<a href={Routes.downloadAndroidApp} target='_blank'> |
|||
{Routes.downloadAndroidApp} |
|||
</a> |
|||
</AccordionContent> |
|||
</AccordionItem> |
|||
<AccordionItem value='step-2'> |
|||
<AccordionTrigger>Step 2: Generate an API key</AccordionTrigger> |
|||
<AccordionContent> |
|||
Generate an API key from the dashboard |
|||
</AccordionContent> |
|||
</AccordionItem> |
|||
<AccordionItem value='step-3'> |
|||
<AccordionTrigger>Step 3: Scan the QR code</AccordionTrigger> |
|||
<AccordionContent> |
|||
Open the textbee mobile app and scan the QR code or enter your api |
|||
key manually and enable the gateway app |
|||
</AccordionContent> |
|||
</AccordionItem> |
|||
<AccordionItem value='step-4'> |
|||
<AccordionTrigger>Step 4: Start sending</AccordionTrigger> |
|||
<AccordionContent> |
|||
Start sending SMS messages from the dashboard or using the API |
|||
</AccordionContent> |
|||
</AccordionItem> |
|||
</Accordion> |
|||
</div> |
|||
</section> |
|||
) |
|||
} |
|||
@ -0,0 +1,50 @@ |
|||
import Link from 'next/link' |
|||
import { MessageSquarePlus, Moon } from 'lucide-react' |
|||
import { Button } from '@/components/ui/button' |
|||
import { ExternalLinks } from '@/config/external-links' |
|||
import { Routes } from '@/config/routes' |
|||
|
|||
export default function LandingPageHeader() { |
|||
return ( |
|||
<header className='sticky top-0 z-50 w-full border-b bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/60'> |
|||
<div className='container flex h-14 items-center justify-between px-2'> |
|||
<Link |
|||
className='flex items-center space-x-2' |
|||
href={Routes.landingPage} |
|||
> |
|||
<MessageSquarePlus className='h-6 w-6 text-blue-500' /> |
|||
<span className='font-bold'> |
|||
Text<span className='text-blue-500'>Bee</span> |
|||
</span> |
|||
</Link> |
|||
<nav className='flex items-center space-x-4'> |
|||
{/* <Button variant='ghost' size='icon'> |
|||
<Moon className='h-4 w-4' /> |
|||
<span className='sr-only'>Toggle theme</span> |
|||
</Button> */} |
|||
<Link |
|||
className='text-sm font-medium hover:text-blue-500' |
|||
href={ExternalLinks.github} |
|||
> |
|||
Github |
|||
</Link> |
|||
|
|||
<Link |
|||
className='text-sm font-medium hover:text-blue-500' |
|||
href={Routes.dashboard} |
|||
> |
|||
<Button className='bg-blue-500 hover:bg-blue-600 rounded-full'> |
|||
Go to Dashboard |
|||
</Button> |
|||
</Link> |
|||
{/* <Link |
|||
className='text-sm font-medium hover:text-blue-500' |
|||
href='/register' |
|||
> |
|||
Register |
|||
</Link> */} |
|||
</nav> |
|||
</div> |
|||
</header> |
|||
) |
|||
} |
|||
@ -0,0 +1,114 @@ |
|||
'use client' |
|||
|
|||
import { toast } from '@/hooks/use-toast' |
|||
import { Button } from '../../../components/ui/button' |
|||
import { Heart, Coins, Check, Copy } from 'lucide-react' |
|||
import { useState } from 'react' |
|||
import { |
|||
Dialog, |
|||
DialogContent, |
|||
DialogHeader, |
|||
DialogTitle, |
|||
} from '../../../components/ui/dialog' |
|||
import Link from 'next/link' |
|||
import { ExternalLinks } from '@/config/external-links' |
|||
|
|||
export default function SupportProjectSection() { |
|||
const [cryptoOpen, setCryptoOpen] = useState(false) |
|||
const [copiedAddress, setCopiedAddress] = useState('') |
|||
|
|||
const cryptoWallets = [ |
|||
{ |
|||
name: 'Bitcoin (BTC)', |
|||
address: '1Ag3nQKGDdmcqSZicRRKnGGKwfgSkhhA5M', |
|||
network: 'Bitcoin', |
|||
}, |
|||
{ |
|||
name: 'Ethereum (ETH)', |
|||
address: '0x61368be0052ee9245287ee88f7f1fceb5343e207', |
|||
network: 'Ethereum (ERC20)', |
|||
}, |
|||
{ |
|||
name: 'Tether (USDT)', |
|||
address: '0x61368be0052ee9245287ee88f7f1fceb5343e207', |
|||
network: 'Ethereum (ERC20)', |
|||
}, |
|||
{ |
|||
name: 'Tether (USDT)', |
|||
address: 'TD6txzY61D6EgnVfMLPsqKhYfyV5iHrbkw', |
|||
network: 'Tron (TRC20)', |
|||
}, |
|||
] |
|||
|
|||
const copyToClipboard = (address: string) => { |
|||
navigator.clipboard.writeText(address) |
|||
setCopiedAddress(address) |
|||
toast({ |
|||
title: 'Address copied!', |
|||
description: 'The wallet address has been copied to your clipboard.', |
|||
}) |
|||
setTimeout(() => setCopiedAddress(''), 3000) |
|||
} |
|||
|
|||
return ( |
|||
<> |
|||
<section className='container mx-auto py-24 px-4 sm:px-6 lg:px-8 max-w-7xl bg-gray-50'> |
|||
<div className='mx-auto max-w-[58rem] text-center'> |
|||
<h2 className='text-3xl font-bold mb-4'>Support The Project</h2> |
|||
<p className='text-gray-500 mb-8'> |
|||
Maintaining an open-source project requires time and dedication. By |
|||
becoming a patron or donating cryptocurrency, your contribution will |
|||
directly support the development, including implementation of new |
|||
features, enhance performance, and ensure the highest level of |
|||
security and reliability. |
|||
</p> |
|||
<div className='flex flex-col sm:flex-row justify-center gap-4'> |
|||
<Link href={ExternalLinks.patreon} prefetch={false} target='_blank'> |
|||
<Button className='bg-blue-500 hover:bg-blue-600'> |
|||
<Heart className='mr-2 h-4 w-4' /> Become a Patron |
|||
</Button> |
|||
</Link> |
|||
<Button variant='outline' onClick={() => setCryptoOpen(true)}> |
|||
<Coins className='mr-2 h-4 w-4' /> Donate Crypto |
|||
</Button> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
|
|||
<Dialog open={cryptoOpen} onOpenChange={setCryptoOpen}> |
|||
<DialogContent className='sm:max-w-[500px]'> |
|||
<DialogHeader> |
|||
<DialogTitle>Donate Cryptocurrency</DialogTitle> |
|||
</DialogHeader> |
|||
<div className='grid gap-4 py-4'> |
|||
{cryptoWallets.map((wallet, index) => ( |
|||
<div |
|||
key={index} |
|||
className='flex items-center justify-between p-4 rounded-lg bg-gray-100' |
|||
> |
|||
<div> |
|||
<h4 className='font-semibold'>{wallet.name}</h4> |
|||
<p className='text-sm text-gray-500'>{wallet.network}</p> |
|||
<p className='text-xs text-gray-400 mt-1 break-all'> |
|||
{wallet.address} |
|||
</p> |
|||
</div> |
|||
<Button |
|||
variant='outline' |
|||
size='icon' |
|||
onClick={() => copyToClipboard(wallet.address)} |
|||
> |
|||
{copiedAddress === wallet.address ? ( |
|||
<Check className='h-4 w-4' /> |
|||
) : ( |
|||
<Copy className='h-4 w-4' /> |
|||
)} |
|||
</Button> |
|||
</div> |
|||
))} |
|||
</div> |
|||
</DialogContent> |
|||
</Dialog> |
|||
</> |
|||
) |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
import { PropsWithChildren } from 'react' |
|||
import LandingPageHeader from './(components)/landing-page-header' |
|||
|
|||
export default async function RootLayout({ children }: PropsWithChildren) { |
|||
return ( |
|||
<> |
|||
<LandingPageHeader /> |
|||
{children} |
|||
</> |
|||
) |
|||
} |
|||
@ -0,0 +1,23 @@ |
|||
import DownloadAppSection from '@/app/(landing-page)/(components)/download-app-section' |
|||
import FeaturesSection from '@/app/(landing-page)/(components)/features-section' |
|||
import HeroSection from '@/app/(landing-page)/(components)/hero-section' |
|||
import HowItWorksSection from '@/app/(landing-page)/(components)/how-it-works-section' |
|||
import CustomizationSection from '@/app/(landing-page)/(components)/customization-section' |
|||
import SupportProjectSection from '@/app/(landing-page)/(components)/support-project-section' |
|||
import CodeSnippetSection from '@/app/(landing-page)/(components)/code-snippet-section' |
|||
|
|||
export default function LandingPage() { |
|||
return ( |
|||
<div className='flex min-h-screen flex-col'> |
|||
<main className='flex-1'> |
|||
<HeroSection /> |
|||
<FeaturesSection /> |
|||
<HowItWorksSection /> |
|||
<DownloadAppSection /> |
|||
<CustomizationSection /> |
|||
<CodeSnippetSection /> |
|||
<SupportProjectSection /> |
|||
</main> |
|||
</div> |
|||
) |
|||
} |
|||
@ -0,0 +1,82 @@ |
|||
import { Metadata } from 'next' |
|||
import { Card, CardContent } from '@/components/ui/card' |
|||
import LandingPageHeader from '../(components)/landing-page-header' |
|||
|
|||
export const metadata: Metadata = { |
|||
title: 'Privacy Policy | TextBee', |
|||
description: 'Privacy Policy for TextBee SMS Gateway Platform', |
|||
} |
|||
|
|||
export default function PrivacyPolicyPage() { |
|||
return ( |
|||
<> |
|||
<LandingPageHeader /> |
|||
<div className='container max-w-7xl py-6 md:px-12'> |
|||
<Card className='border-none shadow-none'> |
|||
<CardContent className='space-y-6'> |
|||
<h1 className='scroll-m-20 text-4xl font-bold tracking-tight lg:text-5xl'> |
|||
Privacy Policy |
|||
</h1> |
|||
|
|||
<h2 className='scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight'> |
|||
Effective Date: May 2022 |
|||
</h2> |
|||
|
|||
<p className='leading-7 [&:not(:first-child)]:mt-6'> |
|||
Thank you for using our TextBee SMS Gateway Platform |
|||
(“Platform”). This Privacy Policy is intended to |
|||
inform you about how we collect, use, and disclose information |
|||
when you use our Platform. We are committed to protecting your |
|||
privacy and ensuring the security of your personal information. By |
|||
using our Platform, you consent to the practices described in this |
|||
Privacy Policy. |
|||
</p> |
|||
|
|||
<div className='space-y-4'> |
|||
<h2 className='scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight'> |
|||
1. Information We Collect |
|||
</h2> |
|||
<h3 className='scroll-m-20 text-2xl font-semibold tracking-tight'> |
|||
1.1 Personal Information: |
|||
</h3> |
|||
<p className='leading-7'> |
|||
We may collect the following types of personal information from |
|||
you when you use our Platform: |
|||
</p> |
|||
<ul className='my-6 ml-6 list-disc [&>li]:mt-2'> |
|||
<li>Your name</li> |
|||
<li> |
|||
Contact information (such as email address and phone number) |
|||
</li> |
|||
<li> |
|||
Device information (such as device ID, model, and operating |
|||
system) |
|||
</li> |
|||
<li>SMS content and metadata</li> |
|||
</ul> |
|||
</div> |
|||
|
|||
{/* ... Continue with other sections ... */} |
|||
|
|||
<div className='space-y-4'> |
|||
<h2 className='scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight'> |
|||
8. Contact Us |
|||
</h2> |
|||
<p className='leading-7'> |
|||
If you have any questions or concerns about this Privacy Policy |
|||
or our data practices, please contact us at{' '} |
|||
<a |
|||
href='mailto:contact@textbee.dev' |
|||
className='font-medium text-primary underline underline-offset-4 hover:text-primary/80' |
|||
> |
|||
contact@textbee.dev |
|||
</a> |
|||
. |
|||
</p> |
|||
</div> |
|||
</CardContent> |
|||
</Card> |
|||
</div> |
|||
</> |
|||
) |
|||
} |
|||
@ -1,9 +0,0 @@ |
|||
import React from 'react' |
|||
|
|||
export default function page() { |
|||
return ( |
|||
<div> |
|||
v2 |
|||
</div> |
|||
) |
|||
} |
|||
@ -0,0 +1,6 @@ |
|||
import { authOptions } from '@/lib/auth' |
|||
import NextAuth, { AuthOptions } from 'next-auth' |
|||
|
|||
const handler = NextAuth(authOptions as AuthOptions) |
|||
|
|||
export { handler as GET, handler as POST } |
|||
@ -0,0 +1,59 @@ |
|||
import { |
|||
NextRequest, |
|||
NextResponse, |
|||
userAgent, |
|||
userAgentFromString, |
|||
} from 'next/server' |
|||
|
|||
import prismaClient from '@/lib/prismaClient' |
|||
import { sendMail } from '@/lib/mail' |
|||
|
|||
export async function POST(req: NextRequest) { |
|||
const ip = req.ip || req.headers.get('x-forwarded-for') |
|||
const { browser, device, os, isBot, ua } = userAgent(req) |
|||
// const userAgentString = userAgentFromString(ua)
|
|||
|
|||
const body = await req.json() |
|||
|
|||
try { |
|||
const result = await prismaClient.supportMessage.create({ |
|||
data: { |
|||
...body, |
|||
ip, |
|||
userAgent: ua, |
|||
}, |
|||
}) |
|||
|
|||
// send email to user
|
|||
await sendMail({ |
|||
to: body.email, |
|||
cc: process.env.ADMIN_EMAIL, |
|||
subject: `Support request submitted: ${body.category}-${result.id}`, |
|||
html: `<pre>
|
|||
<h1>Support request submitted</h1> |
|||
<p>Thank you for contacting us. We will get back to you soon.</p> |
|||
<p>Here is a copy of your message:</p> |
|||
<hr/> |
|||
<h2>Category</h2><br/>${body.category} |
|||
<h2>Message</h2><br/>${body.message} |
|||
|
|||
<h2>Contact Information</h2> |
|||
<p>Name: ${body.name}</p> |
|||
<p>Email: ${body.email}</p> |
|||
<p>Phone: ${body.phone || 'N/A'}</p> |
|||
</pre>`,
|
|||
}) |
|||
|
|||
return NextResponse.json({ |
|||
message: 'Support request submitted', |
|||
}) |
|||
} catch (error) { |
|||
console.error(error) |
|||
return NextResponse.json( |
|||
{ |
|||
message: `Support request failed to submit : ${error.message}`, |
|||
}, |
|||
{ status: 400 } |
|||
) |
|||
} |
|||
} |
|||
@ -0,0 +1,86 @@ |
|||
import { |
|||
NextRequest, |
|||
NextResponse, |
|||
userAgent, |
|||
userAgentFromString, |
|||
} from 'next/server' |
|||
|
|||
import prismaClient from '@/lib/prismaClient' |
|||
import { sendMail } from '@/lib/mail' |
|||
import { getServerSession, User } from 'next-auth' |
|||
import { authOptions } from '@/lib/auth' |
|||
|
|||
export async function POST(req: NextRequest) { |
|||
const ip = req.ip || req.headers.get('x-forwarded-for') |
|||
const { browser, device, os, isBot, ua } = userAgent(req) |
|||
// const userAgentString = userAgentFromString(ua)
|
|||
|
|||
const body = await req.json() |
|||
|
|||
const session = await getServerSession(authOptions as any) |
|||
if (!session) { |
|||
return NextResponse.json( |
|||
{ |
|||
message: 'You must be logged in to request account deletion', |
|||
}, |
|||
{ status: 401 } |
|||
) |
|||
} |
|||
// @ts-ignore
|
|||
const currentUser = session?.user as User |
|||
|
|||
if (!currentUser) { |
|||
return NextResponse.json( |
|||
{ |
|||
message: 'You must be logged in to request account deletion', |
|||
}, |
|||
{ status: 401 } |
|||
) |
|||
} |
|||
|
|||
const category = 'account-deletion' |
|||
const message = body.message ?? 'No message provided' |
|||
|
|||
try { |
|||
const result = await prismaClient.supportMessage.create({ |
|||
data: { |
|||
user: currentUser.id, |
|||
category, |
|||
message, |
|||
ip, |
|||
userAgent: ua, |
|||
}, |
|||
}) |
|||
|
|||
// send email to user
|
|||
await sendMail({ |
|||
to: currentUser.email, |
|||
cc: process.env.ADMIN_EMAIL, |
|||
subject: `Account deletion request submitted: ${category}-${result.id}`, |
|||
html: `<pre>
|
|||
<h1>Account deletion request submitted</h1> |
|||
<p>Thank you for contacting us. We will get back to you soon.</p> |
|||
<p>Here is a copy of your message:</p> |
|||
<hr/> |
|||
<h2>Category</h2><br/>${category} |
|||
<h2>Message</h2><br/>${message} |
|||
|
|||
<h2>Contact Information</h2> |
|||
<p>Name: ${currentUser.name}</p> |
|||
<p>Email: ${currentUser.email}</p> |
|||
</pre>`,
|
|||
}) |
|||
|
|||
return NextResponse.json({ |
|||
message: 'Support request submitted', |
|||
}) |
|||
} catch (error) { |
|||
console.error(error) |
|||
return NextResponse.json( |
|||
{ |
|||
message: `Support request failed to submit : ${error.message}`, |
|||
}, |
|||
{ status: 400 } |
|||
) |
|||
} |
|||
} |
|||
@ -1,25 +0,0 @@ |
|||
import Link from 'next/link' |
|||
import React from 'react' |
|||
|
|||
export default function CustomerSupportPage() { |
|||
return ( |
|||
<div> |
|||
<div> |
|||
<Link |
|||
href='/' |
|||
style={{ |
|||
margin: '5px', |
|||
padding: '5px', |
|||
}} |
|||
>{`<-- Go Back Home`}</Link> |
|||
</div> |
|||
<iframe |
|||
src='https://docs.google.com/forms/d/e/1FAIpQLScdlaaW28BdL-J0DrfKbz5TY5JvaGbbc6IVp95cptOQlq4ElQ/viewform?embedded=true' |
|||
width='100%' |
|||
height='1015' |
|||
> |
|||
Loading… |
|||
</iframe> |
|||
</div> |
|||
) |
|||
} |
|||
@ -1,11 +1,74 @@ |
|||
import { PropsWithChildren } from 'react' |
|||
import '@/styles/main.css' |
|||
import { Metadata } from 'next' |
|||
import CustomerSupport from '@/components/shared/customer-support' |
|||
import Footer from '@/components/shared/footer' |
|||
import { Toaster } from '@/components/ui/toaster' |
|||
import Analytics from '@/components/shared/analytics' |
|||
import { Session } from 'next-auth' |
|||
import { getServerSession } from 'next-auth' |
|||
import { headers } from 'next/dist/client/components/headers' |
|||
import { authOptions } from '@/lib/auth' |
|||
|
|||
export const metadata: Metadata = { |
|||
title: 'textbee.dev - Free and Open-Source SMS Gateway', |
|||
description: |
|||
'TextBee is an open-source solution that turns your Android device into a powerful SMS gateway. Send SMS effortlessly through your applications.', |
|||
authors: [ |
|||
{ name: 'Israel Abebe Kokiso', url: 'https://israelabebe.com' }, |
|||
{ name: 'vernu.dev', url: 'https://vernu.dev' }, |
|||
], |
|||
applicationName: 'textbee.dev', |
|||
keywords: [ |
|||
'textbee', |
|||
'sms gateway', |
|||
'open-source', |
|||
'android', |
|||
'sms', |
|||
'gateway', |
|||
'oss', |
|||
'free', |
|||
'opensource', |
|||
'foss', |
|||
'freeware', |
|||
'react', |
|||
'nextjs', |
|||
'tailwindcss', |
|||
'shadcn', |
|||
'typescript', |
|||
'nodejs', |
|||
'express', |
|||
'next-auth', |
|||
'vercel', |
|||
'nestjs', |
|||
], |
|||
creator: 'Israel Abebe Kokiso', |
|||
publisher: 'vernu.dev', |
|||
robots: 'index, follow', |
|||
alternates: { |
|||
canonical: 'https://textbee.dev', |
|||
}, |
|||
openGraph: { |
|||
title: 'textbee.dev - Free and Open-Source SMS Gateway', |
|||
description: |
|||
'TextBee is an open-source solution that turns your Android device into a powerful SMS gateway. Send SMS effortlessly through your applications.', |
|||
}, |
|||
icons: { |
|||
icon: '/favicon.ico', |
|||
}, |
|||
metadataBase: new URL('https://textbee.dev'), |
|||
} |
|||
|
|||
export default async function RootLayout({ children }: PropsWithChildren) { |
|||
const session: Session | null = await getServerSession(authOptions as any) |
|||
return ( |
|||
<html lang='en'> |
|||
<body> |
|||
<main>{children}</main> |
|||
<Analytics user={session?.user} /> |
|||
<Footer /> |
|||
<Toaster /> |
|||
<CustomerSupport /> |
|||
</body> |
|||
</html> |
|||
) |
|||
|
|||
@ -1,30 +0,0 @@ |
|||
import React, { ReactNode } from 'react' |
|||
import { motion } from 'framer-motion' |
|||
|
|||
interface AnimatedScrollWrapperProps { |
|||
children: ReactNode |
|||
} |
|||
const AnimatedScrollWrapper = ({ children }: AnimatedScrollWrapperProps) => { |
|||
return ( |
|||
<motion.div |
|||
variants={{ |
|||
hidden: { opacity: 0, y: 10 }, |
|||
visible: { |
|||
opacity: 1, |
|||
y: 0, |
|||
transition: { |
|||
duration: 0.5, |
|||
ease: 'easeInOut', |
|||
// delay: 0.25,
|
|||
}, |
|||
}, |
|||
}} |
|||
initial='hidden' |
|||
whileInView='visible' |
|||
> |
|||
{children} |
|||
</motion.div> |
|||
) |
|||
} |
|||
|
|||
export default AnimatedScrollWrapper |
|||
@ -1,82 +0,0 @@ |
|||
import { |
|||
Box, |
|||
chakra, |
|||
Container, |
|||
Stack, |
|||
Text, |
|||
useColorModeValue, |
|||
} from '@chakra-ui/react' |
|||
import dynamic from 'next/dynamic' |
|||
import Link from 'next/link' |
|||
|
|||
export default function Footer() { |
|||
const NoSSRAnimatedWrapper = dynamic( |
|||
() => import('../components/AnimatedScrollWrapper'), |
|||
{ |
|||
ssr: false, |
|||
} |
|||
) |
|||
|
|||
return ( |
|||
<Box |
|||
bg={useColorModeValue('gray.50', 'gray.900')} |
|||
color={useColorModeValue('gray.700', 'gray.200')} |
|||
> |
|||
<Container |
|||
as={Stack} |
|||
maxW={'6xl'} |
|||
py={4} |
|||
spacing={4} |
|||
justify={'center'} |
|||
align={'center'} |
|||
> |
|||
<Stack |
|||
direction={{ |
|||
base: 'column', |
|||
sm: 'column', |
|||
md: 'row', |
|||
}} |
|||
spacing={6} |
|||
> |
|||
<Link href='/'>Home</Link> |
|||
<Link href='/dashboard'>Dashboard</Link> |
|||
<NoSSRAnimatedWrapper> |
|||
<a |
|||
href='https://www.patreon.com/bePatron?u=124342375' |
|||
data-patreon-widget-type='become-patron-button' |
|||
> |
|||
Become a Patron! |
|||
</a> |
|||
<script |
|||
async |
|||
src='https://c6.patreon.com/becomePatronButton.bundle.js' |
|||
></script> |
|||
</NoSSRAnimatedWrapper> |
|||
<Link href='https://dl.textbee.dev' target='_blank'> |
|||
{' '} |
|||
Download App |
|||
</Link> |
|||
<Link href='https://github.com/vernu/textbee'>Github</Link> |
|||
</Stack> |
|||
</Container> |
|||
|
|||
<Box |
|||
borderTopWidth={1} |
|||
borderStyle={'solid'} |
|||
borderColor={useColorModeValue('gray.200', 'gray.700')} |
|||
> |
|||
<Container |
|||
as={Stack} |
|||
maxW={'6xl'} |
|||
py={4} |
|||
direction={{ base: 'column', md: 'row' }} |
|||
spacing={4} |
|||
justify='center' |
|||
align={{ base: 'center', md: 'center' }} |
|||
> |
|||
<Text>© {new Date().getFullYear()} All rights reserved</Text> |
|||
</Container> |
|||
</Box> |
|||
</Box> |
|||
) |
|||
} |
|||
@ -1,151 +0,0 @@ |
|||
import { |
|||
Box, |
|||
Flex, |
|||
Avatar, |
|||
Button, |
|||
Menu, |
|||
MenuButton, |
|||
MenuList, |
|||
MenuItem, |
|||
MenuDivider, |
|||
useColorModeValue, |
|||
Stack, |
|||
useColorMode, |
|||
SimpleGrid, |
|||
} from '@chakra-ui/react' |
|||
import Link from 'next/link' |
|||
import { MoonIcon, SunIcon } from '@chakra-ui/icons' |
|||
import Router from 'next/router' |
|||
import { useDispatch, useSelector } from 'react-redux' |
|||
import { logout, selectAuthUser } from '../store/authSlice' |
|||
import Image from 'next/image' |
|||
import { useEffect } from 'react' |
|||
import { authService } from '../services/authService' |
|||
|
|||
export default function Navbar() { |
|||
const dispatch = useDispatch() |
|||
const { colorMode, toggleColorMode } = useColorMode() |
|||
const authUser = useSelector(selectAuthUser) |
|||
|
|||
useEffect(() => { |
|||
const timout = setTimeout(async () => { |
|||
if (authUser) { |
|||
authService |
|||
.whoAmI() |
|||
.catch((e) => { |
|||
if (e.response?.status === 401) { |
|||
dispatch(logout()) |
|||
} |
|||
}) |
|||
.then((res) => {}) |
|||
} |
|||
}, 5000) |
|||
return () => clearTimeout(timout) |
|||
}, [authUser, dispatch]) |
|||
|
|||
return ( |
|||
<> |
|||
<Box |
|||
bg={useColorModeValue('gray.100', 'blue.600')} |
|||
px={4} |
|||
shadow='lg' |
|||
mb={1} |
|||
> |
|||
<Flex h={16} alignItems={'center'} justifyContent={'space-between'}> |
|||
<Link href='/' passHref> |
|||
<Flex alignItems={'center'}> |
|||
<Image |
|||
alt={'Hero Image'} |
|||
width={30} |
|||
height={30} |
|||
src={'/images/sms-gateway-logo.png'} |
|||
style={{ borderRadius: '50%' }} |
|||
/> |
|||
<Box style={{ cursor: 'pointer', marginLeft: '5px' }}> |
|||
TextBee |
|||
</Box> |
|||
</Flex> |
|||
</Link> |
|||
|
|||
<Stack alignItems='center' direction='row' spacing={5}> |
|||
<Button onClick={toggleColorMode} aria-label={'Toggle Color Mode'}> |
|||
{colorMode === 'light' ? <MoonIcon /> : <SunIcon />} |
|||
</Button> |
|||
|
|||
{/* <Menu> |
|||
<Link |
|||
href='https://www.patreon.com/bePatron?u=124342375' |
|||
passHref |
|||
> |
|||
<MenuButton>Support</MenuButton> |
|||
</Link> |
|||
</Menu> */} |
|||
|
|||
<Menu> |
|||
<Link href='https://github.com/vernu/textbee' passHref> |
|||
<MenuButton>Github</MenuButton> |
|||
</Link> |
|||
</Menu> |
|||
|
|||
{!authUser && ( |
|||
<Menu> |
|||
<Link href='/login' passHref> |
|||
<MenuButton>Login</MenuButton> |
|||
</Link> |
|||
<Link href='/register' passHref> |
|||
<MenuButton>Register</MenuButton> |
|||
</Link> |
|||
</Menu> |
|||
)} |
|||
|
|||
{authUser && ( |
|||
<Menu> |
|||
<MenuButton |
|||
as={Button} |
|||
rounded={'full'} |
|||
variant={'link'} |
|||
cursor={'pointer'} |
|||
minW={0} |
|||
> |
|||
<Avatar |
|||
size={'sm'} |
|||
name={authUser.name} |
|||
src={authUser?.avatar} |
|||
/> |
|||
</MenuButton> |
|||
<MenuList alignItems={'center'}> |
|||
<MenuItem> |
|||
<SimpleGrid columns={2} spacing={3}> |
|||
<Avatar |
|||
size={'sm'} |
|||
name={authUser.name} |
|||
src={authUser?.avatar} |
|||
/> |
|||
{authUser?.name} |
|||
</SimpleGrid> |
|||
</MenuItem> |
|||
|
|||
<MenuDivider /> |
|||
<MenuItem |
|||
onClick={() => { |
|||
Router.push('/dashboard') |
|||
}} |
|||
> |
|||
Dashboard |
|||
</MenuItem> |
|||
<MenuItem |
|||
onClick={() => { |
|||
dispatch(logout()) |
|||
}} |
|||
> |
|||
Logout |
|||
</MenuItem> |
|||
</MenuList> |
|||
</Menu> |
|||
)} |
|||
</Stack> |
|||
</Flex> |
|||
</Box> |
|||
</> |
|||
) |
|||
} |
|||
@ -1,29 +0,0 @@ |
|||
import { Box, SimpleGrid } from '@chakra-ui/react' |
|||
|
|||
import React from 'react' |
|||
import ErrorBoundary from '../ErrorBoundary' |
|||
import ApiKeyList from './ApiKeyList' |
|||
import DeviceList from './DeviceList' |
|||
import GenerateApiKey from './GenerateApiKey' |
|||
|
|||
export default function APIKeyAndDevices() { |
|||
return ( |
|||
<Box backdropBlur='2xl' borderWidth='0px' borderRadius='lg'> |
|||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={{ base: 5, lg: 8 }}> |
|||
<Box backdropBlur='2xl' borderWidth='0px' borderRadius='lg'> |
|||
<ErrorBoundary> |
|||
<Box maxW='xl' mx={'auto'} pt={5} px={{ base: 2, sm: 4, md: 17 }}> |
|||
<GenerateApiKey /> |
|||
<DeviceList /> |
|||
</Box> |
|||
</ErrorBoundary> |
|||
</Box> |
|||
<Box backdropBlur='2xl' borderWidth='0px' borderRadius='lg'> |
|||
<ErrorBoundary> |
|||
<ApiKeyList /> |
|||
</ErrorBoundary> |
|||
</Box> |
|||
</SimpleGrid> |
|||
</Box> |
|||
) |
|||
} |
|||
@ -1,94 +0,0 @@ |
|||
import { DeleteIcon } from '@chakra-ui/icons' |
|||
import { |
|||
Spinner, |
|||
Table, |
|||
TableContainer, |
|||
Tbody, |
|||
Td, |
|||
Th, |
|||
Thead, |
|||
Tooltip, |
|||
Tr, |
|||
} from '@chakra-ui/react' |
|||
import { useEffect } from 'react' |
|||
import { useSelector } from 'react-redux' |
|||
import { |
|||
deleteApiKey, |
|||
fetchApiKeys, |
|||
selectApiKeyList, |
|||
selectApiKeyLoading, |
|||
} from '../../store/apiKeySlice' |
|||
import { selectAuthUser } from '../../store/authSlice' |
|||
import { useAppDispatch } from '../../store/hooks' |
|||
|
|||
const ApiKeyRow = ({ apiKey }: any) => { |
|||
const dispatch = useAppDispatch() |
|||
|
|||
const handleDelete = async () => { |
|||
dispatch(deleteApiKey(apiKey._id)) |
|||
} |
|||
|
|||
return ( |
|||
<Tr> |
|||
<Td>{apiKey.apiKey}</Td> |
|||
<Td>{apiKey.status}</Td> |
|||
<Td> |
|||
{/* <Tooltip label='Double Click to delete'> |
|||
<DeleteIcon |
|||
// onDoubleClick={handleDelete}
|
|||
/> |
|||
</Tooltip> */} |
|||
</Td> |
|||
</Tr> |
|||
) |
|||
} |
|||
|
|||
const ApiKeyList = () => { |
|||
const dispatch = useAppDispatch() |
|||
const loading = useSelector(selectApiKeyLoading) |
|||
const apiKeyList = useSelector(selectApiKeyList) |
|||
|
|||
const authUser = useSelector(selectAuthUser) |
|||
useEffect(() => { |
|||
if (authUser) { |
|||
dispatch(fetchApiKeys()) |
|||
} |
|||
}, [dispatch, authUser]) |
|||
|
|||
return ( |
|||
<TableContainer> |
|||
<Table variant='striped'> |
|||
<Thead> |
|||
<Tr> |
|||
<Th>Your API Keys</Th> |
|||
<Th>Status</Th> |
|||
<Th></Th> |
|||
</Tr> |
|||
</Thead> |
|||
<Tbody> |
|||
{loading && ( |
|||
<Tr> |
|||
<Td colSpan={3} textAlign='center'> |
|||
<Spinner size='lg' /> |
|||
</Td> |
|||
</Tr> |
|||
)} |
|||
|
|||
{!loading && apiKeyList.length == 0 && ( |
|||
<Td colSpan={3} textAlign='center'> |
|||
No API Keys |
|||
</Td> |
|||
)} |
|||
|
|||
{!loading && |
|||
apiKeyList.length > 0 && |
|||
apiKeyList.map((apiKey) => ( |
|||
<ApiKeyRow key={apiKey._id} apiKey={apiKey} /> |
|||
))} |
|||
</Tbody> |
|||
</Table> |
|||
</TableContainer> |
|||
) |
|||
} |
|||
|
|||
export default ApiKeyList |
|||
@ -1,116 +0,0 @@ |
|||
import { CopyIcon, DeleteIcon } from '@chakra-ui/icons' |
|||
import { |
|||
IconButton, |
|||
Spinner, |
|||
Table, |
|||
TableContainer, |
|||
Tbody, |
|||
Td, |
|||
Th, |
|||
Thead, |
|||
Tooltip, |
|||
Tr, |
|||
useToast, |
|||
} from '@chakra-ui/react' |
|||
import { useEffect } from 'react' |
|||
import { useSelector } from 'react-redux' |
|||
import { selectAuthUser } from '../../store/authSlice' |
|||
import { deleteDevice, fetchDevices, selectDeviceList, selectDeviceLoading } from '../../store/deviceSlice' |
|||
import { useAppDispatch } from '../../store/hooks' |
|||
|
|||
const DeviceRow = ({ device, onDelete }: any) => { |
|||
const { enabled, model, brand, _id, createdAt } = device |
|||
|
|||
const toast = useToast(); |
|||
|
|||
return ( |
|||
<Tr> |
|||
<Td>{`${brand}/ ${model}`}</Td> |
|||
<Td> |
|||
{_id} |
|||
<IconButton |
|||
variant='ghost' |
|||
icon={<CopyIcon />} |
|||
onClick={() => { |
|||
navigator.clipboard.writeText(_id) |
|||
toast({ |
|||
title: 'Copied to clipboard', |
|||
status: 'success', |
|||
}) |
|||
} } aria-label={''} > |
|||
Copy to Clipboard |
|||
</IconButton> |
|||
|
|||
</Td> |
|||
<Td>{enabled ? 'enabled' : 'disabled'}</Td> |
|||
<Td>{/* <EmailIcon onDoubleClick={(e) => {}} /> */}</Td> |
|||
<Td> |
|||
{/* <Tooltip label='Double Click to delete'> |
|||
<IconButton |
|||
aria-label='Delete' |
|||
icon={<DeleteIcon />} |
|||
// onDoubleClick={onDelete}
|
|||
/> |
|||
</Tooltip> */} |
|||
</Td> |
|||
</Tr> |
|||
) |
|||
} |
|||
|
|||
const DeviceList = () => { |
|||
const dispatch = useAppDispatch() |
|||
|
|||
const authUser = useSelector(selectAuthUser) |
|||
useEffect(() => { |
|||
if (authUser) { |
|||
dispatch(fetchDevices()) |
|||
} |
|||
}, [authUser, dispatch]) |
|||
|
|||
const deviceList = useSelector(selectDeviceList) |
|||
const loading = useSelector(selectDeviceLoading) |
|||
|
|||
const onDelete = (apiKeyId: string) => { |
|||
dispatch(deleteDevice(apiKeyId)) |
|||
} |
|||
|
|||
return ( |
|||
<TableContainer> |
|||
<Table variant='striped'> |
|||
<Thead> |
|||
<Tr> |
|||
<Th>Your Devices</Th> |
|||
<Th>Device ID</Th> |
|||
<Th>Status</Th> |
|||
<Th colSpan={2}>Actions</Th> |
|||
</Tr> |
|||
</Thead> |
|||
<Tbody> |
|||
{loading && ( |
|||
<Tr> |
|||
<Td colSpan={3} textAlign='center'> |
|||
<Spinner size='lg' /> |
|||
</Td> |
|||
</Tr> |
|||
)} |
|||
|
|||
{!loading && deviceList.length === 0 && ( |
|||
<Tr> |
|||
<Td colSpan={3} textAlign='center'> |
|||
No Devices |
|||
</Td> |
|||
</Tr> |
|||
)} |
|||
|
|||
{!loading && |
|||
deviceList.length > 0 && |
|||
deviceList.map((device) => ( |
|||
<DeviceRow key={device._id} device={device} onDelete={() => onDelete(device._id)} /> |
|||
))} |
|||
</Tbody> |
|||
</Table> |
|||
</TableContainer> |
|||
) |
|||
} |
|||
|
|||
export default DeviceList |
|||
@ -1,179 +0,0 @@ |
|||
import { |
|||
Box, |
|||
Button, |
|||
chakra, |
|||
Flex, |
|||
Modal, |
|||
ModalBody, |
|||
ModalCloseButton, |
|||
ModalContent, |
|||
ModalFooter, |
|||
ModalHeader, |
|||
ModalOverlay, |
|||
useColorModeValue, |
|||
useToast, |
|||
} from '@chakra-ui/react' |
|||
import { useState } from 'react' |
|||
import QRCode from 'react-qr-code' |
|||
import { fetchApiKeys } from '../../store/apiKeySlice' |
|||
import { gatewayService } from '../../services/gatewayService' |
|||
import { useAppDispatch } from '../../store/hooks' |
|||
|
|||
const NewApiKeyGeneratedModal = ({ |
|||
isOpen = false, |
|||
generatedApiKey, |
|||
onClose, |
|||
showQR = false, |
|||
...props |
|||
}) => { |
|||
const toast = useToast() |
|||
return ( |
|||
<Modal isOpen={isOpen} onClose={onClose}> |
|||
<ModalOverlay /> |
|||
<ModalContent> |
|||
<ModalHeader>Api Key Generated</ModalHeader> |
|||
<ModalCloseButton /> |
|||
<ModalBody> |
|||
{showQR && ( |
|||
<> |
|||
<chakra.h1 |
|||
fontSize='md' |
|||
fontWeight='bold' |
|||
mt={2} |
|||
// color={useColorModeValue('gray.800', 'white')}
|
|||
> |
|||
Open the SMS Gateway App and scan this QR to get started |
|||
</chakra.h1> |
|||
|
|||
<Flex |
|||
justifyContent='center' |
|||
style={{ backgroundColor: '#fff', padding: '5px' }} |
|||
> |
|||
<QRCode value={generatedApiKey} />{' '} |
|||
</Flex> |
|||
</> |
|||
)} |
|||
<chakra.h1 |
|||
fontSize='lg' |
|||
fontWeight='bold' |
|||
mt={2} |
|||
// color={useColorModeValue('gray.800', 'white')}
|
|||
> |
|||
{generatedApiKey} |
|||
</chakra.h1> |
|||
<chakra.h1 |
|||
fontSize='lg' |
|||
fontWeight='bold' |
|||
mt={2} |
|||
color={useColorModeValue('red.800', 'white')} |
|||
> |
|||
{'Save this key, it wont be shown again ;)'} |
|||
</chakra.h1> |
|||
</ModalBody> |
|||
|
|||
<ModalFooter> |
|||
<Button |
|||
variant='ghost' |
|||
onClick={() => { |
|||
navigator.clipboard.writeText(generatedApiKey) |
|||
toast({ |
|||
title: 'Copied to clipboard', |
|||
status: 'success', |
|||
}) |
|||
}} |
|||
> |
|||
Copy to Clipboard |
|||
</Button>{' '} |
|||
<Button |
|||
colorScheme='blue' |
|||
mr={3} |
|||
onClick={() => { |
|||
onClose() |
|||
}} |
|||
> |
|||
Close |
|||
</Button> |
|||
</ModalFooter> |
|||
</ModalContent> |
|||
</Modal> |
|||
) |
|||
} |
|||
|
|||
export default function GenerateApiKey() { |
|||
const [generatedApiKey, setGeneratedApiKey] = useState(null) |
|||
const [generatingApiKey, setGeneratingApiKey] = useState(null) |
|||
const [showGeneratedApiKeyModal, setShowGeneratedApiKeyModal] = |
|||
useState(false) |
|||
|
|||
const dispatch = useAppDispatch() |
|||
|
|||
const generateApiKey = async () => { |
|||
setGeneratingApiKey(true) |
|||
const newApiKey = await gatewayService.generateApiKey() |
|||
setGeneratedApiKey(newApiKey) |
|||
setShowGeneratedApiKeyModal(true) |
|||
setGeneratingApiKey(false) |
|||
dispatch(fetchApiKeys()) |
|||
} |
|||
return ( |
|||
<> |
|||
<Box |
|||
padding={5} |
|||
border='1px solid #ccc' |
|||
marginBottom={10} |
|||
borderRadius='2xl' |
|||
> |
|||
<Flex direction='row' justifyContent='space-between'> |
|||
{' '} |
|||
<chakra.h1 |
|||
fontSize='md' |
|||
fontWeight='bold' |
|||
mt={2} |
|||
color={useColorModeValue('gray.800', 'white')} |
|||
> |
|||
Generate Api Key and Register Device |
|||
</chakra.h1> |
|||
<Button |
|||
/* flex={1} */ |
|||
px={4} |
|||
fontSize={'sm'} |
|||
rounded={'full'} |
|||
bg={'blue.400'} |
|||
color={'white'} |
|||
boxShadow={ |
|||
'0px 1px 25px -5px rgb(66 153 225 / 48%), 0 10px 10px -5px rgb(66 153 225 / 43%)' |
|||
} |
|||
_hover={{ |
|||
bg: 'blue.500', |
|||
}} |
|||
_focus={{ |
|||
bg: 'blue.500', |
|||
}} |
|||
onClick={generateApiKey} |
|||
disabled={generatingApiKey} |
|||
> |
|||
{generatingApiKey ? 'loading... ' : 'Get Started'} |
|||
</Button> |
|||
</Flex>{' '} |
|||
<Flex> |
|||
Start sending SMS by clicking "Get Started" and scan the QR code or |
|||
paste the API Key in the TextBee Mobile App |
|||
</Flex> |
|||
</Box> |
|||
{generatedApiKey && ( |
|||
<> |
|||
{ |
|||
<NewApiKeyGeneratedModal |
|||
isOpen={showGeneratedApiKeyModal} |
|||
generatedApiKey={generatedApiKey} |
|||
showQR={true} |
|||
onClose={() => { |
|||
setShowGeneratedApiKeyModal(false) |
|||
}} |
|||
/> |
|||
} |
|||
</> |
|||
)} |
|||
</> |
|||
) |
|||
} |
|||
@ -1,170 +0,0 @@ |
|||
import { |
|||
Alert, |
|||
AlertIcon, |
|||
Grid, |
|||
GridItem, |
|||
Spinner, |
|||
Stack, |
|||
Tab, |
|||
TabList, |
|||
TabPanel, |
|||
TabPanels, |
|||
Table, |
|||
TableContainer, |
|||
Tabs, |
|||
Tbody, |
|||
Td, |
|||
Th, |
|||
Thead, |
|||
Tr, |
|||
} from '@chakra-ui/react' |
|||
import { useEffect, useMemo, useState } from 'react' |
|||
import { useSelector } from 'react-redux' |
|||
import { |
|||
fetchReceivedSMSList, |
|||
selectDeviceList, |
|||
selectReceivedSMSList, |
|||
} from '../../store/deviceSlice' |
|||
import { useAppDispatch } from '../../store/hooks' |
|||
import { selectAuthUser } from '../../store/authSlice' |
|||
|
|||
export default function ReceiveSMS() { |
|||
return ( |
|||
<> |
|||
<Grid |
|||
templateColumns={{ base: 'repeat(1, 1fr)', md: 'repeat(3, 1fr)' }} |
|||
gap={6} |
|||
> |
|||
<GridItem colSpan={2}> |
|||
<ReceivedSMSList /> |
|||
</GridItem> |
|||
<GridItem colSpan={1}> |
|||
<ReceiveSMSNotifications /> |
|||
</GridItem> |
|||
</Grid> |
|||
</> |
|||
) |
|||
} |
|||
|
|||
const ReceiveSMSNotifications = () => { |
|||
return ( |
|||
<Stack spacing={3}> |
|||
<Alert status='success'> |
|||
<AlertIcon /> |
|||
You can now receive SMS and view them in the dashboard, or retreive them |
|||
via the API |
|||
</Alert> |
|||
|
|||
<Alert status='warning'> |
|||
<AlertIcon /> |
|||
To receive SMS, you need to have an active device that has receive SMS |
|||
option enabled <small>(Turn on the switch in the app)</small> |
|||
</Alert> |
|||
|
|||
<Alert status='info'> |
|||
<AlertIcon /> |
|||
Webhooks will be available soon 😉 |
|||
</Alert> |
|||
</Stack> |
|||
) |
|||
} |
|||
|
|||
const ReceivedSMSList = () => { |
|||
const dispatch = useAppDispatch() |
|||
|
|||
const [tabIndex, setTabIndex] = useState(0) |
|||
|
|||
const { loading: receivedSMSListLoading, data: receivedSMSListData } = |
|||
useSelector(selectReceivedSMSList) |
|||
const deviceList = useSelector(selectDeviceList) |
|||
|
|||
const authUser = useSelector(selectAuthUser) |
|||
|
|||
const activeDeviceId = useMemo(() => { |
|||
return deviceList[tabIndex]?._id |
|||
}, [tabIndex, deviceList]) |
|||
|
|||
useEffect(() => { |
|||
if (authUser && activeDeviceId) { |
|||
dispatch(fetchReceivedSMSList(activeDeviceId)) |
|||
} |
|||
}, [dispatch, authUser, activeDeviceId]) |
|||
|
|||
if (!receivedSMSListLoading && (!deviceList || deviceList.length == 0)) { |
|||
return ( |
|||
<Alert status='warning'> |
|||
<AlertIcon /> |
|||
You dont have any devices yet. Please register a device to receive SMS |
|||
</Alert> |
|||
) |
|||
} |
|||
|
|||
return ( |
|||
<> |
|||
<Tabs isLazy={false} index={tabIndex} onChange={setTabIndex}> |
|||
<TabList> |
|||
{deviceList.map(({ _id, brand, model }) => ( |
|||
<Tab key={_id}>{`${brand} ${model}`}</Tab> |
|||
))} |
|||
</TabList> |
|||
<TabPanels> |
|||
{deviceList.map(({ _id, brand, model }) => ( |
|||
<TabPanel key={_id}> |
|||
<TableContainer> |
|||
<Table variant='striped'> |
|||
<Thead> |
|||
<Tr> |
|||
<Th>sender</Th> |
|||
<Th colSpan={4}>message</Th> |
|||
<Th>received at</Th> |
|||
</Tr> |
|||
</Thead> |
|||
<Tbody> |
|||
{receivedSMSListLoading && ( |
|||
<Tr> |
|||
<Td colSpan={6} textAlign='center'> |
|||
<Spinner size='lg' /> |
|||
</Td> |
|||
</Tr> |
|||
)} |
|||
|
|||
{!receivedSMSListLoading && |
|||
receivedSMSListData.length == 0 && ( |
|||
<Td colSpan={6} textAlign='center'> |
|||
No SMS received |
|||
</Td> |
|||
)} |
|||
|
|||
{!receivedSMSListLoading && |
|||
receivedSMSListData.length > 0 && |
|||
receivedSMSListData.map( |
|||
({ _id, sender, message, receivedAt }) => ( |
|||
<Tr key={_id}> |
|||
<Td>{sender}</Td> |
|||
<Td whiteSpace='pre-wrap' colSpan={4}> |
|||
{message} |
|||
</Td> |
|||
<Td> |
|||
{new Date(receivedAt).toLocaleString('en-US', { |
|||
month: 'long', |
|||
day: 'numeric', |
|||
year: 'numeric', |
|||
hour: 'numeric', |
|||
minute: 'numeric', |
|||
hour12: true, |
|||
})} |
|||
</Td> |
|||
<Td></Td> |
|||
</Tr> |
|||
) |
|||
)} |
|||
</Tbody> |
|||
</Table> |
|||
</TableContainer> |
|||
</TabPanel> |
|||
))} |
|||
</TabPanels> |
|||
</Tabs> |
|||
</> |
|||
) |
|||
} |
|||
@ -1,139 +0,0 @@ |
|||
import { |
|||
Box, |
|||
Button, |
|||
FormLabel, |
|||
Input, |
|||
Select, |
|||
SimpleGrid, |
|||
Spinner, |
|||
Textarea, |
|||
useToast, |
|||
} from '@chakra-ui/react' |
|||
import { useState } from 'react' |
|||
import { useSelector } from 'react-redux' |
|||
import { |
|||
selectDeviceList, |
|||
selectSendingSMS, |
|||
sendSMS, |
|||
} from '../../store/deviceSlice' |
|||
import { useAppDispatch } from '../../store/hooks' |
|||
|
|||
export const SendSMSForm = ({ deviceList, formData, handleChange }) => { |
|||
return ( |
|||
<> |
|||
<Box> |
|||
<FormLabel htmlFor='device'>Select Device</FormLabel> |
|||
<Select |
|||
id='device' |
|||
name='device' |
|||
placeholder='Select Device' |
|||
onChange={handleChange} |
|||
value={formData.device} |
|||
> |
|||
{deviceList.map((device) => ( |
|||
<option |
|||
key={device._id} |
|||
value={device._id} |
|||
disabled={!device.enabled} |
|||
> |
|||
{device.model} |
|||
</option> |
|||
))} |
|||
</Select> |
|||
</Box> |
|||
<Box> |
|||
<FormLabel htmlFor='recipient'>Recipient</FormLabel> |
|||
<Input |
|||
placeholder='recipient' |
|||
name='recipients' |
|||
onChange={handleChange} |
|||
value={formData.recipients} |
|||
type='tel' |
|||
/> |
|||
</Box> |
|||
<Box> |
|||
<FormLabel htmlFor='message'>Message</FormLabel> |
|||
<Textarea |
|||
id='message' |
|||
name='message' |
|||
onChange={handleChange} |
|||
value={formData.message} |
|||
/> |
|||
</Box> |
|||
</> |
|||
) |
|||
} |
|||
|
|||
export default function SendSMS() { |
|||
const deviceList = useSelector(selectDeviceList) |
|||
const toast = useToast() |
|||
const dispatch = useAppDispatch() |
|||
|
|||
const sendingSMS = useSelector(selectSendingSMS) |
|||
|
|||
const [formData, setFormData] = useState({ |
|||
device: '', |
|||
recipients: '', |
|||
message: '', |
|||
}) |
|||
|
|||
const handSend = (e) => { |
|||
e.preventDefault() |
|||
const { device: deviceId, recipients, message } = formData |
|||
const recipientsArray = recipients.replace(' ', '').split(',') |
|||
|
|||
if (!deviceId || !recipients || !message) { |
|||
toast({ |
|||
title: 'Please fill all fields', |
|||
status: 'error', |
|||
}) |
|||
return |
|||
} |
|||
|
|||
for (let recipient of recipientsArray) { |
|||
// TODO: validate phone numbers
|
|||
} |
|||
|
|||
dispatch( |
|||
sendSMS({ |
|||
deviceId, |
|||
payload: { |
|||
recipients: recipientsArray, |
|||
message, |
|||
}, |
|||
}) |
|||
) |
|||
} |
|||
|
|||
const handleChange = (e) => { |
|||
setFormData({ |
|||
...formData, |
|||
[e.target.name]: e.target.value, |
|||
}) |
|||
} |
|||
|
|||
return ( |
|||
<> |
|||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={{ base: 5, lg: 8 }}> |
|||
<Box backdropBlur='2xl' borderWidth='0px' borderRadius='lg'> |
|||
<SendSMSForm |
|||
deviceList={deviceList} |
|||
formData={formData} |
|||
handleChange={handleChange} |
|||
/> |
|||
|
|||
<Button |
|||
variant='outline' |
|||
colorScheme='blue' |
|||
onClick={handSend} |
|||
disabled={sendingSMS} |
|||
marginTop={3} |
|||
> |
|||
{sendingSMS ? <Spinner size='md' /> : 'Send'} |
|||
</Button> |
|||
</Box> |
|||
<Box backdropBlur='2xl' borderWidth='0px' borderRadius='lg'></Box> |
|||
</SimpleGrid> |
|||
</> |
|||
) |
|||
} |
|||
@ -1,78 +0,0 @@ |
|||
import { Box, Grid, GridItem, SimpleGrid, chakra } from '@chakra-ui/react' |
|||
import React, { useEffect } from 'react' |
|||
import { useSelector } from 'react-redux' |
|||
import { selectAuthUser } from '../../store/authSlice' |
|||
import UserStatsCard from './UserStatsCard' |
|||
import { |
|||
fetchStats, |
|||
selectStatsData, |
|||
selectStatsLoading, |
|||
} from '../../store/statsSlice' |
|||
import { useAppDispatch, useAppSelector } from '../../store/hooks' |
|||
|
|||
const UserStats = () => { |
|||
const authUser = useSelector(selectAuthUser) |
|||
|
|||
const { |
|||
totalApiKeyCount, |
|||
totalDeviceCount, |
|||
totalReceivedSMSCount, |
|||
totalSentSMSCount, |
|||
} = useAppSelector(selectStatsData) |
|||
const statsLoading = useAppSelector(selectStatsLoading) |
|||
|
|||
const dispatch = useAppDispatch() |
|||
|
|||
useEffect(() => { |
|||
dispatch(fetchStats()) |
|||
}, [dispatch]) |
|||
|
|||
return ( |
|||
<> |
|||
<Box maxW='12xl' mx={'auto'} pt={5} px={{ base: 2, sm: 12, md: 17 }}> |
|||
<Grid |
|||
templateColumns={{ base: 'repeat(1, 1fr)', md: 'repeat(3, 1fr)' }} |
|||
gap={6} |
|||
> |
|||
<GridItem colSpan={1}> |
|||
<chakra.h1 |
|||
textAlign={'center'} |
|||
fontSize={'2xl'} |
|||
py={10} |
|||
fontWeight={'bold'} |
|||
> |
|||
Welcome {authUser?.name} |
|||
</chakra.h1> |
|||
</GridItem> |
|||
<GridItem colSpan={2}> |
|||
<SimpleGrid |
|||
columns={{ base: 2, md: 4 }} |
|||
spacing={{ base: 5, lg: 8 }} |
|||
> |
|||
<UserStatsCard |
|||
title={'Registered '} |
|||
stat={`${statsLoading ? '-:-' : totalDeviceCount} Devices`} |
|||
/> |
|||
<UserStatsCard |
|||
title={'Generated'} |
|||
stat={`${statsLoading ? '-:-' : totalApiKeyCount} API Keys`} |
|||
/> |
|||
<UserStatsCard |
|||
title={'Sent'} |
|||
stat={`${statsLoading ? '-:-' : totalSentSMSCount} SMS Sent`} |
|||
/> |
|||
<UserStatsCard |
|||
title={'Received'} |
|||
stat={`${ |
|||
statsLoading ? '-:-' : totalReceivedSMSCount |
|||
} SMS Received`}
|
|||
/> |
|||
</SimpleGrid> |
|||
</GridItem> |
|||
</Grid> |
|||
</Box> |
|||
</> |
|||
) |
|||
} |
|||
|
|||
export default UserStats |
|||
@ -1,32 +0,0 @@ |
|||
import { |
|||
Stat, |
|||
StatLabel, |
|||
StatNumber, |
|||
useColorModeValue, |
|||
} from '@chakra-ui/react' |
|||
import React from 'react' |
|||
|
|||
export default function UserStatsCard({ ...props }) { |
|||
const { title, stat } = props |
|||
return ( |
|||
<Stat |
|||
px={{ base: 2, md: 4 }} |
|||
py={'3'} |
|||
shadow={'xl'} |
|||
border={'1px solid'} |
|||
borderColor={useColorModeValue('gray.300', 'gray.700')} |
|||
rounded={'lg'} |
|||
style={{ |
|||
height: '90px', |
|||
}} |
|||
alignContent={'center'} |
|||
> |
|||
<StatLabel fontWeight={'medium'} isTruncated> |
|||
{title} |
|||
</StatLabel> |
|||
<StatNumber fontSize={'md'} fontWeight={'medium'}> |
|||
{stat} |
|||
</StatNumber> |
|||
</Stat> |
|||
) |
|||
} |
|||
@ -1,90 +0,0 @@ |
|||
import { |
|||
Box, |
|||
Flex, |
|||
Heading, |
|||
Tab, |
|||
TabList, |
|||
TabPanel, |
|||
TabPanels, |
|||
Tabs, |
|||
Text, |
|||
} from '@chakra-ui/react' |
|||
import React from 'react' |
|||
import AnimatedScrollWrapper from '../AnimatedScrollWrapper' |
|||
import SyntaxHighlighter from 'react-syntax-highlighter' |
|||
|
|||
export default function CodeSnippetSection() { |
|||
const codeSnippets = [ |
|||
{ |
|||
tech: 'Node.Js', |
|||
language: 'javascript', |
|||
snippet: `
|
|||
const BASE_URL = 'https://api.textbee.dev/api/v1' |
|||
const API_KEY = 'YOUR_API_KEY' |
|||
const DEVICE_ID = 'YOUR_DEVICE_ID' |
|||
|
|||
await axios.post(\`\$\{BASE_URL\}/gateway/devices/\$\{DEVICE_ID}/sendSMS\`, {
|
|||
recipients: [ '+251912345678' ], |
|||
message: 'Hello World!', |
|||
}, { |
|||
headers: { |
|||
'x-api-key': API_KEY, |
|||
}, |
|||
}) |
|||
`,
|
|||
}, |
|||
{ |
|||
tech: 'cURL', |
|||
language: 'shell', |
|||
snippet: `curl -X POST \ https://api.textbee.dev/api/v1/gateway/devices/<DEVICE_ID>/sendSMS \ -H 'x-api-key: <API_KEY>' \ -H 'Content-Type: application/json' \ -d '{ "recipients": ["+251912345678"], "message": "Hello World!" }'`, |
|||
}, |
|||
] |
|||
|
|||
return ( |
|||
<AnimatedScrollWrapper> |
|||
<Box m={{ base: 0, md: 8 }} p={{ base: 0, md: 8 }}> |
|||
<Flex |
|||
height='100%' |
|||
direction='column' |
|||
justifyContent='center' |
|||
alignItems='center' |
|||
> |
|||
<Heading fontSize={'3xl'} textAlign={'center'} py={8}> |
|||
Code Snippet |
|||
</Heading> |
|||
<Text color={'gray.600'} fontSize={'lg'} textAlign={'center'} pb='4'> |
|||
Send SMS messages from your web application using our REST API. You |
|||
can use any programming language to interact with our API. Here is a |
|||
sample code snippet in JavaScript using axios library. |
|||
</Text> |
|||
|
|||
<Box |
|||
borderRadius={'lg'} |
|||
padding={{ base: 0, md: 8 }} |
|||
border={'1px solid #E2E8F0'} |
|||
w={{ base: '100%', md: '90%' }} |
|||
> |
|||
<Tabs> |
|||
<TabList> |
|||
{codeSnippets.map((snippet) => ( |
|||
<Tab key={snippet.tech}>{snippet.tech}</Tab> |
|||
))} |
|||
</TabList> |
|||
<TabPanels> |
|||
{codeSnippets.map((snippet) => { |
|||
return ( |
|||
<TabPanel key={snippet.tech}> |
|||
<SyntaxHighlighter language={snippet.language}> |
|||
{snippet.snippet} |
|||
</SyntaxHighlighter> |
|||
</TabPanel> |
|||
) |
|||
})} |
|||
</TabPanels> |
|||
</Tabs> |
|||
</Box> |
|||
</Flex> |
|||
</Box> |
|||
</AnimatedScrollWrapper> |
|||
) |
|||
} |
|||
@ -1,54 +0,0 @@ |
|||
import { |
|||
Box, |
|||
VStack, |
|||
Button, |
|||
Flex, |
|||
Divider, |
|||
chakra, |
|||
Grid, |
|||
GridItem, |
|||
Container, |
|||
} from '@chakra-ui/react' |
|||
import Link from 'next/link' |
|||
|
|||
export default function Customization() { |
|||
return ( |
|||
<Box as={Container} maxW='6xl' my={14} p={4}> |
|||
<Grid |
|||
templateColumns={{ |
|||
base: 'repeat(1, 1fr)', |
|||
sm: 'repeat(1, 1fr)', |
|||
md: 'repeat(3, 1fr)', |
|||
}} |
|||
gap={4} |
|||
> |
|||
<GridItem colSpan={1}> |
|||
<VStack alignItems='flex-start' spacing='20px'> |
|||
<chakra.h2 fontSize='3xl' fontWeight='700'> |
|||
Customization |
|||
</chakra.h2> |
|||
<Button |
|||
colorScheme='blue' |
|||
size='md' |
|||
as={Link} |
|||
href='https://forms.gle/WmUHvPkf4WZ69cWj9' |
|||
target='_blank' |
|||
> |
|||
Contact Us |
|||
</Button> |
|||
</VStack> |
|||
</GridItem> |
|||
<GridItem colSpan={2}> |
|||
<Flex> |
|||
<chakra.p p={6}> |
|||
If you need help getting this platform customized and deploy it on |
|||
your own server, giving you more flexibility and control, reach |
|||
out for paid customization, deployment and new feature |
|||
development. |
|||
</chakra.p> |
|||
</Flex> |
|||
</GridItem> |
|||
</Grid> |
|||
</Box> |
|||
) |
|||
} |
|||
@ -1,91 +0,0 @@ |
|||
import { |
|||
Box, |
|||
Button, |
|||
chakra, |
|||
Flex, |
|||
Image, |
|||
useColorModeValue, |
|||
} from '@chakra-ui/react' |
|||
import React from 'react' |
|||
import AnimatedScrollWrapper from '../AnimatedScrollWrapper' |
|||
|
|||
export default function DownloadAppSection() { |
|||
return ( |
|||
<AnimatedScrollWrapper> |
|||
<Box my={16}> |
|||
<Flex |
|||
padding={5} |
|||
background={useColorModeValue('gray.100', 'gray.700')} |
|||
borderRadius='2xl' |
|||
> |
|||
<Flex |
|||
borderRadius='2xl' |
|||
m={{ base: 5, md: 8 }} |
|||
p={{ base: 5, md: 8 }} |
|||
width='100%' |
|||
border='1px solid gray' |
|||
direction='row' |
|||
justifyContent='center' |
|||
> |
|||
<Box> |
|||
<Image |
|||
alt={'Hero Image'} |
|||
fit={'cover'} |
|||
align={'center'} |
|||
w={'180px'} |
|||
// h={'100%'}
|
|||
src={'/images/smsgatewayandroid.png'} |
|||
/> |
|||
</Box> |
|||
<Box> |
|||
<Flex |
|||
height='100%' |
|||
direction='column' |
|||
justifyContent='center' |
|||
alignItems='center' |
|||
> |
|||
<chakra.h1 |
|||
fontSize='md' |
|||
fontWeight='bold' |
|||
my={4} |
|||
color={useColorModeValue('gray.800', 'white')} |
|||
> |
|||
Download the App to get started! |
|||
</chakra.h1> |
|||
<chakra.p |
|||
fontSize='sm' |
|||
color={useColorModeValue('gray.600', 'gray.400')} |
|||
mb={4} |
|||
> |
|||
Unlock the power of messaging with our open-source Android SMS |
|||
Gateway. |
|||
</chakra.p> |
|||
<a href='https://dl.textbee.dev' target='_blank'> |
|||
<Button |
|||
/* flex={1} */ |
|||
px={4} |
|||
fontSize={'sm'} |
|||
rounded={'full'} |
|||
bg={'blue.400'} |
|||
color={'white'} |
|||
boxShadow={ |
|||
'0px 1px 25px -5px rgb(66 153 225 / 48%), 0 10px 10px -5px rgb(66 153 225 / 43%)' |
|||
} |
|||
_hover={{ |
|||
bg: 'blue.500', |
|||
}} |
|||
_focus={{ |
|||
bg: 'blue.500', |
|||
}} |
|||
> |
|||
Download App |
|||
</Button> |
|||
</a> |
|||
</Flex> |
|||
</Box> |
|||
</Flex> |
|||
</Flex> |
|||
</Box> |
|||
</AnimatedScrollWrapper> |
|||
) |
|||
} |
|||
@ -1,64 +0,0 @@ |
|||
import { CheckIcon } from '@chakra-ui/icons' |
|||
import { |
|||
Box, |
|||
Container, |
|||
Heading, |
|||
HStack, |
|||
Icon, |
|||
SimpleGrid, |
|||
Text, |
|||
useColorModeValue, |
|||
VStack, |
|||
} from '@chakra-ui/react' |
|||
import React from 'react' |
|||
import { featuresContent } from './featuresContent' |
|||
import AnimatedScrollWrapper from '../AnimatedScrollWrapper' |
|||
|
|||
const FeatureCard = ({ feature }) => { |
|||
const boxBgColor = useColorModeValue('gray.100', 'gray.800') |
|||
return ( |
|||
<HStack |
|||
align={'top'} |
|||
borderWidth='1px' |
|||
borderRadius='sm' |
|||
p={2} |
|||
shadow='lg' |
|||
background={boxBgColor} |
|||
> |
|||
<Box color={'green.400'} px={1}> |
|||
<Icon as={CheckIcon} /> |
|||
</Box> |
|||
<VStack align={'start'}> |
|||
<Text fontWeight={800}>{feature.title}</Text> |
|||
<Text fontWeight='normal'>{feature.description}</Text> |
|||
</VStack> |
|||
</HStack> |
|||
) |
|||
} |
|||
|
|||
export default function FeaturesSection() { |
|||
return ( |
|||
<AnimatedScrollWrapper> |
|||
<Box p={4} my={16} maxW={'6xl'}> |
|||
<Heading fontSize={'3xl'} textAlign={'center'} pb={0}> |
|||
Features |
|||
</Heading> |
|||
<Text color={'gray.600'} fontSize={'lg'} textAlign={'center'}> |
|||
The ultimate solution for your messaging needs! Our free open-source |
|||
Android-based SMS Gateway provides you with all the features you need |
|||
to effectively manage your SMS communications. From sending messages |
|||
and automating messaging workflows via API, our SMS Gateway is the |
|||
perfect tool for any small/mid business or individual. |
|||
</Text> |
|||
|
|||
<Container maxW={'6xl'} mt={0}> |
|||
<SimpleGrid columns={{ base: 1, md: 2, lg: 4 }} spacing={3} pt={16}> |
|||
{featuresContent.map((feature, i) => ( |
|||
<FeatureCard key={feature.title} feature={feature} /> |
|||
))} |
|||
</SimpleGrid> |
|||
</Container> |
|||
</Box> |
|||
</AnimatedScrollWrapper> |
|||
) |
|||
} |
|||
@ -1,60 +0,0 @@ |
|||
import { AddIcon, MinusIcon } from '@chakra-ui/icons' |
|||
import { |
|||
Accordion, |
|||
AccordionButton, |
|||
AccordionItem, |
|||
AccordionPanel, |
|||
Box, |
|||
Container, |
|||
Heading, |
|||
Text, |
|||
} from '@chakra-ui/react' |
|||
import React from 'react' |
|||
import { howItWorksContent } from './howItWorksContent' |
|||
import AnimatedScrollWrapper from '../AnimatedScrollWrapper' |
|||
|
|||
export default function HowItWorksSection() { |
|||
return ( |
|||
<AnimatedScrollWrapper> |
|||
<Box px={4} my={24} maxW={'6xl'}> |
|||
{/* @ts-ignore */} |
|||
<a name='how-it-works'> |
|||
<Heading fontSize={'3xl'} textAlign={'center'}> |
|||
How It Works |
|||
</Heading> |
|||
</a> |
|||
<Text color={'gray.600'} fontSize={'lg'} textAlign={'center'}> |
|||
How it works is simple. You install the app on your Android device, |
|||
and it will turn your device into a SMS Gateway. You can then use the |
|||
API to send SMS messages from your own applications. |
|||
</Text> |
|||
|
|||
<Container maxW={'6xl'} mt={10} pt={0}> |
|||
<Accordion allowMultiple defaultIndex={[]}> |
|||
{howItWorksContent.map(({ title, description }) => ( |
|||
<AccordionItem key={title}> |
|||
{({ isExpanded }) => ( |
|||
<> |
|||
<h2> |
|||
<AccordionButton> |
|||
<Box as='span' flex='1' textAlign='left'> |
|||
{title} |
|||
</Box> |
|||
{isExpanded ? ( |
|||
<MinusIcon fontSize='12px' /> |
|||
) : ( |
|||
<AddIcon fontSize='12px' /> |
|||
)} |
|||
</AccordionButton> |
|||
</h2> |
|||
<AccordionPanel pb={4}>{description}</AccordionPanel> |
|||
</> |
|||
)} |
|||
</AccordionItem> |
|||
))} |
|||
</Accordion> |
|||
</Container> |
|||
</Box> |
|||
</AnimatedScrollWrapper> |
|||
) |
|||
} |
|||
@ -1,153 +0,0 @@ |
|||
import { |
|||
Container, |
|||
Stack, |
|||
Flex, |
|||
Box, |
|||
Heading, |
|||
Text, |
|||
Button, |
|||
Image, |
|||
createIcon, |
|||
} from '@chakra-ui/react' |
|||
import Link from 'next/link' |
|||
import Router from 'next/router' |
|||
import { selectAuthUser } from '../../store/authSlice' |
|||
import { useSelector } from 'react-redux' |
|||
import { ChatIcon } from '@chakra-ui/icons' |
|||
import AnimatedScrollWrapper from '../AnimatedScrollWrapper' |
|||
import { motion } from 'framer-motion' |
|||
|
|||
const AnimatedScreenshotImage = () => { |
|||
const animateVariants = { |
|||
hidden: { |
|||
opacity: 0, |
|||
y: Math.floor(Math.random() * 100) + -50, |
|||
x: Math.floor(Math.random() * 100) + 50, |
|||
}, |
|||
visible: { |
|||
opacity: 1, |
|||
y: 0, |
|||
x: 0, |
|||
transition: { |
|||
duration: 2.0, |
|||
}, |
|||
}, |
|||
} |
|||
return ( |
|||
<motion.div |
|||
variants={animateVariants} |
|||
initial='hidden' |
|||
whileInView='visible' |
|||
> |
|||
<Box |
|||
position={'relative'} |
|||
height={'400px'} |
|||
rounded={'2xl'} |
|||
boxShadow={'xs'} |
|||
width={'full'} |
|||
overflow={'hidden'} |
|||
> |
|||
<Image |
|||
alt={'TextBee App Screenshot'} |
|||
fit={'cover'} |
|||
align={'center'} |
|||
src={'/images/smsgatewayandroid.png'} |
|||
/> |
|||
</Box> |
|||
</motion.div> |
|||
) |
|||
} |
|||
|
|||
export default function IntroSection() { |
|||
const authUser = useSelector(selectAuthUser) |
|||
|
|||
const handleGetStarted = () => { |
|||
if (!authUser) { |
|||
Router.push('/register') |
|||
} else { |
|||
Router.push('/dashboard') |
|||
} |
|||
} |
|||
|
|||
return ( |
|||
<AnimatedScrollWrapper> |
|||
<Container maxW={'7xl'} py={8}> |
|||
<Stack |
|||
align={'center'} |
|||
spacing={{ base: 8, md: 10 }} |
|||
py={{ base: 20, md: 28 }} |
|||
direction={{ base: 'column', md: 'row' }} |
|||
> |
|||
<Stack flex={1} spacing={{ base: 5, md: 10 }}> |
|||
<Heading |
|||
lineHeight={1.1} |
|||
fontWeight={600} |
|||
fontSize={{ base: '3xl', sm: '4xl', lg: '5xl' }} |
|||
> |
|||
<Text as={'span'} position={'relative'} fontWeight={600}> |
|||
<ChatIcon /> Text |
|||
<Text as={'span'} color={'blue.400'} decoration='underline'> |
|||
Bee |
|||
</Text> |
|||
</Text> |
|||
<br /> |
|||
<Text as={'span'} color={'blue.400'} fontWeight={300}> |
|||
Make your android device a portable SMS Gateway! |
|||
</Text> |
|||
</Heading> |
|||
<Text |
|||
color={'gray.500'} |
|||
fontSize={{ base: 'md', sm: 'lg', lg: 'xl' }} |
|||
> |
|||
Unlock the power of messaging with our open-source Android SMS |
|||
Gateway. |
|||
</Text> |
|||
<Stack |
|||
spacing={{ base: 4, sm: 6 }} |
|||
direction={{ base: 'column', sm: 'row' }} |
|||
> |
|||
<Button |
|||
rounded={'full'} |
|||
size={'lg'} |
|||
fontWeight={'normal'} |
|||
px={6} |
|||
colorScheme={'blue'} |
|||
bg={'blue.400'} |
|||
_hover={{ bg: 'blue.500' }} |
|||
onClick={handleGetStarted} |
|||
> |
|||
Get Started |
|||
</Button> |
|||
<Link href={'#how-it-works'} passHref> |
|||
<Button |
|||
rounded={'full'} |
|||
size={'lg'} |
|||
fontWeight={'normal'} |
|||
px={6} |
|||
leftIcon={<PlayIcon h={4} w={4} color={'gray.300'} />} |
|||
> |
|||
How It Works |
|||
</Button> |
|||
</Link> |
|||
</Stack> |
|||
</Stack> |
|||
<Flex |
|||
flex={1} |
|||
justify={'center'} |
|||
align={'center'} |
|||
position={'relative'} |
|||
w={'full'} |
|||
> |
|||
<AnimatedScreenshotImage /> |
|||
</Flex> |
|||
</Stack> |
|||
</Container> |
|||
</AnimatedScrollWrapper> |
|||
) |
|||
} |
|||
|
|||
const PlayIcon = createIcon({ |
|||
displayName: 'PlayIcon', |
|||
viewBox: '0 0 58 58', |
|||
d: 'M28.9999 0.562988C13.3196 0.562988 0.562378 13.3202 0.562378 29.0005C0.562378 44.6808 13.3196 57.438 28.9999 57.438C44.6801 57.438 57.4374 44.6808 57.4374 29.0005C57.4374 13.3202 44.6801 0.562988 28.9999 0.562988ZM39.2223 30.272L23.5749 39.7247C23.3506 39.8591 23.0946 39.9314 22.8332 39.9342C22.5717 39.9369 22.3142 39.8701 22.0871 39.7406C21.86 39.611 21.6715 39.4234 21.5408 39.1969C21.4102 38.9705 21.3421 38.7133 21.3436 38.4519V19.5491C21.3421 19.2877 21.4102 19.0305 21.5408 18.8041C21.6715 18.5776 21.86 18.3899 22.0871 18.2604C22.3142 18.1308 22.5717 18.064 22.8332 18.0668C23.0946 18.0696 23.3506 18.1419 23.5749 18.2763L39.2223 27.729C39.4404 27.8619 39.6207 28.0486 39.7458 28.2713C39.8709 28.494 39.9366 28.7451 39.9366 29.0005C39.9366 29.2559 39.8709 29.507 39.7458 29.7297C39.6207 29.9523 39.4404 30.1391 39.2223 30.272Z', |
|||
}) |
|||
@ -1,56 +0,0 @@ |
|||
import { |
|||
Box, |
|||
VStack, |
|||
Button, |
|||
Flex, |
|||
Divider, |
|||
chakra, |
|||
Grid, |
|||
GridItem, |
|||
Container, |
|||
} from '@chakra-ui/react' |
|||
import Link from 'next/link' |
|||
|
|||
export default function SupportTheProject() { |
|||
return ( |
|||
<Box as={Container} maxW='6xl' my={14} p={4}> |
|||
<Grid |
|||
templateColumns={{ |
|||
base: 'repeat(1, 1fr)', |
|||
sm: 'repeat(1, 1fr)', |
|||
md: 'repeat(3, 1fr)', |
|||
}} |
|||
gap={4} |
|||
> |
|||
<GridItem colSpan={2}> |
|||
<Flex> |
|||
<VStack alignItems='flex-start' spacing='20px'> |
|||
<chakra.h2 fontSize='3xl' fontWeight='700'> |
|||
Support The Project |
|||
</chakra.h2> |
|||
<chakra.p p={0}> |
|||
Maintaining this open-source project requires resources and |
|||
dedication. By becoming a patron, your contributions will |
|||
directly support the development, enabling implementation of new |
|||
features, enhance performance, and ensure the highest level of |
|||
security and reliability. |
|||
</chakra.p> |
|||
</VStack> |
|||
</Flex> |
|||
</GridItem> |
|||
<GridItem colSpan={1} justifySelf={'center'} alignSelf={'center'}> |
|||
<Button |
|||
colorScheme='blue' |
|||
size='md' |
|||
my={8} |
|||
as={Link} |
|||
href='https://www.patreon.com/bePatron?u=124342375' |
|||
target='_blank' |
|||
> |
|||
Become a Patron |
|||
</Button> |
|||
</GridItem> |
|||
</Grid> |
|||
</Box> |
|||
) |
|||
} |
|||
@ -1,19 +0,0 @@ |
|||
export const featuresContent = [ |
|||
{ |
|||
title: 'Send SMS', |
|||
description: 'Send SMS to any number from your dashboard or via REST API.', |
|||
}, |
|||
{ |
|||
title: 'Bulk SMS', |
|||
description: 'Send SMS to multiple numbers at once.', |
|||
}, |
|||
{ |
|||
title: '100% Free', |
|||
description: |
|||
'No credit card required. No hidden fees. No strings attached.', |
|||
}, |
|||
{ |
|||
title: 'Open Source', |
|||
description: 'The entire codebase is open source and available on GitHub.', |
|||
}, |
|||
] |
|||
@ -1,23 +0,0 @@ |
|||
export const howItWorksContent = [ |
|||
{ |
|||
title: 'Step 1: Download The Android App from dl.textbee.dev', |
|||
description: |
|||
'', |
|||
}, |
|||
{ |
|||
title: 'Step 2: Generate an API Key from the dashboard', |
|||
description: |
|||
'', |
|||
}, |
|||
{ |
|||
title: |
|||
'Step 3: Scan the QR/ enter your api key manually and enable the gateway app', |
|||
description: |
|||
'', |
|||
}, |
|||
{ |
|||
title: 'Step 4: Start sending', |
|||
description: |
|||
'You can now send SMS from the dashboard. or visit the API docs at https://api.textbee.dev to send SMS programatically', |
|||
}, |
|||
] |
|||
@ -1,30 +0,0 @@ |
|||
import Script from 'next/script' |
|||
import React from 'react' |
|||
|
|||
export default function LiveChat() { |
|||
if (!process.env.NEXT_PUBLIC_TAWKTO_EMBED_URL) { |
|||
return null |
|||
} |
|||
|
|||
return ( |
|||
<> |
|||
<Script |
|||
id='tawkto' |
|||
strategy='afterInteractive' |
|||
dangerouslySetInnerHTML={{ |
|||
__html: `
|
|||
var Tawk_API=Tawk_API||{}, Tawk_LoadStart=new Date(); |
|||
(function(){ |
|||
var s1=document.createElement("script"),s0=document.getElementsByTagName("script")[0]; |
|||
s1.async=true; |
|||
s1.src='${process.env.NEXT_PUBLIC_TAWKTO_EMBED_URL}'; |
|||
s1.charset='UTF-8'; |
|||
s1.setAttribute('crossorigin','*'); |
|||
s0.parentNode.insertBefore(s1,s0); |
|||
})(); |
|||
`,
|
|||
}} |
|||
/> |
|||
</> |
|||
) |
|||
} |
|||
@ -1,24 +0,0 @@ |
|||
import Head from 'next/head' |
|||
|
|||
export default function Meta() { |
|||
return ( |
|||
<Head> |
|||
<title>TextBee - SMS Gateway</title> |
|||
<meta name='viewport' content='initial-scale=1.0, width=device-width' /> |
|||
<meta |
|||
name='description' |
|||
content={`TextBee is an open-source SMS gateway platform built for Android devices.
|
|||
It allows businesses to send SMS messages from dashboard or API, receiving SMS messages and forwarding them to a webhook, |
|||
streamlining communication and automating SMS workflows. With its robust features, |
|||
TextBee is an ideal solution for CRM's, notifications, alerts, two-factor authentication, and various other use cases. |
|||
`}
|
|||
/> |
|||
<meta |
|||
name='keywords' |
|||
content='android, text, sms, gateway, sms-gateway, open-source foss' |
|||
/> |
|||
<meta name='author' content='Israel Abebe Kokiso' /> |
|||
<link rel='icon' href='/favicon.ico' /> |
|||
</Head> |
|||
) |
|||
} |
|||
@ -0,0 +1,174 @@ |
|||
'use client' |
|||
|
|||
import { useMemo } from 'react' |
|||
import Link from 'next/link' |
|||
import { useRouter } from 'next/navigation' |
|||
import { Button } from '@/components/ui/button' |
|||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' |
|||
import { |
|||
DropdownMenu, |
|||
DropdownMenuContent, |
|||
DropdownMenuItem, |
|||
DropdownMenuSeparator, |
|||
DropdownMenuTrigger, |
|||
} from '@/components/ui/dropdown-menu' |
|||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet' |
|||
import { Menu, LogOut, LayoutDashboard, MessageSquarePlus } from 'lucide-react' |
|||
import { signOut, useSession } from 'next-auth/react' |
|||
import { Routes } from '@/config/routes' |
|||
|
|||
export default function AppHeader() { |
|||
const session = useSession() |
|||
const router = useRouter() |
|||
|
|||
const handleLogout = () => { |
|||
signOut() |
|||
router.push(Routes.login) |
|||
} |
|||
|
|||
const isAuthenticated = useMemo( |
|||
() => session.status === 'authenticated' && session.data?.user, |
|||
[session.status, session.data?.user] |
|||
) |
|||
|
|||
const AuthenticatedMenu = () => ( |
|||
<DropdownMenu> |
|||
<DropdownMenuTrigger asChild> |
|||
<Button variant='ghost' className='relative h-8 w-8 rounded-full'> |
|||
<Avatar className='h-8 w-8'> |
|||
<AvatarImage |
|||
src={session.data?.user?.avatar} |
|||
alt={session.data?.user?.name} |
|||
/> |
|||
<AvatarFallback> |
|||
{session.data?.user?.name?.charAt(0)} |
|||
</AvatarFallback> |
|||
</Avatar> |
|||
<div className='hidden md:block'>{session.data?.user?.name}</div> |
|||
</Button> |
|||
</DropdownMenuTrigger> |
|||
<DropdownMenuContent className='w-56' align='end' forceMount> |
|||
<DropdownMenuItem className='flex flex-col items-start'> |
|||
<div className='font-medium'>{session.data?.user?.name}</div> |
|||
<div className='text-xs text-muted-foreground'> |
|||
{session.data?.user?.email} |
|||
</div> |
|||
</DropdownMenuItem> |
|||
<DropdownMenuSeparator /> |
|||
<DropdownMenuItem asChild> |
|||
<Link href={Routes.dashboard} className='w-full flex items-center'> |
|||
<LayoutDashboard className='mr-2 h-4 w-4' /> |
|||
<span>Dashboard</span> |
|||
</Link> |
|||
</DropdownMenuItem> |
|||
<DropdownMenuItem onClick={handleLogout} className='text-red-600'> |
|||
<LogOut className='mr-2 h-4 w-4' /> |
|||
<span>Log out</span> |
|||
</DropdownMenuItem> |
|||
</DropdownMenuContent> |
|||
</DropdownMenu> |
|||
) |
|||
|
|||
const MobileMenu = () => ( |
|||
<Sheet> |
|||
<SheetTrigger asChild> |
|||
<Button variant='ghost' className='md:hidden' size='icon'> |
|||
<Menu className='h-5 w-5' /> |
|||
<span className='sr-only'>Toggle menu</span> |
|||
</Button> |
|||
</SheetTrigger> |
|||
<SheetContent side='right' className='w-[300px] sm:w-[400px]'> |
|||
<nav className='flex flex-col gap-4'> |
|||
{isAuthenticated ? ( |
|||
<> |
|||
<div className='flex items-center gap-2 py-2'> |
|||
<Avatar className='h-8 w-8'> |
|||
<AvatarImage |
|||
src={session.data?.user?.avatar} |
|||
alt={session.data?.user?.name} |
|||
/> |
|||
<AvatarFallback> |
|||
{session.data?.user?.name?.charAt(0)} |
|||
</AvatarFallback> |
|||
</Avatar> |
|||
<div> |
|||
<div className='font-medium'>{session.data?.user?.name}</div> |
|||
<div className='text-xs text-muted-foreground'> |
|||
{session.data?.user?.email} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<Link |
|||
href={Routes.dashboard} |
|||
className='flex items-center gap-2 py-2' |
|||
> |
|||
<LayoutDashboard className='h-4 w-4' /> |
|||
Dashboard |
|||
</Link> |
|||
<Button |
|||
onClick={handleLogout} |
|||
variant='ghost' |
|||
className='justify-start text-red-600' |
|||
> |
|||
<LogOut className='mr-2 h-4 w-4' /> |
|||
Log out |
|||
</Button> |
|||
</> |
|||
) : ( |
|||
<> |
|||
<Button asChild variant='ghost' className='justify-start'> |
|||
<Link href={Routes.login}>Log in</Link> |
|||
</Button> |
|||
<Button |
|||
asChild |
|||
color='primary' |
|||
className='bg-blue-500 hover:bg-blue-600 rounded-full' |
|||
> |
|||
<Link href={Routes.register}>Get started</Link> |
|||
</Button> |
|||
</> |
|||
)} |
|||
</nav> |
|||
</SheetContent> |
|||
</Sheet> |
|||
) |
|||
|
|||
return ( |
|||
<header className='sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60'> |
|||
<div className='container flex h-14 items-center'> |
|||
<div className='mr-4 flex'> |
|||
<Link |
|||
className='flex items-center space-x-2' |
|||
href={Routes.landingPage} |
|||
> |
|||
<MessageSquarePlus className='h-6 w-6 text-blue-500' /> |
|||
<span className='font-bold'> |
|||
Text<span className='text-blue-500'>Bee</span> |
|||
</span> |
|||
</Link> |
|||
</div> |
|||
<div className='flex flex-1 items-center justify-end space-x-2'> |
|||
<nav className='flex items-center space-x-2'> |
|||
{isAuthenticated ? ( |
|||
<AuthenticatedMenu /> |
|||
) : ( |
|||
<div className='hidden md:flex md:items-center md:gap-2'> |
|||
<Button asChild variant='ghost'> |
|||
<Link href={Routes.login}>Log in</Link> |
|||
</Button> |
|||
<Button |
|||
asChild |
|||
color='primary' |
|||
className='bg-blue-500 hover:bg-blue-600 rounded-full' |
|||
> |
|||
<Link href={Routes.register}>Get started</Link> |
|||
</Button> |
|||
</div> |
|||
)} |
|||
<MobileMenu /> |
|||
</nav> |
|||
</div> |
|||
</div> |
|||
</header> |
|||
) |
|||
} |
|||
@ -0,0 +1,227 @@ |
|||
'use client' |
|||
|
|||
import { Button } from '@/components/ui/button' |
|||
import { |
|||
Dialog, |
|||
DialogContent, |
|||
DialogHeader, |
|||
DialogTitle, |
|||
DialogTrigger, |
|||
} from '@/components/ui/dialog' |
|||
import { |
|||
Form, |
|||
FormControl, |
|||
FormField, |
|||
FormItem, |
|||
FormLabel, |
|||
FormMessage, |
|||
} from '@/components/ui/form' |
|||
import { Input } from '@/components/ui/input' |
|||
import { |
|||
Select, |
|||
SelectContent, |
|||
SelectItem, |
|||
SelectTrigger, |
|||
SelectValue, |
|||
} from '@/components/ui/select' |
|||
import { Textarea } from '@/components/ui/textarea' |
|||
import { AlertTriangle, Check, Loader2, MessageSquarePlus } from 'lucide-react' |
|||
import { useState } from 'react' |
|||
import { useForm } from 'react-hook-form' |
|||
import { z } from 'zod' |
|||
import { zodResolver } from '@hookform/resolvers/zod' |
|||
import { toast } from '@/hooks/use-toast' |
|||
import axios from 'axios' |
|||
|
|||
const SupportFormSchema = z.object({ |
|||
name: z.string().min(1, { message: 'Name is required' }), |
|||
email: z.string().email({ message: 'Invalid email address' }), |
|||
phone: z.string().optional(), |
|||
category: z.enum(['general', 'technical'], { |
|||
message: 'Support category is required', |
|||
}), |
|||
message: z.string().min(1, { message: 'Message is required' }), |
|||
}) |
|||
|
|||
export default function SupportButton() { |
|||
const [open, setOpen] = useState(false) |
|||
const form = useForm({ |
|||
resolver: zodResolver(SupportFormSchema), |
|||
defaultValues: { |
|||
name: '', |
|||
email: '', |
|||
phone: '', |
|||
category: 'general', |
|||
message: '', |
|||
}, |
|||
}) |
|||
|
|||
const onSubmit = async (data: any) => { |
|||
try { |
|||
const response = await axios.post('/api/customer-support', data) |
|||
|
|||
const result = response.data |
|||
|
|||
toast({ |
|||
title: 'Support request submitted', |
|||
description: result.message, |
|||
}) |
|||
} catch (error) { |
|||
form.setError('root.serverError', { |
|||
message: 'Error submitting support request', |
|||
}) |
|||
toast({ |
|||
title: 'Error submitting support request', |
|||
description: 'Please try again later', |
|||
}) |
|||
} |
|||
} |
|||
|
|||
const onOpenChange = (open: boolean) => { |
|||
setOpen(open) |
|||
if (!open) { |
|||
form.reset() |
|||
} |
|||
} |
|||
|
|||
return ( |
|||
<Dialog open={open} onOpenChange={onOpenChange}> |
|||
<DialogTrigger asChild> |
|||
<Button |
|||
className='fixed bottom-4 right-4 shadow-lg bg-blue-500 hover:bg-blue-600 rounded-full' |
|||
size='sm' |
|||
> |
|||
<MessageSquarePlus className='h-5 w-5 mr-1' /> |
|||
<span className='mr-1'>Support</span> |
|||
</Button> |
|||
</DialogTrigger> |
|||
<DialogContent> |
|||
<DialogHeader> |
|||
<DialogTitle>Contact Support</DialogTitle> |
|||
</DialogHeader> |
|||
<Form {...form}> |
|||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'> |
|||
<FormField |
|||
control={form.control} |
|||
name='category' |
|||
disabled={form.formState.isSubmitting} |
|||
render={({ field }) => ( |
|||
<FormItem> |
|||
<FormLabel>Support Category</FormLabel> |
|||
<Select |
|||
onValueChange={field.onChange} |
|||
disabled={form.formState.isSubmitting} |
|||
defaultValue={field.value} |
|||
> |
|||
<FormControl> |
|||
<SelectTrigger> |
|||
<SelectValue placeholder='Select support category' /> |
|||
</SelectTrigger> |
|||
</FormControl> |
|||
<SelectContent> |
|||
<SelectItem value='general'>General Inquiry</SelectItem> |
|||
<SelectItem value='technical'> |
|||
Technical Support |
|||
</SelectItem> |
|||
</SelectContent> |
|||
</Select> |
|||
<FormMessage /> |
|||
</FormItem> |
|||
)} |
|||
/> |
|||
<FormField |
|||
control={form.control} |
|||
name='name' |
|||
disabled={form.formState.isSubmitting} |
|||
render={({ field }) => ( |
|||
<FormItem> |
|||
<FormLabel>Name</FormLabel> |
|||
<FormControl> |
|||
<Input placeholder='Your name' {...field} /> |
|||
</FormControl> |
|||
<FormMessage /> |
|||
</FormItem> |
|||
)} |
|||
/> |
|||
<FormField |
|||
control={form.control} |
|||
name='email' |
|||
disabled={form.formState.isSubmitting} |
|||
render={({ field }) => ( |
|||
<FormItem> |
|||
<FormLabel>Email</FormLabel> |
|||
<FormControl> |
|||
<Input |
|||
placeholder='your@email.com' |
|||
type='email' |
|||
{...field} |
|||
/> |
|||
</FormControl> |
|||
<FormMessage /> |
|||
</FormItem> |
|||
)} |
|||
/> |
|||
<FormField |
|||
control={form.control} |
|||
name='phone' |
|||
disabled={form.formState.isSubmitting} |
|||
render={({ field }) => ( |
|||
<FormItem> |
|||
<FormLabel>Phone (Optional)</FormLabel> |
|||
<FormControl> |
|||
<Input placeholder='+1234567890' type='tel' {...field} /> |
|||
</FormControl> |
|||
<FormMessage /> |
|||
</FormItem> |
|||
)} |
|||
/> |
|||
<FormField |
|||
control={form.control} |
|||
name='message' |
|||
disabled={form.formState.isSubmitting} |
|||
render={({ field }) => ( |
|||
<FormItem> |
|||
<FormLabel>Message</FormLabel> |
|||
<FormControl> |
|||
<Textarea |
|||
placeholder='How can we help you?' |
|||
className='min-h-[100px]' |
|||
{...field} |
|||
/> |
|||
</FormControl> |
|||
<FormMessage /> |
|||
</FormItem> |
|||
)} |
|||
/> |
|||
{form.formState.isSubmitSuccessful && ( |
|||
<div className='flex items-center gap-2 text-green-500'> |
|||
<Check className='h-4 w-4' /> We received your message, we will |
|||
get back to you soon. |
|||
</div> |
|||
)} |
|||
|
|||
{form.formState.errors.root?.serverError && ( |
|||
<> |
|||
<AlertTriangle className='h-4 w-4' />{' '} |
|||
{form.formState.errors.root.serverError.message} |
|||
</> |
|||
)} |
|||
<Button |
|||
type='submit' |
|||
disabled={form.formState.isSubmitting} |
|||
className='w-full' |
|||
> |
|||
{form.formState.isSubmitting ? ( |
|||
<> |
|||
<Loader2 className='h-4 w-4 animate-spin' /> Submitting ...{' '} |
|||
</> |
|||
) : ( |
|||
'Submit' |
|||
)} |
|||
</Button> |
|||
</form> |
|||
</Form> |
|||
</DialogContent> |
|||
</Dialog> |
|||
) |
|||
} |
|||
@ -0,0 +1,51 @@ |
|||
import { ExternalLinks } from '@/config/external-links' |
|||
import { Routes } from '@/config/routes' |
|||
import { MessageSquarePlus } from 'lucide-react' |
|||
import Link from 'next/link' |
|||
export default function Footer() { |
|||
return ( |
|||
<footer className='border-t py-6 bg-gray-50'> |
|||
<div className='container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl flex flex-col items-center justify-between gap-4 md:h-24 md:flex-row'> |
|||
<div className='flex flex-col items-center gap-4 px-8 md:flex-row md:gap-2 md:px-0'> |
|||
<MessageSquarePlus className='h-6 w-6 text-blue-500' /> |
|||
<p className='text-center text-sm leading-loose md:text-left'> |
|||
© {new Date().getFullYear()} All rights reserved |
|||
</p> |
|||
</div> |
|||
<nav className='flex gap-4 sm:gap-6 flex-col md:flex-row items-center'> |
|||
<Link |
|||
className='text-sm font-medium hover:text-blue-500' |
|||
href={Routes.landingPage} |
|||
> |
|||
Home |
|||
</Link> |
|||
<Link |
|||
className='text-sm font-medium hover:text-blue-500' |
|||
href={Routes.dashboard} |
|||
> |
|||
Dashboard |
|||
</Link> |
|||
<Link |
|||
className='text-sm font-medium hover:text-blue-500' |
|||
href={ExternalLinks.patreon} |
|||
> |
|||
Become a Patron |
|||
</Link> |
|||
<Link |
|||
className='text-sm font-medium hover:text-blue-500' |
|||
href={Routes.downloadAndroidApp} |
|||
> |
|||
Download App |
|||
</Link> |
|||
<Link |
|||
className='text-sm font-medium hover:text-blue-500' |
|||
href={ExternalLinks.github} |
|||
target='_blank' |
|||
> |
|||
GitHub |
|||
</Link> |
|||
</nav> |
|||
</div> |
|||
</footer> |
|||
) |
|||
} |
|||
@ -0,0 +1,57 @@ |
|||
"use client" |
|||
|
|||
import * as React from "react" |
|||
import * as AccordionPrimitive from "@radix-ui/react-accordion" |
|||
import { ChevronDownIcon } from "@radix-ui/react-icons" |
|||
|
|||
import { cn } from "@/lib/utils" |
|||
|
|||
const Accordion = AccordionPrimitive.Root |
|||
|
|||
const AccordionItem = React.forwardRef< |
|||
React.ElementRef<typeof AccordionPrimitive.Item>, |
|||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item> |
|||
>(({ className, ...props }, ref) => ( |
|||
<AccordionPrimitive.Item |
|||
ref={ref} |
|||
className={cn("border-b", className)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
AccordionItem.displayName = "AccordionItem" |
|||
|
|||
const AccordionTrigger = React.forwardRef< |
|||
React.ElementRef<typeof AccordionPrimitive.Trigger>, |
|||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> |
|||
>(({ className, children, ...props }, ref) => ( |
|||
<AccordionPrimitive.Header className="flex"> |
|||
<AccordionPrimitive.Trigger |
|||
ref={ref} |
|||
className={cn( |
|||
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180", |
|||
className |
|||
)} |
|||
{...props} |
|||
> |
|||
{children} |
|||
<ChevronDownIcon className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" /> |
|||
</AccordionPrimitive.Trigger> |
|||
</AccordionPrimitive.Header> |
|||
)) |
|||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName |
|||
|
|||
const AccordionContent = React.forwardRef< |
|||
React.ElementRef<typeof AccordionPrimitive.Content>, |
|||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> |
|||
>(({ className, children, ...props }, ref) => ( |
|||
<AccordionPrimitive.Content |
|||
ref={ref} |
|||
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down" |
|||
{...props} |
|||
> |
|||
<div className={cn("pb-4 pt-0", className)}>{children}</div> |
|||
</AccordionPrimitive.Content> |
|||
)) |
|||
AccordionContent.displayName = AccordionPrimitive.Content.displayName |
|||
|
|||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } |
|||
@ -0,0 +1,59 @@ |
|||
import * as React from "react" |
|||
import { cva, type VariantProps } from "class-variance-authority" |
|||
|
|||
import { cn } from "@/lib/utils" |
|||
|
|||
const alertVariants = cva( |
|||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", |
|||
{ |
|||
variants: { |
|||
variant: { |
|||
default: "bg-background text-foreground", |
|||
destructive: |
|||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", |
|||
}, |
|||
}, |
|||
defaultVariants: { |
|||
variant: "default", |
|||
}, |
|||
} |
|||
) |
|||
|
|||
const Alert = React.forwardRef< |
|||
HTMLDivElement, |
|||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants> |
|||
>(({ className, variant, ...props }, ref) => ( |
|||
<div |
|||
ref={ref} |
|||
role="alert" |
|||
className={cn(alertVariants({ variant }), className)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
Alert.displayName = "Alert" |
|||
|
|||
const AlertTitle = React.forwardRef< |
|||
HTMLParagraphElement, |
|||
React.HTMLAttributes<HTMLHeadingElement> |
|||
>(({ className, ...props }, ref) => ( |
|||
<h5 |
|||
ref={ref} |
|||
className={cn("mb-1 font-medium leading-none tracking-tight", className)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
AlertTitle.displayName = "AlertTitle" |
|||
|
|||
const AlertDescription = React.forwardRef< |
|||
HTMLParagraphElement, |
|||
React.HTMLAttributes<HTMLParagraphElement> |
|||
>(({ className, ...props }, ref) => ( |
|||
<div |
|||
ref={ref} |
|||
className={cn("text-sm [&_p]:leading-relaxed", className)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
AlertDescription.displayName = "AlertDescription" |
|||
|
|||
export { Alert, AlertTitle, AlertDescription } |
|||
@ -0,0 +1,50 @@ |
|||
"use client" |
|||
|
|||
import * as React from "react" |
|||
import * as AvatarPrimitive from "@radix-ui/react-avatar" |
|||
|
|||
import { cn } from "@/lib/utils" |
|||
|
|||
const Avatar = React.forwardRef< |
|||
React.ElementRef<typeof AvatarPrimitive.Root>, |
|||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> |
|||
>(({ className, ...props }, ref) => ( |
|||
<AvatarPrimitive.Root |
|||
ref={ref} |
|||
className={cn( |
|||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
Avatar.displayName = AvatarPrimitive.Root.displayName |
|||
|
|||
const AvatarImage = React.forwardRef< |
|||
React.ElementRef<typeof AvatarPrimitive.Image>, |
|||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> |
|||
>(({ className, ...props }, ref) => ( |
|||
<AvatarPrimitive.Image |
|||
ref={ref} |
|||
className={cn("aspect-square h-full w-full", className)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
AvatarImage.displayName = AvatarPrimitive.Image.displayName |
|||
|
|||
const AvatarFallback = React.forwardRef< |
|||
React.ElementRef<typeof AvatarPrimitive.Fallback>, |
|||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> |
|||
>(({ className, ...props }, ref) => ( |
|||
<AvatarPrimitive.Fallback |
|||
ref={ref} |
|||
className={cn( |
|||
"flex h-full w-full items-center justify-center rounded-full bg-muted", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName |
|||
|
|||
export { Avatar, AvatarImage, AvatarFallback } |
|||
@ -0,0 +1,36 @@ |
|||
import * as React from "react" |
|||
import { cva, type VariantProps } from "class-variance-authority" |
|||
|
|||
import { cn } from "@/lib/utils" |
|||
|
|||
const badgeVariants = cva( |
|||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", |
|||
{ |
|||
variants: { |
|||
variant: { |
|||
default: |
|||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", |
|||
secondary: |
|||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", |
|||
destructive: |
|||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", |
|||
outline: "text-foreground", |
|||
}, |
|||
}, |
|||
defaultVariants: { |
|||
variant: "default", |
|||
}, |
|||
} |
|||
) |
|||
|
|||
export interface BadgeProps |
|||
extends React.HTMLAttributes<HTMLDivElement>, |
|||
VariantProps<typeof badgeVariants> {} |
|||
|
|||
function Badge({ className, variant, ...props }: BadgeProps) { |
|||
return ( |
|||
<div className={cn(badgeVariants({ variant }), className)} {...props} /> |
|||
) |
|||
} |
|||
|
|||
export { Badge, badgeVariants } |
|||
@ -0,0 +1,57 @@ |
|||
import * as React from "react" |
|||
import { Slot } from "@radix-ui/react-slot" |
|||
import { cva, type VariantProps } from "class-variance-authority" |
|||
|
|||
import { cn } from "@/lib/utils" |
|||
|
|||
const buttonVariants = cva( |
|||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", |
|||
{ |
|||
variants: { |
|||
variant: { |
|||
default: |
|||
"bg-primary text-primary-foreground shadow hover:bg-primary/90", |
|||
destructive: |
|||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", |
|||
outline: |
|||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", |
|||
secondary: |
|||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", |
|||
ghost: "hover:bg-accent hover:text-accent-foreground", |
|||
link: "text-primary underline-offset-4 hover:underline", |
|||
}, |
|||
size: { |
|||
default: "h-9 px-4 py-2", |
|||
sm: "h-8 rounded-md px-3 text-xs", |
|||
lg: "h-10 rounded-md px-8", |
|||
icon: "h-9 w-9", |
|||
}, |
|||
}, |
|||
defaultVariants: { |
|||
variant: "default", |
|||
size: "default", |
|||
}, |
|||
} |
|||
) |
|||
|
|||
export interface ButtonProps |
|||
extends React.ButtonHTMLAttributes<HTMLButtonElement>, |
|||
VariantProps<typeof buttonVariants> { |
|||
asChild?: boolean |
|||
} |
|||
|
|||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( |
|||
({ className, variant, size, asChild = false, ...props }, ref) => { |
|||
const Comp = asChild ? Slot : "button" |
|||
return ( |
|||
<Comp |
|||
className={cn(buttonVariants({ variant, size, className }))} |
|||
ref={ref} |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
) |
|||
Button.displayName = "Button" |
|||
|
|||
export { Button, buttonVariants } |
|||
@ -0,0 +1,76 @@ |
|||
import * as React from "react" |
|||
|
|||
import { cn } from "@/lib/utils" |
|||
|
|||
const Card = React.forwardRef< |
|||
HTMLDivElement, |
|||
React.HTMLAttributes<HTMLDivElement> |
|||
>(({ className, ...props }, ref) => ( |
|||
<div |
|||
ref={ref} |
|||
className={cn( |
|||
"rounded-xl border bg-card text-card-foreground shadow", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
Card.displayName = "Card" |
|||
|
|||
const CardHeader = React.forwardRef< |
|||
HTMLDivElement, |
|||
React.HTMLAttributes<HTMLDivElement> |
|||
>(({ className, ...props }, ref) => ( |
|||
<div |
|||
ref={ref} |
|||
className={cn("flex flex-col space-y-1.5 p-6", className)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
CardHeader.displayName = "CardHeader" |
|||
|
|||
const CardTitle = React.forwardRef< |
|||
HTMLParagraphElement, |
|||
React.HTMLAttributes<HTMLHeadingElement> |
|||
>(({ className, ...props }, ref) => ( |
|||
<h3 |
|||
ref={ref} |
|||
className={cn("font-semibold leading-none tracking-tight", className)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
CardTitle.displayName = "CardTitle" |
|||
|
|||
const CardDescription = React.forwardRef< |
|||
HTMLParagraphElement, |
|||
React.HTMLAttributes<HTMLParagraphElement> |
|||
>(({ className, ...props }, ref) => ( |
|||
<p |
|||
ref={ref} |
|||
className={cn("text-sm text-muted-foreground", className)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
CardDescription.displayName = "CardDescription" |
|||
|
|||
const CardContent = React.forwardRef< |
|||
HTMLDivElement, |
|||
React.HTMLAttributes<HTMLDivElement> |
|||
>(({ className, ...props }, ref) => ( |
|||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> |
|||
)) |
|||
CardContent.displayName = "CardContent" |
|||
|
|||
const CardFooter = React.forwardRef< |
|||
HTMLDivElement, |
|||
React.HTMLAttributes<HTMLDivElement> |
|||
>(({ className, ...props }, ref) => ( |
|||
<div |
|||
ref={ref} |
|||
className={cn("flex items-center p-6 pt-0", className)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
CardFooter.displayName = "CardFooter" |
|||
|
|||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } |
|||
@ -0,0 +1,29 @@ |
|||
"use client" |
|||
|
|||
import * as React from "react" |
|||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox" |
|||
import { cn } from "@/lib/utils" |
|||
import { CheckIcon } from "@radix-ui/react-icons" |
|||
|
|||
const Checkbox = React.forwardRef< |
|||
React.ElementRef<typeof CheckboxPrimitive.Root>, |
|||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> |
|||
>(({ className, ...props }, ref) => ( |
|||
<CheckboxPrimitive.Root |
|||
ref={ref} |
|||
className={cn( |
|||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground", |
|||
className |
|||
)} |
|||
{...props} |
|||
> |
|||
<CheckboxPrimitive.Indicator |
|||
className={cn("flex items-center justify-center text-current")} |
|||
> |
|||
<CheckIcon className="h-4 w-4" /> |
|||
</CheckboxPrimitive.Indicator> |
|||
</CheckboxPrimitive.Root> |
|||
)) |
|||
Checkbox.displayName = CheckboxPrimitive.Root.displayName |
|||
|
|||
export { Checkbox } |
|||
@ -0,0 +1,122 @@ |
|||
"use client" |
|||
|
|||
import * as React from "react" |
|||
import * as DialogPrimitive from "@radix-ui/react-dialog" |
|||
import { Cross2Icon } from "@radix-ui/react-icons" |
|||
|
|||
import { cn } from "@/lib/utils" |
|||
|
|||
const Dialog = DialogPrimitive.Root |
|||
|
|||
const DialogTrigger = DialogPrimitive.Trigger |
|||
|
|||
const DialogPortal = DialogPrimitive.Portal |
|||
|
|||
const DialogClose = DialogPrimitive.Close |
|||
|
|||
const DialogOverlay = React.forwardRef< |
|||
React.ElementRef<typeof DialogPrimitive.Overlay>, |
|||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> |
|||
>(({ className, ...props }, ref) => ( |
|||
<DialogPrimitive.Overlay |
|||
ref={ref} |
|||
className={cn( |
|||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName |
|||
|
|||
const DialogContent = React.forwardRef< |
|||
React.ElementRef<typeof DialogPrimitive.Content>, |
|||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> |
|||
>(({ className, children, ...props }, ref) => ( |
|||
<DialogPortal> |
|||
<DialogOverlay /> |
|||
<DialogPrimitive.Content |
|||
ref={ref} |
|||
className={cn( |
|||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", |
|||
className |
|||
)} |
|||
{...props} |
|||
> |
|||
{children} |
|||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> |
|||
<Cross2Icon className="h-4 w-4" /> |
|||
<span className="sr-only">Close</span> |
|||
</DialogPrimitive.Close> |
|||
</DialogPrimitive.Content> |
|||
</DialogPortal> |
|||
)) |
|||
DialogContent.displayName = DialogPrimitive.Content.displayName |
|||
|
|||
const DialogHeader = ({ |
|||
className, |
|||
...props |
|||
}: React.HTMLAttributes<HTMLDivElement>) => ( |
|||
<div |
|||
className={cn( |
|||
"flex flex-col space-y-1.5 text-center sm:text-left", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
) |
|||
DialogHeader.displayName = "DialogHeader" |
|||
|
|||
const DialogFooter = ({ |
|||
className, |
|||
...props |
|||
}: React.HTMLAttributes<HTMLDivElement>) => ( |
|||
<div |
|||
className={cn( |
|||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
) |
|||
DialogFooter.displayName = "DialogFooter" |
|||
|
|||
const DialogTitle = React.forwardRef< |
|||
React.ElementRef<typeof DialogPrimitive.Title>, |
|||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> |
|||
>(({ className, ...props }, ref) => ( |
|||
<DialogPrimitive.Title |
|||
ref={ref} |
|||
className={cn( |
|||
"text-lg font-semibold leading-none tracking-tight", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
DialogTitle.displayName = DialogPrimitive.Title.displayName |
|||
|
|||
const DialogDescription = React.forwardRef< |
|||
React.ElementRef<typeof DialogPrimitive.Description>, |
|||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> |
|||
>(({ className, ...props }, ref) => ( |
|||
<DialogPrimitive.Description |
|||
ref={ref} |
|||
className={cn("text-sm text-muted-foreground", className)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
DialogDescription.displayName = DialogPrimitive.Description.displayName |
|||
|
|||
export { |
|||
Dialog, |
|||
DialogPortal, |
|||
DialogOverlay, |
|||
DialogTrigger, |
|||
DialogClose, |
|||
DialogContent, |
|||
DialogHeader, |
|||
DialogFooter, |
|||
DialogTitle, |
|||
DialogDescription, |
|||
} |
|||
@ -0,0 +1,200 @@ |
|||
"use client" |
|||
|
|||
import * as React from "react" |
|||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" |
|||
import { cn } from "@/lib/utils" |
|||
import { CheckIcon, ChevronRightIcon, DotFilledIcon } from "@radix-ui/react-icons" |
|||
|
|||
const DropdownMenu = DropdownMenuPrimitive.Root |
|||
|
|||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger |
|||
|
|||
const DropdownMenuGroup = DropdownMenuPrimitive.Group |
|||
|
|||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal |
|||
|
|||
const DropdownMenuSub = DropdownMenuPrimitive.Sub |
|||
|
|||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup |
|||
|
|||
const DropdownMenuSubTrigger = React.forwardRef< |
|||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, |
|||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { |
|||
inset?: boolean |
|||
} |
|||
>(({ className, inset, children, ...props }, ref) => ( |
|||
<DropdownMenuPrimitive.SubTrigger |
|||
ref={ref} |
|||
className={cn( |
|||
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", |
|||
inset && "pl-8", |
|||
className |
|||
)} |
|||
{...props} |
|||
> |
|||
{children} |
|||
<ChevronRightIcon className="ml-auto" /> |
|||
</DropdownMenuPrimitive.SubTrigger> |
|||
)) |
|||
DropdownMenuSubTrigger.displayName = |
|||
DropdownMenuPrimitive.SubTrigger.displayName |
|||
|
|||
const DropdownMenuSubContent = React.forwardRef< |
|||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, |
|||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> |
|||
>(({ className, ...props }, ref) => ( |
|||
<DropdownMenuPrimitive.SubContent |
|||
ref={ref} |
|||
className={cn( |
|||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
DropdownMenuSubContent.displayName = |
|||
DropdownMenuPrimitive.SubContent.displayName |
|||
|
|||
const DropdownMenuContent = React.forwardRef< |
|||
React.ElementRef<typeof DropdownMenuPrimitive.Content>, |
|||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> |
|||
>(({ className, sideOffset = 4, ...props }, ref) => ( |
|||
<DropdownMenuPrimitive.Portal> |
|||
<DropdownMenuPrimitive.Content |
|||
ref={ref} |
|||
sideOffset={sideOffset} |
|||
className={cn( |
|||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md", |
|||
"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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
</DropdownMenuPrimitive.Portal> |
|||
)) |
|||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName |
|||
|
|||
const DropdownMenuItem = React.forwardRef< |
|||
React.ElementRef<typeof DropdownMenuPrimitive.Item>, |
|||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { |
|||
inset?: boolean |
|||
} |
|||
>(({ className, inset, ...props }, ref) => ( |
|||
<DropdownMenuPrimitive.Item |
|||
ref={ref} |
|||
className={cn( |
|||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", |
|||
inset && "pl-8", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName |
|||
|
|||
const DropdownMenuCheckboxItem = React.forwardRef< |
|||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, |
|||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> |
|||
>(({ className, children, checked, ...props }, ref) => ( |
|||
<DropdownMenuPrimitive.CheckboxItem |
|||
ref={ref} |
|||
className={cn( |
|||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", |
|||
className |
|||
)} |
|||
checked={checked} |
|||
{...props} |
|||
> |
|||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> |
|||
<DropdownMenuPrimitive.ItemIndicator> |
|||
<CheckIcon className="h-4 w-4" /> |
|||
</DropdownMenuPrimitive.ItemIndicator> |
|||
</span> |
|||
{children} |
|||
</DropdownMenuPrimitive.CheckboxItem> |
|||
)) |
|||
DropdownMenuCheckboxItem.displayName = |
|||
DropdownMenuPrimitive.CheckboxItem.displayName |
|||
|
|||
const DropdownMenuRadioItem = React.forwardRef< |
|||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, |
|||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> |
|||
>(({ className, children, ...props }, ref) => ( |
|||
<DropdownMenuPrimitive.RadioItem |
|||
ref={ref} |
|||
className={cn( |
|||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", |
|||
className |
|||
)} |
|||
{...props} |
|||
> |
|||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> |
|||
<DropdownMenuPrimitive.ItemIndicator> |
|||
<DotFilledIcon className="h-2 w-2 fill-current" /> |
|||
</DropdownMenuPrimitive.ItemIndicator> |
|||
</span> |
|||
{children} |
|||
</DropdownMenuPrimitive.RadioItem> |
|||
)) |
|||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName |
|||
|
|||
const DropdownMenuLabel = React.forwardRef< |
|||
React.ElementRef<typeof DropdownMenuPrimitive.Label>, |
|||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { |
|||
inset?: boolean |
|||
} |
|||
>(({ className, inset, ...props }, ref) => ( |
|||
<DropdownMenuPrimitive.Label |
|||
ref={ref} |
|||
className={cn( |
|||
"px-2 py-1.5 text-sm font-semibold", |
|||
inset && "pl-8", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName |
|||
|
|||
const DropdownMenuSeparator = React.forwardRef< |
|||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>, |
|||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> |
|||
>(({ className, ...props }, ref) => ( |
|||
<DropdownMenuPrimitive.Separator |
|||
ref={ref} |
|||
className={cn("-mx-1 my-1 h-px bg-muted", className)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName |
|||
|
|||
const DropdownMenuShortcut = ({ |
|||
className, |
|||
...props |
|||
}: React.HTMLAttributes<HTMLSpanElement>) => { |
|||
return ( |
|||
<span |
|||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)} |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut" |
|||
|
|||
export { |
|||
DropdownMenu, |
|||
DropdownMenuTrigger, |
|||
DropdownMenuContent, |
|||
DropdownMenuItem, |
|||
DropdownMenuCheckboxItem, |
|||
DropdownMenuRadioItem, |
|||
DropdownMenuLabel, |
|||
DropdownMenuSeparator, |
|||
DropdownMenuShortcut, |
|||
DropdownMenuGroup, |
|||
DropdownMenuPortal, |
|||
DropdownMenuSub, |
|||
DropdownMenuSubContent, |
|||
DropdownMenuSubTrigger, |
|||
DropdownMenuRadioGroup, |
|||
} |
|||
@ -0,0 +1,178 @@ |
|||
"use client" |
|||
|
|||
import * as React from "react" |
|||
import * as LabelPrimitive from "@radix-ui/react-label" |
|||
import { Slot } from "@radix-ui/react-slot" |
|||
import { |
|||
Controller, |
|||
ControllerProps, |
|||
FieldPath, |
|||
FieldValues, |
|||
FormProvider, |
|||
useFormContext, |
|||
} from "react-hook-form" |
|||
|
|||
import { cn } from "@/lib/utils" |
|||
import { Label } from "@/components/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, formState } = useFormContext() |
|||
|
|||
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 |
|||
) |
|||
|
|||
const FormItem = React.forwardRef< |
|||
HTMLDivElement, |
|||
React.HTMLAttributes<HTMLDivElement> |
|||
>(({ className, ...props }, ref) => { |
|||
const id = React.useId() |
|||
|
|||
return ( |
|||
<FormItemContext.Provider value={{ id }}> |
|||
<div ref={ref} className={cn("space-y-2", className)} {...props} /> |
|||
</FormItemContext.Provider> |
|||
) |
|||
}) |
|||
FormItem.displayName = "FormItem" |
|||
|
|||
const FormLabel = React.forwardRef< |
|||
React.ElementRef<typeof LabelPrimitive.Root>, |
|||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> |
|||
>(({ className, ...props }, ref) => { |
|||
const { error, formItemId } = useFormField() |
|||
|
|||
return ( |
|||
<Label |
|||
ref={ref} |
|||
className={cn(error && "text-destructive", className)} |
|||
htmlFor={formItemId} |
|||
{...props} |
|||
/> |
|||
) |
|||
}) |
|||
FormLabel.displayName = "FormLabel" |
|||
|
|||
const FormControl = React.forwardRef< |
|||
React.ElementRef<typeof Slot>, |
|||
React.ComponentPropsWithoutRef<typeof Slot> |
|||
>(({ ...props }, ref) => { |
|||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField() |
|||
|
|||
return ( |
|||
<Slot |
|||
ref={ref} |
|||
id={formItemId} |
|||
aria-describedby={ |
|||
!error |
|||
? `${formDescriptionId}` |
|||
: `${formDescriptionId} ${formMessageId}` |
|||
} |
|||
aria-invalid={!!error} |
|||
{...props} |
|||
/> |
|||
) |
|||
}) |
|||
FormControl.displayName = "FormControl" |
|||
|
|||
const FormDescription = React.forwardRef< |
|||
HTMLParagraphElement, |
|||
React.HTMLAttributes<HTMLParagraphElement> |
|||
>(({ className, ...props }, ref) => { |
|||
const { formDescriptionId } = useFormField() |
|||
|
|||
return ( |
|||
<p |
|||
ref={ref} |
|||
id={formDescriptionId} |
|||
className={cn("text-[0.8rem] text-muted-foreground", className)} |
|||
{...props} |
|||
/> |
|||
) |
|||
}) |
|||
FormDescription.displayName = "FormDescription" |
|||
|
|||
const FormMessage = React.forwardRef< |
|||
HTMLParagraphElement, |
|||
React.HTMLAttributes<HTMLParagraphElement> |
|||
>(({ className, children, ...props }, ref) => { |
|||
const { error, formMessageId } = useFormField() |
|||
const body = error ? String(error?.message) : children |
|||
|
|||
if (!body) { |
|||
return null |
|||
} |
|||
|
|||
return ( |
|||
<p |
|||
ref={ref} |
|||
id={formMessageId} |
|||
className={cn("text-[0.8rem] font-medium text-destructive", className)} |
|||
{...props} |
|||
> |
|||
{body} |
|||
</p> |
|||
) |
|||
}) |
|||
FormMessage.displayName = "FormMessage" |
|||
|
|||
export { |
|||
useFormField, |
|||
Form, |
|||
FormItem, |
|||
FormLabel, |
|||
FormControl, |
|||
FormDescription, |
|||
FormMessage, |
|||
FormField, |
|||
} |
|||
@ -0,0 +1,25 @@ |
|||
import * as React from "react" |
|||
|
|||
import { cn } from "@/lib/utils" |
|||
|
|||
export interface InputProps |
|||
extends React.InputHTMLAttributes<HTMLInputElement> {} |
|||
|
|||
const Input = React.forwardRef<HTMLInputElement, InputProps>( |
|||
({ className, type, ...props }, ref) => { |
|||
return ( |
|||
<input |
|||
type={type} |
|||
className={cn( |
|||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", |
|||
className |
|||
)} |
|||
ref={ref} |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
) |
|||
Input.displayName = "Input" |
|||
|
|||
export { Input } |
|||
@ -0,0 +1,26 @@ |
|||
"use client" |
|||
|
|||
import * as React from "react" |
|||
import * as LabelPrimitive from "@radix-ui/react-label" |
|||
import { cva, type VariantProps } from "class-variance-authority" |
|||
|
|||
import { cn } from "@/lib/utils" |
|||
|
|||
const labelVariants = cva( |
|||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" |
|||
) |
|||
|
|||
const Label = React.forwardRef< |
|||
React.ElementRef<typeof LabelPrimitive.Root>, |
|||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & |
|||
VariantProps<typeof labelVariants> |
|||
>(({ className, ...props }, ref) => ( |
|||
<LabelPrimitive.Root |
|||
ref={ref} |
|||
className={cn(labelVariants(), className)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
Label.displayName = LabelPrimitive.Root.displayName |
|||
|
|||
export { Label } |
|||
@ -0,0 +1,127 @@ |
|||
import * as React from "react" |
|||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu" |
|||
import { cva } from "class-variance-authority" |
|||
import { cn } from "@/lib/utils" |
|||
import { ChevronDownIcon } from "@radix-ui/react-icons" |
|||
|
|||
const NavigationMenu = React.forwardRef< |
|||
React.ElementRef<typeof NavigationMenuPrimitive.Root>, |
|||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root> |
|||
>(({ className, children, ...props }, ref) => ( |
|||
<NavigationMenuPrimitive.Root |
|||
ref={ref} |
|||
className={cn( |
|||
"relative z-10 flex max-w-max flex-1 items-center justify-center", |
|||
className |
|||
)} |
|||
{...props} |
|||
> |
|||
{children} |
|||
<NavigationMenuViewport /> |
|||
</NavigationMenuPrimitive.Root> |
|||
)) |
|||
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName |
|||
|
|||
const NavigationMenuList = React.forwardRef< |
|||
React.ElementRef<typeof NavigationMenuPrimitive.List>, |
|||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List> |
|||
>(({ className, ...props }, ref) => ( |
|||
<NavigationMenuPrimitive.List |
|||
ref={ref} |
|||
className={cn( |
|||
"group flex flex-1 list-none items-center justify-center space-x-1", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName |
|||
|
|||
const NavigationMenuItem = NavigationMenuPrimitive.Item |
|||
|
|||
const navigationMenuTriggerStyle = cva( |
|||
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50" |
|||
) |
|||
|
|||
const NavigationMenuTrigger = React.forwardRef< |
|||
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>, |
|||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger> |
|||
>(({ className, children, ...props }, ref) => ( |
|||
<NavigationMenuPrimitive.Trigger |
|||
ref={ref} |
|||
className={cn(navigationMenuTriggerStyle(), "group", className)} |
|||
{...props} |
|||
> |
|||
{children}{" "} |
|||
<ChevronDownIcon |
|||
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180" |
|||
aria-hidden="true" |
|||
/> |
|||
</NavigationMenuPrimitive.Trigger> |
|||
)) |
|||
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName |
|||
|
|||
const NavigationMenuContent = React.forwardRef< |
|||
React.ElementRef<typeof NavigationMenuPrimitive.Content>, |
|||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content> |
|||
>(({ className, ...props }, ref) => ( |
|||
<NavigationMenuPrimitive.Content |
|||
ref={ref} |
|||
className={cn( |
|||
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName |
|||
|
|||
const NavigationMenuLink = NavigationMenuPrimitive.Link |
|||
|
|||
const NavigationMenuViewport = React.forwardRef< |
|||
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>, |
|||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport> |
|||
>(({ className, ...props }, ref) => ( |
|||
<div className={cn("absolute left-0 top-full flex justify-center")}> |
|||
<NavigationMenuPrimitive.Viewport |
|||
className={cn( |
|||
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]", |
|||
className |
|||
)} |
|||
ref={ref} |
|||
{...props} |
|||
/> |
|||
</div> |
|||
)) |
|||
NavigationMenuViewport.displayName = |
|||
NavigationMenuPrimitive.Viewport.displayName |
|||
|
|||
const NavigationMenuIndicator = React.forwardRef< |
|||
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>, |
|||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator> |
|||
>(({ className, ...props }, ref) => ( |
|||
<NavigationMenuPrimitive.Indicator |
|||
ref={ref} |
|||
className={cn( |
|||
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in", |
|||
className |
|||
)} |
|||
{...props} |
|||
> |
|||
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" /> |
|||
</NavigationMenuPrimitive.Indicator> |
|||
)) |
|||
NavigationMenuIndicator.displayName = |
|||
NavigationMenuPrimitive.Indicator.displayName |
|||
|
|||
export { |
|||
navigationMenuTriggerStyle, |
|||
NavigationMenu, |
|||
NavigationMenuList, |
|||
NavigationMenuItem, |
|||
NavigationMenuContent, |
|||
NavigationMenuTrigger, |
|||
NavigationMenuLink, |
|||
NavigationMenuIndicator, |
|||
NavigationMenuViewport, |
|||
} |
|||
@ -0,0 +1,48 @@ |
|||
"use client" |
|||
|
|||
import * as React from "react" |
|||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" |
|||
|
|||
import { cn } from "@/lib/utils" |
|||
|
|||
const ScrollArea = React.forwardRef< |
|||
React.ElementRef<typeof ScrollAreaPrimitive.Root>, |
|||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> |
|||
>(({ className, children, ...props }, ref) => ( |
|||
<ScrollAreaPrimitive.Root |
|||
ref={ref} |
|||
className={cn("relative overflow-hidden", className)} |
|||
{...props} |
|||
> |
|||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]"> |
|||
{children} |
|||
</ScrollAreaPrimitive.Viewport> |
|||
<ScrollBar /> |
|||
<ScrollAreaPrimitive.Corner /> |
|||
</ScrollAreaPrimitive.Root> |
|||
)) |
|||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName |
|||
|
|||
const ScrollBar = React.forwardRef< |
|||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>, |
|||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> |
|||
>(({ className, orientation = "vertical", ...props }, ref) => ( |
|||
<ScrollAreaPrimitive.ScrollAreaScrollbar |
|||
ref={ref} |
|||
orientation={orientation} |
|||
className={cn( |
|||
"flex touch-none select-none transition-colors", |
|||
orientation === "vertical" && |
|||
"h-full w-2.5 border-l border-l-transparent p-[1px]", |
|||
orientation === "horizontal" && |
|||
"h-2.5 flex-col border-t border-t-transparent p-[1px]", |
|||
className |
|||
)} |
|||
{...props} |
|||
> |
|||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" /> |
|||
</ScrollAreaPrimitive.ScrollAreaScrollbar> |
|||
)) |
|||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName |
|||
|
|||
export { ScrollArea, ScrollBar } |
|||
@ -0,0 +1,164 @@ |
|||
"use client" |
|||
|
|||
import * as React from "react" |
|||
import { |
|||
CaretSortIcon, |
|||
CheckIcon, |
|||
ChevronDownIcon, |
|||
ChevronUpIcon, |
|||
} from "@radix-ui/react-icons" |
|||
import * as SelectPrimitive from "@radix-ui/react-select" |
|||
|
|||
import { cn } from "@/lib/utils" |
|||
|
|||
const Select = SelectPrimitive.Root |
|||
|
|||
const SelectGroup = SelectPrimitive.Group |
|||
|
|||
const SelectValue = SelectPrimitive.Value |
|||
|
|||
const SelectTrigger = React.forwardRef< |
|||
React.ElementRef<typeof SelectPrimitive.Trigger>, |
|||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> |
|||
>(({ className, children, ...props }, ref) => ( |
|||
<SelectPrimitive.Trigger |
|||
ref={ref} |
|||
className={cn( |
|||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", |
|||
className |
|||
)} |
|||
{...props} |
|||
> |
|||
{children} |
|||
<SelectPrimitive.Icon asChild> |
|||
<CaretSortIcon className="h-4 w-4 opacity-50" /> |
|||
</SelectPrimitive.Icon> |
|||
</SelectPrimitive.Trigger> |
|||
)) |
|||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName |
|||
|
|||
const SelectScrollUpButton = React.forwardRef< |
|||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, |
|||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> |
|||
>(({ className, ...props }, ref) => ( |
|||
<SelectPrimitive.ScrollUpButton |
|||
ref={ref} |
|||
className={cn( |
|||
"flex cursor-default items-center justify-center py-1", |
|||
className |
|||
)} |
|||
{...props} |
|||
> |
|||
<ChevronUpIcon /> |
|||
</SelectPrimitive.ScrollUpButton> |
|||
)) |
|||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName |
|||
|
|||
const SelectScrollDownButton = React.forwardRef< |
|||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, |
|||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> |
|||
>(({ className, ...props }, ref) => ( |
|||
<SelectPrimitive.ScrollDownButton |
|||
ref={ref} |
|||
className={cn( |
|||
"flex cursor-default items-center justify-center py-1", |
|||
className |
|||
)} |
|||
{...props} |
|||
> |
|||
<ChevronDownIcon /> |
|||
</SelectPrimitive.ScrollDownButton> |
|||
)) |
|||
SelectScrollDownButton.displayName = |
|||
SelectPrimitive.ScrollDownButton.displayName |
|||
|
|||
const SelectContent = React.forwardRef< |
|||
React.ElementRef<typeof SelectPrimitive.Content>, |
|||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> |
|||
>(({ className, children, position = "popper", ...props }, ref) => ( |
|||
<SelectPrimitive.Portal> |
|||
<SelectPrimitive.Content |
|||
ref={ref} |
|||
className={cn( |
|||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", |
|||
position === "popper" && |
|||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", |
|||
className |
|||
)} |
|||
position={position} |
|||
{...props} |
|||
> |
|||
<SelectScrollUpButton /> |
|||
<SelectPrimitive.Viewport |
|||
className={cn( |
|||
"p-1", |
|||
position === "popper" && |
|||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" |
|||
)} |
|||
> |
|||
{children} |
|||
</SelectPrimitive.Viewport> |
|||
<SelectScrollDownButton /> |
|||
</SelectPrimitive.Content> |
|||
</SelectPrimitive.Portal> |
|||
)) |
|||
SelectContent.displayName = SelectPrimitive.Content.displayName |
|||
|
|||
const SelectLabel = React.forwardRef< |
|||
React.ElementRef<typeof SelectPrimitive.Label>, |
|||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> |
|||
>(({ className, ...props }, ref) => ( |
|||
<SelectPrimitive.Label |
|||
ref={ref} |
|||
className={cn("px-2 py-1.5 text-sm font-semibold", className)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
SelectLabel.displayName = SelectPrimitive.Label.displayName |
|||
|
|||
const SelectItem = React.forwardRef< |
|||
React.ElementRef<typeof SelectPrimitive.Item>, |
|||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> |
|||
>(({ className, children, ...props }, ref) => ( |
|||
<SelectPrimitive.Item |
|||
ref={ref} |
|||
className={cn( |
|||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", |
|||
className |
|||
)} |
|||
{...props} |
|||
> |
|||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center"> |
|||
<SelectPrimitive.ItemIndicator> |
|||
<CheckIcon className="h-4 w-4" /> |
|||
</SelectPrimitive.ItemIndicator> |
|||
</span> |
|||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> |
|||
</SelectPrimitive.Item> |
|||
)) |
|||
SelectItem.displayName = SelectPrimitive.Item.displayName |
|||
|
|||
const SelectSeparator = React.forwardRef< |
|||
React.ElementRef<typeof SelectPrimitive.Separator>, |
|||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> |
|||
>(({ className, ...props }, ref) => ( |
|||
<SelectPrimitive.Separator |
|||
ref={ref} |
|||
className={cn("-mx-1 my-1 h-px bg-muted", className)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName |
|||
|
|||
export { |
|||
Select, |
|||
SelectGroup, |
|||
SelectValue, |
|||
SelectTrigger, |
|||
SelectContent, |
|||
SelectLabel, |
|||
SelectItem, |
|||
SelectSeparator, |
|||
SelectScrollUpButton, |
|||
SelectScrollDownButton, |
|||
} |
|||
@ -0,0 +1,139 @@ |
|||
"use client" |
|||
|
|||
import * as React from "react" |
|||
import * as SheetPrimitive from "@radix-ui/react-dialog" |
|||
import { cva, type VariantProps } from "class-variance-authority" |
|||
import { cn } from "@/lib/utils" |
|||
import { Cross2Icon } from "@radix-ui/react-icons" |
|||
|
|||
const Sheet = SheetPrimitive.Root |
|||
|
|||
const SheetTrigger = SheetPrimitive.Trigger |
|||
|
|||
const SheetClose = SheetPrimitive.Close |
|||
|
|||
const SheetPortal = SheetPrimitive.Portal |
|||
|
|||
const SheetOverlay = React.forwardRef< |
|||
React.ElementRef<typeof SheetPrimitive.Overlay>, |
|||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay> |
|||
>(({ className, ...props }, ref) => ( |
|||
<SheetPrimitive.Overlay |
|||
className={cn( |
|||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", |
|||
className |
|||
)} |
|||
{...props} |
|||
ref={ref} |
|||
/> |
|||
)) |
|||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName |
|||
|
|||
const sheetVariants = cva( |
|||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out", |
|||
{ |
|||
variants: { |
|||
side: { |
|||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", |
|||
bottom: |
|||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", |
|||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", |
|||
right: |
|||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", |
|||
}, |
|||
}, |
|||
defaultVariants: { |
|||
side: "right", |
|||
}, |
|||
} |
|||
) |
|||
|
|||
interface SheetContentProps |
|||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, |
|||
VariantProps<typeof sheetVariants> {} |
|||
|
|||
const SheetContent = React.forwardRef< |
|||
React.ElementRef<typeof SheetPrimitive.Content>, |
|||
SheetContentProps |
|||
>(({ side = "right", className, children, ...props }, ref) => ( |
|||
<SheetPortal> |
|||
<SheetOverlay /> |
|||
<SheetPrimitive.Content |
|||
ref={ref} |
|||
className={cn(sheetVariants({ side }), className)} |
|||
{...props} |
|||
> |
|||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> |
|||
<Cross2Icon className="h-4 w-4" /> |
|||
<span className="sr-only">Close</span> |
|||
</SheetPrimitive.Close> |
|||
{children} |
|||
</SheetPrimitive.Content> |
|||
</SheetPortal> |
|||
)) |
|||
SheetContent.displayName = SheetPrimitive.Content.displayName |
|||
|
|||
const SheetHeader = ({ |
|||
className, |
|||
...props |
|||
}: React.HTMLAttributes<HTMLDivElement>) => ( |
|||
<div |
|||
className={cn( |
|||
"flex flex-col space-y-2 text-center sm:text-left", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
) |
|||
SheetHeader.displayName = "SheetHeader" |
|||
|
|||
const SheetFooter = ({ |
|||
className, |
|||
...props |
|||
}: React.HTMLAttributes<HTMLDivElement>) => ( |
|||
<div |
|||
className={cn( |
|||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
) |
|||
SheetFooter.displayName = "SheetFooter" |
|||
|
|||
const SheetTitle = React.forwardRef< |
|||
React.ElementRef<typeof SheetPrimitive.Title>, |
|||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title> |
|||
>(({ className, ...props }, ref) => ( |
|||
<SheetPrimitive.Title |
|||
ref={ref} |
|||
className={cn("text-lg font-semibold text-foreground", className)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
SheetTitle.displayName = SheetPrimitive.Title.displayName |
|||
|
|||
const SheetDescription = React.forwardRef< |
|||
React.ElementRef<typeof SheetPrimitive.Description>, |
|||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description> |
|||
>(({ className, ...props }, ref) => ( |
|||
<SheetPrimitive.Description |
|||
ref={ref} |
|||
className={cn("text-sm text-muted-foreground", className)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
SheetDescription.displayName = SheetPrimitive.Description.displayName |
|||
|
|||
export { |
|||
Sheet, |
|||
SheetPortal, |
|||
SheetOverlay, |
|||
SheetTrigger, |
|||
SheetClose, |
|||
SheetContent, |
|||
SheetHeader, |
|||
SheetFooter, |
|||
SheetTitle, |
|||
SheetDescription, |
|||
} |
|||
@ -0,0 +1,35 @@ |
|||
import { cn } from "@/lib/utils" |
|||
import { Loader2 } from 'lucide-react' |
|||
|
|||
interface SpinnerProps extends React.HTMLAttributes<HTMLDivElement> { |
|||
size?: 'sm' | 'md' | 'lg' |
|||
color?: 'primary' | 'secondary' | 'muted' | 'white' |
|||
} |
|||
|
|||
export function Spinner({ size = 'md', color = 'primary', className, ...props }: SpinnerProps) { |
|||
return ( |
|||
<div |
|||
role="status" |
|||
className={cn( |
|||
"animate-spin", |
|||
{ |
|||
'h-4 w-4': size === 'sm', |
|||
'h-6 w-6': size === 'md', |
|||
'h-8 w-8': size === 'lg', |
|||
}, |
|||
{ |
|||
'text-primary': color === 'primary', |
|||
'text-secondary-foreground': color === 'secondary', |
|||
'text-muted-foreground': color === 'muted', |
|||
'text-white': color === 'white', |
|||
}, |
|||
className |
|||
)} |
|||
{...props} |
|||
> |
|||
<Loader2 className="h-full w-full" /> |
|||
<span className="sr-only">Loading...</span> |
|||
</div> |
|||
) |
|||
} |
|||
|
|||
@ -0,0 +1,29 @@ |
|||
"use client" |
|||
|
|||
import * as React from "react" |
|||
import * as SwitchPrimitives from "@radix-ui/react-switch" |
|||
|
|||
import { cn } from "@/lib/utils" |
|||
|
|||
const Switch = React.forwardRef< |
|||
React.ElementRef<typeof SwitchPrimitives.Root>, |
|||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> |
|||
>(({ className, ...props }, ref) => ( |
|||
<SwitchPrimitives.Root |
|||
className={cn( |
|||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", |
|||
className |
|||
)} |
|||
{...props} |
|||
ref={ref} |
|||
> |
|||
<SwitchPrimitives.Thumb |
|||
className={cn( |
|||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0" |
|||
)} |
|||
/> |
|||
</SwitchPrimitives.Root> |
|||
)) |
|||
Switch.displayName = SwitchPrimitives.Root.displayName |
|||
|
|||
export { Switch } |
|||
@ -0,0 +1,55 @@ |
|||
"use client" |
|||
|
|||
import * as React from "react" |
|||
import * as TabsPrimitive from "@radix-ui/react-tabs" |
|||
|
|||
import { cn } from "@/lib/utils" |
|||
|
|||
const Tabs = TabsPrimitive.Root |
|||
|
|||
const TabsList = React.forwardRef< |
|||
React.ElementRef<typeof TabsPrimitive.List>, |
|||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> |
|||
>(({ className, ...props }, ref) => ( |
|||
<TabsPrimitive.List |
|||
ref={ref} |
|||
className={cn( |
|||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
TabsList.displayName = TabsPrimitive.List.displayName |
|||
|
|||
const TabsTrigger = React.forwardRef< |
|||
React.ElementRef<typeof TabsPrimitive.Trigger>, |
|||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> |
|||
>(({ className, ...props }, ref) => ( |
|||
<TabsPrimitive.Trigger |
|||
ref={ref} |
|||
className={cn( |
|||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName |
|||
|
|||
const TabsContent = React.forwardRef< |
|||
React.ElementRef<typeof TabsPrimitive.Content>, |
|||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> |
|||
>(({ className, ...props }, ref) => ( |
|||
<TabsPrimitive.Content |
|||
ref={ref} |
|||
className={cn( |
|||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
TabsContent.displayName = TabsPrimitive.Content.displayName |
|||
|
|||
export { Tabs, TabsList, TabsTrigger, TabsContent } |
|||
@ -0,0 +1,24 @@ |
|||
import * as React from "react" |
|||
|
|||
import { cn } from "@/lib/utils" |
|||
|
|||
export interface TextareaProps |
|||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {} |
|||
|
|||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( |
|||
({ className, ...props }, ref) => { |
|||
return ( |
|||
<textarea |
|||
className={cn( |
|||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", |
|||
className |
|||
)} |
|||
ref={ref} |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
) |
|||
Textarea.displayName = "Textarea" |
|||
|
|||
export { Textarea } |
|||
@ -0,0 +1,128 @@ |
|||
"use client" |
|||
|
|||
import * as React from "react" |
|||
import * as ToastPrimitives from "@radix-ui/react-toast" |
|||
import { cva, type VariantProps } from "class-variance-authority" |
|||
import { cn } from "@/lib/utils" |
|||
import { Cross2Icon } from "@radix-ui/react-icons" |
|||
|
|||
const ToastProvider = ToastPrimitives.Provider |
|||
|
|||
const ToastViewport = React.forwardRef< |
|||
React.ElementRef<typeof ToastPrimitives.Viewport>, |
|||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> |
|||
>(({ className, ...props }, ref) => ( |
|||
<ToastPrimitives.Viewport |
|||
ref={ref} |
|||
className={cn( |
|||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName |
|||
|
|||
const toastVariants = cva( |
|||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", |
|||
{ |
|||
variants: { |
|||
variant: { |
|||
default: "border bg-background text-foreground", |
|||
destructive: |
|||
"destructive group border-destructive bg-destructive text-destructive-foreground", |
|||
}, |
|||
}, |
|||
defaultVariants: { |
|||
variant: "default", |
|||
}, |
|||
} |
|||
) |
|||
|
|||
const Toast = React.forwardRef< |
|||
React.ElementRef<typeof ToastPrimitives.Root>, |
|||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & |
|||
VariantProps<typeof toastVariants> |
|||
>(({ className, variant, ...props }, ref) => { |
|||
return ( |
|||
<ToastPrimitives.Root |
|||
ref={ref} |
|||
className={cn(toastVariants({ variant }), className)} |
|||
{...props} |
|||
/> |
|||
) |
|||
}) |
|||
Toast.displayName = ToastPrimitives.Root.displayName |
|||
|
|||
const ToastAction = React.forwardRef< |
|||
React.ElementRef<typeof ToastPrimitives.Action>, |
|||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> |
|||
>(({ className, ...props }, ref) => ( |
|||
<ToastPrimitives.Action |
|||
ref={ref} |
|||
className={cn( |
|||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
ToastAction.displayName = ToastPrimitives.Action.displayName |
|||
|
|||
const ToastClose = React.forwardRef< |
|||
React.ElementRef<typeof ToastPrimitives.Close>, |
|||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> |
|||
>(({ className, ...props }, ref) => ( |
|||
<ToastPrimitives.Close |
|||
ref={ref} |
|||
className={cn( |
|||
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", |
|||
className |
|||
)} |
|||
toast-close="" |
|||
{...props} |
|||
> |
|||
<Cross2Icon className="h-4 w-4" /> |
|||
</ToastPrimitives.Close> |
|||
)) |
|||
ToastClose.displayName = ToastPrimitives.Close.displayName |
|||
|
|||
const ToastTitle = React.forwardRef< |
|||
React.ElementRef<typeof ToastPrimitives.Title>, |
|||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> |
|||
>(({ className, ...props }, ref) => ( |
|||
<ToastPrimitives.Title |
|||
ref={ref} |
|||
className={cn("text-sm font-semibold [&+div]:text-xs", className)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
ToastTitle.displayName = ToastPrimitives.Title.displayName |
|||
|
|||
const ToastDescription = React.forwardRef< |
|||
React.ElementRef<typeof ToastPrimitives.Description>, |
|||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> |
|||
>(({ className, ...props }, ref) => ( |
|||
<ToastPrimitives.Description |
|||
ref={ref} |
|||
className={cn("text-sm opacity-90", className)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
ToastDescription.displayName = ToastPrimitives.Description.displayName |
|||
|
|||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast> |
|||
|
|||
type ToastActionElement = React.ReactElement<typeof ToastAction> |
|||
|
|||
export { |
|||
type ToastProps, |
|||
type ToastActionElement, |
|||
ToastProvider, |
|||
ToastViewport, |
|||
Toast, |
|||
ToastTitle, |
|||
ToastDescription, |
|||
ToastClose, |
|||
ToastAction, |
|||
} |
|||
@ -0,0 +1,35 @@ |
|||
"use client" |
|||
|
|||
import { useToast } from "@/hooks/use-toast" |
|||
import { |
|||
Toast, |
|||
ToastClose, |
|||
ToastDescription, |
|||
ToastProvider, |
|||
ToastTitle, |
|||
ToastViewport, |
|||
} from "@/components/ui/toast" |
|||
|
|||
export function Toaster() { |
|||
const { toasts } = useToast() |
|||
|
|||
return ( |
|||
<ToastProvider> |
|||
{toasts.map(function ({ id, title, description, action, ...props }) { |
|||
return ( |
|||
<Toast key={id} {...props}> |
|||
<div className="grid gap-1"> |
|||
{title && <ToastTitle>{title}</ToastTitle>} |
|||
{description && ( |
|||
<ToastDescription>{description}</ToastDescription> |
|||
)} |
|||
</div> |
|||
{action} |
|||
<ToastClose /> |
|||
</Toast> |
|||
) |
|||
})} |
|||
<ToastViewport /> |
|||
</ToastProvider> |
|||
) |
|||
} |
|||
@ -0,0 +1,27 @@ |
|||
export const ApiEndpoints = { |
|||
auth: { |
|||
login: () => '/auth/login', |
|||
register: () => '/auth/register', |
|||
signInWithGoogle: () => '/auth/google-login', |
|||
updateProfile: () => '/auth/update-profile', |
|||
changePassword: () => '/auth/change-password', |
|||
|
|||
whoAmI: () => '/auth/who-am-i', |
|||
|
|||
requestPasswordReset: () => '/auth/request-password-reset', |
|||
resetPassword: () => '/auth/reset-password', |
|||
|
|||
generateApiKey: () => '/auth/api-keys', |
|||
listApiKeys: () => '/auth/api-keys', |
|||
revokeApiKey: (id: string) => `/auth/api-keys/${id}/revoke`, |
|||
renameApiKey: (id: string) => `/auth/api-keys/${id}/rename`, |
|||
deleteApiKey: (id: string) => `/auth/api-keys/${id}`, |
|||
}, |
|||
gateway: { |
|||
listDevices: () => '/gateway/devices', |
|||
sendSMS: (id: string) => `/gateway/devices/${id}/send-sms`, |
|||
getReceivedSMS: (id: string) => `/gateway/devices/${id}/get-received-sms`, |
|||
|
|||
getStats: () => '/gateway/stats', |
|||
}, |
|||
} |
|||
@ -0,0 +1,5 @@ |
|||
export const ExternalLinks = { |
|||
patreon: 'https://patreon.com/vernu', |
|||
github: 'https://github.com/vernu', |
|||
discord: 'https://discord.gg/d7vyfBpWbQ', |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
export const Routes = { |
|||
landingPage: '/', |
|||
login: '/login', |
|||
register: '/register', |
|||
logout: '/logout', |
|||
resetPassword: '/reset-password', |
|||
|
|||
dashboard: '/dashboard', |
|||
|
|||
downloadAndroidApp: 'https://dl.textbee.dev', |
|||
privacyPolicy: '/privacy-policy', |
|||
} |
|||
@ -0,0 +1,194 @@ |
|||
"use client" |
|||
|
|||
// Inspired by react-hot-toast library
|
|||
import * as React from "react" |
|||
|
|||
import type { |
|||
ToastActionElement, |
|||
ToastProps, |
|||
} from "@/components/ui/toast" |
|||
|
|||
const TOAST_LIMIT = 1 |
|||
const TOAST_REMOVE_DELAY = 1000000 |
|||
|
|||
type ToasterToast = ToastProps & { |
|||
id: string |
|||
title?: React.ReactNode |
|||
description?: React.ReactNode |
|||
action?: ToastActionElement |
|||
} |
|||
|
|||
const actionTypes = { |
|||
ADD_TOAST: "ADD_TOAST", |
|||
UPDATE_TOAST: "UPDATE_TOAST", |
|||
DISMISS_TOAST: "DISMISS_TOAST", |
|||
REMOVE_TOAST: "REMOVE_TOAST", |
|||
} as const |
|||
|
|||
let count = 0 |
|||
|
|||
function genId() { |
|||
count = (count + 1) % Number.MAX_SAFE_INTEGER |
|||
return count.toString() |
|||
} |
|||
|
|||
type ActionType = typeof actionTypes |
|||
|
|||
type Action = |
|||
| { |
|||
type: ActionType["ADD_TOAST"] |
|||
toast: ToasterToast |
|||
} |
|||
| { |
|||
type: ActionType["UPDATE_TOAST"] |
|||
toast: Partial<ToasterToast> |
|||
} |
|||
| { |
|||
type: ActionType["DISMISS_TOAST"] |
|||
toastId?: ToasterToast["id"] |
|||
} |
|||
| { |
|||
type: ActionType["REMOVE_TOAST"] |
|||
toastId?: ToasterToast["id"] |
|||
} |
|||
|
|||
interface State { |
|||
toasts: ToasterToast[] |
|||
} |
|||
|
|||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>() |
|||
|
|||
const addToRemoveQueue = (toastId: string) => { |
|||
if (toastTimeouts.has(toastId)) { |
|||
return |
|||
} |
|||
|
|||
const timeout = setTimeout(() => { |
|||
toastTimeouts.delete(toastId) |
|||
dispatch({ |
|||
type: "REMOVE_TOAST", |
|||
toastId: toastId, |
|||
}) |
|||
}, TOAST_REMOVE_DELAY) |
|||
|
|||
toastTimeouts.set(toastId, timeout) |
|||
} |
|||
|
|||
export const reducer = (state: State, action: Action): State => { |
|||
switch (action.type) { |
|||
case "ADD_TOAST": |
|||
return { |
|||
...state, |
|||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), |
|||
} |
|||
|
|||
case "UPDATE_TOAST": |
|||
return { |
|||
...state, |
|||
toasts: state.toasts.map((t) => |
|||
t.id === action.toast.id ? { ...t, ...action.toast } : t |
|||
), |
|||
} |
|||
|
|||
case "DISMISS_TOAST": { |
|||
const { toastId } = action |
|||
|
|||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
|||
// but I'll keep it here for simplicity
|
|||
if (toastId) { |
|||
addToRemoveQueue(toastId) |
|||
} else { |
|||
state.toasts.forEach((toast) => { |
|||
addToRemoveQueue(toast.id) |
|||
}) |
|||
} |
|||
|
|||
return { |
|||
...state, |
|||
toasts: state.toasts.map((t) => |
|||
t.id === toastId || toastId === undefined |
|||
? { |
|||
...t, |
|||
open: false, |
|||
} |
|||
: t |
|||
), |
|||
} |
|||
} |
|||
case "REMOVE_TOAST": |
|||
if (action.toastId === undefined) { |
|||
return { |
|||
...state, |
|||
toasts: [], |
|||
} |
|||
} |
|||
return { |
|||
...state, |
|||
toasts: state.toasts.filter((t) => t.id !== action.toastId), |
|||
} |
|||
} |
|||
} |
|||
|
|||
const listeners: Array<(state: State) => void> = [] |
|||
|
|||
let memoryState: State = { toasts: [] } |
|||
|
|||
function dispatch(action: Action) { |
|||
memoryState = reducer(memoryState, action) |
|||
listeners.forEach((listener) => { |
|||
listener(memoryState) |
|||
}) |
|||
} |
|||
|
|||
type Toast = Omit<ToasterToast, "id"> |
|||
|
|||
function toast({ ...props }: Toast) { |
|||
const id = genId() |
|||
|
|||
const update = (props: ToasterToast) => |
|||
dispatch({ |
|||
type: "UPDATE_TOAST", |
|||
toast: { ...props, id }, |
|||
}) |
|||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) |
|||
|
|||
dispatch({ |
|||
type: "ADD_TOAST", |
|||
toast: { |
|||
...props, |
|||
id, |
|||
open: true, |
|||
onOpenChange: (open) => { |
|||
if (!open) dismiss() |
|||
}, |
|||
}, |
|||
}) |
|||
|
|||
return { |
|||
id: id, |
|||
dismiss, |
|||
update, |
|||
} |
|||
} |
|||
|
|||
function useToast() { |
|||
const [state, setState] = React.useState<State>(memoryState) |
|||
|
|||
React.useEffect(() => { |
|||
listeners.push(setState) |
|||
return () => { |
|||
const index = listeners.indexOf(setState) |
|||
if (index > -1) { |
|||
listeners.splice(index, 1) |
|||
} |
|||
} |
|||
}, [state]) |
|||
|
|||
return { |
|||
...state, |
|||
toast, |
|||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), |
|||
} |
|||
} |
|||
|
|||
export { useToast, toast } |
|||
@ -0,0 +1,153 @@ |
|||
import CredentialsProvider from 'next-auth/providers/credentials' |
|||
import httpBrowserClient from './httpBrowserClient' |
|||
import { DefaultSession } from 'next-auth' |
|||
import { ApiEndpoints } from '@/config/api' |
|||
import { Routes } from '@/config/routes' |
|||
|
|||
// add custom fields to the session and user interfaces
|
|||
declare module 'next-auth' { |
|||
interface Session { |
|||
user: { |
|||
avatar?: string |
|||
accessToken?: string |
|||
} & DefaultSession['user'] |
|||
} |
|||
|
|||
interface User { |
|||
avatar?: string |
|||
accessToken?: string |
|||
} |
|||
} |
|||
|
|||
export const authOptions = { |
|||
providers: [ |
|||
CredentialsProvider({ |
|||
id: 'email-password-login', |
|||
name: 'email-password-login', |
|||
credentials: { |
|||
email: { label: 'email', type: 'text' }, |
|||
password: { label: 'Password', type: 'password' }, |
|||
}, |
|||
async authorize(credentials) { |
|||
const { email, password } = credentials |
|||
try { |
|||
const res = await httpBrowserClient.post(ApiEndpoints.auth.login(), { |
|||
email, |
|||
password, |
|||
}) |
|||
|
|||
const user = res.data.data.user |
|||
const accessToken = res.data.data.accessToken |
|||
|
|||
return { |
|||
...user, |
|||
accessToken, |
|||
} |
|||
} catch (e) { |
|||
console.log(e) |
|||
|
|||
return null |
|||
} |
|||
}, |
|||
}), |
|||
CredentialsProvider({ |
|||
id: 'email-password-register', |
|||
name: 'email-password-register', |
|||
credentials: { |
|||
email: { label: 'email', type: 'text' }, |
|||
password: { label: 'Password', type: 'password' }, |
|||
name: { label: 'Name', type: 'text' }, |
|||
phone: { label: 'Phone', type: 'text' }, |
|||
}, |
|||
async authorize(credentials) { |
|||
const { email, password, name, phone } = credentials |
|||
try { |
|||
const res = await httpBrowserClient.post( |
|||
ApiEndpoints.auth.register(), |
|||
{ |
|||
email, |
|||
password, |
|||
name, |
|||
phone, |
|||
} |
|||
) |
|||
|
|||
const user = res.data.data.user |
|||
const accessToken = res.data.data.accessToken |
|||
|
|||
return { |
|||
...user, |
|||
accessToken, |
|||
} |
|||
} catch (e) { |
|||
console.log(e) |
|||
return null |
|||
} |
|||
}, |
|||
}), |
|||
CredentialsProvider({ |
|||
id: 'google-id-token-login', |
|||
name: 'google-id-token-login', |
|||
credentials: { |
|||
idToken: { label: 'idToken', type: 'text' }, |
|||
}, |
|||
async authorize(credentials) { |
|||
const { idToken } = credentials |
|||
try { |
|||
const res = await httpBrowserClient.post( |
|||
ApiEndpoints.auth.signInWithGoogle(), |
|||
{ |
|||
idToken, |
|||
} |
|||
) |
|||
|
|||
const user = res.data.data.user |
|||
const accessToken = res.data.data.accessToken |
|||
|
|||
return { |
|||
...user, |
|||
accessToken, |
|||
} |
|||
} catch (e) { |
|||
console.log(e) |
|||
|
|||
return null |
|||
} |
|||
}, |
|||
}), |
|||
], |
|||
pages: { |
|||
signIn: Routes.login, |
|||
}, |
|||
session: { |
|||
strategy: 'jwt', |
|||
}, |
|||
callbacks: { |
|||
async jwt({ token, user, trigger, session }) { |
|||
if (trigger === 'update') { |
|||
if (session.name !== token.name) { |
|||
token.name = session.name |
|||
} |
|||
if (session.phone !== token.phone) { |
|||
token.phone = session.phone |
|||
} |
|||
return token |
|||
} |
|||
|
|||
if (user) { |
|||
token.id = user._id |
|||
token.role = user.role |
|||
token.accessToken = user.accessToken |
|||
token.avatar = user.avatar |
|||
} |
|||
return token |
|||
}, |
|||
async session({ session, token }): Promise<any> { |
|||
session.user.id = token.id |
|||
session.user.role = token.role |
|||
session.user.accessToken = token.accessToken |
|||
session.user.avatar = token.avatar |
|||
return session |
|||
}, |
|||
}, |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
import axios from 'axios' |
|||
import { getSession } from 'next-auth/react' |
|||
|
|||
const httpBrowserClient = axios.create({ |
|||
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || '', |
|||
}) |
|||
|
|||
httpBrowserClient.interceptors.request.use(async (config) => { |
|||
const session: any = await getSession() |
|||
|
|||
if (session?.user?.accessToken) { |
|||
config.headers.Authorization = `Bearer ${session.user.accessToken}` |
|||
} |
|||
return config |
|||
}) |
|||
|
|||
export default httpBrowserClient |
|||
@ -1,16 +0,0 @@ |
|||
import axios from 'axios' |
|||
import { LOCAL_STORAGE_KEY } from '../shared/constants' |
|||
|
|||
const httpClient = axios.create({ |
|||
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, |
|||
}) |
|||
|
|||
httpClient.interceptors.request.use((config) => { |
|||
const token = localStorage.getItem(LOCAL_STORAGE_KEY.TOKEN) |
|||
if (token) { |
|||
config.headers.Authorization = `Bearer ${token}` |
|||
} |
|||
return config |
|||
}) |
|||
|
|||
export default httpClient |
|||
Some files were not shown because too many files changed in this diff
Write
Preview
Loading…
Cancel
Save
Reference in new issue