MVP
This commit is contained in:
116
src/components/CookieBanner.tsx
Normal file
116
src/components/CookieBanner.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export default function CookieBanner() {
|
||||
const [showBanner, setShowBanner] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user has already made a choice
|
||||
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||
if (!cookieConsent) {
|
||||
// Show banner after a short delay for better UX
|
||||
const timer = setTimeout(() => {
|
||||
setShowBanner(true);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAccept = () => {
|
||||
localStorage.setItem('cookieConsent', 'accepted');
|
||||
setShowBanner(false);
|
||||
// Reload page to initialize PostHog
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const handleDecline = () => {
|
||||
localStorage.setItem('cookieConsent', 'declined');
|
||||
setShowBanner(false);
|
||||
};
|
||||
|
||||
if (!showBanner) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Cookie Banner - Bottom Left Corner */}
|
||||
<div className="fixed bottom-4 left-4 z-50 max-w-md animate-slide-in">
|
||||
<div className="bg-white rounded-lg shadow-2xl border border-gray-200 p-6">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">
|
||||
We use cookies
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm leading-relaxed mb-3">
|
||||
We use essential cookies for authentication and analytics cookies to improve your experience.{' '}
|
||||
<Link href="/privacy" className="text-primary-600 hover:text-primary-700 font-medium underline">
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{/* Cookie Categories */}
|
||||
<div className="space-y-1.5 mb-4">
|
||||
<div className="flex items-center text-xs">
|
||||
<svg className="w-3.5 h-3.5 text-success-600 mr-1.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-gray-700"><strong>Essential:</strong> Authentication, CSRF protection</span>
|
||||
</div>
|
||||
<div className="flex items-center text-xs">
|
||||
<svg className="w-3.5 h-3.5 text-primary-600 mr-1.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-gray-700"><strong>Analytics:</strong> PostHog for website analytics</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDecline}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleAccept}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
Accept All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slide-in 0.4s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
88
src/components/PostHogProvider.tsx
Normal file
88
src/components/PostHogProvider.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import posthog from 'posthog-js';
|
||||
|
||||
export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user has consented to analytics cookies
|
||||
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||
|
||||
// Only initialize PostHog if user has accepted cookies
|
||||
if (cookieConsent === 'accepted') {
|
||||
posthog.init('phc_97JBJVVQlqqiZuTVRHuBnnG9HasOv3GSsdeVjossizJ', {
|
||||
api_host: 'https://us.i.posthog.com',
|
||||
person_profiles: 'identified_only',
|
||||
capture_pageview: false, // We'll capture manually
|
||||
capture_pageleave: true,
|
||||
autocapture: true,
|
||||
// Privacy-friendly settings
|
||||
respect_dnt: true,
|
||||
opt_out_capturing_by_default: false,
|
||||
loaded: (posthog) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
posthog.debug();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (cookieConsent === 'accepted') {
|
||||
posthog.opt_out_capturing();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Track page views
|
||||
useEffect(() => {
|
||||
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||
|
||||
if (cookieConsent === 'accepted' && pathname) {
|
||||
let url = window.origin + pathname;
|
||||
if (searchParams && searchParams.toString()) {
|
||||
url = url + `?${searchParams.toString()}`;
|
||||
}
|
||||
posthog.capture('$pageview', {
|
||||
$current_url: url,
|
||||
});
|
||||
}
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to identify user after login
|
||||
*/
|
||||
export function identifyUser(userId: string, traits?: Record<string, any>) {
|
||||
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||
if (cookieConsent === 'accepted') {
|
||||
posthog.identify(userId, traits);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to track custom events
|
||||
*/
|
||||
export function trackEvent(eventName: string, properties?: Record<string, any>) {
|
||||
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||
if (cookieConsent === 'accepted') {
|
||||
posthog.capture(eventName, properties);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to reset user on logout
|
||||
*/
|
||||
export function resetUser() {
|
||||
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||
if (cookieConsent === 'accepted') {
|
||||
posthog.reset();
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,9 @@ interface QRCodeCardProps {
|
||||
status: 'ACTIVE' | 'PAUSED';
|
||||
createdAt: string;
|
||||
scans?: number;
|
||||
style?: any;
|
||||
};
|
||||
onEdit: (id: string) => void;
|
||||
onDuplicate: (id: string) => void;
|
||||
onPause: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
@@ -28,7 +28,6 @@ interface QRCodeCardProps {
|
||||
export const QRCodeCard: React.FC<QRCodeCardProps> = ({
|
||||
qr,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onPause,
|
||||
onDelete,
|
||||
}) => {
|
||||
@@ -138,11 +137,14 @@ export const QRCodeCard: React.FC<QRCodeCardProps> = ({
|
||||
>
|
||||
<DropdownItem onClick={() => downloadQR('png')}>Download PNG</DropdownItem>
|
||||
<DropdownItem onClick={() => downloadQR('svg')}>Download SVG</DropdownItem>
|
||||
<DropdownItem onClick={() => onEdit(qr.id)}>Edit</DropdownItem>
|
||||
<DropdownItem onClick={() => onDuplicate(qr.id)}>Duplicate</DropdownItem>
|
||||
<DropdownItem onClick={() => onPause(qr.id)}>
|
||||
{qr.status === 'ACTIVE' ? 'Pause' : 'Resume'}
|
||||
</DropdownItem>
|
||||
{qr.type === 'DYNAMIC' && (
|
||||
<DropdownItem onClick={() => onEdit(qr.id)}>Edit</DropdownItem>
|
||||
)}
|
||||
{qr.type === 'DYNAMIC' && (
|
||||
<DropdownItem onClick={() => onPause(qr.id)}>
|
||||
{qr.status === 'ACTIVE' ? 'Pause' : 'Resume'}
|
||||
</DropdownItem>
|
||||
)}
|
||||
<DropdownItem onClick={() => onDelete(qr.id)} className="text-red-600">
|
||||
Delete
|
||||
</DropdownItem>
|
||||
@@ -153,8 +155,8 @@ export const QRCodeCard: React.FC<QRCodeCardProps> = ({
|
||||
<QRCodeSVG
|
||||
value={qrUrl}
|
||||
size={96}
|
||||
fgColor="#000000"
|
||||
bgColor="#FFFFFF"
|
||||
fgColor={qr.style?.foregroundColor || '#000000'}
|
||||
bgColor={qr.style?.backgroundColor || '#FFFFFF'}
|
||||
level="M"
|
||||
/>
|
||||
</div>
|
||||
@@ -164,10 +166,12 @@ export const QRCodeCard: React.FC<QRCodeCardProps> = ({
|
||||
<span className="text-gray-500">Type:</span>
|
||||
<span className="text-gray-900">{qr.contentType}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-500">Scans:</span>
|
||||
<span className="text-gray-900">{qr.scans || 0}</span>
|
||||
</div>
|
||||
{qr.type === 'DYNAMIC' && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-500">Scans:</span>
|
||||
<span className="text-gray-900">{qr.scans || 0}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-500">Created:</span>
|
||||
<span className="text-gray-900">{formatDate(qr.createdAt)}</span>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { formatNumber } from '@/lib/utils';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
interface StatsGridProps {
|
||||
stats: {
|
||||
@@ -13,12 +14,13 @@ interface StatsGridProps {
|
||||
}
|
||||
|
||||
export const StatsGrid: React.FC<StatsGridProps> = ({ stats }) => {
|
||||
const { t } = useTranslation();
|
||||
// Only show growth if there are actual scans
|
||||
const showGrowth = stats.totalScans > 0;
|
||||
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: 'Total Scans',
|
||||
title: t('dashboard.stats.total_scans'),
|
||||
value: formatNumber(stats.totalScans),
|
||||
change: showGrowth ? '+12%' : 'No data yet',
|
||||
changeType: showGrowth ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
|
||||
@@ -30,7 +32,7 @@ export const StatsGrid: React.FC<StatsGridProps> = ({ stats }) => {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Active QR Codes',
|
||||
title: t('dashboard.stats.active_codes'),
|
||||
value: stats.activeQRCodes.toString(),
|
||||
change: stats.activeQRCodes > 0 ? `${stats.activeQRCodes} active` : 'Create your first',
|
||||
changeType: stats.activeQRCodes > 0 ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
|
||||
@@ -41,7 +43,7 @@ export const StatsGrid: React.FC<StatsGridProps> = ({ stats }) => {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Conversion Rate',
|
||||
title: t('dashboard.stats.conversion_rate'),
|
||||
value: `${stats.conversionRate}%`,
|
||||
change: stats.totalScans > 0 ? `${stats.conversionRate}% rate` : 'No scans yet',
|
||||
changeType: stats.conversionRate > 0 ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
|
||||
|
||||
@@ -15,15 +15,12 @@ export const FAQ: React.FC<FAQProps> = ({ t }) => {
|
||||
'static_vs_dynamic',
|
||||
'forever',
|
||||
'file_type',
|
||||
'password',
|
||||
'analytics',
|
||||
'privacy',
|
||||
'bulk',
|
||||
];
|
||||
|
||||
return (
|
||||
<section id="faq" className="py-16 bg-gray-50">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
|
||||
{t.faq.title}
|
||||
@@ -57,6 +54,12 @@ export const FAQ: React.FC<FAQProps> = ({ t }) => {
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-8">
|
||||
<a href="/faq" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||
View All Questions →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -27,11 +27,20 @@ export const Features: React.FC<FeaturesProps> = ({ t }) => {
|
||||
),
|
||||
color: 'text-purple-600 bg-purple-100',
|
||||
},
|
||||
{
|
||||
key: 'unlimited',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
color: 'text-green-600 bg-green-100',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="py-16 bg-gray-50">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
|
||||
{t.features.title}
|
||||
|
||||
@@ -12,15 +12,15 @@ interface HeroProps {
|
||||
|
||||
export const Hero: React.FC<HeroProps> = ({ t }) => {
|
||||
const templateCards = [
|
||||
{ title: 'Restaurant Menu', color: 'bg-pink-100', icon: '🍽️' },
|
||||
{ title: 'Business Card', color: 'bg-blue-100', icon: '💼' },
|
||||
{ title: 'Event Tickets', color: 'bg-green-100', icon: '🎫' },
|
||||
{ title: 'WiFi Access', color: 'bg-purple-100', icon: '📶' },
|
||||
{ title: 'URL/Website', color: 'bg-blue-100', icon: '🌐' },
|
||||
{ title: 'WiFi', color: 'bg-purple-100', icon: '📶' },
|
||||
{ title: 'Email', color: 'bg-green-100', icon: '📧' },
|
||||
{ title: 'Phone Number', color: 'bg-pink-100', icon: '📞' },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-white to-purple-50 py-20">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||
{/* Left Content */}
|
||||
<div className="space-y-8">
|
||||
@@ -56,7 +56,7 @@ export const Hero: React.FC<HeroProps> = ({ t }) => {
|
||||
{t.hero.cta_primary}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/create">
|
||||
<Link href="/#pricing">
|
||||
<Button variant="outline" size="lg" className="text-lg px-8 py-4 w-full sm:w-auto">
|
||||
{t.hero.cta_secondary}
|
||||
</Button>
|
||||
|
||||
@@ -28,22 +28,6 @@ export default function HomePageClient() {
|
||||
return (
|
||||
<>
|
||||
<Hero t={t} />
|
||||
<StatsStrip t={t} />
|
||||
|
||||
{/* Industry Buttons */}
|
||||
<section className="py-8">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
{industries.map((industry) => (
|
||||
<Button key={industry} variant="outline" size="sm">
|
||||
{industry}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<TemplateCards t={t} />
|
||||
<InstantGenerator t={t} />
|
||||
<StaticVsDynamic t={t} />
|
||||
<Features t={t} />
|
||||
|
||||
@@ -74,7 +74,7 @@ export const InstantGenerator: React.FC<InstantGeneratorProps> = ({ t }) => {
|
||||
|
||||
return (
|
||||
<section className="py-16 bg-gray-50">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
|
||||
{t.generator.title}
|
||||
@@ -180,7 +180,7 @@ export const InstantGenerator: React.FC<InstantGeneratorProps> = ({ t }) => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button className="w-full">
|
||||
<Button className="w-full" onClick={() => window.location.href = '/login'}>
|
||||
{t.generator.save_track}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
@@ -27,7 +27,7 @@ export const Pricing: React.FC<PricingProps> = ({ t }) => {
|
||||
|
||||
return (
|
||||
<section id="pricing" className="py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
|
||||
{t.pricing.title}
|
||||
@@ -67,7 +67,7 @@ export const Pricing: React.FC<PricingProps> = ({ t }) => {
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<ul className="space-y-3">
|
||||
{t.pricing[plan.key].features.map((feature: string, index: number) => (
|
||||
{t.pricing[plan.key].features.slice(0, 3).map((feature: string, index: number) => (
|
||||
<li key={index} className="flex items-start space-x-3">
|
||||
<svg className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
@@ -88,6 +88,12 @@ export const Pricing: React.FC<PricingProps> = ({ t }) => {
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-8">
|
||||
<a href="/#pricing" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||
View Full Pricing Details →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ interface StaticVsDynamicProps {
|
||||
export const StaticVsDynamic: React.FC<StaticVsDynamicProps> = ({ t }) => {
|
||||
return (
|
||||
<section className="py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||
<div className="grid lg:grid-cols-2 gap-8 max-w-6xl mx-auto">
|
||||
{/* Static QR Codes */}
|
||||
<Card className="relative">
|
||||
|
||||
@@ -16,7 +16,7 @@ export const StatsStrip: React.FC<StatsStripProps> = ({ t }) => {
|
||||
|
||||
return (
|
||||
<section className="py-16 bg-gray-50">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{stats.map((stat, index) => (
|
||||
<div key={stat.key} className="text-center">
|
||||
|
||||
@@ -42,7 +42,7 @@ export const TemplateCards: React.FC<TemplateCardsProps> = ({ t }) => {
|
||||
|
||||
return (
|
||||
<section className="py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
|
||||
{t.templates.title}
|
||||
|
||||
164
src/components/settings/ChangePasswordModal.tsx
Normal file
164
src/components/settings/ChangePasswordModal.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
|
||||
interface ChangePasswordModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export default function ChangePasswordModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: ChangePasswordModalProps) {
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validation
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
showToast('Please fill in all fields', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
showToast('New password must be at least 8 characters', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
showToast('New passwords do not match', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPassword === newPassword) {
|
||||
showToast('New password must be different from current password', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/user/password', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to change password');
|
||||
}
|
||||
|
||||
showToast('Password changed successfully!', 'success');
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
onSuccess();
|
||||
} catch (error: any) {
|
||||
showToast(error.message || 'Failed to change password', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="relative bg-white rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Change Password</h2>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Enter your current password and choose a new one
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Current Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Enter current password"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Enter new password (min. 8 characters)"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Confirm new password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="flex-1"
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Updating...' : 'Update Password'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user