feat: convert to nextjs
This commit is contained in:
39
components/Button.tsx
Normal file
39
components/Button.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Spinner } from './Spinner';
|
||||
|
||||
interface ButtonProps {
|
||||
onClick: () => void;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
labelLoading?: string;
|
||||
labelReady: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Button = ({
|
||||
onClick,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
labelLoading = '',
|
||||
labelReady,
|
||||
className
|
||||
}: ButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={
|
||||
className ??
|
||||
'w-full h-10 px-4 flex items-center justify-center gap-2 bg-blue-600 text-white rounded-lg font-medium shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:bg-blue-400 disabled:cursor-not-allowed transition-colors'
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Spinner />
|
||||
<span>{labelLoading}</span>
|
||||
</>
|
||||
) : (
|
||||
labelReady
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
337
components/Content.tsx
Normal file
337
components/Content.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import { useState } from 'react';
|
||||
import { Consumer, consumerSchema } from '@utils/consumer/types';
|
||||
import { PurchaseList, purchasesRequestSchema } from '@purchases/types';
|
||||
import { Button } from './Button';
|
||||
import { useToast } from '../context/toast/ToastContext';
|
||||
import { Toasts } from './Toast';
|
||||
import { LineChart, PersonStanding, Download, Sparkles } from 'lucide-react';
|
||||
|
||||
export const Content = () => {
|
||||
const [consumerId, setConsumerId] = useState<number>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [consumer, setConsumer] = useState<Consumer | null>(null);
|
||||
const [purchasesError, setPurchasesError] = useState<string | null>(null);
|
||||
const [editedConsumer, setEditedConsumer] = useState('');
|
||||
const [purchasesResult, setPurchasesResult] = useState<PurchaseList | null>(
|
||||
null
|
||||
);
|
||||
const { showToast, toasts } = useToast();
|
||||
|
||||
const downloadJson = (data: Consumer | PurchaseList, filename: string) => {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: 'application/json'
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleDownloadConsumer = () => {
|
||||
if (!consumer) return;
|
||||
try {
|
||||
downloadJson(consumer, 'consumer.json');
|
||||
} catch (err) {
|
||||
showToast('Failed to download consumer data');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadPurchases = () => {
|
||||
if (!purchasesResult) return;
|
||||
try {
|
||||
downloadJson(purchasesResult, 'purchases.json');
|
||||
} catch (err) {
|
||||
showToast('Failed to download purchase history');
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateConsumer = async () => {
|
||||
setLoading(true);
|
||||
setConsumer(null);
|
||||
setPurchasesError(null);
|
||||
setEditedConsumer('');
|
||||
setPurchasesResult(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/consumer', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to generate consumer');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
setConsumerId(data.id);
|
||||
setConsumer(data.consumer);
|
||||
|
||||
setEditedConsumer(JSON.stringify(data.consumer, null, 2));
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : 'Something went wrong');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJsonEdit = (value: string) => {
|
||||
setEditedConsumer(value);
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
const validConsumer = consumerSchema.safeParse(parsed);
|
||||
if (!validConsumer.success) {
|
||||
setPurchasesError('Invalid consumer format');
|
||||
return;
|
||||
}
|
||||
setPurchasesError(null);
|
||||
} catch {
|
||||
setPurchasesError('Invalid JSON format');
|
||||
}
|
||||
};
|
||||
|
||||
const handleGeneratePurchases = async () => {
|
||||
if (purchasesError) {
|
||||
showToast('Please fix the JSON errors before submitting');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setPurchasesResult(null);
|
||||
|
||||
try {
|
||||
const jsonData = JSON.parse(editedConsumer);
|
||||
const requestData = { id: consumerId, consumer: jsonData };
|
||||
|
||||
const validationResult = purchasesRequestSchema.safeParse(requestData);
|
||||
if (!validationResult.success) {
|
||||
throw new Error(validationResult.error.issues[0].message);
|
||||
}
|
||||
|
||||
const response = await fetch('/api/purchase-list', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to generate purchases');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
setPurchasesResult(data);
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : 'Something went wrong');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='min-h-screen relative'>
|
||||
<div
|
||||
className='fixed inset-0 bg-white'
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(30deg, #e2e8f0 12%, transparent 12.5%, transparent 87.5%, #e2e8f0 87.5%, #e2e8f0),
|
||||
linear-gradient(150deg, #e2e8f0 12%, transparent 12.5%, transparent 87.5%, #e2e8f0 87.5%, #e2e8f0),
|
||||
linear-gradient(30deg, #e2e8f0 12%, transparent 12.5%, transparent 87.5%, #e2e8f0 87.5%, #e2e8f0),
|
||||
linear-gradient(150deg, #e2e8f0 12%, transparent 12.5%, transparent 87.5%, #e2e8f0 87.5%, #e2e8f0),
|
||||
linear-gradient(60deg, rgba(226,232,240,0.25) 25%, transparent 25.5%, transparent 75%, rgba(226,232,240,0.25) 75%, rgba(226,232,240,0.25)),
|
||||
linear-gradient(60deg, rgba(226,232,240,0.25) 25%, transparent 25.5%, transparent 75%, rgba(226,232,240,0.25) 75%, rgba(226,232,240,0.25))
|
||||
`,
|
||||
backgroundSize: '80px 140px',
|
||||
backgroundPosition: '0 0, 0 0, 40px 70px, 40px 70px, 0 0, 40px 70px',
|
||||
opacity: 0.4
|
||||
}}
|
||||
/>
|
||||
<div className='container mx-auto px-4 py-12'>
|
||||
<Toasts toasts={toasts} />
|
||||
|
||||
<div className='max-w-7xl mx-auto mb-12'>
|
||||
<div className='bg-gradient-to-r from-white/70 to-white/90 backdrop-blur-sm rounded-2xl border border-slate-200 shadow-xl p-6 hover:shadow-2xl transition-all duration-300'>
|
||||
<div className='flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='p-2 bg-indigo-100 rounded-lg'>
|
||||
<Sparkles className='w-5 h-5 text-indigo-600' />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className='text-lg font-semibold text-slate-900'>
|
||||
Quick Start
|
||||
</h2>
|
||||
<p className='text-sm text-slate-600'>
|
||||
Generate synthetic data in minutes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<a
|
||||
href='/samples/consumer.json'
|
||||
download
|
||||
className='flex-1 md:flex-none inline-flex items-center justify-center gap-2 px-3 py-2 bg-slate-100 hover:bg-slate-200
|
||||
text-slate-700 rounded-lg text-sm font-medium transition-colors'
|
||||
>
|
||||
<Download className='w-4 h-4' />
|
||||
Sample Consumer
|
||||
</a>
|
||||
<a
|
||||
href='/samples/purchases.json'
|
||||
download
|
||||
className='flex-1 md:flex-none inline-flex items-center justify-center gap-2 px-3 py-2 bg-slate-100 hover:bg-slate-200
|
||||
text-slate-700 rounded-lg text-sm font-medium transition-colors'
|
||||
>
|
||||
<Download className='w-4 h-4' />
|
||||
Sample Purchases
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid md:grid-cols-3 gap-4'>
|
||||
<div className='p-4 bg-blue-50/50 rounded-lg border border-blue-100'>
|
||||
<div className='text-xl font-bold text-blue-600 mb-1'>01</div>
|
||||
<h3 className='font-medium text-slate-900'>Generate Profile</h3>
|
||||
<p className='text-sm text-slate-600'>
|
||||
Create a synthetic consumer profile
|
||||
</p>
|
||||
</div>
|
||||
<div className='p-4 bg-indigo-50/50 rounded-lg border border-indigo-100'>
|
||||
<div className='text-xl font-bold text-indigo-600 mb-1'>02</div>
|
||||
<h3 className='font-medium text-slate-900'>Review Data</h3>
|
||||
<p className='text-sm text-slate-600'>
|
||||
Check and modify the generated profile
|
||||
</p>
|
||||
</div>
|
||||
<div className='p-4 bg-purple-50/50 rounded-lg border border-purple-100'>
|
||||
<div className='text-xl font-bold text-purple-600 mb-1'>03</div>
|
||||
<h3 className='font-medium text-slate-900'>Get History</h3>
|
||||
<p className='text-sm text-slate-600'>
|
||||
Generate purchase history data
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 lg:grid-cols-2 gap-8 max-w-7xl mx-auto'>
|
||||
<div className='bg-white/70 backdrop-blur-sm rounded-2xl border border-slate-200 shadow-xl hover:shadow-2xl transition-all duration-300'>
|
||||
<div className='p-6 border-b border-slate-100 bg-gradient-to-r from-blue-50 to-indigo-50'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<div className='p-2 bg-blue-100 rounded-lg'>
|
||||
<PersonStanding className='w-5 h-5 text-blue-600' />
|
||||
</div>
|
||||
<h2 className='text-xl font-semibold text-slate-900'>
|
||||
Consumer Data
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className='p-8 space-y-6'>
|
||||
<Button
|
||||
loading={loading}
|
||||
disabled={loading || submitting}
|
||||
labelLoading='Generating consumer... (it can take up to 30 seconds)'
|
||||
labelReady='Generate Consumer Profile'
|
||||
onClick={handleGenerateConsumer}
|
||||
/>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<label className='text-sm font-medium text-slate-700'>
|
||||
Edit Consumer Data
|
||||
</label>
|
||||
{purchasesError && (
|
||||
<span className='text-sm text-red-600 bg-red-50 px-4 py-1 rounded-full'>
|
||||
{purchasesError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='relative'>
|
||||
<textarea
|
||||
value={editedConsumer}
|
||||
onChange={e => handleJsonEdit(e.target.value)}
|
||||
className='w-full h-96 p-4 font-mono text-sm
|
||||
bg-slate-50 rounded-xl border border-slate-200
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||
resize-none transition-all duration-200'
|
||||
spellCheck='false'
|
||||
disabled={submitting}
|
||||
placeholder={
|
||||
consumer
|
||||
? ''
|
||||
: 'Generated consumer profile will appear here'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
labelReady='Download generated consumer data'
|
||||
onClick={handleDownloadConsumer}
|
||||
disabled={!consumer}
|
||||
className='w-full inline-flex items-center justify-center gap-2 px-4 py-2 bg-slate-100 hover:bg-slate-200 disabled:opacity-50 disabled:cursor-not-allowed text-slate-700 rounded-lg text-sm font-medium transition-colors'
|
||||
/>
|
||||
|
||||
<Button
|
||||
loading={submitting}
|
||||
disabled={
|
||||
!editedConsumer || Boolean(purchasesError) || submitting
|
||||
}
|
||||
labelLoading='Generating purchases... (it can take up to a minute)'
|
||||
labelReady='Generate Purchase History'
|
||||
onClick={handleGeneratePurchases}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='bg-white/70 backdrop-blur-sm rounded-2xl border border-slate-200 shadow-xl hover:shadow-2xl transition-all duration-300'>
|
||||
<div className='p-6 border-b border-slate-100 bg-gradient-to-r from-indigo-50 to-blue-50'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<div className='p-2 bg-blue-100 rounded-lg'>
|
||||
<LineChart className='w-5 h-5 text-blue-600' />
|
||||
</div>
|
||||
<h2 className='text-xl font-semibold text-slate-900'>
|
||||
Purchase History
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className='p-8'>
|
||||
<div className='h-[34rem] rounded-xl'>
|
||||
{purchasesResult ? (
|
||||
<div className='h-full bg-slate-50 border border-slate-200 rounded-xl'>
|
||||
<pre className='text-sm text-slate-700 whitespace-pre-wrap p-6 h-full overflow-auto'>
|
||||
{JSON.stringify(purchasesResult, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className='flex flex-col items-center justify-center h-full
|
||||
bg-slate-50 border border-slate-200 rounded-xl gap-4'
|
||||
>
|
||||
<LineChart className='w-12 h-12 text-slate-300' />
|
||||
<p className='text-sm font-medium text-slate-500'>
|
||||
No purchase history generated yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='px-8'>
|
||||
<Button
|
||||
labelReady='Download generated purchase history data'
|
||||
onClick={handleDownloadPurchases}
|
||||
disabled={!purchasesResult}
|
||||
className='w-full inline-flex items-center justify-center gap-2 px-4 py-2 bg-slate-100 hover:bg-slate-200 disabled:opacity-50 disabled:cursor-not-allowed text-slate-700 rounded-lg text-sm font-medium transition-colors'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
32
components/Footer.tsx
Normal file
32
components/Footer.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { AlertTriangle, Mail } from 'lucide-react';
|
||||
|
||||
export const Footer = () => {
|
||||
return (
|
||||
<footer className='bg-slate-900 fixed bottom-0 left-0 right-0 z-50'>
|
||||
<div className='max-w-[100rem] mx-auto py-4 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-3 items-center gap-4'>
|
||||
<p className='text-xs text-slate-400 text-center sm:text-left'>
|
||||
All data generated through this platform is AI-generated content
|
||||
intended for testing and development. Any resemblance to real
|
||||
persons, businesses, or events is purely coincidental. Users are
|
||||
responsible for ensuring compliance with applicable laws and
|
||||
regulations.
|
||||
</p>
|
||||
<div className='flex items-center justify-center gap-2 text-amber-400 order-first sm:order-none'>
|
||||
<AlertTriangle className='w-5 h-5' />
|
||||
<p className='font-medium'>Use Responsibly</p>
|
||||
</div>
|
||||
<div className='flex items-center justify-center sm:justify-end gap-2'>
|
||||
<Mail className='w-4 h-4 text-slate-400' />
|
||||
<a
|
||||
href={`mailto:${process.env.NEXT_PUBLIC_CONTACT_EMAIL}`}
|
||||
className='text-sm text-slate-400 hover:text-slate-300'
|
||||
>
|
||||
{process.env.NEXT_PUBLIC_CONTACT_EMAIL}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
32
components/Header.tsx
Normal file
32
components/Header.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Database, Sparkles } from 'lucide-react';
|
||||
|
||||
export const Header = () => {
|
||||
return (
|
||||
<header className='bg-gradient-to-r from-blue-600 to-indigo-600 fixed top-0 left-0 right-0 z-50'>
|
||||
<div className='max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<div className='p-3 bg-white/10 rounded-xl backdrop-blur-sm'>
|
||||
<Database className='text-white w-8 h-8' />
|
||||
</div>
|
||||
<div>
|
||||
<div className='flex items-center gap-3'>
|
||||
<h1 className='text-2xl font-bold text-white'>
|
||||
Synthetic Consumer Generator
|
||||
</h1>
|
||||
<span className='px-2 py-0.5 text-xs font-medium text-blue-100 bg-white/10 rounded-full backdrop-blur-sm'>
|
||||
Beta
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 text-blue-100 mt-1'>
|
||||
<Sparkles className='w-4 h-4' />
|
||||
<p className='text-sm'>
|
||||
Generate realistic customer profiles and purchase histories in
|
||||
seconds
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
5
components/Spinner.tsx
Normal file
5
components/Spinner.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export const Spinner = () => {
|
||||
return <Loader2 className='h-5 w-5 animate-spin' />;
|
||||
};
|
||||
24
components/Toast.tsx
Normal file
24
components/Toast.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { Toast } from '../context/toast/ToastContext';
|
||||
|
||||
interface ToastsProps {
|
||||
toasts: Toast[];
|
||||
}
|
||||
|
||||
export const Toasts = ({ toasts }: ToastsProps) => {
|
||||
return (
|
||||
<div className='fixed bottom-4 left-4 z-[9999] space-y-2'>
|
||||
{toasts.map(toast => {
|
||||
return (
|
||||
<div
|
||||
key={toast.id}
|
||||
className='flex items-center gap-2 p-3 text-red-600 bg-red-50 rounded-lg border border-red-200 shadow-lg hover:shadow-xl'
|
||||
>
|
||||
<AlertCircle className='h-5 w-5 shrink-0' />
|
||||
<p className='text-sm'>{toast.message}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user