mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2025-12-11 08:34:43 -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