Browse Source

Merge pull request #27 from vernu/ui-refactor

major ui refactor
pull/28/head
Israel Abebe 1 year ago
committed by GitHub
parent
commit
6cfe7381b5
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 15
      web/.env.example
  2. 119
      web/app/(app)/(auth)/(components)/login-form.tsx
  3. 47
      web/app/(app)/(auth)/(components)/login-with-google.tsx
  4. 179
      web/app/(app)/(auth)/(components)/register-form.tsx
  5. 134
      web/app/(app)/(auth)/(components)/request-password-reset-form.tsx
  6. 199
      web/app/(app)/(auth)/(components)/reset-password-form.tsx
  7. 7
      web/app/(app)/(auth)/layout.tsx
  8. 66
      web/app/(app)/(auth)/login/page.tsx
  9. 16
      web/app/(app)/(auth)/logout/page.tsx
  10. 49
      web/app/(app)/(auth)/register/page.tsx
  11. 18
      web/app/(app)/(auth)/reset-password/page.tsx
  12. 509
      web/app/(app)/dashboard/(components)/account-settings.tsx
  13. 347
      web/app/(app)/dashboard/(components)/api-keys.tsx
  14. 37
      web/app/(app)/dashboard/(components)/community-alert.tsx
  15. 62
      web/app/(app)/dashboard/(components)/community-links.tsx
  16. 83
      web/app/(app)/dashboard/(components)/dashboard-layout.tsx
  17. 118
      web/app/(app)/dashboard/(components)/device-list.tsx
  18. 186
      web/app/(app)/dashboard/(components)/generate-api-key.tsx
  19. 71
      web/app/(app)/dashboard/(components)/get-started.tsx
  20. 75
      web/app/(app)/dashboard/(components)/main-dashboard.tsx
  21. 64
      web/app/(app)/dashboard/(components)/messaging.tsx
  22. 66
      web/app/(app)/dashboard/(components)/overview.tsx
  23. 146
      web/app/(app)/dashboard/(components)/received-sms.tsx
  24. 206
      web/app/(app)/dashboard/(components)/send-sms.tsx
  25. 9
      web/app/(app)/dashboard/layout.tsx
  26. 3
      web/app/(app)/dashboard/page.tsx
  27. 49
      web/app/(app)/layout-wrapper.tsx
  28. 19
      web/app/(app)/layout.tsx
  29. 91
      web/app/(landing-page)/(components)/code-snippet-section.tsx
  30. 35
      web/app/(landing-page)/(components)/customization-section.tsx
  31. 36
      web/app/(landing-page)/(components)/download-app-section.tsx
  32. 48
      web/app/(landing-page)/(components)/features-section.tsx
  33. 75
      web/app/(landing-page)/(components)/hero-section.tsx
  34. 57
      web/app/(landing-page)/(components)/how-it-works-section.tsx
  35. 50
      web/app/(landing-page)/(components)/landing-page-header.tsx
  36. 114
      web/app/(landing-page)/(components)/support-project-section.tsx
  37. 11
      web/app/(landing-page)/layout.tsx
  38. 23
      web/app/(landing-page)/page.tsx
  39. 82
      web/app/(landing-page)/privacy-policy/page.tsx
  40. 9
      web/app/(todo-migrate-pages-to-app-router)/v2/page.tsx
  41. 6
      web/app/api/auth/[...nextauth]/route.ts
  42. 59
      web/app/api/customer-support/route.ts
  43. 86
      web/app/api/request-account-deletion/route.ts
  44. 25
      web/app/customer-support/page.tsx
  45. 63
      web/app/layout.tsx
  46. 30
      web/components/AnimatedScrollWrapper.tsx
  47. 82
      web/components/Footer.tsx
  48. 151
      web/components/Navbar.tsx
  49. 29
      web/components/dashboard/APIKeyAndDevices.tsx
  50. 94
      web/components/dashboard/ApiKeyList.tsx
  51. 116
      web/components/dashboard/DeviceList.tsx
  52. 179
      web/components/dashboard/GenerateApiKey.tsx
  53. 170
      web/components/dashboard/ReceiveSMS.tsx
  54. 139
      web/components/dashboard/SendSMS.tsx
  55. 78
      web/components/dashboard/UserStats.tsx
  56. 32
      web/components/dashboard/UserStatsCard.tsx
  57. 90
      web/components/landing/CodeSnippetSection.tsx
  58. 54
      web/components/landing/Customization.tsx
  59. 91
      web/components/landing/DownloadAppSection.tsx
  60. 64
      web/components/landing/FeaturesSection.tsx
  61. 60
      web/components/landing/HowItWorksSection.tsx
  62. 153
      web/components/landing/IntroSection.tsx
  63. 56
      web/components/landing/SupportTheProject.tsx
  64. 19
      web/components/landing/featuresContent.ts
  65. 23
      web/components/landing/howItWorksContent.ts
  66. 30
      web/components/livechat/LiveChat.tsx
  67. 24
      web/components/meta/Meta.tsx
  68. 11
      web/components/shared/analytics.tsx
  69. 174
      web/components/shared/app-header.tsx
  70. 227
      web/components/shared/customer-support.tsx
  71. 51
      web/components/shared/footer.tsx
  72. 57
      web/components/ui/accordion.tsx
  73. 59
      web/components/ui/alert.tsx
  74. 50
      web/components/ui/avatar.tsx
  75. 36
      web/components/ui/badge.tsx
  76. 57
      web/components/ui/button.tsx
  77. 76
      web/components/ui/card.tsx
  78. 29
      web/components/ui/checkbox.tsx
  79. 122
      web/components/ui/dialog.tsx
  80. 200
      web/components/ui/dropdown-menu.tsx
  81. 178
      web/components/ui/form.tsx
  82. 25
      web/components/ui/input.tsx
  83. 26
      web/components/ui/label.tsx
  84. 127
      web/components/ui/navigation-menu.tsx
  85. 48
      web/components/ui/scroll-area.tsx
  86. 164
      web/components/ui/select.tsx
  87. 139
      web/components/ui/sheet.tsx
  88. 35
      web/components/ui/spinner.tsx
  89. 29
      web/components/ui/switch.tsx
  90. 55
      web/components/ui/tabs.tsx
  91. 24
      web/components/ui/textarea.tsx
  92. 128
      web/components/ui/toast.tsx
  93. 35
      web/components/ui/toaster.tsx
  94. 27
      web/config/api.ts
  95. 5
      web/config/external-links.ts
  96. 12
      web/config/routes.ts
  97. 194
      web/hooks/use-toast.ts
  98. 153
      web/lib/auth.ts
  99. 17
      web/lib/httpBrowserClient.ts
  100. 16
      web/lib/httpClient.ts

15
web/.env.example

@ -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=

119
web/app/(app)/(auth)/(components)/login-form.tsx

@ -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>
)
}

47
web/app/(app)/(auth)/(components)/login-with-google.tsx

@ -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'
/>
)
}

179
web/app/(app)/(auth)/(components)/register-form.tsx

@ -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>
)
}

134
web/app/(app)/(auth)/(components)/request-password-reset-form.tsx

@ -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&apos;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&apos;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>
)
}

199
web/app/(app)/(auth)/(components)/reset-password-form.tsx

@ -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>
)
}

7
web/app/(app)/(auth)/layout.tsx

@ -0,0 +1,7 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode
}) {
return <>{children}</>
}

66
web/app/(app)/(auth)/login/page.tsx

@ -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&apos;t have an account?{' '}
<Link
href={Routes.register}
className='font-medium text-blue-600 hover:underline'
>
Sign up
</Link>
</p>
</CardFooter>
</Card>
</div>
)
}

16
web/app/(app)/(auth)/logout/page.tsx

@ -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>
)
}

49
web/app/(app)/(auth)/register/page.tsx

@ -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>
)
}

18
web/app/(app)/(auth)/reset-password/page.tsx

@ -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 />
}

509
web/app/(app)/dashboard/(components)/account-settings.tsx

@ -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>
)
}

347
web/app/(app)/dashboard/(components)/api-keys.tsx

@ -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>
)
}

37
web/app/(app)/dashboard/(components)/community-alert.tsx

@ -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>
)
}

62
web/app/(app)/dashboard/(components)/community-links.tsx

@ -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>
)
}

83
web/app/(app)/dashboard/(components)/dashboard-layout.tsx

@ -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>
)
}

118
web/app/(app)/dashboard/(components)/device-list.tsx

@ -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>
)
}

186
web/app/(app)/dashboard/(components)/generate-api-key.tsx

@ -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&apos;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>
</>
)
}

71
web/app/(app)/dashboard/(components)/get-started.tsx

@ -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>
</>
)
}

75
web/app/(app)/dashboard/(components)/main-dashboard.tsx

@ -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&apos;ll be able to configure
endpoints to receive SMS notifications in real-time.
</AlertDescription>
</Alert>
</CardContent>
</Card>
</TabsContent>
<TabsContent value='messaging'>
<Messaging />
</TabsContent>
</Tabs>
)
}

64
web/app/(app)/dashboard/(components)/messaging.tsx

@ -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>
)
}

66
web/app/(app)/dashboard/(components)/overview.tsx

@ -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>
)
}

146
web/app/(app)/dashboard/(components)/received-sms.tsx

@ -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>
)
}

206
web/app/(app)/dashboard/(components)/send-sms.tsx

@ -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>
)
}

9
web/app/(app)/dashboard/layout.tsx

@ -0,0 +1,9 @@
import Dashboard from "./(components)/dashboard-layout";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return <Dashboard>{children}</Dashboard>;
}

3
web/app/(app)/dashboard/page.tsx

@ -0,0 +1,3 @@
export default function DashboardPage() {
return <div>DashboardPage</div>
}

49
web/app/(app)/layout-wrapper.tsx

@ -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>
</>
)
}

19
web/app/(app)/layout.tsx

@ -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>
</>
)
}

91
web/app/(landing-page)/(components)/code-snippet-section.tsx

@ -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>
)
}

35
web/app/(landing-page)/(components)/customization-section.tsx

@ -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&apos;s explore how we can customize TextBee to align perfectly
with your business requirements. Whether you&apos;re looking for new
features or need assistance in deploying the platform on your own
server, or need dedicated support we&apos;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>
)
}

36
web/app/(landing-page)/(components)/download-app-section.tsx

@ -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>
)
}

48
web/app/(landing-page)/(components)/features-section.tsx

@ -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>
)
}

75
web/app/(landing-page)/(components)/hero-section.tsx

@ -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>
)
}

57
web/app/(landing-page)/(components)/how-it-works-section.tsx

@ -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>
)
}

50
web/app/(landing-page)/(components)/landing-page-header.tsx

@ -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>
)
}

114
web/app/(landing-page)/(components)/support-project-section.tsx

@ -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>
</>
)
}

11
web/app/(landing-page)/layout.tsx

@ -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}
</>
)
}

23
web/app/(landing-page)/page.tsx

@ -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>
)
}

82
web/app/(landing-page)/privacy-policy/page.tsx

@ -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
(&ldquo;Platform&rdquo;). 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>
</>
)
}

9
web/app/(todo-migrate-pages-to-app-router)/v2/page.tsx

@ -1,9 +0,0 @@
import React from 'react'
export default function page() {
return (
<div>
v2
</div>
)
}

6
web/app/api/auth/[...nextauth]/route.ts

@ -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 }

59
web/app/api/customer-support/route.ts

@ -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 }
)
}
}

86
web/app/api/request-account-deletion/route.ts

@ -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 }
)
}
}

25
web/app/customer-support/page.tsx

@ -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>
)
}

63
web/app/layout.tsx

@ -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>
)

30
web/components/AnimatedScrollWrapper.tsx

@ -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

82
web/components/Footer.tsx

@ -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>
)
}

151
web/components/Navbar.tsx

@ -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>
</>
)
}

29
web/components/dashboard/APIKeyAndDevices.tsx

@ -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>
)
}

94
web/components/dashboard/ApiKeyList.tsx

@ -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

116
web/components/dashboard/DeviceList.tsx

@ -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

179
web/components/dashboard/GenerateApiKey.tsx

@ -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 &quot;Get Started&quot; 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)
}}
/>
}
</>
)}
</>
)
}

170
web/components/dashboard/ReceiveSMS.tsx

@ -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>
</>
)
}

139
web/components/dashboard/SendSMS.tsx

@ -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>
</>
)
}

78
web/components/dashboard/UserStats.tsx

@ -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

32
web/components/dashboard/UserStatsCard.tsx

@ -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>
)
}

90
web/components/landing/CodeSnippetSection.tsx

@ -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>
)
}

54
web/components/landing/Customization.tsx

@ -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>
)
}

91
web/components/landing/DownloadAppSection.tsx

@ -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>
)
}

64
web/components/landing/FeaturesSection.tsx

@ -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>
)
}

60
web/components/landing/HowItWorksSection.tsx

@ -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>
)
}

153
web/components/landing/IntroSection.tsx

@ -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',
})

56
web/components/landing/SupportTheProject.tsx

@ -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>
)
}

19
web/components/landing/featuresContent.ts

@ -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.',
},
]

23
web/components/landing/howItWorksContent.ts

@ -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',
},
]

30
web/components/livechat/LiveChat.tsx

@ -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);
})();
`,
}}
/>
</>
)
}

24
web/components/meta/Meta.tsx

@ -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>
)
}

11
web/components/analytics/Analytics.tsx → web/components/shared/analytics.tsx

@ -1,11 +1,8 @@
import Script from 'next/script'
import { useSelector } from 'react-redux'
import { selectAuthUser } from '../../store/authSlice'
const Analytics = () => {
'use client'
const authUser = useSelector(selectAuthUser)
import Script from 'next/script'
const Analytics = ({ user }) => {
return (
<>
{/* Global Site Tag (gtag.js) - Google Analytics */}
@ -37,7 +34,7 @@ const Analytics = () => {
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
clarity('set', 'userId', '${authUser?._id || ''}');
clarity('set', 'userId', '${user?.email || ''}');
})(window, document, "clarity", "script", "iacr7j4ozh");
`,
}}

174
web/components/shared/app-header.tsx

@ -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>
)
}

227
web/components/shared/customer-support.tsx

@ -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>
)
}

51
web/components/shared/footer.tsx

@ -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>
)
}

57
web/components/ui/accordion.tsx

@ -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 }

59
web/components/ui/alert.tsx

@ -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 }

50
web/components/ui/avatar.tsx

@ -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 }

36
web/components/ui/badge.tsx

@ -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 }

57
web/components/ui/button.tsx

@ -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 }

76
web/components/ui/card.tsx

@ -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 }

29
web/components/ui/checkbox.tsx

@ -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 }

122
web/components/ui/dialog.tsx

@ -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,
}

200
web/components/ui/dropdown-menu.tsx

@ -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,
}

178
web/components/ui/form.tsx

@ -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,
}

25
web/components/ui/input.tsx

@ -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 }

26
web/components/ui/label.tsx

@ -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 }

127
web/components/ui/navigation-menu.tsx

@ -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,
}

48
web/components/ui/scroll-area.tsx

@ -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 }

164
web/components/ui/select.tsx

@ -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,
}

139
web/components/ui/sheet.tsx

@ -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,
}

35
web/components/ui/spinner.tsx

@ -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>
)
}

29
web/components/ui/switch.tsx

@ -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 }

55
web/components/ui/tabs.tsx

@ -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 }

24
web/components/ui/textarea.tsx

@ -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 }

128
web/components/ui/toast.tsx

@ -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,
}

35
web/components/ui/toaster.tsx

@ -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>
)
}

27
web/config/api.ts

@ -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',
},
}

5
web/config/external-links.ts

@ -0,0 +1,5 @@
export const ExternalLinks = {
patreon: 'https://patreon.com/vernu',
github: 'https://github.com/vernu',
discord: 'https://discord.gg/d7vyfBpWbQ',
}

12
web/config/routes.ts

@ -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',
}

194
web/hooks/use-toast.ts

@ -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 }

153
web/lib/auth.ts

@ -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
},
},
}

17
web/lib/httpBrowserClient.ts

@ -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

16
web/lib/httpClient.ts

@ -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

Loading…
Cancel
Save