diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml index 5ab7ea8..d0c1dd4 100644 --- a/.github/workflows/build-and-test.yaml +++ b/.github/workflows/build-and-test.yaml @@ -62,7 +62,6 @@ jobs: run: | cd web pnpm install - pnpm run prisma:generate pnpm run build build-and-test-android: diff --git a/web/app/(app)/(auth)/(components)/login-form.tsx b/web/app/(app)/(auth)/(components)/login-form.tsx index 3bbd8b2..141e10a 100644 --- a/web/app/(app)/(auth)/(components)/login-form.tsx +++ b/web/app/(app)/(auth)/(components)/login-form.tsx @@ -88,10 +88,10 @@ export default function LoginForm() { Password - diff --git a/web/app/(app)/(auth)/(components)/request-password-reset-form.tsx b/web/app/(app)/(auth)/(components)/request-password-reset-form.tsx index 2bdcf42..7df481d 100644 --- a/web/app/(app)/(auth)/(components)/request-password-reset-form.tsx +++ b/web/app/(app)/(auth)/(components)/request-password-reset-form.tsx @@ -123,7 +123,7 @@ export default function RequestPasswordResetForm() { Back to login diff --git a/web/app/(app)/(auth)/(components)/reset-password-form.tsx b/web/app/(app)/(auth)/(components)/reset-password-form.tsx index 80b0a53..54ef108 100644 --- a/web/app/(app)/(auth)/(components)/reset-password-form.tsx +++ b/web/app/(app)/(auth)/(components)/reset-password-form.tsx @@ -188,7 +188,7 @@ export default function ResetPasswordForm({ Back to login diff --git a/web/app/(app)/(auth)/login/page.tsx b/web/app/(app)/(auth)/login/page.tsx index 7716a33..8daa396 100644 --- a/web/app/(app)/(auth)/login/page.tsx +++ b/web/app/(app)/(auth)/login/page.tsx @@ -50,7 +50,7 @@ export default function LoginPage() { Forgot your password? @@ -63,7 +63,7 @@ export default function LoginPage() { redirect: redirect ? decodeURIComponent(redirect) : undefined, }, }} - className='font-medium text-blue-600 hover:underline' + className='font-medium text-brand-600 hover:underline' > Sign up diff --git a/web/app/(app)/(auth)/register/page.tsx b/web/app/(app)/(auth)/register/page.tsx index 66850b7..a63cbaa 100644 --- a/web/app/(app)/(auth)/register/page.tsx +++ b/web/app/(app)/(auth)/register/page.tsx @@ -56,7 +56,7 @@ export default function RegisterPage() { redirect: redirect ? decodeURIComponent(redirect) : undefined, }, }} - className='font-medium text-blue-600 hover:underline' + className='font-medium text-brand-600 hover:underline' > Sign in diff --git a/web/app/(app)/(auth)/verify-email/page.tsx b/web/app/(app)/(auth)/verify-email/page.tsx index 813a7fe..002b849 100644 --- a/web/app/(app)/(auth)/verify-email/page.tsx +++ b/web/app/(app)/(auth)/verify-email/page.tsx @@ -40,8 +40,8 @@ const SuccessAlert = ({ title, message }: { title: string; message: string }) => ) const InfoAlert = ({ title, message }: { title: string; message: string }) => ( - - + + {title} {message} @@ -54,7 +54,7 @@ const LoadingSpinner = () => ( ) const DashboardButton = () => ( - @@ -61,7 +64,7 @@ export default function CheckoutPage({ params }) { return (
- +

Hang Tight!

We're processing your order. This won't take long! diff --git a/web/app/(app)/dashboard/(components)/account-settings.tsx b/web/app/(app)/dashboard/(components)/account-settings.tsx index 6836698..bad9404 100644 --- a/web/app/(app)/dashboard/(components)/account-settings.tsx +++ b/web/app/(app)/dashboard/(components)/account-settings.tsx @@ -218,7 +218,7 @@ export default function AccountSettings() { if (isLoadingSubscription) return ( -

+
) @@ -230,22 +230,25 @@ export default function AccountSettings() { ) // Format price with currency symbol - const formatPrice = (amount: number | null | undefined, currency: string | null | undefined) => { - if (amount == null || currency == null) return 'Free'; - + const formatPrice = ( + amount: number | null | undefined, + currency: string | null | undefined + ) => { + if (amount == null || currency == null) return 'Free' + const formatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: currency.toUpperCase() || 'USD', minimumFractionDigits: 2, - }); - - return formatter.format(amount / 100); - }; + }) + + return formatter.format(amount / 100) + } const getBillingInterval = (interval: string | null | undefined) => { - if (!interval) return ''; - return interval.toLowerCase() === 'month' ? 'monthly' : 'yearly'; - }; + if (!interval) return '' + return interval.toLowerCase() === 'month' ? 'monthly' : 'yearly' + } return (
@@ -259,40 +262,54 @@ export default function AccountSettings() { Current subscription

{currentSubscription?.amount > 0 && ( - - {formatPrice(currentSubscription?.amount, currentSubscription?.currency)} + + {formatPrice( + currentSubscription?.amount, + currentSubscription?.currency + )} {currentSubscription?.recurringInterval && ( - / {getBillingInterval(currentSubscription?.recurringInterval)} + + /{' '} + {getBillingInterval( + currentSubscription?.recurringInterval + )} + )} )}
-
- + - + - {currentSubscription?.status + }`} + > + {currentSubscription?.status ? currentSubscription.status .split('_') - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' ') : 'Active'} @@ -301,7 +318,7 @@ export default function AccountSettings() {
- +

Start Date @@ -321,7 +338,7 @@ export default function AccountSettings() {

- +

Next Payment @@ -376,7 +393,8 @@ export default function AccountSettings() {

{currentSubscription?.plan?.monthlyLimit === -1 ? 'Unlimited' - : currentSubscription?.plan?.monthlyLimit?.toLocaleString() || '0'} + : currentSubscription?.plan?.monthlyLimit?.toLocaleString() || + '0'} {currentSubscription?.plan?.monthlyLimit === -1 && ( @@ -420,10 +438,11 @@ export default function AccountSettings() {

- {(!currentSubscription?.plan?.name || currentSubscription?.plan?.name?.toLowerCase() === 'free') ? ( + {!currentSubscription?.plan?.name || + currentSubscription?.plan?.name?.toLowerCase() === 'free' ? ( Upgrade to Pro → @@ -442,13 +461,13 @@ export default function AccountSettings() { if (isLoadingUser) return ( -
+
) return ( -
+
@@ -599,7 +618,7 @@ export default function AccountSettings() {
- + - {(requestAccountDeletionError as any).response?.data?.message || - requestAccountDeletionError.message || - 'Failed to submit account deletion request'} + {(requestAccountDeletionError as any).response?.data + ?.message || + requestAccountDeletionError.message || + 'Failed to submit account deletion request'}

)} diff --git a/web/app/(app)/dashboard/(components)/api-keys.tsx b/web/app/(app)/dashboard/(components)/api-keys.tsx index c46ffc4..3351426 100644 --- a/web/app/(app)/dashboard/(components)/api-keys.tsx +++ b/web/app/(app)/dashboard/(components)/api-keys.tsx @@ -129,7 +129,6 @@ export default function ApiKeys() { API Keys -
{isPending && ( <> @@ -255,7 +254,6 @@ export default function ApiKeys() { ))}
-
{/* Revoke Dialog */} diff --git a/web/app/(app)/dashboard/(components)/bulk-sms-send.tsx b/web/app/(app)/dashboard/(components)/bulk-sms-send.tsx index b5a8851..f6312b9 100644 --- a/web/app/(app)/dashboard/(components)/bulk-sms-send.tsx +++ b/web/app/(app)/dashboard/(components)/bulk-sms-send.tsx @@ -143,7 +143,7 @@ export default function BulkSMSSend() { const isStep3Disabled = isStep2Disabled || !selectedColumn || !messageTemplate return ( -
+
Send Bulk SMS diff --git a/web/app/(app)/dashboard/(components)/change-password-form.tsx b/web/app/(app)/dashboard/(components)/change-password-form.tsx new file mode 100644 index 0000000..599f821 --- /dev/null +++ b/web/app/(app)/dashboard/(components)/change-password-form.tsx @@ -0,0 +1,155 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Loader2 } 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 } from '@tanstack/react-query' +import Link from 'next/link' +import { Routes } from '@/config/routes' + +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 + +export default function ChangePasswordForm() { + const { toast } = useToast() + + const changePasswordForm = useForm({ + resolver: zodResolver(changePasswordSchema), + }) + + 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', + }) + }, + }) + + return ( + <> +

+ If you signed in with Google, you can reset your password{' '} + + here + + . +

+ +
changePassword(data))} + className='space-y-4' + > +
+ + + {changePasswordForm.formState.errors.oldPassword && ( +

+ {changePasswordForm.formState.errors.oldPassword.message} +

+ )} +
+ +
+ + + {changePasswordForm.formState.errors.newPassword && ( +

+ {changePasswordForm.formState.errors.newPassword.message} +

+ )} +
+ +
+ + + {changePasswordForm.formState.errors.confirmPassword && ( +

+ {changePasswordForm.formState.errors.confirmPassword.message} +

+ )} +
+ + {changePasswordForm.formState.errors.root?.serverError && ( +

+ {changePasswordForm.formState.errors.root.serverError.message} +

+ )} + + {isChangePasswordSuccess && ( +

+ Password changed successfully! +

+ )} + + +
+ + ) +} \ No newline at end of file diff --git a/web/app/(app)/dashboard/(components)/community-links.tsx b/web/app/(app)/dashboard/(components)/community-links.tsx index 6b32b22..506f3b8 100644 --- a/web/app/(app)/dashboard/(components)/community-links.tsx +++ b/web/app/(app)/dashboard/(components)/community-links.tsx @@ -6,8 +6,8 @@ import { ExternalLinks } from '@/config/external-links' export default function CommunityLinks() { return ( -
- +
+ {/* One-time Donation @@ -22,9 +22,9 @@ export default function CommunityLinks() { - + */} - + {/* Support on Patreon @@ -39,7 +39,7 @@ export default function CommunityLinks() { - + */} diff --git a/web/app/(app)/dashboard/(components)/danger-zone-form.tsx b/web/app/(app)/dashboard/(components)/danger-zone-form.tsx new file mode 100644 index 0000000..3e30b00 --- /dev/null +++ b/web/app/(app)/dashboard/(components)/danger-zone-form.tsx @@ -0,0 +1,160 @@ +'use client' + +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { Label } from '@/components/ui/label' +import { AlertTriangle, Loader2 } 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 { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' + +export default function DangerZoneForm() { + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) + const [deleteConfirmEmail, setDeleteConfirmEmail] = useState('') + const [deleteReason, setDeleteReason] = useState('') + const { toast } = useToast() + + const { data: currentUser } = useQuery({ + queryKey: ['currentUser'], + queryFn: () => + httpBrowserClient + .get(ApiEndpoints.auth.whoAmI()) + .then((res) => res.data?.data), + }) + + const handleDeleteAccount = () => { + if (deleteConfirmEmail !== currentUser?.email) { + toast({ + title: 'Please enter your correct email address', + }) + return + } + requestAccountDeletion() + } + + const { + mutate: requestAccountDeletion, + isPending: isRequestingAccountDeletion, + error: requestAccountDeletionError, + isSuccess: isRequestAccountDeletionSuccess, + } = useMutation({ + mutationFn: () => + httpBrowserClient.post(ApiEndpoints.support.requestAccountDeletion(), { + message: deleteReason, + }), + onSuccess: () => { + toast({ + title: 'Account deletion request submitted', + }) + setIsDeleteDialogOpen(false) + }, + onError: () => { + toast({ + title: 'Failed to submit account deletion request', + }) + }, + }) + + return ( + <> +

+ Once you delete your account, there is no going back. This action + permanently removes all your data, cancels subscriptions, and revokes + access to all services. +

+ + + + + + + + Delete Account + + +

+ Are you sure you want to delete your account? This action: +

+
    +
  • Cannot be undone
  • +
  • Will permanently delete all your data
  • +
  • Will cancel all active subscriptions
  • +
  • Will remove access to all services
  • +
+ + +