search console SEO ableitungen
This commit is contained in:
@@ -1,37 +1,37 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface BreadcrumbsProps {
|
||||
items: BreadcrumbItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Breadcrumbs({ items, className }: BreadcrumbsProps) {
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className={`mb-6 ${className || ''}`}>
|
||||
<ol className="flex items-center space-x-2 text-sm text-gray-600">
|
||||
{items.map((item, index) => (
|
||||
<li key={item.url} className="flex items-center">
|
||||
{index > 0 && <span className="mx-2">/</span>}
|
||||
{index === items.length - 1 ? (
|
||||
<span className="font-semibold text-gray-900" aria-current="page">
|
||||
{item.name}
|
||||
</span>
|
||||
) : (
|
||||
<Link href={item.url} className="hover:text-blue-600 transition-colors">
|
||||
{item.name}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export { type BreadcrumbItem as BreadcrumbItemType };
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface BreadcrumbsProps {
|
||||
items: BreadcrumbItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Breadcrumbs({ items, className }: BreadcrumbsProps) {
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className={`mb-6 ${className || ''}`}>
|
||||
<ol className="flex items-center space-x-2 text-sm text-gray-600">
|
||||
{items.map((item, index) => (
|
||||
<li key={item.url} className="flex items-center">
|
||||
{index > 0 && <span className="mx-2">/</span>}
|
||||
{index === items.length - 1 ? (
|
||||
<span className="font-semibold text-gray-900" aria-current="page">
|
||||
{item.name}
|
||||
</span>
|
||||
) : (
|
||||
<Link href={item.url} className="hover:text-blue-600 transition-colors">
|
||||
{item.name}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export { type BreadcrumbItem as BreadcrumbItemType };
|
||||
|
||||
@@ -1,118 +1,118 @@
|
||||
'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);
|
||||
};
|
||||
|
||||
return (
|
||||
<div suppressHydrationWarning>
|
||||
{showBanner && (
|
||||
<>
|
||||
{/* 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 about our privacy policy
|
||||
</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 & Google 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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
'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);
|
||||
};
|
||||
|
||||
return (
|
||||
<div suppressHydrationWarning>
|
||||
{showBanner && (
|
||||
<>
|
||||
{/* 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 about our privacy policy
|
||||
</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 & Google 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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
|
||||
export default function AuthProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
'use client';
|
||||
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
|
||||
export default function AuthProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
|
||||
@@ -1,108 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { formatNumber } from '@/lib/utils';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { TrendData } from '@/types/analytics';
|
||||
|
||||
interface StatsGridProps {
|
||||
stats: {
|
||||
totalScans: number;
|
||||
activeQRCodes: number;
|
||||
conversionRate: number;
|
||||
uniqueScans?: number;
|
||||
};
|
||||
trends?: {
|
||||
totalScans?: TrendData;
|
||||
comparisonPeriod?: 'week' | 'month';
|
||||
};
|
||||
}
|
||||
|
||||
export const StatsGrid: React.FC<StatsGridProps> = ({ stats, trends }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Build trend display text
|
||||
const getTrendText = () => {
|
||||
if (!trends?.totalScans) {
|
||||
return 'No data yet';
|
||||
}
|
||||
|
||||
const trend = trends.totalScans;
|
||||
const sign = trend.isNegative ? '-' : '+';
|
||||
const period = trends.comparisonPeriod || 'period';
|
||||
const newLabel = trend.isNew ? ' (new)' : '';
|
||||
|
||||
return `${sign}${trend.percentage}%${newLabel} from last ${period}`;
|
||||
};
|
||||
|
||||
const getTrendType = (): 'positive' | 'negative' | 'neutral' => {
|
||||
if (!trends?.totalScans) return 'neutral';
|
||||
if (trends.totalScans.trend === 'up') return 'positive';
|
||||
if (trends.totalScans.trend === 'down') return 'negative';
|
||||
return 'neutral';
|
||||
};
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: t('dashboard.stats.total_scans'),
|
||||
value: formatNumber(stats.totalScans),
|
||||
change: getTrendText(),
|
||||
changeType: getTrendType(),
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
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',
|
||||
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 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Unique Users',
|
||||
value: formatNumber(stats.uniqueScans ?? 0),
|
||||
change: stats.totalScans > 0 ? `${stats.uniqueScans ?? 0} unique visitors` : 'No scans yet',
|
||||
changeType: (stats.uniqueScans ?? 0) > 0 ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{cards.map((card, index) => (
|
||||
<Card key={index}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">{card.title}</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{card.value}</p>
|
||||
<p className={`text-sm mt-2 ${card.changeType === 'positive' ? 'text-success-600' :
|
||||
card.changeType === 'negative' ? 'text-red-600' :
|
||||
'text-gray-500'
|
||||
}`}>
|
||||
{card.change}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center text-primary-600">
|
||||
{card.icon}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { formatNumber } from '@/lib/utils';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { TrendData } from '@/types/analytics';
|
||||
|
||||
interface StatsGridProps {
|
||||
stats: {
|
||||
totalScans: number;
|
||||
activeQRCodes: number;
|
||||
conversionRate: number;
|
||||
uniqueScans?: number;
|
||||
};
|
||||
trends?: {
|
||||
totalScans?: TrendData;
|
||||
comparisonPeriod?: 'week' | 'month';
|
||||
};
|
||||
}
|
||||
|
||||
export const StatsGrid: React.FC<StatsGridProps> = ({ stats, trends }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Build trend display text
|
||||
const getTrendText = () => {
|
||||
if (!trends?.totalScans) {
|
||||
return 'No data yet';
|
||||
}
|
||||
|
||||
const trend = trends.totalScans;
|
||||
const sign = trend.isNegative ? '-' : '+';
|
||||
const period = trends.comparisonPeriod || 'period';
|
||||
const newLabel = trend.isNew ? ' (new)' : '';
|
||||
|
||||
return `${sign}${trend.percentage}%${newLabel} from last ${period}`;
|
||||
};
|
||||
|
||||
const getTrendType = (): 'positive' | 'negative' | 'neutral' => {
|
||||
if (!trends?.totalScans) return 'neutral';
|
||||
if (trends.totalScans.trend === 'up') return 'positive';
|
||||
if (trends.totalScans.trend === 'down') return 'negative';
|
||||
return 'neutral';
|
||||
};
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: t('dashboard.stats.total_scans'),
|
||||
value: formatNumber(stats.totalScans),
|
||||
change: getTrendText(),
|
||||
changeType: getTrendType(),
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
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',
|
||||
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 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Unique Users',
|
||||
value: formatNumber(stats.uniqueScans ?? 0),
|
||||
change: stats.totalScans > 0 ? `${stats.uniqueScans ?? 0} unique visitors` : 'No scans yet',
|
||||
changeType: (stats.uniqueScans ?? 0) > 0 ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{cards.map((card, index) => (
|
||||
<Card key={index}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">{card.title}</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{card.value}</p>
|
||||
<p className={`text-sm mt-2 ${card.changeType === 'positive' ? 'text-success-600' :
|
||||
card.changeType === 'negative' ? 'text-red-600' :
|
||||
'text-gray-500'
|
||||
}`}>
|
||||
{card.change}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center text-primary-600">
|
||||
{card.icon}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,157 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { calculateContrast } from '@/lib/utils';
|
||||
|
||||
interface QRPreviewProps {
|
||||
content: string;
|
||||
style: {
|
||||
foregroundColor: string;
|
||||
backgroundColor: string;
|
||||
cornerStyle: 'square' | 'rounded';
|
||||
size: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const QRPreview: React.FC<QRPreviewProps> = ({ content, style }) => {
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string>('');
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const contrast = calculateContrast(style.foregroundColor, style.backgroundColor);
|
||||
const hasGoodContrast = contrast >= 4.5;
|
||||
|
||||
useEffect(() => {
|
||||
const generateQR = async () => {
|
||||
try {
|
||||
if (!content) {
|
||||
setQrDataUrl('');
|
||||
return;
|
||||
}
|
||||
|
||||
const options = {
|
||||
width: style.size,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: style.foregroundColor,
|
||||
light: style.backgroundColor,
|
||||
},
|
||||
errorCorrectionLevel: 'M' as const,
|
||||
};
|
||||
|
||||
const dataUrl = await QRCode.toDataURL(content, options);
|
||||
setQrDataUrl(dataUrl);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
console.error('Error generating QR code:', err);
|
||||
setError('Failed to generate QR code');
|
||||
}
|
||||
};
|
||||
|
||||
generateQR();
|
||||
}, [content, style]);
|
||||
|
||||
const downloadQR = async (format: 'svg' | 'png') => {
|
||||
if (!content) return;
|
||||
|
||||
try {
|
||||
if (format === 'svg') {
|
||||
const svg = await QRCode.toString(content, {
|
||||
type: 'svg',
|
||||
width: style.size,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: style.foregroundColor,
|
||||
light: style.backgroundColor,
|
||||
},
|
||||
});
|
||||
|
||||
const blob = new Blob([svg], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'qrcode.svg';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
// For PNG, use the canvas
|
||||
const canvas = document.createElement('canvas');
|
||||
await QRCode.toCanvas(canvas, content, {
|
||||
width: style.size,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: style.foregroundColor,
|
||||
light: style.backgroundColor,
|
||||
},
|
||||
});
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'qrcode.png';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error downloading QR code:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-center">
|
||||
{error ? (
|
||||
<div className="w-[200px] h-[200px] bg-gray-100 rounded-lg flex items-center justify-center text-gray-500">
|
||||
{error}
|
||||
</div>
|
||||
) : qrDataUrl ? (
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="QR Code Preview"
|
||||
className={`border-2 border-gray-200 ${style.cornerStyle === 'rounded' ? 'rounded-lg' : ''}`}
|
||||
style={{ width: Math.min(style.size, 300), height: Math.min(style.size, 300) }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-[200px] h-[200px] bg-gray-100 rounded-lg flex items-center justify-center text-gray-500">
|
||||
Enter content to generate QR code
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant={hasGoodContrast ? 'success' : 'warning'}>
|
||||
{hasGoodContrast ? 'Good contrast' : 'Low contrast'}
|
||||
</Badge>
|
||||
<span className="text-sm text-gray-500">
|
||||
Contrast: {contrast.toFixed(1)}:1
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => downloadQR('svg')}
|
||||
disabled={!content || !qrDataUrl}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Download SVG
|
||||
</button>
|
||||
<button
|
||||
onClick={() => downloadQR('png')}
|
||||
disabled={!content || !qrDataUrl}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Download PNG
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { calculateContrast } from '@/lib/utils';
|
||||
|
||||
interface QRPreviewProps {
|
||||
content: string;
|
||||
style: {
|
||||
foregroundColor: string;
|
||||
backgroundColor: string;
|
||||
cornerStyle: 'square' | 'rounded';
|
||||
size: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const QRPreview: React.FC<QRPreviewProps> = ({ content, style }) => {
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string>('');
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const contrast = calculateContrast(style.foregroundColor, style.backgroundColor);
|
||||
const hasGoodContrast = contrast >= 4.5;
|
||||
|
||||
useEffect(() => {
|
||||
const generateQR = async () => {
|
||||
try {
|
||||
if (!content) {
|
||||
setQrDataUrl('');
|
||||
return;
|
||||
}
|
||||
|
||||
const options = {
|
||||
width: style.size,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: style.foregroundColor,
|
||||
light: style.backgroundColor,
|
||||
},
|
||||
errorCorrectionLevel: 'M' as const,
|
||||
};
|
||||
|
||||
const dataUrl = await QRCode.toDataURL(content, options);
|
||||
setQrDataUrl(dataUrl);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
console.error('Error generating QR code:', err);
|
||||
setError('Failed to generate QR code');
|
||||
}
|
||||
};
|
||||
|
||||
generateQR();
|
||||
}, [content, style]);
|
||||
|
||||
const downloadQR = async (format: 'svg' | 'png') => {
|
||||
if (!content) return;
|
||||
|
||||
try {
|
||||
if (format === 'svg') {
|
||||
const svg = await QRCode.toString(content, {
|
||||
type: 'svg',
|
||||
width: style.size,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: style.foregroundColor,
|
||||
light: style.backgroundColor,
|
||||
},
|
||||
});
|
||||
|
||||
const blob = new Blob([svg], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'qrcode.svg';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
// For PNG, use the canvas
|
||||
const canvas = document.createElement('canvas');
|
||||
await QRCode.toCanvas(canvas, content, {
|
||||
width: style.size,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: style.foregroundColor,
|
||||
light: style.backgroundColor,
|
||||
},
|
||||
});
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'qrcode.png';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error downloading QR code:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-center">
|
||||
{error ? (
|
||||
<div className="w-[200px] h-[200px] bg-gray-100 rounded-lg flex items-center justify-center text-gray-500">
|
||||
{error}
|
||||
</div>
|
||||
) : qrDataUrl ? (
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="QR Code Preview"
|
||||
className={`border-2 border-gray-200 ${style.cornerStyle === 'rounded' ? 'rounded-lg' : ''}`}
|
||||
style={{ width: Math.min(style.size, 300), height: Math.min(style.size, 300) }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-[200px] h-[200px] bg-gray-100 rounded-lg flex items-center justify-center text-gray-500">
|
||||
Enter content to generate QR code
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant={hasGoodContrast ? 'success' : 'warning'}>
|
||||
{hasGoodContrast ? 'Good contrast' : 'Low contrast'}
|
||||
</Badge>
|
||||
<span className="text-sm text-gray-500">
|
||||
Contrast: {contrast.toFixed(1)}:1
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => downloadQR('svg')}
|
||||
disabled={!content || !qrDataUrl}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Download SVG
|
||||
</button>
|
||||
<button
|
||||
onClick={() => downloadQR('png')}
|
||||
disabled={!content || !qrDataUrl}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Download PNG
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,73 +1,73 @@
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
import { TrackedCtaLink } from "@/components/marketing/MarketingAnalytics";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
|
||||
type PageType = "commercial" | "use_case_hub" | "use_case";
|
||||
|
||||
type GrowthLink = {
|
||||
href: string;
|
||||
title: string;
|
||||
description: string;
|
||||
ctaLabel: string;
|
||||
};
|
||||
|
||||
export function GrowthLinksSection({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
links,
|
||||
pageType,
|
||||
cluster,
|
||||
useCase,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
links: GrowthLink[];
|
||||
pageType: PageType;
|
||||
cluster: string;
|
||||
useCase?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="py-20 bg-slate-50">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||
<div className="max-w-3xl mb-12">
|
||||
<div className="text-sm font-semibold uppercase tracking-[0.22em] text-blue-700">
|
||||
{eyebrow}
|
||||
</div>
|
||||
<h2 className="mt-3 text-4xl font-bold text-slate-900">{title}</h2>
|
||||
<p className="mt-4 text-xl text-slate-600">{description}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-4">
|
||||
{links.map((link) => (
|
||||
<TrackedCtaLink
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
ctaLabel={link.ctaLabel}
|
||||
ctaLocation="related_workflows"
|
||||
pageType={pageType}
|
||||
cluster={cluster}
|
||||
useCase={useCase}
|
||||
className="group block h-full"
|
||||
>
|
||||
<Card className="h-full rounded-3xl border-slate-200 bg-white p-7 shadow-sm transition-all hover:-translate-y-1 hover:shadow-lg">
|
||||
<div className="text-lg font-semibold text-slate-900">
|
||||
{link.title}
|
||||
</div>
|
||||
<p className="mt-3 text-base leading-7 text-slate-600">
|
||||
{link.description}
|
||||
</p>
|
||||
<div className="mt-6 flex items-center gap-2 text-sm font-semibold text-blue-700">
|
||||
<span>Open workflow</span>
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</div>
|
||||
</Card>
|
||||
</TrackedCtaLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
import { TrackedCtaLink } from "@/components/marketing/MarketingAnalytics";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
|
||||
type PageType = "commercial" | "use_case_hub" | "use_case";
|
||||
|
||||
type GrowthLink = {
|
||||
href: string;
|
||||
title: string;
|
||||
description: string;
|
||||
ctaLabel: string;
|
||||
};
|
||||
|
||||
export function GrowthLinksSection({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
links,
|
||||
pageType,
|
||||
cluster,
|
||||
useCase,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
links: GrowthLink[];
|
||||
pageType: PageType;
|
||||
cluster: string;
|
||||
useCase?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="py-20 bg-slate-50">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||
<div className="max-w-3xl mb-12">
|
||||
<div className="text-sm font-semibold uppercase tracking-[0.22em] text-blue-700">
|
||||
{eyebrow}
|
||||
</div>
|
||||
<h2 className="mt-3 text-4xl font-bold text-slate-900">{title}</h2>
|
||||
<p className="mt-4 text-xl text-slate-600">{description}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-4">
|
||||
{links.map((link) => (
|
||||
<TrackedCtaLink
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
ctaLabel={link.ctaLabel}
|
||||
ctaLocation="related_workflows"
|
||||
pageType={pageType}
|
||||
cluster={cluster}
|
||||
useCase={useCase}
|
||||
className="group block h-full"
|
||||
>
|
||||
<Card className="h-full rounded-3xl border-slate-200 bg-white p-7 shadow-sm transition-all hover:-translate-y-1 hover:shadow-lg">
|
||||
<div className="text-lg font-semibold text-slate-900">
|
||||
{link.title}
|
||||
</div>
|
||||
<p className="mt-3 text-base leading-7 text-slate-600">
|
||||
{link.description}
|
||||
</p>
|
||||
<div className="mt-6 flex items-center gap-2 text-sm font-semibold text-blue-700">
|
||||
<span>Open workflow</span>
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</div>
|
||||
</Card>
|
||||
</TrackedCtaLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,94 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { trackEvent } from "@/components/PostHogProvider";
|
||||
|
||||
type PageType = "commercial" | "use_case_hub" | "use_case";
|
||||
|
||||
type TrackingContext = {
|
||||
pageType: PageType;
|
||||
cluster?: string;
|
||||
useCase?: string;
|
||||
};
|
||||
|
||||
function getUtmProperties(searchParams: ReturnType<typeof useSearchParams>) {
|
||||
return {
|
||||
utm_source: searchParams?.get("utm_source") || undefined,
|
||||
utm_medium: searchParams?.get("utm_medium") || undefined,
|
||||
utm_campaign: searchParams?.get("utm_campaign") || undefined,
|
||||
utm_content: searchParams?.get("utm_content") || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function MarketingPageTracker({
|
||||
pageType,
|
||||
cluster,
|
||||
useCase,
|
||||
}: TrackingContext) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
trackEvent("landing_page_viewed", {
|
||||
landing_page_slug: pathname,
|
||||
page_type: pageType,
|
||||
cluster,
|
||||
use_case: useCase,
|
||||
...getUtmProperties(searchParams),
|
||||
});
|
||||
}, [cluster, pageType, pathname, searchParams, useCase]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
type TrackedCtaLinkProps = TrackingContext & {
|
||||
href: string;
|
||||
ctaLabel: string;
|
||||
ctaLocation: string;
|
||||
destination?: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function TrackedCtaLink({
|
||||
href,
|
||||
ctaLabel,
|
||||
ctaLocation,
|
||||
destination,
|
||||
className,
|
||||
children,
|
||||
pageType,
|
||||
cluster,
|
||||
useCase,
|
||||
}: TrackedCtaLinkProps) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={className}
|
||||
onClick={() => {
|
||||
trackEvent("cta_clicked", {
|
||||
landing_page_slug: pathname,
|
||||
page_type: pageType,
|
||||
cluster,
|
||||
use_case: useCase,
|
||||
cta_label: ctaLabel,
|
||||
cta_location: ctaLocation,
|
||||
destination: destination || href,
|
||||
...getUtmProperties(searchParams),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { trackEvent } from "@/components/PostHogProvider";
|
||||
|
||||
type PageType = "commercial" | "use_case_hub" | "use_case";
|
||||
|
||||
type TrackingContext = {
|
||||
pageType: PageType;
|
||||
cluster?: string;
|
||||
useCase?: string;
|
||||
};
|
||||
|
||||
function getUtmProperties(searchParams: ReturnType<typeof useSearchParams>) {
|
||||
return {
|
||||
utm_source: searchParams?.get("utm_source") || undefined,
|
||||
utm_medium: searchParams?.get("utm_medium") || undefined,
|
||||
utm_campaign: searchParams?.get("utm_campaign") || undefined,
|
||||
utm_content: searchParams?.get("utm_content") || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function MarketingPageTracker({
|
||||
pageType,
|
||||
cluster,
|
||||
useCase,
|
||||
}: TrackingContext) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
trackEvent("landing_page_viewed", {
|
||||
landing_page_slug: pathname,
|
||||
page_type: pageType,
|
||||
cluster,
|
||||
use_case: useCase,
|
||||
...getUtmProperties(searchParams),
|
||||
});
|
||||
}, [cluster, pageType, pathname, searchParams, useCase]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
type TrackedCtaLinkProps = TrackingContext & {
|
||||
href: string;
|
||||
ctaLabel: string;
|
||||
ctaLocation: string;
|
||||
destination?: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function TrackedCtaLink({
|
||||
href,
|
||||
ctaLabel,
|
||||
ctaLocation,
|
||||
destination,
|
||||
className,
|
||||
children,
|
||||
pageType,
|
||||
cluster,
|
||||
useCase,
|
||||
}: TrackedCtaLinkProps) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={className}
|
||||
onClick={() => {
|
||||
trackEvent("cta_clicked", {
|
||||
landing_page_slug: pathname,
|
||||
page_type: pageType,
|
||||
cluster,
|
||||
use_case: useCase,
|
||||
cta_label: ctaLabel,
|
||||
cta_location: ctaLocation,
|
||||
destination: destination || href,
|
||||
...getUtmProperties(searchParams),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,132 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Star, CheckCircle } from 'lucide-react';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import type { Testimonial } from '@/lib/types';
|
||||
|
||||
interface TestimonialsProps {
|
||||
testimonials: Testimonial[];
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
showAll?: boolean;
|
||||
}
|
||||
|
||||
export const Testimonials: React.FC<TestimonialsProps> = ({
|
||||
testimonials,
|
||||
title = "What Our Customers Say",
|
||||
subtitle = "Real experiences from businesses using QR Master",
|
||||
showAll = false
|
||||
}) => {
|
||||
const displayTestimonials = showAll ? testimonials : testimonials.slice(0, 3);
|
||||
|
||||
const renderStars = (rating: number) => {
|
||||
return (
|
||||
<div className="flex gap-1" aria-label={`${rating} out of 5 stars`}>
|
||||
{[...Array(5)].map((_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={`w-5 h-5 ${index < rating
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'fill-gray-200 text-gray-200'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="py-16 bg-white">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
||||
{subtitle}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className={`grid gap-8 ${displayTestimonials.length === 1
|
||||
? 'grid-cols-1 max-w-2xl mx-auto'
|
||||
: displayTestimonials.length === 2
|
||||
? 'grid-cols-1 md:grid-cols-2 max-w-4xl mx-auto'
|
||||
: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'
|
||||
}`}>
|
||||
{displayTestimonials.map((testimonial, index) => (
|
||||
<motion.div
|
||||
key={testimonial.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<Card hover className="h-full flex flex-col">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
{renderStars(testimonial.rating)}
|
||||
{testimonial.verified && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-700 text-xs font-medium rounded-full">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Verified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
{testimonial.title}
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-grow">
|
||||
<p className="text-gray-700 leading-relaxed mb-6">
|
||||
{testimonial.content}
|
||||
</p>
|
||||
<div className="border-t border-gray-200 pt-4 mt-auto">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-gray-900">
|
||||
{testimonial.author.name}
|
||||
</span>
|
||||
<div className="text-sm text-gray-600">
|
||||
{testimonial.author.company && (
|
||||
<span>{testimonial.author.company}</span>
|
||||
)}
|
||||
{testimonial.author.company && testimonial.author.location && (
|
||||
<span> • </span>
|
||||
)}
|
||||
{testimonial.author.location && (
|
||||
<span>{testimonial.author.location}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 mt-1">
|
||||
{testimonial.date}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
{!showAll && (
|
||||
<div className="mt-12 text-center">
|
||||
<a href="/testimonials" className="inline-flex items-center text-blue-600 font-semibold hover:text-blue-700 transition-colors">
|
||||
See all reviews
|
||||
<svg className="w-4 h-4 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section >
|
||||
);
|
||||
};
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Star, CheckCircle } from 'lucide-react';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import type { Testimonial } from '@/lib/types';
|
||||
|
||||
interface TestimonialsProps {
|
||||
testimonials: Testimonial[];
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
showAll?: boolean;
|
||||
}
|
||||
|
||||
export const Testimonials: React.FC<TestimonialsProps> = ({
|
||||
testimonials,
|
||||
title = "What Our Customers Say",
|
||||
subtitle = "Real experiences from businesses using QR Master",
|
||||
showAll = false
|
||||
}) => {
|
||||
const displayTestimonials = showAll ? testimonials : testimonials.slice(0, 3);
|
||||
|
||||
const renderStars = (rating: number) => {
|
||||
return (
|
||||
<div className="flex gap-1" aria-label={`${rating} out of 5 stars`}>
|
||||
{[...Array(5)].map((_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={`w-5 h-5 ${index < rating
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'fill-gray-200 text-gray-200'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="py-16 bg-white">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
||||
{subtitle}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className={`grid gap-8 ${displayTestimonials.length === 1
|
||||
? 'grid-cols-1 max-w-2xl mx-auto'
|
||||
: displayTestimonials.length === 2
|
||||
? 'grid-cols-1 md:grid-cols-2 max-w-4xl mx-auto'
|
||||
: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'
|
||||
}`}>
|
||||
{displayTestimonials.map((testimonial, index) => (
|
||||
<motion.div
|
||||
key={testimonial.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<Card hover className="h-full flex flex-col">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
{renderStars(testimonial.rating)}
|
||||
{testimonial.verified && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-700 text-xs font-medium rounded-full">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Verified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
{testimonial.title}
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-grow">
|
||||
<p className="text-gray-700 leading-relaxed mb-6">
|
||||
{testimonial.content}
|
||||
</p>
|
||||
<div className="border-t border-gray-200 pt-4 mt-auto">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-gray-900">
|
||||
{testimonial.author.name}
|
||||
</span>
|
||||
<div className="text-sm text-gray-600">
|
||||
{testimonial.author.company && (
|
||||
<span>{testimonial.author.company}</span>
|
||||
)}
|
||||
{testimonial.author.company && testimonial.author.location && (
|
||||
<span> • </span>
|
||||
)}
|
||||
{testimonial.author.location && (
|
||||
<span>{testimonial.author.location}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 mt-1">
|
||||
{testimonial.date}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
{!showAll && (
|
||||
<div className="mt-12 text-center">
|
||||
<a href="/testimonials" className="inline-flex items-center text-blue-600 font-semibold hover:text-blue-700 transition-colors">
|
||||
See all reviews
|
||||
<svg className="w-4 h-4 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section >
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,427 +1,427 @@
|
||||
import type { FAQItem } from "@/lib/types";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Compass,
|
||||
Link2,
|
||||
Radar,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs, { BreadcrumbItem } from "@/components/Breadcrumbs";
|
||||
import SeoJsonLd from "@/components/SeoJsonLd";
|
||||
import { FAQSection } from "@/components/aeo/FAQSection";
|
||||
import {
|
||||
MarketingPageTracker,
|
||||
TrackedCtaLink,
|
||||
} from "@/components/marketing/MarketingAnalytics";
|
||||
import { AnswerFirstBlock } from "@/components/marketing/AnswerFirstBlock";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { breadcrumbSchema, faqPageSchema } from "@/lib/schema";
|
||||
|
||||
type LinkCard = {
|
||||
href: string;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type UseCasePageTemplateProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
eyebrow: string;
|
||||
intro: string;
|
||||
pageType: "commercial" | "use_case";
|
||||
cluster: string;
|
||||
useCase?: string;
|
||||
breadcrumbs: BreadcrumbItem[];
|
||||
answer: string;
|
||||
whenToUse: string[];
|
||||
comparisonItems: {
|
||||
label: string;
|
||||
value: boolean;
|
||||
text?: string;
|
||||
}[];
|
||||
howToSteps: string[];
|
||||
primaryCta: {
|
||||
href: string;
|
||||
label: string;
|
||||
};
|
||||
secondaryCta: {
|
||||
href: string;
|
||||
label: string;
|
||||
};
|
||||
workflowTitle: string;
|
||||
workflowIntro: string;
|
||||
workflowCards: {
|
||||
title: string;
|
||||
description: string;
|
||||
}[];
|
||||
checklistTitle: string;
|
||||
checklist: string[];
|
||||
supportLinks: LinkCard[];
|
||||
faq: FAQItem[];
|
||||
schemaData?: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
export function buildUseCaseMetadata({
|
||||
title,
|
||||
description,
|
||||
canonicalPath,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
canonicalPath: string;
|
||||
}): Metadata {
|
||||
const canonical = `https://www.qrmaster.net${canonicalPath}`;
|
||||
|
||||
return {
|
||||
title: {
|
||||
absolute: `${title} | QR Master`,
|
||||
},
|
||||
description,
|
||||
alternates: {
|
||||
canonical,
|
||||
languages: {
|
||||
"x-default": canonical,
|
||||
en: canonical,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${title} | QR Master`,
|
||||
description,
|
||||
url: canonical,
|
||||
type: "website",
|
||||
images: ["/og-image.png"],
|
||||
},
|
||||
twitter: {
|
||||
title: `${title} | QR Master`,
|
||||
description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function UseCasePageTemplate({
|
||||
title,
|
||||
description,
|
||||
eyebrow,
|
||||
intro,
|
||||
pageType,
|
||||
cluster,
|
||||
useCase,
|
||||
breadcrumbs,
|
||||
answer,
|
||||
whenToUse,
|
||||
comparisonItems,
|
||||
howToSteps,
|
||||
primaryCta,
|
||||
secondaryCta,
|
||||
workflowTitle,
|
||||
workflowIntro,
|
||||
workflowCards,
|
||||
checklistTitle,
|
||||
checklist,
|
||||
supportLinks,
|
||||
faq,
|
||||
schemaData = [],
|
||||
}: UseCasePageTemplateProps) {
|
||||
return (
|
||||
<>
|
||||
<SeoJsonLd
|
||||
data={[...schemaData, breadcrumbSchema(breadcrumbs), faqPageSchema(faq)]}
|
||||
/>
|
||||
<MarketingPageTracker
|
||||
pageType={pageType}
|
||||
cluster={cluster}
|
||||
useCase={useCase}
|
||||
/>
|
||||
|
||||
<div className="min-h-screen bg-white">
|
||||
<section className="relative overflow-hidden bg-gradient-to-br from-slate-950 via-blue-950 to-cyan-900 text-white">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.22),transparent_38%),radial-gradient(circle_at_bottom_right,rgba(255,255,255,0.08),transparent_30%)]" />
|
||||
<div className="relative container mx-auto max-w-7xl px-4 py-20 sm:px-6 lg:px-8">
|
||||
<Breadcrumbs
|
||||
items={breadcrumbs}
|
||||
className="[&_a]:text-blue-100/80 [&_a:hover]:text-white [&_span]:text-blue-100/80 [&_[aria-current=page]]:text-white"
|
||||
/>
|
||||
|
||||
<div className="grid gap-12 lg:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)] lg:items-center">
|
||||
<div className="space-y-8">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-semibold text-cyan-100 shadow-lg shadow-cyan-950/30 backdrop-blur">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span>{eyebrow}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<h1 className="max-w-4xl text-4xl font-bold tracking-tight text-white md:text-5xl lg:text-6xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="max-w-3xl text-lg leading-8 text-blue-50/88 md:text-xl">
|
||||
{intro}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 text-sm text-blue-50/80 sm:grid-cols-2">
|
||||
{[
|
||||
"Built for QR workflows where the printed surface should stay stable.",
|
||||
"Focused on operational clarity, not inflated ROI claims.",
|
||||
"Connected to a commercial parent and sibling workflows.",
|
||||
"Designed to fit QR Master's existing marketing theme.",
|
||||
].map((line) => (
|
||||
<div
|
||||
key={line}
|
||||
className="flex items-start gap-3 rounded-2xl border border-white/10 bg-white/5 px-4 py-3 backdrop-blur-sm"
|
||||
>
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-cyan-300" />
|
||||
<span>{line}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<TrackedCtaLink
|
||||
href={primaryCta.href}
|
||||
ctaLabel={primaryCta.label}
|
||||
ctaLocation="hero_primary"
|
||||
pageType={pageType}
|
||||
cluster={cluster}
|
||||
useCase={useCase}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full bg-white px-8 py-4 text-base font-semibold text-slate-950 hover:bg-slate-100 sm:w-auto"
|
||||
>
|
||||
{primaryCta.label}
|
||||
</Button>
|
||||
</TrackedCtaLink>
|
||||
|
||||
<TrackedCtaLink
|
||||
href={secondaryCta.href}
|
||||
ctaLabel={secondaryCta.label}
|
||||
ctaLocation="hero_secondary"
|
||||
pageType={pageType}
|
||||
cluster={cluster}
|
||||
useCase={useCase}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full border-white/30 bg-white/5 px-8 py-4 text-base text-white hover:bg-white/10 sm:w-auto"
|
||||
>
|
||||
{secondaryCta.label}
|
||||
</Button>
|
||||
</TrackedCtaLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-white/10 bg-white/10 p-8 text-white shadow-2xl shadow-slate-950/30 backdrop-blur">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between border-b border-white/10 pb-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-cyan-200/70">
|
||||
Workflow snapshot
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">
|
||||
What matters here
|
||||
</div>
|
||||
</div>
|
||||
<Compass className="h-9 w-9 text-cyan-300" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{workflowCards.map((card, index) => (
|
||||
<div
|
||||
key={card.title}
|
||||
className="rounded-2xl border border-white/10 bg-slate-950/30 p-4"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-cyan-400/15 text-sm font-semibold text-cyan-200">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-white">
|
||||
{card.title}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm leading-6 text-blue-50/80">
|
||||
{card.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="container mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<AnswerFirstBlock
|
||||
whatIsIt={answer}
|
||||
whenToUse={whenToUse}
|
||||
comparison={{
|
||||
leftTitle: "Static",
|
||||
rightTitle: "Better fit here",
|
||||
items: comparisonItems,
|
||||
}}
|
||||
howTo={{
|
||||
steps: howToSteps,
|
||||
}}
|
||||
className="mt-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="bg-slate-50 py-16">
|
||||
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-10 max-w-3xl">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-slate-900">
|
||||
{workflowTitle}
|
||||
</h2>
|
||||
<p className="mt-4 text-lg leading-8 text-slate-600">
|
||||
{workflowIntro}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{workflowCards.map((card) => (
|
||||
<Card
|
||||
key={card.title}
|
||||
className="rounded-3xl border-slate-200/80 bg-white p-7 shadow-sm"
|
||||
>
|
||||
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-blue-50 text-blue-700">
|
||||
<Radar className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-slate-900">
|
||||
{card.title}
|
||||
</h3>
|
||||
<p className="mt-3 text-base leading-7 text-slate-600">
|
||||
{card.description}
|
||||
</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-16">
|
||||
<div className="container mx-auto grid max-w-7xl gap-8 px-4 sm:px-6 lg:grid-cols-[minmax(0,0.95fr)_minmax(280px,0.8fr)] lg:px-8">
|
||||
<Card className="rounded-3xl border-slate-200 bg-white p-8 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold uppercase tracking-[0.22em] text-blue-700">
|
||||
Checklist
|
||||
</div>
|
||||
<h2 className="mt-3 text-3xl font-bold text-slate-900">
|
||||
{checklistTitle}
|
||||
</h2>
|
||||
</div>
|
||||
<CheckCircle2 className="h-8 w-8 text-blue-700" />
|
||||
</div>
|
||||
|
||||
<ul className="mt-8 space-y-4">
|
||||
{checklist.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="flex items-start gap-3 text-slate-700"
|
||||
>
|
||||
<CheckCircle2 className="mt-1 h-5 w-5 shrink-0 text-green-600" />
|
||||
<span className="leading-7">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-3xl border-slate-200 bg-slate-950 p-8 text-white shadow-xl shadow-slate-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link2 className="h-5 w-5 text-cyan-300" />
|
||||
<h2 className="text-2xl font-bold">Related links</h2>
|
||||
</div>
|
||||
<div className="mt-6 space-y-4">
|
||||
{supportLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="group block rounded-2xl border border-white/10 bg-white/5 p-4 transition-colors hover:bg-white/10"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-white">
|
||||
{link.title}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-blue-50/78">
|
||||
{link.description}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="mt-1 h-4 w-4 shrink-0 text-cyan-300 transition-transform group-hover:translate-x-1" />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="container mx-auto max-w-5xl px-4 pb-6 sm:px-6 lg:px-8">
|
||||
<FAQSection items={faq} title={`${title} FAQ`} />
|
||||
</div>
|
||||
|
||||
<section className="pb-20 pt-6">
|
||||
<div className="container mx-auto max-w-5xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="rounded-[2rem] bg-gradient-to-r from-blue-700 via-indigo-700 to-slate-900 px-8 py-10 text-white shadow-2xl shadow-blue-100">
|
||||
<div className="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-2xl">
|
||||
<div className="text-sm font-semibold uppercase tracking-[0.22em] text-blue-100/80">
|
||||
Next step
|
||||
</div>
|
||||
<h2 className="mt-3 text-3xl font-bold tracking-tight">
|
||||
Use a QR workflow that stays useful after the print run starts.
|
||||
</h2>
|
||||
<p className="mt-4 text-lg leading-8 text-blue-50/84">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<TrackedCtaLink
|
||||
href={primaryCta.href}
|
||||
ctaLabel={primaryCta.label}
|
||||
ctaLocation="footer_primary"
|
||||
pageType={pageType}
|
||||
cluster={cluster}
|
||||
useCase={useCase}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full bg-white px-7 text-slate-950 hover:bg-slate-100 sm:w-auto"
|
||||
>
|
||||
{primaryCta.label}
|
||||
</Button>
|
||||
</TrackedCtaLink>
|
||||
|
||||
<TrackedCtaLink
|
||||
href={secondaryCta.href}
|
||||
ctaLabel={secondaryCta.label}
|
||||
ctaLocation="footer_secondary"
|
||||
pageType={pageType}
|
||||
cluster={cluster}
|
||||
useCase={useCase}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full border-white/30 bg-white/5 text-white hover:bg-white/10 sm:w-auto"
|
||||
>
|
||||
{secondaryCta.label}
|
||||
</Button>
|
||||
</TrackedCtaLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import type { FAQItem } from "@/lib/types";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Compass,
|
||||
Link2,
|
||||
Radar,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs, { BreadcrumbItem } from "@/components/Breadcrumbs";
|
||||
import SeoJsonLd from "@/components/SeoJsonLd";
|
||||
import { FAQSection } from "@/components/aeo/FAQSection";
|
||||
import {
|
||||
MarketingPageTracker,
|
||||
TrackedCtaLink,
|
||||
} from "@/components/marketing/MarketingAnalytics";
|
||||
import { AnswerFirstBlock } from "@/components/marketing/AnswerFirstBlock";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { breadcrumbSchema, faqPageSchema } from "@/lib/schema";
|
||||
|
||||
type LinkCard = {
|
||||
href: string;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type UseCasePageTemplateProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
eyebrow: string;
|
||||
intro: string;
|
||||
pageType: "commercial" | "use_case";
|
||||
cluster: string;
|
||||
useCase?: string;
|
||||
breadcrumbs: BreadcrumbItem[];
|
||||
answer: string;
|
||||
whenToUse: string[];
|
||||
comparisonItems: {
|
||||
label: string;
|
||||
value: boolean;
|
||||
text?: string;
|
||||
}[];
|
||||
howToSteps: string[];
|
||||
primaryCta: {
|
||||
href: string;
|
||||
label: string;
|
||||
};
|
||||
secondaryCta: {
|
||||
href: string;
|
||||
label: string;
|
||||
};
|
||||
workflowTitle: string;
|
||||
workflowIntro: string;
|
||||
workflowCards: {
|
||||
title: string;
|
||||
description: string;
|
||||
}[];
|
||||
checklistTitle: string;
|
||||
checklist: string[];
|
||||
supportLinks: LinkCard[];
|
||||
faq: FAQItem[];
|
||||
schemaData?: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
export function buildUseCaseMetadata({
|
||||
title,
|
||||
description,
|
||||
canonicalPath,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
canonicalPath: string;
|
||||
}): Metadata {
|
||||
const canonical = `https://www.qrmaster.net${canonicalPath}`;
|
||||
|
||||
return {
|
||||
title: {
|
||||
absolute: `${title} | QR Master`,
|
||||
},
|
||||
description,
|
||||
alternates: {
|
||||
canonical,
|
||||
languages: {
|
||||
"x-default": canonical,
|
||||
en: canonical,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${title} | QR Master`,
|
||||
description,
|
||||
url: canonical,
|
||||
type: "website",
|
||||
images: ["/og-image.png"],
|
||||
},
|
||||
twitter: {
|
||||
title: `${title} | QR Master`,
|
||||
description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function UseCasePageTemplate({
|
||||
title,
|
||||
description,
|
||||
eyebrow,
|
||||
intro,
|
||||
pageType,
|
||||
cluster,
|
||||
useCase,
|
||||
breadcrumbs,
|
||||
answer,
|
||||
whenToUse,
|
||||
comparisonItems,
|
||||
howToSteps,
|
||||
primaryCta,
|
||||
secondaryCta,
|
||||
workflowTitle,
|
||||
workflowIntro,
|
||||
workflowCards,
|
||||
checklistTitle,
|
||||
checklist,
|
||||
supportLinks,
|
||||
faq,
|
||||
schemaData = [],
|
||||
}: UseCasePageTemplateProps) {
|
||||
return (
|
||||
<>
|
||||
<SeoJsonLd
|
||||
data={[...schemaData, breadcrumbSchema(breadcrumbs), faqPageSchema(faq)]}
|
||||
/>
|
||||
<MarketingPageTracker
|
||||
pageType={pageType}
|
||||
cluster={cluster}
|
||||
useCase={useCase}
|
||||
/>
|
||||
|
||||
<div className="min-h-screen bg-white">
|
||||
<section className="relative overflow-hidden bg-gradient-to-br from-slate-950 via-blue-950 to-cyan-900 text-white">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.22),transparent_38%),radial-gradient(circle_at_bottom_right,rgba(255,255,255,0.08),transparent_30%)]" />
|
||||
<div className="relative container mx-auto max-w-7xl px-4 py-20 sm:px-6 lg:px-8">
|
||||
<Breadcrumbs
|
||||
items={breadcrumbs}
|
||||
className="[&_a]:text-blue-100/80 [&_a:hover]:text-white [&_span]:text-blue-100/80 [&_[aria-current=page]]:text-white"
|
||||
/>
|
||||
|
||||
<div className="grid gap-12 lg:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)] lg:items-center">
|
||||
<div className="space-y-8">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-semibold text-cyan-100 shadow-lg shadow-cyan-950/30 backdrop-blur">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span>{eyebrow}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<h1 className="max-w-4xl text-4xl font-bold tracking-tight text-white md:text-5xl lg:text-6xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="max-w-3xl text-lg leading-8 text-blue-50/88 md:text-xl">
|
||||
{intro}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 text-sm text-blue-50/80 sm:grid-cols-2">
|
||||
{[
|
||||
"Built for QR workflows where the printed surface should stay stable.",
|
||||
"Focused on operational clarity, not inflated ROI claims.",
|
||||
"Connected to a commercial parent and sibling workflows.",
|
||||
"Designed to fit QR Master's existing marketing theme.",
|
||||
].map((line) => (
|
||||
<div
|
||||
key={line}
|
||||
className="flex items-start gap-3 rounded-2xl border border-white/10 bg-white/5 px-4 py-3 backdrop-blur-sm"
|
||||
>
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-cyan-300" />
|
||||
<span>{line}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<TrackedCtaLink
|
||||
href={primaryCta.href}
|
||||
ctaLabel={primaryCta.label}
|
||||
ctaLocation="hero_primary"
|
||||
pageType={pageType}
|
||||
cluster={cluster}
|
||||
useCase={useCase}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full bg-white px-8 py-4 text-base font-semibold text-slate-950 hover:bg-slate-100 sm:w-auto"
|
||||
>
|
||||
{primaryCta.label}
|
||||
</Button>
|
||||
</TrackedCtaLink>
|
||||
|
||||
<TrackedCtaLink
|
||||
href={secondaryCta.href}
|
||||
ctaLabel={secondaryCta.label}
|
||||
ctaLocation="hero_secondary"
|
||||
pageType={pageType}
|
||||
cluster={cluster}
|
||||
useCase={useCase}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full border-white/30 bg-white/5 px-8 py-4 text-base text-white hover:bg-white/10 sm:w-auto"
|
||||
>
|
||||
{secondaryCta.label}
|
||||
</Button>
|
||||
</TrackedCtaLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-white/10 bg-white/10 p-8 text-white shadow-2xl shadow-slate-950/30 backdrop-blur">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between border-b border-white/10 pb-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-cyan-200/70">
|
||||
Workflow snapshot
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">
|
||||
What matters here
|
||||
</div>
|
||||
</div>
|
||||
<Compass className="h-9 w-9 text-cyan-300" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{workflowCards.map((card, index) => (
|
||||
<div
|
||||
key={card.title}
|
||||
className="rounded-2xl border border-white/10 bg-slate-950/30 p-4"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-cyan-400/15 text-sm font-semibold text-cyan-200">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-white">
|
||||
{card.title}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm leading-6 text-blue-50/80">
|
||||
{card.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="container mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<AnswerFirstBlock
|
||||
whatIsIt={answer}
|
||||
whenToUse={whenToUse}
|
||||
comparison={{
|
||||
leftTitle: "Static",
|
||||
rightTitle: "Better fit here",
|
||||
items: comparisonItems,
|
||||
}}
|
||||
howTo={{
|
||||
steps: howToSteps,
|
||||
}}
|
||||
className="mt-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="bg-slate-50 py-16">
|
||||
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-10 max-w-3xl">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-slate-900">
|
||||
{workflowTitle}
|
||||
</h2>
|
||||
<p className="mt-4 text-lg leading-8 text-slate-600">
|
||||
{workflowIntro}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{workflowCards.map((card) => (
|
||||
<Card
|
||||
key={card.title}
|
||||
className="rounded-3xl border-slate-200/80 bg-white p-7 shadow-sm"
|
||||
>
|
||||
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-blue-50 text-blue-700">
|
||||
<Radar className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-slate-900">
|
||||
{card.title}
|
||||
</h3>
|
||||
<p className="mt-3 text-base leading-7 text-slate-600">
|
||||
{card.description}
|
||||
</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-16">
|
||||
<div className="container mx-auto grid max-w-7xl gap-8 px-4 sm:px-6 lg:grid-cols-[minmax(0,0.95fr)_minmax(280px,0.8fr)] lg:px-8">
|
||||
<Card className="rounded-3xl border-slate-200 bg-white p-8 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold uppercase tracking-[0.22em] text-blue-700">
|
||||
Checklist
|
||||
</div>
|
||||
<h2 className="mt-3 text-3xl font-bold text-slate-900">
|
||||
{checklistTitle}
|
||||
</h2>
|
||||
</div>
|
||||
<CheckCircle2 className="h-8 w-8 text-blue-700" />
|
||||
</div>
|
||||
|
||||
<ul className="mt-8 space-y-4">
|
||||
{checklist.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="flex items-start gap-3 text-slate-700"
|
||||
>
|
||||
<CheckCircle2 className="mt-1 h-5 w-5 shrink-0 text-green-600" />
|
||||
<span className="leading-7">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-3xl border-slate-200 bg-slate-950 p-8 text-white shadow-xl shadow-slate-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link2 className="h-5 w-5 text-cyan-300" />
|
||||
<h2 className="text-2xl font-bold">Related links</h2>
|
||||
</div>
|
||||
<div className="mt-6 space-y-4">
|
||||
{supportLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="group block rounded-2xl border border-white/10 bg-white/5 p-4 transition-colors hover:bg-white/10"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-white">
|
||||
{link.title}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-blue-50/78">
|
||||
{link.description}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="mt-1 h-4 w-4 shrink-0 text-cyan-300 transition-transform group-hover:translate-x-1" />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="container mx-auto max-w-5xl px-4 pb-6 sm:px-6 lg:px-8">
|
||||
<FAQSection items={faq} title={`${title} FAQ`} />
|
||||
</div>
|
||||
|
||||
<section className="pb-20 pt-6">
|
||||
<div className="container mx-auto max-w-5xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="rounded-[2rem] bg-gradient-to-r from-blue-700 via-indigo-700 to-slate-900 px-8 py-10 text-white shadow-2xl shadow-blue-100">
|
||||
<div className="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-2xl">
|
||||
<div className="text-sm font-semibold uppercase tracking-[0.22em] text-blue-100/80">
|
||||
Next step
|
||||
</div>
|
||||
<h2 className="mt-3 text-3xl font-bold tracking-tight">
|
||||
Use a QR workflow that stays useful after the print run starts.
|
||||
</h2>
|
||||
<p className="mt-4 text-lg leading-8 text-blue-50/84">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<TrackedCtaLink
|
||||
href={primaryCta.href}
|
||||
ctaLabel={primaryCta.label}
|
||||
ctaLocation="footer_primary"
|
||||
pageType={pageType}
|
||||
cluster={cluster}
|
||||
useCase={useCase}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full bg-white px-7 text-slate-950 hover:bg-slate-100 sm:w-auto"
|
||||
>
|
||||
{primaryCta.label}
|
||||
</Button>
|
||||
</TrackedCtaLink>
|
||||
|
||||
<TrackedCtaLink
|
||||
href={secondaryCta.href}
|
||||
ctaLabel={secondaryCta.label}
|
||||
ctaLocation="footer_secondary"
|
||||
pageType={pageType}
|
||||
cluster={cluster}
|
||||
useCase={useCase}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full border-white/30 bg-white/5 text-white hover:bg-white/10 sm:w-auto"
|
||||
>
|
||||
{secondaryCta.label}
|
||||
</Button>
|
||||
</TrackedCtaLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,165 +1,165 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
|
||||
interface ChangePasswordModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export default function ChangePasswordModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: ChangePasswordModalProps) {
|
||||
const { fetchWithCsrf } = useCsrf();
|
||||
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 fetchWithCsrf('/api/user/password', {
|
||||
method: 'PATCH',
|
||||
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>
|
||||
);
|
||||
}
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
|
||||
interface ChangePasswordModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export default function ChangePasswordModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: ChangePasswordModalProps) {
|
||||
const { fetchWithCsrf } = useCsrf();
|
||||
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 fetchWithCsrf('/api/user/password', {
|
||||
method: 'PATCH',
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: 'default' | 'success' | 'warning' | 'info' | 'error';
|
||||
}
|
||||
|
||||
export const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||
({ className, variant = 'default', ...props }, ref) => {
|
||||
const variants = {
|
||||
default: 'bg-gray-100 text-gray-800',
|
||||
success: 'bg-success-100 text-success-800',
|
||||
warning: 'bg-warning-100 text-warning-800',
|
||||
info: 'bg-info-100 text-info-800',
|
||||
error: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||
variants[variant],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: 'default' | 'success' | 'warning' | 'info' | 'error';
|
||||
}
|
||||
|
||||
export const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||
({ className, variant = 'default', ...props }, ref) => {
|
||||
const variants = {
|
||||
default: 'bg-gray-100 text-gray-800',
|
||||
success: 'bg-success-100 text-success-800',
|
||||
warning: 'bg-warning-100 text-warning-800',
|
||||
info: 'bg-info-100 text-info-800',
|
||||
error: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||
variants[variant],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Badge.displayName = 'Badge';
|
||||
@@ -1,38 +1,38 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface BillingToggleProps {
|
||||
value: 'month' | 'year';
|
||||
onChange: (value: 'month' | 'year') => void;
|
||||
}
|
||||
|
||||
export const BillingToggle: React.FC<BillingToggleProps> = ({ value, onChange }) => {
|
||||
return (
|
||||
<div className="inline-flex items-center rounded-lg border border-gray-300 bg-gray-50 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('month')}
|
||||
className={cn(
|
||||
'px-6 py-2 text-sm font-medium rounded-md transition-all duration-200',
|
||||
value === 'month'
|
||||
? 'bg-primary-600 text-white shadow-sm'
|
||||
: 'bg-transparent text-gray-700 hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('year')}
|
||||
className={cn(
|
||||
'px-6 py-2 text-sm font-medium rounded-md transition-all duration-200',
|
||||
value === 'year'
|
||||
? 'bg-primary-600 text-white shadow-sm'
|
||||
: 'bg-transparent text-gray-700 hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
Yearly
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface BillingToggleProps {
|
||||
value: 'month' | 'year';
|
||||
onChange: (value: 'month' | 'year') => void;
|
||||
}
|
||||
|
||||
export const BillingToggle: React.FC<BillingToggleProps> = ({ value, onChange }) => {
|
||||
return (
|
||||
<div className="inline-flex items-center rounded-lg border border-gray-300 bg-gray-50 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('month')}
|
||||
className={cn(
|
||||
'px-6 py-2 text-sm font-medium rounded-md transition-all duration-200',
|
||||
value === 'month'
|
||||
? 'bg-primary-600 text-white shadow-sm'
|
||||
: 'bg-transparent text-gray-700 hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('year')}
|
||||
className={cn(
|
||||
'px-6 py-2 text-sm font-medium rounded-md transition-all duration-200',
|
||||
value === 'year'
|
||||
? 'bg-primary-600 text-white shadow-sm'
|
||||
: 'bg-transparent text-gray-700 hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
Yearly
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = 'primary', size = 'md', loading, children, disabled, ...props }, ref) => {
|
||||
const baseClasses = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
|
||||
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500',
|
||||
outline: 'border border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-gray-500',
|
||||
ghost: 'text-gray-700 hover:bg-gray-100 focus:ring-gray-500',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(baseClasses, variants[variant], sizes[size], className)}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = 'primary', size = 'md', loading, children, disabled, ...props }, ref) => {
|
||||
const baseClasses = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
|
||||
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500',
|
||||
outline: 'border border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-gray-500',
|
||||
ghost: 'text-gray-700 hover:bg-gray-100 focus:ring-gray-500',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(baseClasses, variants[variant], sizes[size], className)}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
@@ -1,94 +1,94 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
hover?: boolean;
|
||||
}
|
||||
|
||||
export const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, hover = false, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-white rounded-xl shadow-sm border border-gray-200 p-6',
|
||||
hover && 'transition-all duration-200 hover:shadow-md hover:border-gray-300',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Card.displayName = 'Card';
|
||||
|
||||
export const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 pb-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
export const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
export const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-gray-600', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
export const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
export const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center pt-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
hover?: boolean;
|
||||
}
|
||||
|
||||
export const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, hover = false, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-white rounded-xl shadow-sm border border-gray-200 p-6',
|
||||
hover && 'transition-all duration-200 hover:shadow-md hover:border-gray-300',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Card.displayName = 'Card';
|
||||
|
||||
export const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 pb-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
export const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
export const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-gray-600', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
export const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
export const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center pt-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
@@ -1,92 +1,92 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Dialog: React.FC<DialogProps> = ({ open, onOpenChange, children }) => {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50"
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
<div className="relative z-50 w-full max-w-lg mx-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-white rounded-xl shadow-lg border border-gray-200 p-6',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
DialogContent.displayName = 'DialogContent';
|
||||
|
||||
interface DialogHeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const DialogHeader = React.forwardRef<HTMLDivElement, DialogHeaderProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
interface DialogTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}
|
||||
|
||||
export const DialogTitle = React.forwardRef<HTMLHeadingElement, DialogTitleProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h2
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
DialogTitle.displayName = 'DialogTitle';
|
||||
|
||||
interface DialogDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {}
|
||||
|
||||
export const DialogDescription = React.forwardRef<HTMLParagraphElement, DialogDescriptionProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-gray-600', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
DialogDescription.displayName = 'DialogDescription';
|
||||
|
||||
interface DialogFooterProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const DialogFooter = React.forwardRef<HTMLDivElement, DialogFooterProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 pt-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Dialog: React.FC<DialogProps> = ({ open, onOpenChange, children }) => {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50"
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
<div className="relative z-50 w-full max-w-lg mx-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-white rounded-xl shadow-lg border border-gray-200 p-6',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
DialogContent.displayName = 'DialogContent';
|
||||
|
||||
interface DialogHeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const DialogHeader = React.forwardRef<HTMLDivElement, DialogHeaderProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
interface DialogTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}
|
||||
|
||||
export const DialogTitle = React.forwardRef<HTMLHeadingElement, DialogTitleProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h2
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
DialogTitle.displayName = 'DialogTitle';
|
||||
|
||||
interface DialogDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {}
|
||||
|
||||
export const DialogDescription = React.forwardRef<HTMLParagraphElement, DialogDescriptionProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-gray-600', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
DialogDescription.displayName = 'DialogDescription';
|
||||
|
||||
interface DialogFooterProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const DialogFooter = React.forwardRef<HTMLDivElement, DialogFooterProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 pt-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
@@ -1,63 +1,63 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DropdownProps {
|
||||
trigger: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
align?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export const Dropdown: React.FC<DropdownProps> = ({ trigger, children, align = 'left' }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<div onClick={() => setIsOpen(!isOpen)}>
|
||||
{trigger}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-full mt-1 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50',
|
||||
align === 'right' ? 'right-0' : 'left-0'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DropdownItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DropdownItem = React.forwardRef<HTMLDivElement, DropdownItemProps>(
|
||||
({ className, icon, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex items-center px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon && <span className="mr-2">{icon}</span>}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DropdownProps {
|
||||
trigger: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
align?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export const Dropdown: React.FC<DropdownProps> = ({ trigger, children, align = 'left' }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<div onClick={() => setIsOpen(!isOpen)}>
|
||||
{trigger}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-full mt-1 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50',
|
||||
align === 'right' ? 'right-0' : 'left-0'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DropdownItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DropdownItem = React.forwardRef<HTMLDivElement, DropdownItemProps>(
|
||||
({ className, icon, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex items-center px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon && <span className="mr-2">{icon}</span>}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
DropdownItem.displayName = 'DropdownItem';
|
||||
@@ -1,52 +1,52 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, label, error, onInvalid, id, ...props }, ref) => {
|
||||
// Generate a unique id for accessibility if not provided
|
||||
const inputId = id || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
|
||||
|
||||
// Default English validation message
|
||||
const handleInvalid = (e: React.InvalidEvent<HTMLInputElement>) => {
|
||||
e.target.setCustomValidity('Please fill out this field.');
|
||||
if (onInvalid) onInvalid(e);
|
||||
};
|
||||
|
||||
const handleInput = (e: React.FormEvent<HTMLInputElement>) => {
|
||||
e.currentTarget.setCustomValidity('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="block text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
id={inputId}
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
error && 'border-red-500 focus-visible:ring-red-500',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
onInvalid={handleInvalid}
|
||||
onInput={handleInput}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, label, error, onInvalid, id, ...props }, ref) => {
|
||||
// Generate a unique id for accessibility if not provided
|
||||
const inputId = id || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
|
||||
|
||||
// Default English validation message
|
||||
const handleInvalid = (e: React.InvalidEvent<HTMLInputElement>) => {
|
||||
e.target.setCustomValidity('Please fill out this field.');
|
||||
if (onInvalid) onInvalid(e);
|
||||
};
|
||||
|
||||
const handleInput = (e: React.FormEvent<HTMLInputElement>) => {
|
||||
e.currentTarget.setCustomValidity('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="block text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
id={inputId}
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
error && 'border-red-500 focus-visible:ring-red-500',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
onInvalid={handleInvalid}
|
||||
onInput={handleInput}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
@@ -1,54 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
|
||||
interface QRCodeProps {
|
||||
value: string;
|
||||
size?: number;
|
||||
fgColor?: string;
|
||||
bgColor?: string;
|
||||
level?: 'L' | 'M' | 'Q' | 'H';
|
||||
includeMargin?: boolean;
|
||||
imageSettings?: {
|
||||
src: string;
|
||||
height: number;
|
||||
width: number;
|
||||
excavate: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const QRCode: React.FC<QRCodeProps> = ({
|
||||
value,
|
||||
size = 128,
|
||||
fgColor = '#000000',
|
||||
bgColor = '#FFFFFF',
|
||||
level = 'M',
|
||||
includeMargin = false,
|
||||
imageSettings,
|
||||
}) => {
|
||||
if (!value) {
|
||||
return (
|
||||
<div
|
||||
className="bg-gray-200 flex items-center justify-center text-gray-500"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
No data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<QRCodeSVG
|
||||
value={value}
|
||||
size={size}
|
||||
fgColor={fgColor}
|
||||
bgColor={bgColor}
|
||||
level={level}
|
||||
includeMargin={includeMargin}
|
||||
imageSettings={imageSettings}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
|
||||
interface QRCodeProps {
|
||||
value: string;
|
||||
size?: number;
|
||||
fgColor?: string;
|
||||
bgColor?: string;
|
||||
level?: 'L' | 'M' | 'Q' | 'H';
|
||||
includeMargin?: boolean;
|
||||
imageSettings?: {
|
||||
src: string;
|
||||
height: number;
|
||||
width: number;
|
||||
excavate: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const QRCode: React.FC<QRCodeProps> = ({
|
||||
value,
|
||||
size = 128,
|
||||
fgColor = '#000000',
|
||||
bgColor = '#FFFFFF',
|
||||
level = 'M',
|
||||
includeMargin = false,
|
||||
imageSettings,
|
||||
}) => {
|
||||
if (!value) {
|
||||
return (
|
||||
<div
|
||||
className="bg-gray-200 flex items-center justify-center text-gray-500"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
No data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<QRCodeSVG
|
||||
value={value}
|
||||
size={size}
|
||||
fgColor={fgColor}
|
||||
bgColor={bgColor}
|
||||
level={level}
|
||||
includeMargin={includeMargin}
|
||||
imageSettings={imageSettings}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default QRCode;
|
||||
@@ -1,57 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
export function ScrollToTop() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
// Show button when page is scrolled down
|
||||
useEffect(() => {
|
||||
const toggleVisibility = () => {
|
||||
if (window.pageYOffset > 300) {
|
||||
setIsVisible(true);
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', toggleVisibility);
|
||||
|
||||
return () => window.removeEventListener('scroll', toggleVisibility);
|
||||
}, []);
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isVisible && (
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className="fixed bottom-8 right-8 z-50 p-4 bg-primary-600 hover:bg-primary-700 text-white rounded-full shadow-lg transition-all duration-300 hover:scale-110 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||
aria-label="Scroll to top"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
export function ScrollToTop() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
// Show button when page is scrolled down
|
||||
useEffect(() => {
|
||||
const toggleVisibility = () => {
|
||||
if (window.pageYOffset > 300) {
|
||||
setIsVisible(true);
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', toggleVisibility);
|
||||
|
||||
return () => window.removeEventListener('scroll', toggleVisibility);
|
||||
}, []);
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isVisible && (
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className="fixed bottom-8 right-8 z-50 p-4 bg-primary-600 hover:bg-primary-700 text-white rounded-full shadow-lg transition-all duration-300 hover:scale-110 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||
aria-label="Scroll to top"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
options: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, label, error, options, id, ...props }, ref) => {
|
||||
// Generate a unique id for accessibility if not provided
|
||||
const selectId = id || (label ? `select-${label.toLowerCase().replace(/\s+/g, '-')}` : undefined);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && (
|
||||
<label htmlFor={selectId} className="block text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
id={selectId}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
error && 'border-red-500 focus-visible:ring-red-500',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
options: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, label, error, options, id, ...props }, ref) => {
|
||||
// Generate a unique id for accessibility if not provided
|
||||
const selectId = id || (label ? `select-${label.toLowerCase().replace(/\s+/g, '-')}` : undefined);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && (
|
||||
<label htmlFor={selectId} className="block text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
id={selectId}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
error && 'border-red-500 focus-visible:ring-red-500',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Select.displayName = 'Select';
|
||||
@@ -1,105 +1,105 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
Table.displayName = 'Table';
|
||||
|
||||
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
)
|
||||
);
|
||||
TableHeader.displayName = 'TableHeader';
|
||||
|
||||
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn('[&_tr:last-child]:border-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableBody.displayName = 'TableBody';
|
||||
|
||||
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn('border-t bg-gray-50/50 font-medium [&>tr]:last:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableFooter.displayName = 'TableFooter';
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-b transition-colors hover:bg-gray-50/50 data-[state=selected]:bg-gray-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableRow.displayName = 'TableRow';
|
||||
|
||||
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-12 px-4 text-left align-middle font-medium text-gray-500 [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableHead.displayName = 'TableHead';
|
||||
|
||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableCell.displayName = 'TableCell';
|
||||
|
||||
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn('mt-4 text-sm text-gray-500', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableCaption.displayName = 'TableCaption';
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
Table.displayName = 'Table';
|
||||
|
||||
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
)
|
||||
);
|
||||
TableHeader.displayName = 'TableHeader';
|
||||
|
||||
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn('[&_tr:last-child]:border-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableBody.displayName = 'TableBody';
|
||||
|
||||
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn('border-t bg-gray-50/50 font-medium [&>tr]:last:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableFooter.displayName = 'TableFooter';
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-b transition-colors hover:bg-gray-50/50 data-[state=selected]:bg-gray-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableRow.displayName = 'TableRow';
|
||||
|
||||
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-12 px-4 text-left align-middle font-medium text-gray-500 [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableHead.displayName = 'TableHead';
|
||||
|
||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableCell.displayName = 'TableCell';
|
||||
|
||||
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn('mt-4 text-sm text-gray-500', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableCaption.displayName = 'TableCaption';
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
@@ -1,140 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
export interface ToastProps {
|
||||
id: string;
|
||||
message: string;
|
||||
type?: 'success' | 'error' | 'info' | 'warning';
|
||||
duration?: number;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const Toast: React.FC<ToastProps> = ({
|
||||
id,
|
||||
message,
|
||||
type = 'info',
|
||||
duration = 3000,
|
||||
onClose,
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => onClose?.(), 300);
|
||||
}, duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [duration, onClose]);
|
||||
|
||||
const icons = {
|
||||
success: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
),
|
||||
error: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
),
|
||||
warning: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
),
|
||||
info: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
const colors = {
|
||||
success: 'bg-success-50 text-success-900 border-success-200',
|
||||
error: 'bg-red-50 text-red-900 border-red-200',
|
||||
warning: 'bg-warning-50 text-warning-900 border-warning-200',
|
||||
info: 'bg-info-50 text-info-900 border-info-200',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex items-center space-x-3 px-4 py-3 rounded-lg border shadow-lg
|
||||
${colors[type]}
|
||||
transition-all duration-300 transform
|
||||
${isVisible ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'}
|
||||
`}
|
||||
>
|
||||
<div className="flex-shrink-0">{icons[type]}</div>
|
||||
<p className="text-sm font-medium">{message}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => onClose?.(), 300);
|
||||
}}
|
||||
className="ml-auto flex-shrink-0 hover:opacity-70"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Toast Container
|
||||
export const ToastContainer: React.FC = () => {
|
||||
const [toasts, setToasts] = useState<ToastProps[]>([]);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
|
||||
const handleToast = (event: CustomEvent<Omit<ToastProps, 'id'>>) => {
|
||||
const newToast: ToastProps = {
|
||||
...event.detail,
|
||||
id: Date.now().toString(),
|
||||
};
|
||||
setToasts(prev => [...prev, newToast]);
|
||||
};
|
||||
|
||||
window.addEventListener('toast' as any, handleToast);
|
||||
return () => window.removeEventListener('toast' as any, handleToast);
|
||||
}, []);
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
};
|
||||
|
||||
if (!isMounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed top-4 right-4 z-50 space-y-2">
|
||||
{toasts.map(toast => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
{...toast}
|
||||
onClose={() => removeToast(toast.id)}
|
||||
/>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to show toast
|
||||
export const showToast = (
|
||||
message: string,
|
||||
type: 'success' | 'error' | 'info' | 'warning' = 'info',
|
||||
duration = 3000
|
||||
) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const event = new CustomEvent('toast', {
|
||||
detail: { message, type, duration },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
export interface ToastProps {
|
||||
id: string;
|
||||
message: string;
|
||||
type?: 'success' | 'error' | 'info' | 'warning';
|
||||
duration?: number;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const Toast: React.FC<ToastProps> = ({
|
||||
id,
|
||||
message,
|
||||
type = 'info',
|
||||
duration = 3000,
|
||||
onClose,
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => onClose?.(), 300);
|
||||
}, duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [duration, onClose]);
|
||||
|
||||
const icons = {
|
||||
success: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
),
|
||||
error: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
),
|
||||
warning: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
),
|
||||
info: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
const colors = {
|
||||
success: 'bg-success-50 text-success-900 border-success-200',
|
||||
error: 'bg-red-50 text-red-900 border-red-200',
|
||||
warning: 'bg-warning-50 text-warning-900 border-warning-200',
|
||||
info: 'bg-info-50 text-info-900 border-info-200',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex items-center space-x-3 px-4 py-3 rounded-lg border shadow-lg
|
||||
${colors[type]}
|
||||
transition-all duration-300 transform
|
||||
${isVisible ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'}
|
||||
`}
|
||||
>
|
||||
<div className="flex-shrink-0">{icons[type]}</div>
|
||||
<p className="text-sm font-medium">{message}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => onClose?.(), 300);
|
||||
}}
|
||||
className="ml-auto flex-shrink-0 hover:opacity-70"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Toast Container
|
||||
export const ToastContainer: React.FC = () => {
|
||||
const [toasts, setToasts] = useState<ToastProps[]>([]);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
|
||||
const handleToast = (event: CustomEvent<Omit<ToastProps, 'id'>>) => {
|
||||
const newToast: ToastProps = {
|
||||
...event.detail,
|
||||
id: Date.now().toString(),
|
||||
};
|
||||
setToasts(prev => [...prev, newToast]);
|
||||
};
|
||||
|
||||
window.addEventListener('toast' as any, handleToast);
|
||||
return () => window.removeEventListener('toast' as any, handleToast);
|
||||
}, []);
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
};
|
||||
|
||||
if (!isMounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed top-4 right-4 z-50 space-y-2">
|
||||
{toasts.map(toast => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
{...toast}
|
||||
onClose={() => removeToast(toast.id)}
|
||||
/>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to show toast
|
||||
export const showToast = (
|
||||
message: string,
|
||||
type: 'success' | 'error' | 'info' | 'warning' = 'info',
|
||||
duration = 3000
|
||||
) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const event = new CustomEvent('toast', {
|
||||
detail: { message, type, duration },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user