mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2025-12-13 01:24:44 -05:00
Theme overhaul - missing files
This commit is contained in:
BIN
public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf
Normal file
BIN
public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf
Normal file
Binary file not shown.
BIN
public/fonts/geist/sans/Geist-VariableFont_wght.ttf
Normal file
BIN
public/fonts/geist/sans/Geist-VariableFont_wght.ttf
Normal file
Binary file not shown.
390
src/app/(legal)/privacy/page.tsx
Normal file
390
src/app/(legal)/privacy/page.tsx
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
|
export default function PrivacyPolicyPage() {
|
||||||
|
return (
|
||||||
|
<div className="bg-background min-h-screen">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-card border-b">
|
||||||
|
<div className="container mx-auto max-w-4xl px-6 py-6">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Link href="/auth/signin">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Privacy Policy</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Last updated: {new Date().toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="container mx-auto max-w-4xl px-6 py-8">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Introduction</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
beenvoice ("we", "our", or "us")
|
||||||
|
is committed to protecting your privacy. This Privacy Policy
|
||||||
|
explains how we collect, use, disclose, and safeguard your
|
||||||
|
information when you use our invoicing platform and services.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Please read this Privacy Policy carefully. If you do not agree
|
||||||
|
with the terms of this Privacy Policy, please do not access or
|
||||||
|
use our Service.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Information We Collect</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<h4>Personal Information</h4>
|
||||||
|
<p>
|
||||||
|
We may collect personal information that you voluntarily provide
|
||||||
|
to us when you:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Register for an account</li>
|
||||||
|
<li>Create invoices or manage client information</li>
|
||||||
|
<li>Contact us for support</li>
|
||||||
|
<li>Subscribe to our newsletters or communications</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>This personal information may include:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Name and contact information (email, phone, address)</li>
|
||||||
|
<li>Business information and tax details</li>
|
||||||
|
<li>Client information you input into the system</li>
|
||||||
|
<li>Financial information related to your invoices</li>
|
||||||
|
<li>
|
||||||
|
Payment information (processed securely by third-party
|
||||||
|
providers)
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>Automatically Collected Information</h4>
|
||||||
|
<p>
|
||||||
|
We may automatically collect certain information when you visit
|
||||||
|
our Service:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Device information (IP address, browser type, operating
|
||||||
|
system)
|
||||||
|
</li>
|
||||||
|
<li>Usage data (pages visited, time spent, features used)</li>
|
||||||
|
<li>Log files and analytics data</li>
|
||||||
|
<li>Cookies and similar tracking technologies</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>How We Use Your Information</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>We use the information we collect to:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Provide, operate, and maintain our Service</li>
|
||||||
|
<li>Process your transactions and manage your account</li>
|
||||||
|
<li>Improve and personalize your experience</li>
|
||||||
|
<li>
|
||||||
|
Communicate with you about your account and our services
|
||||||
|
</li>
|
||||||
|
<li>Send you technical notices and support messages</li>
|
||||||
|
<li>Respond to your comments, questions, and requests</li>
|
||||||
|
<li>Monitor usage and analyze trends</li>
|
||||||
|
<li>
|
||||||
|
Detect, prevent, and address technical issues and security
|
||||||
|
breaches
|
||||||
|
</li>
|
||||||
|
<li>Comply with legal obligations</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>How We Share Your Information</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
We do not sell, trade, or rent your personal information to
|
||||||
|
third parties. We may share your information in the following
|
||||||
|
circumstances:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4>Service Providers</h4>
|
||||||
|
<p>
|
||||||
|
We may share your information with trusted third-party service
|
||||||
|
providers who assist us in operating our Service, such as:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Cloud hosting and storage providers</li>
|
||||||
|
<li>Payment processors</li>
|
||||||
|
<li>Email service providers</li>
|
||||||
|
<li>Analytics and monitoring services</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>Legal Requirements</h4>
|
||||||
|
<p>
|
||||||
|
We may disclose your information if required to do so by law or
|
||||||
|
in response to:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Legal processes (subpoenas, court orders)</li>
|
||||||
|
<li>Government requests</li>
|
||||||
|
<li>Law enforcement investigations</li>
|
||||||
|
<li>Protection of our rights, property, or safety</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>Business Transfers</h4>
|
||||||
|
<p>
|
||||||
|
In the event of a merger, acquisition, or sale of assets, your
|
||||||
|
information may be transferred as part of that transaction.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Data Security</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
We implement appropriate technical and organizational security
|
||||||
|
measures to protect your information:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Encryption of data in transit and at rest</li>
|
||||||
|
<li>Secure access controls and authentication</li>
|
||||||
|
<li>Regular security assessments and updates</li>
|
||||||
|
<li>Employee training on data protection</li>
|
||||||
|
<li>Incident response procedures</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
However, no method of transmission over the internet or
|
||||||
|
electronic storage is 100% secure. While we strive to protect
|
||||||
|
your information, we cannot guarantee absolute security.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Data Retention</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
We retain your personal information only for as long as
|
||||||
|
necessary to fulfill the purposes outlined in this Privacy
|
||||||
|
Policy, unless a longer retention period is required by law.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Factors we consider when determining retention periods include:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>The nature and sensitivity of the information</li>
|
||||||
|
<li>Legal and regulatory requirements</li>
|
||||||
|
<li>Business and operational needs</li>
|
||||||
|
<li>Your account status and activity</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Your Rights and Choices</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
Depending on your location, you may have the following rights
|
||||||
|
regarding your personal information:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4>Access and Portability</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Request access to your personal information</li>
|
||||||
|
<li>Receive a copy of your data in a portable format</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>Correction and Updates</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Correct inaccurate or incomplete information</li>
|
||||||
|
<li>Update your account information at any time</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>Deletion</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Request deletion of your personal information</li>
|
||||||
|
<li>Close your account and remove your data</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>Restriction and Objection</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Restrict the processing of your information</li>
|
||||||
|
<li>Object to certain uses of your data</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
To exercise these rights, please contact us using the
|
||||||
|
information provided in the "Contact Us" section
|
||||||
|
below.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Cookies and Tracking Technologies</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>We use cookies and similar technologies to:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Remember your preferences and settings</li>
|
||||||
|
<li>Authenticate your account</li>
|
||||||
|
<li>Analyze usage patterns and improve our Service</li>
|
||||||
|
<li>Provide personalized content and features</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
You can control cookies through your browser settings. However,
|
||||||
|
disabling cookies may affect the functionality of our Service.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4>Types of Cookies We Use</h4>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Essential Cookies:</strong> Required for the Service
|
||||||
|
to function properly
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Analytics Cookies:</strong> Help us understand how you
|
||||||
|
use our Service
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Preference Cookies:</strong> Remember your settings
|
||||||
|
and preferences
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Third-Party Links and Services</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
Our Service may contain links to third-party websites or
|
||||||
|
integrate with third-party services. We are not responsible for
|
||||||
|
the privacy practices of these third parties.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
We encourage you to read the privacy policies of any third-party
|
||||||
|
services you use in connection with our Service.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Children's Privacy</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
Our Service is not intended for children under the age of 13. We
|
||||||
|
do not knowingly collect personal information from children
|
||||||
|
under 13.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you are a parent or guardian and believe your child has
|
||||||
|
provided us with personal information, please contact us
|
||||||
|
immediately so we can remove such information.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>International Data Transfers</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
Your information may be transferred to and processed in
|
||||||
|
countries other than your own. We ensure that such transfers
|
||||||
|
comply with applicable data protection laws.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
When we transfer your information internationally, we implement
|
||||||
|
appropriate safeguards to protect your data, including:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Standard contractual clauses</li>
|
||||||
|
<li>Adequacy decisions by relevant authorities</li>
|
||||||
|
<li>Certified privacy frameworks</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Changes to This Privacy Policy</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
We may update this Privacy Policy from time to time. We will
|
||||||
|
notify you of any material changes by:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Posting the updated policy on our Service</li>
|
||||||
|
<li>Sending you an email notification</li>
|
||||||
|
<li>Displaying a prominent notice on our Service</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Your continued use of our Service after any changes indicates
|
||||||
|
your acceptance of the updated Privacy Policy.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Contact Us</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
If you have questions about this Privacy Policy or our privacy
|
||||||
|
practices, please contact us at:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Email: privacy@beenvoice.com</li>
|
||||||
|
<li>Address: [Your Business Address]</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
We will respond to your inquiries within a reasonable timeframe
|
||||||
|
and in accordance with applicable law.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
306
src/app/(legal)/terms/page.tsx
Normal file
306
src/app/(legal)/terms/page.tsx
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
|
export default function TermsOfServicePage() {
|
||||||
|
return (
|
||||||
|
<div className="bg-background min-h-screen">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-card border-b">
|
||||||
|
<div className="container mx-auto max-w-4xl px-6 py-6">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Link href="/auth/signin">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Terms of Service</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Last updated: {new Date().toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="container mx-auto max-w-4xl px-6 py-8">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Agreement to Terms</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
These Terms of Service ("Terms") govern your use of the
|
||||||
|
beenvoice platform and services (the "Service") operated by
|
||||||
|
beenvoice ("us", "we", or "our").
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
By accessing or using our Service, you agree to be bound by
|
||||||
|
these Terms. If you disagree with any part of these terms, then
|
||||||
|
you may not access the Service.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Description of Service</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
beenvoice is a web-based invoicing platform that allows users
|
||||||
|
to:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Create and manage professional invoices</li>
|
||||||
|
<li>Track client information and billing details</li>
|
||||||
|
<li>Monitor payment status and financial metrics</li>
|
||||||
|
<li>Generate reports and analytics</li>
|
||||||
|
<li>Manage business profiles and settings</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>User Accounts</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
When you create an account with us, you must provide information
|
||||||
|
that is accurate, complete, and current at all times. You are
|
||||||
|
responsible for safeguarding the password and for all activities
|
||||||
|
that occur under your account.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You agree not to disclose your password to any third party. You
|
||||||
|
must notify us immediately upon becoming aware of any breach of
|
||||||
|
security or unauthorized use of your account.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Acceptable Use</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>You agree not to use the Service:</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
For any unlawful purpose or to solicit others to perform
|
||||||
|
unlawful acts
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
To violate any international, federal, provincial, or state
|
||||||
|
regulations, rules, laws, or local ordinances
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
To infringe upon or violate our intellectual property rights
|
||||||
|
or the intellectual property rights of others
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
To harass, abuse, insult, harm, defame, slander, disparage,
|
||||||
|
intimidate, or discriminate
|
||||||
|
</li>
|
||||||
|
<li>To submit false or misleading information</li>
|
||||||
|
<li>
|
||||||
|
To upload or transmit viruses or any other type of malicious
|
||||||
|
code
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
To spam, phish, pharm, pretext, spider, crawl, or scrape
|
||||||
|
</li>
|
||||||
|
<li>For any obscene or immoral purpose</li>
|
||||||
|
<li>
|
||||||
|
To interfere with or circumvent the security features of the
|
||||||
|
Service
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Data and Privacy</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
Your privacy is important to us. Please review our Privacy
|
||||||
|
Policy, which also governs your use of the Service, to
|
||||||
|
understand our practices.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You retain ownership of your data. We will not sell, rent, or
|
||||||
|
share your personal information with third parties without your
|
||||||
|
explicit consent, except as described in our Privacy Policy.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You are responsible for backing up your data. While we implement
|
||||||
|
regular backups, we recommend you maintain your own copies of
|
||||||
|
important information.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Payment Terms</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
Some aspects of the Service may require payment. You will be
|
||||||
|
charged according to your subscription plan. All fees are
|
||||||
|
non-refundable unless otherwise stated.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
We may change our fees at any time. We will provide you with
|
||||||
|
reasonable notice of any fee changes by posting the new fees on
|
||||||
|
the Service or sending you email notification.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you fail to pay any fees when due, we may suspend or
|
||||||
|
terminate your access to the Service until payment is made.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Intellectual Property Rights</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
The Service and its original content, features, and
|
||||||
|
functionality are and will remain the exclusive property of
|
||||||
|
beenvoice and its licensors. The Service is protected by
|
||||||
|
copyright, trademark, and other laws.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Our trademarks and trade dress may not be used in connection
|
||||||
|
with any product or service without our prior written consent.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Termination</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
We may terminate or suspend your account and bar access to the
|
||||||
|
Service immediately, without prior notice or liability, under
|
||||||
|
our sole discretion, for any reason whatsoever and without
|
||||||
|
limitation, including but not limited to a breach of the Terms.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you wish to terminate your account, you may simply
|
||||||
|
discontinue using the Service and contact us to request account
|
||||||
|
deletion.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Upon termination, your right to use the Service will cease
|
||||||
|
immediately. If you wish to terminate your account, you may
|
||||||
|
simply discontinue using the Service.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Disclaimer of Warranties</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
The information on this Service is provided on an "as
|
||||||
|
is" basis. To the fullest extent permitted by law, we
|
||||||
|
exclude all representations, warranties, and conditions relating
|
||||||
|
to our Service and the use of this Service.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Nothing in this disclaimer will limit or exclude our or your
|
||||||
|
liability for death or personal injury resulting from
|
||||||
|
negligence, fraud, or fraudulent misrepresentation.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Limitation of Liability</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
In no event shall beenvoice, nor its directors, employees,
|
||||||
|
partners, agents, suppliers, or affiliates, be liable for any
|
||||||
|
indirect, incidental, special, consequential, or punitive
|
||||||
|
damages, including without limitation, loss of profits, data,
|
||||||
|
use, goodwill, or other intangible losses, resulting from your
|
||||||
|
use of the Service.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Governing Law</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
These Terms shall be interpreted and governed by the laws of the
|
||||||
|
jurisdiction in which beenvoice operates, without regard to its
|
||||||
|
conflict of law provisions.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Our failure to enforce any right or provision of these Terms
|
||||||
|
will not be considered a waiver of those rights.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Changes to Terms</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
We reserve the right, at our sole discretion, to modify or
|
||||||
|
replace these Terms at any time. If a revision is material, we
|
||||||
|
will provide at least 30 days notice prior to any new terms
|
||||||
|
taking effect.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
What constitutes a material change will be determined at our
|
||||||
|
sole discretion. By continuing to access or use our Service
|
||||||
|
after any revisions become effective, you agree to be bound by
|
||||||
|
the revised terms.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Contact Information</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
If you have any questions about these Terms of Service, please
|
||||||
|
contact us at:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Email: legal@beenvoice.com</li>
|
||||||
|
<li>Address: [Your Business Address]</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
src/app/api/auth/forgot-password/route.ts
Normal file
101
src/app/api/auth/forgot-password/route.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { users } from "~/server/db/schema";
|
||||||
|
import { Resend } from "resend";
|
||||||
|
import { env } from "~/env";
|
||||||
|
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { email } = (await request.json()) as { email: string };
|
||||||
|
|
||||||
|
if (!email || typeof email !== "string") {
|
||||||
|
return NextResponse.json({ error: "Email is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid email format" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.email, email.toLowerCase()),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Always return success to prevent email enumeration attacks
|
||||||
|
// Don't reveal whether the user exists or not
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message:
|
||||||
|
"If an account with that email exists, password reset instructions have been sent.",
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate reset token
|
||||||
|
const resetToken = crypto.randomBytes(32).toString("hex");
|
||||||
|
const resetTokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
||||||
|
|
||||||
|
// Update user with reset token
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
resetToken,
|
||||||
|
resetTokenExpiry,
|
||||||
|
})
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
// Send password reset email using Resend
|
||||||
|
try {
|
||||||
|
const resend = new Resend(env.RESEND_API_KEY);
|
||||||
|
const resetUrl = `${process.env.NEXTAUTH_URL ?? "http://localhost:3000"}/auth/reset-password?token=${resetToken}`;
|
||||||
|
|
||||||
|
const emailTemplate = generatePasswordResetEmailTemplate({
|
||||||
|
userEmail: email,
|
||||||
|
userName: user.name ?? undefined,
|
||||||
|
resetToken,
|
||||||
|
resetUrl,
|
||||||
|
expiryHours: 24,
|
||||||
|
});
|
||||||
|
|
||||||
|
await resend.emails.send({
|
||||||
|
from: "beenvoice <noreply@beenvoice.com>",
|
||||||
|
to: email,
|
||||||
|
subject: emailTemplate.subject,
|
||||||
|
html: emailTemplate.html,
|
||||||
|
text: emailTemplate.text,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Password reset email sent to: ${email}`);
|
||||||
|
} catch (emailError) {
|
||||||
|
console.error("Failed to send password reset email:", emailError);
|
||||||
|
// Continue execution - don't fail the request if email fails
|
||||||
|
// This prevents revealing whether an account exists based on email delivery
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message:
|
||||||
|
"If an account with that email exists, password reset instructions have been sent.",
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Password reset error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "An error occurred while processing your request" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/app/api/auth/reset-password/route.ts
Normal file
74
src/app/api/auth/reset-password/route.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { eq, and, gt } from "drizzle-orm";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { users } from "~/server/db/schema";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { token, password } = (await request.json()) as {
|
||||||
|
token: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!token || typeof token !== "string") {
|
||||||
|
return NextResponse.json({ error: "Token is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password || typeof password !== "string") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Password is required" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Password must be at least 8 characters long" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find user with valid reset token that hasn't expired
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(users.resetToken, token),
|
||||||
|
gt(users.resetTokenExpiry, new Date()),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid or expired token" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the new password
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
// Update user with new password and clear reset token
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
password: hashedPassword,
|
||||||
|
resetToken: null,
|
||||||
|
resetTokenExpiry: null,
|
||||||
|
})
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: "Password has been reset successfully",
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Password reset error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "An error occurred while resetting your password" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/app/api/auth/validate-reset-token/route.ts
Normal file
37
src/app/api/auth/validate-reset-token/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { eq, and, gt } from "drizzle-orm";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { users } from "~/server/db/schema";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { token } = (await request.json()) as { token: string };
|
||||||
|
|
||||||
|
if (!token || typeof token !== "string") {
|
||||||
|
return NextResponse.json({ error: "Token is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find user with valid reset token that hasn't expired
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(users.resetToken, token),
|
||||||
|
gt(users.resetTokenExpiry, new Date()),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid or expired token" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ valid: true }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Token validation error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "An error occurred while validating the token" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
385
src/app/auth/forgot-password/page.tsx
Normal file
385
src/app/auth/forgot-password/page.tsx
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, Suspense } from "react";
|
||||||
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Logo } from "~/components/branding/logo";
|
||||||
|
import { LegalModal } from "~/components/ui/legal-modal";
|
||||||
|
import {
|
||||||
|
Mail,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowLeft,
|
||||||
|
Shield,
|
||||||
|
Clock,
|
||||||
|
CheckCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
function ForgotPasswordForm() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/auth/forgot-password", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = (await response.json()) as { error?: string };
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setSent(true);
|
||||||
|
toast.success("Password reset instructions sent to your email");
|
||||||
|
} else {
|
||||||
|
toast.error(data.error ?? "Failed to send reset email");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("An error occurred. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sent) {
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex min-h-screen items-center justify-center">
|
||||||
|
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-4xl md:border md:shadow-lg">
|
||||||
|
<CardContent className="grid h-full p-0 md:grid-cols-2">
|
||||||
|
{/* Hero Section - Hidden on mobile */}
|
||||||
|
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Logo size="xl" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h1 className="text-3xl font-bold lg:text-4xl">
|
||||||
|
Check your
|
||||||
|
<span className="text-primary"> email inbox</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-lg">
|
||||||
|
We've sent password reset instructions to your email
|
||||||
|
address. Follow the link to create a new password.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="bg-primary/10 rounded-lg p-2">
|
||||||
|
<Mail className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-semibold">Check your inbox</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Look for an email from beenvoice with reset instructions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="bg-primary/10 rounded-lg p-2">
|
||||||
|
<Clock className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-semibold">Link expires soon</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
The reset link is valid for 24 hours only
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="bg-primary/10 rounded-lg p-2">
|
||||||
|
<Shield className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-semibold">Secure Process</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Your account security is our top priority
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-primary/5 flex items-center space-x-4 rounded-lg p-4">
|
||||||
|
<CheckCircle className="text-primary h-8 w-8" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Email sent successfully</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Follow the instructions in your email to reset your
|
||||||
|
password
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Success Message */}
|
||||||
|
<div className="flex flex-col justify-center p-6 md:p-12">
|
||||||
|
<div className="mx-auto w-full max-w-sm space-y-6">
|
||||||
|
{/* Mobile Logo */}
|
||||||
|
<div className="flex justify-center md:hidden">
|
||||||
|
<Logo size="lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-center">
|
||||||
|
<div className="bg-primary/10 mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full">
|
||||||
|
<CheckCircle className="text-primary h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold">Check your email</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
We've sent password reset instructions to{" "}
|
||||||
|
<span className="font-medium">{email}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/50 space-y-3 rounded-lg p-4">
|
||||||
|
<h3 className="font-semibold">What's next?</h3>
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
<li className="flex items-start space-x-2">
|
||||||
|
<span className="text-primary">1.</span>
|
||||||
|
<span>Check your email inbox (and spam folder)</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start space-x-2">
|
||||||
|
<span className="text-primary">2.</span>
|
||||||
|
<span>Click the reset link in the email</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start space-x-2">
|
||||||
|
<span className="text-primary">3.</span>
|
||||||
|
<span>Create a new secure password</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setSent(false);
|
||||||
|
setEmail("");
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
className="h-11 w-full"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Try a different email
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<a href="/auth/signin">
|
||||||
|
<Button className="h-11 w-full">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Sign In
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground text-center text-xs">
|
||||||
|
Didn't receive the email? Check your spam folder or{" "}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSent(false);
|
||||||
|
toast.info("You can try sending the email again");
|
||||||
|
}}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
try again
|
||||||
|
</button>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex min-h-screen items-center justify-center">
|
||||||
|
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-4xl md:border md:shadow-lg">
|
||||||
|
<CardContent className="grid h-full p-0 md:grid-cols-2">
|
||||||
|
{/* Hero Section - Hidden on mobile */}
|
||||||
|
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Logo size="xl" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h1 className="text-3xl font-bold lg:text-4xl">
|
||||||
|
Forgot your
|
||||||
|
<span className="text-primary"> password?</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-lg">
|
||||||
|
No worries! Enter your email address and we'll send you
|
||||||
|
instructions to reset your password.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="bg-primary/10 rounded-lg p-2">
|
||||||
|
<Mail className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-semibold">Email Instructions</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
We'll send a secure link to your email address
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="bg-primary/10 rounded-lg p-2">
|
||||||
|
<Clock className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-semibold">Quick Process</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Reset your password in just a few clicks
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="bg-primary/10 rounded-lg p-2">
|
||||||
|
<Shield className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-semibold">Secure & Safe</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Your account security is our top priority
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Forgot Password Form */}
|
||||||
|
<div className="flex flex-col justify-center p-6 md:p-12">
|
||||||
|
<div className="mx-auto w-full max-w-sm space-y-6">
|
||||||
|
{/* Mobile Logo */}
|
||||||
|
<div className="flex justify-center md:hidden">
|
||||||
|
<Logo size="lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-center md:text-left">
|
||||||
|
<h1 className="text-2xl font-bold">Forgot Password</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Enter your email and we'll send you reset instructions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email Address</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
className="h-11 pl-10"
|
||||||
|
placeholder="Enter your email address"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="h-11 w-full"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="border-primary-foreground/30 border-t-primary-foreground h-4 w-4 animate-spin rounded-full border-2"></div>
|
||||||
|
<span>Sending instructions...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span>Send Reset Instructions</span>
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="bg-muted/50 rounded-lg p-4">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<Mail className="text-primary mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium">Check your spam folder</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Sometimes our emails end up in spam or promotions folders
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<a
|
||||||
|
href="/auth/signin"
|
||||||
|
className="text-primary inline-flex items-center space-x-1 text-sm font-medium hover:underline"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3 w-3" />
|
||||||
|
<span>Back to Sign In</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground text-center text-xs">
|
||||||
|
Remember your password?{" "}
|
||||||
|
<a
|
||||||
|
href="/auth/signin"
|
||||||
|
className="text-primary font-medium hover:underline"
|
||||||
|
>
|
||||||
|
Sign in instead
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground text-center text-xs leading-relaxed">
|
||||||
|
By using our service, you agree to our{" "}
|
||||||
|
<LegalModal
|
||||||
|
type="terms"
|
||||||
|
trigger={
|
||||||
|
<span className="text-primary inline cursor-pointer hover:underline">
|
||||||
|
Terms of Service
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>{" "}
|
||||||
|
and{" "}
|
||||||
|
<LegalModal
|
||||||
|
type="privacy"
|
||||||
|
trigger={
|
||||||
|
<span className="text-primary inline cursor-pointer hover:underline">
|
||||||
|
Privacy Policy
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<ForgotPasswordForm />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
462
src/app/auth/reset-password/page.tsx
Normal file
462
src/app/auth/reset-password/page.tsx
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, Suspense, useEffect } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Logo } from "~/components/branding/logo";
|
||||||
|
import { LegalModal } from "~/components/ui/legal-modal";
|
||||||
|
import {
|
||||||
|
Lock,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowLeft,
|
||||||
|
CheckCircle,
|
||||||
|
Shield,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
function ResetPasswordForm() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const token = searchParams.get("token");
|
||||||
|
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
const [tokenValid, setTokenValid] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setTokenValid(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate token on page load
|
||||||
|
const validateToken = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/auth/validate-reset-token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setTokenValid(true);
|
||||||
|
} else {
|
||||||
|
setTokenValid(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setTokenValid(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void validateToken();
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
toast.error("Invalid reset token");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
toast.error("Password must be at least 8 characters long");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
toast.error("Passwords do not match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/auth/reset-password", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ token, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = (await response.json()) as { error?: string };
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setSuccess(true);
|
||||||
|
toast.success("Password reset successfully!");
|
||||||
|
} else {
|
||||||
|
toast.error(data.error ?? "Failed to reset password");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("An error occurred. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenValid === null) {
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex min-h-screen items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="border-primary h-8 w-8 animate-spin rounded-full border-2 border-t-transparent"></div>
|
||||||
|
<p className="text-muted-foreground mt-4">
|
||||||
|
Validating reset token...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenValid === false) {
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex min-h-screen items-center justify-center">
|
||||||
|
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-4xl md:border md:shadow-lg">
|
||||||
|
<CardContent className="grid h-full p-0 md:grid-cols-2">
|
||||||
|
{/* Hero Section - Hidden on mobile */}
|
||||||
|
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Logo size="xl" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h1 className="text-3xl font-bold lg:text-4xl">
|
||||||
|
Invalid or
|
||||||
|
<span className="text-destructive"> expired link</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-lg">
|
||||||
|
This password reset link is either invalid or has expired.
|
||||||
|
Please request a new password reset.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="bg-destructive/10 rounded-lg p-2">
|
||||||
|
<Shield className="text-destructive h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-semibold">Security First</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Reset links expire after 24 hours for your security
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Form */}
|
||||||
|
<div className="flex flex-col justify-center p-6 md:p-12">
|
||||||
|
<div className="mx-auto w-full max-w-sm space-y-6">
|
||||||
|
{/* Mobile Logo */}
|
||||||
|
<div className="flex justify-center md:hidden">
|
||||||
|
<Logo size="lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-center">
|
||||||
|
<div className="bg-destructive/10 justify-content mx-auto mb-4 flex h-16 w-16 items-center rounded-full">
|
||||||
|
<Shield className="text-destructive mx-auto h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold">Link Expired</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
This password reset link is no longer valid
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<a href="/auth/forgot-password">
|
||||||
|
<Button className="h-11 w-full">
|
||||||
|
Request New Reset Link
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/auth/signin">
|
||||||
|
<Button variant="outline" className="h-11 w-full">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Sign In
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex min-h-screen items-center justify-center">
|
||||||
|
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-4xl md:border md:shadow-lg">
|
||||||
|
<CardContent className="grid h-full p-0 md:grid-cols-2">
|
||||||
|
{/* Hero Section - Hidden on mobile */}
|
||||||
|
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Logo size="xl" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h1 className="text-3xl font-bold lg:text-4xl">
|
||||||
|
Password
|
||||||
|
<span className="text-primary"> reset complete</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-lg">
|
||||||
|
Your password has been successfully reset. You can now
|
||||||
|
sign in with your new password.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-primary/5 rounded-lg p-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<CheckCircle className="text-primary h-6 w-6" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Security Updated</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Your account is now secured with your new password
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Success Form */}
|
||||||
|
<div className="flex flex-col justify-center p-6 md:p-12">
|
||||||
|
<div className="mx-auto w-full max-w-sm space-y-6">
|
||||||
|
{/* Mobile Logo */}
|
||||||
|
<div className="flex justify-center md:hidden">
|
||||||
|
<Logo size="lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-center">
|
||||||
|
<div className="bg-primary/10 mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full">
|
||||||
|
<CheckCircle className="text-primary h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold">
|
||||||
|
Password Reset Complete
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Your password has been successfully updated
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<a href="/auth/signin">
|
||||||
|
<Button className="h-11 w-full">
|
||||||
|
<ArrowRight className="mr-2 h-4 w-4" />
|
||||||
|
Sign In Now
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex min-h-screen items-center justify-center">
|
||||||
|
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-4xl md:border md:shadow-lg">
|
||||||
|
<CardContent className="grid h-full p-0 md:grid-cols-2">
|
||||||
|
{/* Hero Section - Hidden on mobile */}
|
||||||
|
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Logo size="xl" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h1 className="text-3xl font-bold lg:text-4xl">
|
||||||
|
Create your
|
||||||
|
<span className="text-primary"> new password</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-lg">
|
||||||
|
Choose a strong password to secure your beenvoice account.
|
||||||
|
Make sure it's something you'll remember.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="bg-primary/10 rounded-lg p-2">
|
||||||
|
<Shield className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-semibold">Secure Password</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Use at least 8 characters with a mix of letters and
|
||||||
|
numbers
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="bg-primary/10 rounded-lg p-2">
|
||||||
|
<Lock className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-semibold">Account Safety</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Your new password will immediately secure your account
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reset Password Form */}
|
||||||
|
<div className="flex flex-col justify-center p-6 md:p-12">
|
||||||
|
<div className="mx-auto w-full max-w-sm space-y-6">
|
||||||
|
{/* Mobile Logo */}
|
||||||
|
<div className="flex justify-center md:hidden">
|
||||||
|
<Logo size="lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-center md:text-left">
|
||||||
|
<h1 className="text-2xl font-bold">Reset Password</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Enter your new password below
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">New Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
className="h-11 pr-10 pl-10"
|
||||||
|
placeholder="Enter new password"
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 z-10 -translate-y-1/2"
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Must be at least 8 characters long
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type={showConfirmPassword ? "text" : "password"}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="h-11 pr-10 pl-10"
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setShowConfirmPassword(!showConfirmPassword)
|
||||||
|
}
|
||||||
|
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 z-10 -translate-y-1/2"
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="h-11 w-full"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="border-primary-foreground/30 border-t-primary-foreground h-4 w-4 animate-spin rounded-full border-2"></div>
|
||||||
|
<span>Updating password...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span>Update Password</span>
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<a
|
||||||
|
href="/auth/signin"
|
||||||
|
className="text-primary inline-flex items-center space-x-1 text-sm font-medium hover:underline"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3 w-3" />
|
||||||
|
<span>Back to Sign In</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground text-center text-xs leading-relaxed">
|
||||||
|
By resetting your password, you agree to our{" "}
|
||||||
|
<LegalModal
|
||||||
|
type="terms"
|
||||||
|
trigger={
|
||||||
|
<span className="text-primary inline cursor-pointer hover:underline">
|
||||||
|
Terms of Service
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>{" "}
|
||||||
|
and{" "}
|
||||||
|
<LegalModal
|
||||||
|
type="privacy"
|
||||||
|
trigger={
|
||||||
|
<span className="text-primary inline cursor-pointer hover:underline">
|
||||||
|
Privacy Policy
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<ResetPasswordForm />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
src/app/dashboard/_components/invoice-status-chart.tsx
Normal file
152
src/app/dashboard/_components/invoice-status-chart.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
|
||||||
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
|
|
||||||
|
interface Invoice {
|
||||||
|
id: string;
|
||||||
|
totalAmount: number;
|
||||||
|
status: string;
|
||||||
|
dueDate: Date | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InvoiceStatusChartProps {
|
||||||
|
invoices: Invoice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
|
||||||
|
// Process invoice data to create status breakdown
|
||||||
|
const statusData = invoices.reduce(
|
||||||
|
(acc, invoice) => {
|
||||||
|
const effectiveStatus = getEffectiveInvoiceStatus(
|
||||||
|
invoice.status as StoredInvoiceStatus,
|
||||||
|
invoice.dueDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
acc[effectiveStatus] ??= {
|
||||||
|
status: effectiveStatus,
|
||||||
|
count: 0,
|
||||||
|
value: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
acc[effectiveStatus].count += 1;
|
||||||
|
acc[effectiveStatus].value += invoice.totalAmount;
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, { status: string; count: number; value: number }>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const chartData = Object.values(statusData).map((item) => ({
|
||||||
|
...item,
|
||||||
|
name: item.status.charAt(0).toUpperCase() + item.status.slice(1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Light pastel colors for different statuses
|
||||||
|
const COLORS = {
|
||||||
|
draft: "hsl(220 9% 46%)", // muted gray
|
||||||
|
sent: "hsl(210 40% 70%)", // light blue
|
||||||
|
paid: "hsl(142 76% 85%)", // light green
|
||||||
|
overdue: "hsl(0 84% 85%)", // light red
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CustomTooltip = ({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{
|
||||||
|
payload: { name: string; count: number; value: number };
|
||||||
|
}>;
|
||||||
|
}) => {
|
||||||
|
if (active && payload?.length) {
|
||||||
|
const data = payload[0]!.payload;
|
||||||
|
return (
|
||||||
|
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
||||||
|
<p className="font-medium">{data.name}</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
{data.count} invoice{data.count !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">{formatCurrency(data.value)}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (chartData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
No invoice data available
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Status breakdown will appear here once you create invoices
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="h-48 w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={chartData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={40}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={2}
|
||||||
|
dataKey="count"
|
||||||
|
>
|
||||||
|
{chartData.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={`cell-${index}`}
|
||||||
|
fill={COLORS[entry.status as keyof typeof COLORS]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{chartData.map((item) => (
|
||||||
|
<div key={item.status} className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div
|
||||||
|
className="h-3 w-3 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: COLORS[item.status as keyof typeof COLORS],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">{item.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-medium">{item.count}</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{formatCurrency(item.value)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
src/app/dashboard/_components/monthly-metrics-chart.tsx
Normal file
206
src/app/dashboard/_components/monthly-metrics-chart.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
|
|
||||||
|
interface Invoice {
|
||||||
|
id: string;
|
||||||
|
totalAmount: number;
|
||||||
|
issueDate: Date | string;
|
||||||
|
status: string;
|
||||||
|
dueDate: Date | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MonthlyMetricsChartProps {
|
||||||
|
invoices: Invoice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
|
||||||
|
// Process invoice data to create monthly metrics
|
||||||
|
const monthlyData = invoices.reduce(
|
||||||
|
(acc, invoice) => {
|
||||||
|
const date = new Date(invoice.issueDate);
|
||||||
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
const effectiveStatus = getEffectiveInvoiceStatus(
|
||||||
|
invoice.status as StoredInvoiceStatus,
|
||||||
|
invoice.dueDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
acc[monthKey] ??= {
|
||||||
|
month: monthKey,
|
||||||
|
totalInvoices: 0,
|
||||||
|
paidInvoices: 0,
|
||||||
|
pendingInvoices: 0,
|
||||||
|
overdueInvoices: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
acc[monthKey].totalInvoices += 1;
|
||||||
|
|
||||||
|
switch (effectiveStatus) {
|
||||||
|
case "paid":
|
||||||
|
acc[monthKey].paidInvoices += 1;
|
||||||
|
break;
|
||||||
|
case "sent":
|
||||||
|
acc[monthKey].pendingInvoices += 1;
|
||||||
|
break;
|
||||||
|
case "overdue":
|
||||||
|
acc[monthKey].overdueInvoices += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
month: string;
|
||||||
|
totalInvoices: number;
|
||||||
|
paidInvoices: number;
|
||||||
|
pendingInvoices: number;
|
||||||
|
overdueInvoices: number;
|
||||||
|
}
|
||||||
|
>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert to array and sort by month
|
||||||
|
const chartData = Object.values(monthlyData)
|
||||||
|
.sort((a, b) => a.month.localeCompare(b.month))
|
||||||
|
.slice(-6) // Show last 6 months
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
monthLabel: new Date(item.month + "-01").toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
year: "2-digit",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const CustomTooltip = ({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{
|
||||||
|
payload: {
|
||||||
|
paidInvoices: number;
|
||||||
|
pendingInvoices: number;
|
||||||
|
overdueInvoices: number;
|
||||||
|
totalInvoices: number;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
label?: string;
|
||||||
|
}) => {
|
||||||
|
if (active && payload?.length) {
|
||||||
|
const data = payload[0]!.payload;
|
||||||
|
return (
|
||||||
|
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
||||||
|
<p className="font-medium">{label}</p>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<p style={{ color: "hsl(142 45% 45%)" }}>
|
||||||
|
Paid: {data.paidInvoices}
|
||||||
|
</p>
|
||||||
|
<p style={{ color: "hsl(210 40% 50%)" }}>
|
||||||
|
Pending: {data.pendingInvoices}
|
||||||
|
</p>
|
||||||
|
<p style={{ color: "hsl(0 65% 55%)" }}>
|
||||||
|
Overdue: {data.overdueInvoices}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground border-t pt-1">
|
||||||
|
Total: {data.totalInvoices}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (chartData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
No metrics data available
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Monthly metrics will appear here once you create invoices
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="h-48 w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={chartData}>
|
||||||
|
<XAxis
|
||||||
|
dataKey="monthLabel"
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Bar
|
||||||
|
dataKey="paidInvoices"
|
||||||
|
stackId="a"
|
||||||
|
fill="hsl(142 76% 85%)"
|
||||||
|
radius={[0, 0, 0, 0]}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="pendingInvoices"
|
||||||
|
stackId="a"
|
||||||
|
fill="hsl(210 40% 85%)"
|
||||||
|
radius={[0, 0, 0, 0]}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="overdueInvoices"
|
||||||
|
stackId="a"
|
||||||
|
fill="hsl(0 84% 85%)"
|
||||||
|
radius={[2, 2, 0, 0]}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex justify-center space-x-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div
|
||||||
|
className="h-3 w-3 rounded-full"
|
||||||
|
style={{ backgroundColor: "hsl(142 76% 85%)" }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs">Paid</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div
|
||||||
|
className="h-3 w-3 rounded-full"
|
||||||
|
style={{ backgroundColor: "hsl(210 40% 85%)" }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs">Pending</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div
|
||||||
|
className="h-3 w-3 rounded-full"
|
||||||
|
style={{ backgroundColor: "hsl(0 84% 85%)" }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs">Overdue</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
src/app/dashboard/_components/revenue-chart.tsx
Normal file
159
src/app/dashboard/_components/revenue-chart.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
|
|
||||||
|
interface Invoice {
|
||||||
|
id: string;
|
||||||
|
totalAmount: number;
|
||||||
|
issueDate: Date | string;
|
||||||
|
status: string;
|
||||||
|
dueDate: Date | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RevenueChartProps {
|
||||||
|
invoices: Invoice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RevenueChart({ invoices }: RevenueChartProps) {
|
||||||
|
// Process invoice data to create monthly revenue data
|
||||||
|
const monthlyData = invoices
|
||||||
|
.filter(
|
||||||
|
(invoice) =>
|
||||||
|
getEffectiveInvoiceStatus(
|
||||||
|
invoice.status as StoredInvoiceStatus,
|
||||||
|
invoice.dueDate,
|
||||||
|
) === "paid",
|
||||||
|
)
|
||||||
|
.reduce(
|
||||||
|
(acc, invoice) => {
|
||||||
|
const date = new Date(invoice.issueDate);
|
||||||
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
acc[monthKey] ??= {
|
||||||
|
month: monthKey,
|
||||||
|
revenue: 0,
|
||||||
|
count: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
acc[monthKey].revenue += invoice.totalAmount;
|
||||||
|
acc[monthKey].count += 1;
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, { month: string; revenue: number; count: number }>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert to array and sort by month
|
||||||
|
const chartData = Object.values(monthlyData)
|
||||||
|
.sort((a, b) => a.month.localeCompare(b.month))
|
||||||
|
.slice(-6) // Show last 6 months
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
monthLabel: new Date(item.month + "-01").toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
year: "2-digit",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CustomTooltip = ({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{ payload: { revenue: number; count: number } }>;
|
||||||
|
label?: string;
|
||||||
|
}) => {
|
||||||
|
if (active && payload?.length) {
|
||||||
|
const data = payload[0]!.payload;
|
||||||
|
return (
|
||||||
|
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
||||||
|
<p className="font-medium">{label}</p>
|
||||||
|
<p style={{ color: "hsl(210 40% 50%)" }}>
|
||||||
|
Revenue: {formatCurrency(data.revenue)}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{data.count} invoice{data.count !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (chartData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
No revenue data available
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Revenue will appear here once you have paid invoices
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-64 w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={chartData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop
|
||||||
|
offset="5%"
|
||||||
|
stopColor="hsl(210 40% 70%)"
|
||||||
|
stopOpacity={0.4}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="95%"
|
||||||
|
stopColor="hsl(210 40% 70%)"
|
||||||
|
stopOpacity={0.05}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<XAxis
|
||||||
|
dataKey="monthLabel"
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
||||||
|
tickFormatter={formatCurrency}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="revenue"
|
||||||
|
stroke="hsl(210 40% 60%)"
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="url(#revenueGradient)"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/app/dashboard/invoices/[id]/edit/page.tsx
Normal file
12
src/app/dashboard/invoices/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import InvoiceForm from "~/components/forms/invoice-form";
|
||||||
|
|
||||||
|
export default function InvoiceFormPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const id = params.id as string;
|
||||||
|
|
||||||
|
// Pass the actual id, let the form component handle the logic
|
||||||
|
return <InvoiceForm invoiceId={id} />;
|
||||||
|
}
|
||||||
346
src/components/ui/legal-modal.tsx
Normal file
346
src/components/ui/legal-modal.tsx
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
interface LegalModalProps {
|
||||||
|
type: "terms" | "privacy";
|
||||||
|
trigger: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LegalModal({ type, trigger }: LegalModalProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const isTerms = type === "terms";
|
||||||
|
const title = isTerms ? "Terms of Service" : "Privacy Policy";
|
||||||
|
|
||||||
|
const TermsContent = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Agreement to Terms</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
These Terms of Service ("Terms") govern your use of the
|
||||||
|
beenvoice platform and services (the "Service") operated
|
||||||
|
by beenvoice ("us", "we", or "our").
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
By accessing or using our Service, you agree to be bound by these
|
||||||
|
Terms. If you disagree with any part of these terms, then you may
|
||||||
|
not access the Service.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Description of Service</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
beenvoice is a web-based invoicing platform that allows users to:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Create and manage professional invoices</li>
|
||||||
|
<li>Track client information and billing details</li>
|
||||||
|
<li>Monitor payment status and financial metrics</li>
|
||||||
|
<li>Generate reports and analytics</li>
|
||||||
|
<li>Manage business profiles and settings</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>User Accounts</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
When you create an account with us, you must provide information
|
||||||
|
that is accurate, complete, and current at all times. You are
|
||||||
|
responsible for safeguarding the password and for all activities
|
||||||
|
that occur under your account.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You agree not to disclose your password to any third party. You must
|
||||||
|
notify us immediately upon becoming aware of any breach of security
|
||||||
|
or unauthorized use of your account.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Acceptable Use</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>You agree not to use the Service:</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
For any unlawful purpose or to solicit others to perform unlawful
|
||||||
|
acts
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
To violate any international, federal, provincial, or state
|
||||||
|
regulations, rules, laws, or local ordinances
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
To infringe upon or violate our intellectual property rights or
|
||||||
|
the intellectual property rights of others
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
To harass, abuse, insult, harm, defame, slander, disparage,
|
||||||
|
intimidate, or discriminate
|
||||||
|
</li>
|
||||||
|
<li>To submit false or misleading information</li>
|
||||||
|
<li>
|
||||||
|
To upload or transmit viruses or any other type of malicious code
|
||||||
|
</li>
|
||||||
|
<li>To spam, phish, pharm, pretext, spider, crawl, or scrape</li>
|
||||||
|
<li>For any obscene or immoral purpose</li>
|
||||||
|
<li>
|
||||||
|
To interfere with or circumvent the security features of the
|
||||||
|
Service
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Payment Terms</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
Some aspects of the Service may require payment. You will be charged
|
||||||
|
according to your subscription plan. All fees are non-refundable
|
||||||
|
unless otherwise stated.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
We may change our fees at any time. We will provide you with
|
||||||
|
reasonable notice of any fee changes by posting the new fees on the
|
||||||
|
Service or sending you email notification.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Termination</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
We may terminate or suspend your account and bar access to the
|
||||||
|
Service immediately, without prior notice or liability, under our
|
||||||
|
sole discretion, for any reason whatsoever and without limitation,
|
||||||
|
including but not limited to a breach of the Terms.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you wish to terminate your account, you may simply discontinue
|
||||||
|
using the Service and contact us to request account deletion.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Contact Information</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
If you have any questions about these Terms of Service, please
|
||||||
|
contact us at:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Email: legal@beenvoice.com</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PrivacyContent = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Information We Collect</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<h4>Personal Information</h4>
|
||||||
|
<p>
|
||||||
|
We may collect personal information that you voluntarily provide to
|
||||||
|
us when you:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Register for an account</li>
|
||||||
|
<li>Create invoices or manage client information</li>
|
||||||
|
<li>Contact us for support</li>
|
||||||
|
</ul>
|
||||||
|
<p>This personal information may include:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Name and contact information (email, phone, address)</li>
|
||||||
|
<li>Business information and tax details</li>
|
||||||
|
<li>Client information you input into the system</li>
|
||||||
|
<li>Financial information related to your invoices</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>How We Use Your Information</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>We use the information we collect to:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Provide, operate, and maintain our Service</li>
|
||||||
|
<li>Process your transactions and manage your account</li>
|
||||||
|
<li>Improve and personalize your experience</li>
|
||||||
|
<li>Communicate with you about your account and our services</li>
|
||||||
|
<li>Send you technical notices and support messages</li>
|
||||||
|
<li>Respond to your comments, questions, and requests</li>
|
||||||
|
<li>Monitor usage and analyze trends</li>
|
||||||
|
<li>
|
||||||
|
Detect, prevent, and address technical issues and security
|
||||||
|
breaches
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>How We Share Your Information</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
We do not sell, trade, or rent your personal information to third
|
||||||
|
parties. We may share your information in the following
|
||||||
|
circumstances:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4>Service Providers</h4>
|
||||||
|
<p>
|
||||||
|
We may share your information with trusted third-party service
|
||||||
|
providers who assist us in operating our Service, such as:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Cloud hosting and storage providers</li>
|
||||||
|
<li>Payment processors</li>
|
||||||
|
<li>Email service providers</li>
|
||||||
|
<li>Analytics and monitoring services</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>Legal Requirements</h4>
|
||||||
|
<p>
|
||||||
|
We may disclose your information if required to do so by law or in
|
||||||
|
response to:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Legal processes (subpoenas, court orders)</li>
|
||||||
|
<li>Government requests</li>
|
||||||
|
<li>Law enforcement investigations</li>
|
||||||
|
<li>Protection of our rights, property, or safety</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Data Security</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
We implement appropriate technical and organizational security
|
||||||
|
measures to protect your information:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Encryption of data in transit and at rest</li>
|
||||||
|
<li>Secure access controls and authentication</li>
|
||||||
|
<li>Regular security assessments and updates</li>
|
||||||
|
<li>Employee training on data protection</li>
|
||||||
|
<li>Incident response procedures</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Your Rights and Choices</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
Depending on your location, you may have the following rights
|
||||||
|
regarding your personal information:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Request access to your personal information</li>
|
||||||
|
<li>Correct inaccurate or incomplete information</li>
|
||||||
|
<li>Request deletion of your personal information</li>
|
||||||
|
<li>Restrict the processing of your information</li>
|
||||||
|
<li>Object to certain uses of your data</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
To exercise these rights, please contact us at
|
||||||
|
privacy@beenvoice.com.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Contact Us</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
If you have questions about this Privacy Policy or our privacy
|
||||||
|
practices, please contact us at:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Email: privacy@beenvoice.com</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<span className="inline" onClick={() => setOpen(true)}>
|
||||||
|
{trigger}
|
||||||
|
</span>
|
||||||
|
<DialogContent className="max-h-[80vh] max-w-6xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center justify-between">
|
||||||
|
{title}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<ScrollArea className="h-full max-h-[60vh] pr-4">
|
||||||
|
{isTerms ? <TermsContent /> : <PrivacyContent />}
|
||||||
|
</ScrollArea>
|
||||||
|
<div className="flex justify-end pt-4">
|
||||||
|
<Button onClick={() => setOpen(false)}>Close</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/components/ui/scroll-area.tsx
Normal file
26
src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
interface ScrollAreaProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<HTMLDivElement, ScrollAreaProps>(
|
||||||
|
({ className, children, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"scrollbar-thin scrollbar-track-transparent scrollbar-thumb-border hover:scrollbar-thumb-border/80 relative overflow-auto",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ScrollArea.displayName = "ScrollArea";
|
||||||
|
|
||||||
|
export { ScrollArea };
|
||||||
65
src/components/ui/sonner.tsx
Normal file
65
src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
className="toaster group"
|
||||||
|
position="top-center"
|
||||||
|
closeButton
|
||||||
|
richColors={false}
|
||||||
|
expand={true}
|
||||||
|
duration={4000}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--normal-bg": "hsl(var(--card))",
|
||||||
|
"--normal-text": "hsl(var(--foreground))",
|
||||||
|
"--normal-border": "hsl(var(--border))",
|
||||||
|
"--success-bg": "hsl(var(--card))",
|
||||||
|
"--success-text": "hsl(var(--foreground))",
|
||||||
|
"--success-border": "hsl(142 76% 36%)",
|
||||||
|
"--error-bg": "hsl(var(--card))",
|
||||||
|
"--error-text": "hsl(var(--foreground))",
|
||||||
|
"--error-border": "hsl(0 84% 60%)",
|
||||||
|
"--warning-bg": "hsl(var(--card))",
|
||||||
|
"--warning-text": "hsl(var(--foreground))",
|
||||||
|
"--warning-border": "hsl(38 92% 50%)",
|
||||||
|
"--info-bg": "hsl(var(--card))",
|
||||||
|
"--info-text": "hsl(var(--foreground))",
|
||||||
|
"--info-border": "hsl(221 83% 53%)",
|
||||||
|
backgroundColor: "hsl(var(--card))",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast:
|
||||||
|
"group toast group-[.toaster]:bg-card group-[.toaster]:text-foreground group-[.toaster]:border group-[.toaster]:border-border group-[.toaster]:shadow-lg group-[.toaster]:rounded-none group-[.toaster]:font-mono !bg-card",
|
||||||
|
description:
|
||||||
|
"group-[.toast]:text-muted-foreground group-[.toast]:text-sm",
|
||||||
|
actionButton:
|
||||||
|
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground group-[.toast]:hover:bg-primary/90 group-[.toast]:rounded-none group-[.toast]:border-none group-[.toast]:font-mono group-[.toast]:font-medium",
|
||||||
|
cancelButton:
|
||||||
|
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground group-[.toast]:hover:bg-muted/80 group-[.toast]:rounded-none group-[.toast]:border group-[.toast]:border-border group-[.toast]:font-mono",
|
||||||
|
closeButton:
|
||||||
|
"group-[.toast]:bg-background group-[.toast]:text-foreground group-[.toast]:border group-[.toast]:border-border group-[.toast]:hover:bg-muted group-[.toast]:rounded-none group-[.toast]:absolute group-[.toast]:top-1/2 group-[.toast]:right-2 group-[.toast]:-translate-y-1/2 group-[.toast]:w-5 group-[.toast]:h-5 group-[.toast]:flex-shrink-0",
|
||||||
|
success:
|
||||||
|
"group-[.toaster]:bg-card group-[.toaster]:text-foreground group-[.toaster]:border group-[.toaster]:border-border group-[.toaster]:border-l-4 group-[.toaster]:border-l-green-500 group-[.toaster]:shadow-lg",
|
||||||
|
error:
|
||||||
|
"group-[.toaster]:bg-card group-[.toaster]:text-foreground group-[.toaster]:border group-[.toaster]:border-border group-[.toaster]:border-l-4 group-[.toaster]:border-l-red-500 group-[.toaster]:shadow-lg",
|
||||||
|
warning:
|
||||||
|
"group-[.toaster]:bg-card group-[.toaster]:text-foreground group-[.toaster]:border group-[.toaster]:border-border group-[.toaster]:border-l-4 group-[.toaster]:border-l-yellow-500 group-[.toaster]:shadow-lg",
|
||||||
|
info: "group-[.toaster]:bg-card group-[.toaster]:text-foreground group-[.toaster]:border group-[.toaster]:border-border group-[.toaster]:border-l-4 group-[.toaster]:border-l-blue-500 group-[.toaster]:shadow-lg",
|
||||||
|
title:
|
||||||
|
"group-[.toast]:text-foreground group-[.toast]:font-semibold group-[.toast]:text-sm group-[.toast]:font-mono",
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontFamily: "var(--font-geist-mono, ui-monospace, monospace)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Toaster };
|
||||||
232
src/lib/email-templates/password-reset-email.ts
Normal file
232
src/lib/email-templates/password-reset-email.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { formatEmailDate } from "src/lib/email-utils";
|
||||||
|
|
||||||
|
interface PasswordResetEmailProps {
|
||||||
|
userEmail: string;
|
||||||
|
userName?: string;
|
||||||
|
resetToken: string;
|
||||||
|
resetUrl: string;
|
||||||
|
expiryHours?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generatePasswordResetEmailTemplate({
|
||||||
|
userEmail,
|
||||||
|
userName,
|
||||||
|
resetUrl,
|
||||||
|
expiryHours = 24,
|
||||||
|
}: PasswordResetEmailProps) {
|
||||||
|
const displayName = userName ?? userEmail.split("@")[0];
|
||||||
|
const currentDate = formatEmailDate(new Date());
|
||||||
|
|
||||||
|
// HTML version
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Password Reset - beenvoice</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Geist Mono', ui-monospace, 'Courier New', monospace;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #0f0f0f;
|
||||||
|
background-color: #fafafa;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #e5e5e5;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 32px 32px 24px;
|
||||||
|
border-bottom: 1px solid #e5e5e5;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f0f0f;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.logo .dollar {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
.logo .voice {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
color: #0f0f0f;
|
||||||
|
}
|
||||||
|
.text {
|
||||||
|
margin: 0 0 24px 0;
|
||||||
|
color: #525252;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.button-container {
|
||||||
|
margin: 32px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #16a34a;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 12px 24px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
border: none;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
.button:hover {
|
||||||
|
background-color: #15803d;
|
||||||
|
}
|
||||||
|
.security-notice {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-left: 4px solid #16a34a;
|
||||||
|
padding: 16px;
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
.security-notice h4 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f0f0f;
|
||||||
|
}
|
||||||
|
.security-notice p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #525252;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 24px 32px;
|
||||||
|
border-top: 1px solid #e5e5e5;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #737373;
|
||||||
|
}
|
||||||
|
.footer a {
|
||||||
|
color: #16a34a;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background-color: #e5e5e5;
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.container {
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.header, .content, .footer {
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1 class="logo">
|
||||||
|
<span class="dollar">$</span> been<span class="voice">voice</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<h2 class="title">Reset Your Password</h2>
|
||||||
|
|
||||||
|
<p class="text">Hello ${displayName},</p>
|
||||||
|
|
||||||
|
<p class="text">
|
||||||
|
We received a request to reset the password for your beenvoice account.
|
||||||
|
If you made this request, click the button below to set a new password.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="button-container">
|
||||||
|
<a href="${resetUrl}" class="button">Reset Your Password</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text">
|
||||||
|
If the button doesn't work, copy and paste this link into your browser:
|
||||||
|
<br>
|
||||||
|
<a href="${resetUrl}" style="color: #16a34a; word-break: break-all;">${resetUrl}</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="security-notice">
|
||||||
|
<h4>Security Information</h4>
|
||||||
|
<p>This password reset link will expire in ${expiryHours} hours for your security.</p>
|
||||||
|
<p>If you didn't request this password reset, you can safely ignore this email.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<p class="text">
|
||||||
|
If you're having trouble accessing your account or have questions,
|
||||||
|
please contact our support team.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="text">
|
||||||
|
Best regards,<br>
|
||||||
|
The beenvoice Team
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>
|
||||||
|
This email was sent to <strong>${userEmail}</strong> on ${currentDate}.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
beenvoice - Professional invoicing made simple<br>
|
||||||
|
<a href="mailto:support@beenvoice.com">support@beenvoice.com</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
// Plain text version
|
||||||
|
const text = `
|
||||||
|
beenvoice - Password Reset
|
||||||
|
|
||||||
|
Hello ${displayName},
|
||||||
|
|
||||||
|
We received a request to reset the password for your beenvoice account.
|
||||||
|
|
||||||
|
To reset your password, please visit this link:
|
||||||
|
${resetUrl}
|
||||||
|
|
||||||
|
SECURITY INFORMATION:
|
||||||
|
- This link will expire in ${expiryHours} hours
|
||||||
|
- If you didn't request this reset, you can safely ignore this email
|
||||||
|
- Never share this link with anyone
|
||||||
|
|
||||||
|
If you're having trouble with the link, copy and paste the entire URL into your browser's address bar.
|
||||||
|
|
||||||
|
If you have any questions or need assistance, please contact our support team at support@beenvoice.com.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
The beenvoice Team
|
||||||
|
|
||||||
|
---
|
||||||
|
This email was sent to ${userEmail} on ${currentDate}.
|
||||||
|
beenvoice - Professional invoicing made simple
|
||||||
|
`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
html: html.trim(),
|
||||||
|
text: text.trim(),
|
||||||
|
subject: "Reset Your beenvoice Password",
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user