Theme overhaul - missing files

This commit is contained in:
2025-07-31 18:37:45 -04:00
parent 8a2565adad
commit 2a4f78a762
17 changed files with 2953 additions and 0 deletions

Binary file not shown.

View 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 (&quot;we&quot;, &quot;our&quot;, or &quot;us&quot;)
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 &quot;Contact Us&quot; 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&apos;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>
);
}

View 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 (&quot;Terms&quot;) govern your use of the
beenvoice platform and services (the &quot;Service&quot;) operated by
beenvoice (&quot;us&quot;, &quot;we&quot;, or &quot;our&quot;).
</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 &quot;as
is&quot; 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>
);
}

View 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 },
);
}
}

View 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 },
);
}
}

View 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 },
);
}
}

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

View 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&apos;s something you&apos;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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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} />;
}

View 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 (&quot;Terms&quot;) govern your use of the
beenvoice platform and services (the &quot;Service&quot;) operated
by beenvoice (&quot;us&quot;, &quot;we&quot;, or &quot;our&quot;).
</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>
);
}

View 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 };

View 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 };

View 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",
};
}