feat: convert to nextjs

This commit is contained in:
2024-12-07 07:45:24 +01:00
parent b248ee80ee
commit 633b8ee207
52 changed files with 4121 additions and 982 deletions

View File

@@ -1,3 +1,13 @@
DATABASE_URL="postgresql://postgres:postgres@localhost:5433/persona?schema=public&connect_timeout=300"
PURCHASE_REFLECTION_THRESHOLD=50
ANTHROPIC_API_KEY= ANTHROPIC_API_KEY=
NEXT_PUBLIC_CONTACT_EMAIL=
POSTGRES_DATABASE=
POSTGRES_HOST=
POSTGRES_PASSWORD=
POSTGRES_PRISMA_URL=
POSTGRES_URL=
POSTGRES_URL_NON_POOLING=
POSTGRES_URL_NO_SSL=
POSTGRES_USER=
PURCHASE_REFLECTION_THRESHOLD=
NUMBER_OF_WEEKS=
RATE_LIMIT=

View File

@@ -1,22 +1,32 @@
{ {
"env": { "env": {
"node": true,
"browser": true, "browser": true,
"es2021": true "es2021": true
}, },
"extends": [ "extends": [
"next/core-web-vitals",
"eslint:recommended", "eslint:recommended",
"plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended",
"prettier" "prettier"
], ],
"plugins": ["@typescript-eslint"],
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"parserOptions": { "parserOptions": {
"ecmaVersion": "latest", "ecmaVersion": "latest",
"sourceType": "module" "sourceType": "module"
}, },
"plugins": ["@typescript-eslint"],
"rules": { "rules": {
"@typescript-eslint/no-unused-vars": "error", "@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/consistent-type-definitions": "error" "@typescript-eslint/consistent-type-definitions": "error",
"@typescript-eslint/no-explicit-any": "warn",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error"
}, },
"ignorePatterns": ["node_modules/", "dist/"] "settings": {
"next": {
"rootDir": ["./"]
}
},
"ignorePatterns": ["node_modules/", ".next/"]
} }

4
.gitignore vendored
View File

@@ -131,7 +131,5 @@ dist
.editorconfig .editorconfig
personas/
purchases/
.DS_Store .DS_Store
.vercel

4
.husky/commit-msg Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx --no-install commitlint --edit $1

8
.husky/pre-commit Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
yarn audit
yarn format
yarn lint-staged
yarn typecheck
yarn build

View File

@@ -1,17 +1,16 @@
# Purchases Personas Generator # Synthetic Consumers Data Generator
A TypeScript application that leverages the Anthropic Claude API to generate realistic fictional personas and their weekly purchase behaviors. For creating synthetic datasets for retail/e-commerce applications with believable user behaviors and spending patterns. A NextJS application that makes use of the Anthropic Claude API to generate synthetic consumers and their weekly purchase history. For creating synthetic datasets for retail/e-commerce applications with believable user behaviors and spending patterns.
## 🌟 Features ## 🌟 Features
- Generate detailed fictional personas including: - Generate detailed synthetic consumers including:
- Personal demographics and household details - Consumer demographics and household details
- Daily routines and activities - Daily routines and activities
- Shopping preferences and brand loyalties - Shopping preferences and brand loyalties
- Financial patterns and spending habits - Financial patterns and spending habits
- Contextual behaviors and upcoming events - Contextual behaviors and upcoming events
- Create realistic weekly purchase histories that match persona profiles - Create realistic weekly purchase histories that match consumer profiles
- Store generated data in JSON files for easy data portability
## 🚀 Getting Started ## 🚀 Getting Started
@@ -48,7 +47,15 @@ yarn dev
- `yarn lint` - Run ESLint with automatic fixes - `yarn lint` - Run ESLint with automatic fixes
- `yarn format` - Format code using Prettier - `yarn format` - Format code using Prettier
- `yarn typecheck` - Check TypeScript types - `yarn typecheck` - Check TypeScript types
- `yarn prepare` - Install husky
- `yarn audit` - Run audit
- `yarn vercel:link` - Link Vercel project
- `yarn vercel:env` - Pull .env from Vercel
- `yarn prisma:migrate` - Migrate database
- `yarn prisma:push` - Push migrations
- `yarn prisma:generate`- Generate Prisma types
- `yarn prisma:reset` - Reset database
## ⚠️ Disclaimer ## ⚠️ Disclaimer
The personas and purchase histories generated by this tool are fictional and should not be used as real user data. They are intended for testing and development purposes only. The consumers and purchase histories generated by this tool are fictional and should not be used as real user data. They are intended for testing and development purposes only.

28
app/api/consumer/route.ts Normal file
View File

@@ -0,0 +1,28 @@
import { NextResponse } from 'next/server';
import { generate } from '@utils/consumer/store';
import { rateLimiter } from '@utils/rateLimiter';
export async function POST() {
const rateLimit = await rateLimiter();
if (rateLimit) {
return NextResponse.json(
{ error: 'Rate limit exceeded.' },
{ status: 429 }
);
}
try {
const data = await generate();
return NextResponse.json({
id: data.id,
consumer: data.consumer
});
} catch (error) {
console.error('Error generating consumer:', error);
return NextResponse.json(
{ error: 'Failed to generate consumer' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,38 @@
import { generate } from '@purchases/store';
import { purchasesRequestSchema } from '@purchases/types';
import { rateLimiter } from '@utils/rateLimiter';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const rateLimit = await rateLimiter();
if (rateLimit) {
return NextResponse.json(
{ error: 'Rate limit exceeded.' },
{ status: 429 }
);
}
try {
const body = await request.json();
const result = purchasesRequestSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: result.error.issues[0].message },
{ status: 400 }
);
}
const { id, consumer } = result.data;
const purchaseList = await generate(id, consumer, new Date());
return NextResponse.json(purchaseList);
} catch (error) {
console.error('Error processing purchases:', error);
return NextResponse.json(
{ error: 'Failed to process purchases' },
{ status: 500 }
);
}
}

44
app/globals.css Normal file
View File

@@ -0,0 +1,44 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
margin: 0;
padding: 0;
overflow-x: hidden;
overscroll-behavior: none;
width: 100%;
position: relative;
}
#root,
#__next {
overflow-x: hidden;
width: 100%;
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
}

20
app/layout.tsx Normal file
View File

@@ -0,0 +1,20 @@
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: 'Synthetic Consumers Data Generator',
description:
'Generate realistic synthetic consumers and their weekly purchase behaviors using AI'
};
export default function RootLayout({
children
}: {
children: React.ReactNode;
}) {
return (
<html lang='en'>
<body>{children}</body>
</html>
);
}

20
app/page.tsx Normal file
View File

@@ -0,0 +1,20 @@
'use client';
import { Footer } from '@components/Footer';
import { Header } from '@components/Header';
import { Content } from '@components/Content';
import { ToastProvider } from '../context/toast/ToastProvider';
export default function Home() {
return (
<ToastProvider>
<div className='min-h-screen flex flex-col'>
<Header />
<main className='flex-1 pt-[100px] pb-[88px]'>
<Content />
</main>
<Footer />
</div>
</ToastProvider>
);
}

39
components/Button.tsx Normal file
View 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
View 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
View 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
View 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
View 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
View 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>
);
};

View File

@@ -0,0 +1,23 @@
import { createContext, useContext } from 'react';
export interface Toast {
id: number;
message: string;
}
interface ToastContextType {
showToast: (message: string) => void;
toasts: Toast[];
}
export const ToastContext = createContext<ToastContextType | undefined>(
undefined
);
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};

View File

@@ -0,0 +1,23 @@
import React, { useState, useCallback } from 'react';
import { Toast, ToastContext } from './ToastContext';
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({
children
}) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((message: string) => {
const id = Date.now();
setToasts(prev => [...prev, { id, message }]);
setTimeout(() => {
setToasts(prev => prev.filter(toast => toast.id !== id));
}, 3000);
}, []);
return (
<ToastContext.Provider value={{ showToast, toasts }}>
{children}
</ToastContext.Provider>
);
};

5
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

View File

@@ -1,41 +1,67 @@
{ {
"name": "purchases-personas", "name": "synthetic-consumer-data",
"version": "1.1.0", "version": "1.0.0",
"description": "Generate realistic fictional personas and their weekly purchase behaviors using AI", "description": "Generate realistic synthetic consumers and their weekly purchase behaviors using AI",
"author": "riccardo@frompixels.com",
"scripts": { "scripts": {
"start": "node dist/index.js", "dev": "next dev",
"dev": "nodemon src/index.ts", "build": "prisma generate && next build",
"build": "tsc", "start": "next start",
"lint": "eslint . --fix", "lint": "next lint && eslint . --fix",
"format": "prettier --config .prettierrc '**/*.{ts,json,md}' --write", "format": "prettier --config .prettierrc '**/*.{ts,tsx,json,md}' --write",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"prepare": "husky install", "prepare": "husky install",
"audit": "audit-ci", "audit": "audit-ci",
"generate": "prisma generate", "vercel:link": "vercel link",
"migrate": "prisma migrate dev" "vercel:env": "vercel env pull .env",
"prisma:migrate": "npx prisma migrate dev",
"prisma:push": "npx prisma db push",
"prisma:generate": "npx prisma generate",
"prisma:reset": "npx prisma db push --force-reset"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.32.1", "@anthropic-ai/sdk": "^0.32.1",
"@prisma/client": "^6.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"crypto": "^1.0.1", "crypto": "^1.0.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.21.1", "lucide-react": "^0.462.0",
"moment": "^2.30.1",
"next": "14.2.10",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwind-merge": "^2.5.5",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^18.4.3", "@commitlint/cli": "^18.4.3",
"@commitlint/config-conventional": "^18.4.3", "@commitlint/config-conventional": "^18.4.3",
"@types/express": "^4.17.21", "@types/node": "^22.10.1",
"@types/node": "^20.10.0", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@typescript-eslint/eslint-plugin": "^6.12.0", "@typescript-eslint/eslint-plugin": "^6.12.0",
"@typescript-eslint/parser": "^6.12.0", "@typescript-eslint/parser": "^6.12.0",
"audit-ci": "^6.6.1", "audit-ci": "^6.6.1",
"autoprefixer": "^10.4.20",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "^15.0.3",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.0.0",
"husky": "^8.0.3", "husky": "^8.0.3",
"lint-staged": "^15.1.0", "lint-staged": "^15.1.0",
"nodemon": "^3.0.2", "postcss": "^8.4.49",
"prettier": "^3.1.0", "prettier": "^3.1.0",
"ts-node": "^10.9.2", "prisma": "^6.0.1",
"tailwindcss": "^3.4.15",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.3.0" "typescript": "^5.3.0"
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --quiet --fix"
],
"*.{json,ts,tsx}": [
"prettier --write --ignore-unknown"
]
} }
} }

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View File

@@ -0,0 +1,26 @@
-- CreateTable
CREATE TABLE "consumer" (
"id" SERIAL NOT NULL,
"letters" TEXT NOT NULL,
"year" INTEGER NOT NULL,
"zipCode" TEXT NOT NULL,
"persona" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "consumer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "purchases" (
"id" SERIAL NOT NULL,
"value" JSONB NOT NULL,
"consumerId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "purchases_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "purchases" ADD CONSTRAINT "purchases_consumerId_fkey" FOREIGN KEY ("consumerId") REFERENCES "consumer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,17 @@
/*
Warnings:
- You are about to drop the column `persona` on the `consumer` table. All the data in the column will be lost.
- You are about to drop the column `year` on the `consumer` table. All the data in the column will be lost.
- Added the required column `birthday` to the `consumer` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "consumer" DROP COLUMN "persona",
DROP COLUMN "year",
ADD COLUMN "birthday" TIMESTAMP(3) NOT NULL,
ADD COLUMN "editedValue" JSONB,
ADD COLUMN "value" JSONB;
-- AlterTable
ALTER TABLE "purchases" ALTER COLUMN "value" DROP NOT NULL;

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "consumer" ALTER COLUMN "letters" DROP NOT NULL,
ALTER COLUMN "zipCode" DROP NOT NULL,
ALTER COLUMN "birthday" DROP NOT NULL;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

9
prisma/prisma.ts Normal file
View File

@@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
export default prisma;

34
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,34 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("POSTGRES_PRISMA_URL") // uses connection pooling
directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection
}
model Consumer {
id Int @id @default(autoincrement())
letters String?
birthday DateTime?
zipCode String?
value Json?
editedValue Json?
purchaseLists PurchaseList[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("consumer")
}
model PurchaseList {
id Int @id @default(autoincrement())
value Json?
consumer Consumer @relation(fields: [consumerId], references: [id])
consumerId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("purchases")
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,16 @@
{
"name": "Synthetic constumer data generator",
"short_name": "Synthetic data",
"description": "Generate synthetic consumer data and purchasing history",
"start_url": "/",
"display": "standalone",
"background_color": "#fff",
"theme_color": "#fff",
"icons": [
{
"src": "favicon.ico",
"sizes": "any",
"type": "image/x-icon"
}
]
}

View File

@@ -0,0 +1,209 @@
{
"core": {
"age": 45,
"name": "Beatrice Ce",
"occupation": {
"title": "Operations Manager",
"level": "senior",
"income": 65000,
"location": "Brescia Industrial District",
"schedule": ["Monday-Friday, 8:30-17:30"]
},
"home": {
"type": "apartment",
"ownership": "owned",
"location": "Brescia Residential Area",
"commute_distance_km": 8.5
},
"household": {
"status": "married",
"members": ["husband", "daughter (14 years)"],
"pets": [
{
"type": "cat",
"name": "Luna"
}
]
}
},
"routines": {
"weekday": {
"1800": {
"activity": "gym or family time",
"location": "varied",
"duration_minutes": 90
},
"0600": {
"activity": "wake up and prepare",
"location": "home",
"duration_minutes": 60
},
"0730": {
"activity": "school drop-off",
"location": "daughter's school",
"duration_minutes": 20
},
"0830": {
"activity": "work",
"location": "office",
"duration_minutes": 540
}
},
"weekend": [
"morning market visits",
"family activities",
"home organization",
"social gatherings",
"occasional day trips to Lake Garda"
],
"commute": {
"method": "car",
"route": ["home", "daughter's school", "industrial district"],
"regular_stops": [
{
"location": "daughter's school",
"purpose": "school drop-off",
"frequency": "weekday mornings"
},
{
"location": "local café",
"purpose": "morning coffee",
"frequency": "2-3 times per week"
}
]
}
},
"preferences": {
"diet": ["Mediterranean", "preference for local products"],
"brands": [
{
"name": "Benetton",
"loyalty_score": 8
},
{
"name": "Lavazza",
"loyalty_score": 7
},
{
"name": "Esselunga",
"loyalty_score": 9
}
],
"price_sensitivity": 6,
"payment_methods": ["credit card", "contactless", "mobile payments"]
},
"finances": {
"subscriptions": [
{
"name": "Mortgage",
"amount": 1200,
"frequency": "monthly",
"next_due_date": "2023-12-01",
"category": "housing",
"is_fixed_expense": true,
"auto_payment": true
},
{
"name": "Utilities Bundle",
"amount": 180,
"frequency": "monthly",
"next_due_date": "2023-12-05",
"category": "utilities",
"is_fixed_expense": true,
"auto_payment": true
},
{
"name": "Home Insurance",
"amount": 45,
"frequency": "monthly",
"next_due_date": "2023-12-15",
"category": "insurance",
"is_fixed_expense": true,
"auto_payment": true
},
{
"name": "Netflix",
"amount": 18,
"frequency": "monthly",
"next_due_date": "2023-12-03",
"category": "digital",
"is_fixed_expense": false,
"auto_payment": true
},
{
"name": "Gym Membership",
"amount": 55,
"frequency": "monthly",
"next_due_date": "2023-12-01",
"category": "memberships",
"is_fixed_expense": false,
"auto_payment": true
}
],
"spending_patterns": {
"impulsive_score": 4,
"categories": {
"groceries": {
"preference_score": 8,
"monthly_budget": 600
},
"dining": {
"preference_score": 7,
"monthly_budget": 300
},
"fashion": {
"preference_score": 6,
"monthly_budget": 200
}
}
}
},
"habits": {
"exercise": [
{
"activity": "gym workout",
"frequency": "3 times per week",
"duration_minutes": 60
},
{
"activity": "yoga",
"frequency": "once per week",
"duration_minutes": 45
}
],
"social": [
{
"activity": "family dinner with parents",
"frequency": "bi-weekly"
},
{
"activity": "coffee with friends",
"frequency": "weekly"
}
]
},
"context": {
"stress_triggers": [
"tight project deadlines",
"family-work balance",
"traffic during rush hour"
],
"reward_behaviors": [
"weekend trips to Lake Garda",
"dining at favorite restaurants",
"shopping for home decor"
],
"upcoming_events": [
{
"name": "Company's Annual Strategic Planning",
"date": "2024-01-15",
"importance": 8
},
{
"name": "Family Summer Vacation Planning",
"date": "2024-02-10",
"importance": 7
}
]
}
}

View File

@@ -0,0 +1,480 @@
{
"weeks": [
{
"weekNumber": 1,
"startDate": "2024-11-25",
"endDate": "2024-12-01",
"purchases": [
{
"name": "Lavazza Coffee",
"amount": 3.5,
"datetime": "2024-11-25T08:15:00Z",
"location": "Local Café",
"category": "dining",
"isPlanned": true
},
{
"name": "Weekly Groceries",
"amount": 135,
"datetime": "2024-11-25T17:30:00Z",
"location": "Esselunga Supermarket",
"category": "groceries",
"isPlanned": true,
"reflections": [
{
"comment": "Weekly grocery shopping aligned with meal planning. Got good deals on fresh produce and stocked up on Luna's cat food.",
"satisfactionScore": 8,
"date": "2024-11-25T20:00:00Z",
"mood": "content"
}
]
},
{
"name": "Morning Pastry",
"amount": 2.5,
"datetime": "2024-11-26T08:15:00Z",
"location": "Local Café",
"category": "dining",
"isPlanned": false
},
{
"name": "Lunch with Colleagues",
"amount": 18,
"datetime": "2024-11-27T12:30:00Z",
"location": "Office District Restaurant",
"category": "dining",
"isPlanned": true
},
{
"name": "Lavazza Coffee",
"amount": 3.5,
"datetime": "2024-11-28T08:15:00Z",
"location": "Local Café",
"category": "dining",
"isPlanned": true
},
{
"name": "Family Dinner",
"amount": 65,
"datetime": "2024-11-28T19:00:00Z",
"location": "Trattoria Bella Vista",
"category": "dining",
"isPlanned": true,
"reflections": [
{
"comment": "Wonderful evening with family. The restaurant choice was perfect for our bi-weekly gathering.",
"satisfactionScore": 9,
"date": "2024-11-28T22:00:00Z",
"mood": "happy"
}
]
},
{
"name": "Yoga Class Drop-in",
"amount": 15,
"datetime": "2024-11-29T16:30:00Z",
"location": "Zen Studio",
"category": "health",
"isPlanned": true
},
{
"name": "Winter Sweater",
"amount": 75,
"datetime": "2024-11-30T10:00:00Z",
"location": "Benetton Store",
"category": "fashion",
"isPlanned": true,
"reflections": [
{
"comment": "Quality winter piece from preferred brand. Good investment for the season.",
"satisfactionScore": 8,
"date": "2024-11-30T18:00:00Z",
"mood": "satisfied"
}
]
},
{
"name": "Coffee with Friends",
"amount": 12,
"datetime": "2024-11-30T11:30:00Z",
"location": "City Center Café",
"category": "dining",
"isPlanned": true
},
{
"name": "Weekend Market Produce",
"amount": 32,
"datetime": "2024-12-01T09:30:00Z",
"location": "Local Farmers Market",
"category": "groceries",
"isPlanned": true
},
{
"name": "Cat Supplies",
"amount": 25,
"datetime": "2024-12-01T14:00:00Z",
"location": "Pet Shop",
"category": "household",
"isPlanned": true
}
],
"weekContext": {
"events": ["Bi-weekly family dinner", "Weekly coffee with friends"],
"stressLevel": 6,
"notes": "Regular work week with standard routines"
}
},
{
"weekNumber": 2,
"startDate": "2024-12-02",
"endDate": "2024-12-08",
"purchases": [
{
"name": "Lavazza Coffee",
"amount": 3.5,
"datetime": "2024-12-02T08:15:00Z",
"location": "Local Café",
"category": "dining",
"isPlanned": true
},
{
"name": "Weekly Groceries",
"amount": 142,
"datetime": "2024-12-02T17:30:00Z",
"location": "Esselunga Supermarket",
"category": "groceries",
"isPlanned": true,
"reflections": [
{
"comment": "Monthly stock-up after salary. Got some extra items for upcoming meals.",
"satisfactionScore": 8,
"date": "2024-12-02T20:00:00Z",
"mood": "satisfied"
}
]
},
{
"name": "Office Lunch",
"amount": 15,
"datetime": "2024-12-03T13:00:00Z",
"location": "Office District Café",
"category": "dining",
"isPlanned": true
},
{
"name": "Lavazza Coffee",
"amount": 3.5,
"datetime": "2024-12-04T08:15:00Z",
"location": "Local Café",
"category": "dining",
"isPlanned": true
},
{
"name": "Yoga Class Drop-in",
"amount": 15,
"datetime": "2024-12-05T16:30:00Z",
"location": "Zen Studio",
"category": "health",
"isPlanned": true
},
{
"name": "Winter Boots",
"amount": 95,
"datetime": "2024-12-06T17:00:00Z",
"location": "City Shopping Center",
"category": "fashion",
"isPlanned": true,
"reflections": [
{
"comment": "Necessary purchase for winter. Good quality and practical for daily commute.",
"satisfactionScore": 9,
"date": "2024-12-06T20:00:00Z",
"mood": "pleased"
}
]
},
{
"name": "Coffee with Friends",
"amount": 10.5,
"datetime": "2024-12-07T10:30:00Z",
"location": "City Center Café",
"category": "dining",
"isPlanned": true
},
{
"name": "Home Decor Items",
"amount": 55,
"datetime": "2024-12-07T12:00:00Z",
"location": "Home Goods Store",
"category": "household",
"isPlanned": false,
"reflections": [
{
"comment": "Impulse purchase but items will add warmth to living room for winter.",
"satisfactionScore": 7,
"date": "2024-12-07T18:00:00Z",
"mood": "content"
}
]
},
{
"name": "Weekend Market Produce",
"amount": 28,
"datetime": "2024-12-08T09:30:00Z",
"location": "Local Farmers Market",
"category": "groceries",
"isPlanned": true
},
{
"name": "Daughter's School Supplies",
"amount": 22,
"datetime": "2024-12-08T14:00:00Z",
"location": "Stationery Store",
"category": "household",
"isPlanned": true
}
],
"weekContext": {
"events": ["Weekly coffee with friends"],
"stressLevel": 5,
"notes": "Post-salary week with regular activities"
}
},
{
"weekNumber": 3,
"startDate": "2024-12-09",
"endDate": "2024-12-15",
"purchases": [
{
"name": "Lavazza Coffee",
"amount": 3.5,
"datetime": "2024-12-09T08:15:00Z",
"location": "Local Café",
"category": "dining",
"isPlanned": true
},
{
"name": "Weekly Groceries",
"amount": 138,
"datetime": "2024-12-09T17:30:00Z",
"location": "Esselunga Supermarket",
"category": "groceries",
"isPlanned": true,
"reflections": [
{
"comment": "Efficient shopping trip despite work stress. Stocked up on easy-to-prepare meals for busy week.",
"satisfactionScore": 7,
"date": "2024-12-09T20:00:00Z",
"mood": "tired"
}
]
},
{
"name": "Lavazza Coffee",
"amount": 3.5,
"datetime": "2024-12-10T08:15:00Z",
"location": "Local Café",
"category": "dining",
"isPlanned": true
},
{
"name": "Work Lunch",
"amount": 16,
"datetime": "2024-12-11T12:30:00Z",
"location": "Office District Restaurant",
"category": "dining",
"isPlanned": true
},
{
"name": "Family Dinner",
"amount": 70,
"datetime": "2024-12-12T19:00:00Z",
"location": "Local Restaurant",
"category": "dining",
"isPlanned": true,
"reflections": [
{
"comment": "Nice break from work stress. Quality time with family worth the expense.",
"satisfactionScore": 9,
"date": "2024-12-12T22:00:00Z",
"mood": "relaxed"
}
]
},
{
"name": "Yoga Class Drop-in",
"amount": 15,
"datetime": "2024-12-13T16:30:00Z",
"location": "Zen Studio",
"category": "health",
"isPlanned": true
},
{
"name": "Coffee with Friends",
"amount": 11,
"datetime": "2024-12-14T10:30:00Z",
"location": "City Center Café",
"category": "dining",
"isPlanned": true
},
{
"name": "Casual Dress",
"amount": 65,
"datetime": "2024-12-14T11:30:00Z",
"location": "Benetton Store",
"category": "fashion",
"isPlanned": false,
"reflections": [
{
"comment": "Stress-induced purchase, though it's from preferred brand. Could have waited for sales.",
"satisfactionScore": 6,
"date": "2024-12-14T18:00:00Z",
"mood": "guilty"
}
]
},
{
"name": "Weekend Market Produce",
"amount": 35,
"datetime": "2024-12-15T09:30:00Z",
"location": "Local Farmers Market",
"category": "groceries",
"isPlanned": true
},
{
"name": "Cat Treats and Toys",
"amount": 18,
"datetime": "2024-12-15T15:00:00Z",
"location": "Pet Shop",
"category": "household",
"isPlanned": false
}
],
"weekContext": {
"events": [
"Bi-weekly family dinner",
"Weekly coffee with friends",
"Work project deadline"
],
"stressLevel": 7,
"notes": "Busy week with work deadlines"
}
},
{
"weekNumber": 4,
"startDate": "2024-12-16",
"endDate": "2024-12-22",
"purchases": [
{
"name": "Lavazza Coffee",
"amount": 3.5,
"datetime": "2024-12-16T08:15:00Z",
"location": "Local Café",
"category": "dining",
"isPlanned": true
},
{
"name": "Weekly Groceries",
"amount": 145,
"datetime": "2024-12-16T17:30:00Z",
"location": "Esselunga Supermarket",
"category": "groceries",
"isPlanned": true,
"reflections": [
{
"comment": "Stocked up on holiday baking supplies and regular groceries. Good planning for upcoming festivities.",
"satisfactionScore": 8,
"date": "2024-12-16T20:00:00Z",
"mood": "organized"
}
]
},
{
"name": "Morning Pastry",
"amount": 2.5,
"datetime": "2024-12-17T08:15:00Z",
"location": "Local Café",
"category": "dining",
"isPlanned": false
},
{
"name": "Lunch with Colleagues",
"amount": 17,
"datetime": "2024-12-18T12:30:00Z",
"location": "Office District Restaurant",
"category": "dining",
"isPlanned": true
},
{
"name": "Yoga Class Drop-in",
"amount": 15,
"datetime": "2024-12-19T16:30:00Z",
"location": "Zen Studio",
"category": "health",
"isPlanned": true
},
{
"name": "Holiday Gifts",
"amount": 120,
"datetime": "2024-12-20T17:00:00Z",
"location": "Shopping Center",
"category": "gifts",
"isPlanned": true,
"reflections": [
{
"comment": "Got most holiday shopping done within budget. Found thoughtful gifts for family.",
"satisfactionScore": 8,
"date": "2024-12-20T20:00:00Z",
"mood": "satisfied"
}
]
},
{
"name": "Coffee with Friends",
"amount": 11.5,
"datetime": "2024-12-21T10:30:00Z",
"location": "City Center Café",
"category": "dining",
"isPlanned": true
},
{
"name": "Holiday Decorations",
"amount": 55,
"datetime": "2024-12-21T12:00:00Z",
"location": "Home Goods Store",
"category": "household",
"isPlanned": true,
"reflections": [
{
"comment": "Nice additions to holiday decor. Will enhance family celebration atmosphere.",
"satisfactionScore": 8,
"date": "2024-12-21T18:00:00Z",
"mood": "festive"
}
]
},
{
"name": "Weekend Market Produce",
"amount": 38,
"datetime": "2024-12-22T09:30:00Z",
"location": "Local Farmers Market",
"category": "groceries",
"isPlanned": true
},
{
"name": "Books for Daughter",
"amount": 45,
"datetime": "2024-12-22T14:00:00Z",
"location": "Bookstore",
"category": "education",
"isPlanned": true
}
],
"weekContext": {
"events": ["Weekly coffee with friends", "Holiday shopping starts"],
"stressLevel": 6,
"notes": "Pre-holiday preparations beginning"
}
}
]
}

View File

@@ -1,20 +0,0 @@
import { generate as generatePersona } from './persona/store';
import { generate as generatePurchases } from './purchase/store';
const personaPromise = generatePersona();
const date = new Date();
const numWeeks = 4;
console.log(`Generating persona...`);
personaPromise.then(id => {
console.log(`Generating purchases for id ${id}...`);
const purchasesPromise = generatePurchases(id, date, numWeeks);
purchasesPromise.then(() => {
console.log('Complete');
});
});

View File

@@ -1,38 +0,0 @@
import 'dotenv/config';
import fs from 'fs';
import { Persona, personaSchema } from './types';
import { Tool } from './tool';
import { BaseTool, makeRequest } from '../utils/anthropicClient';
import { createFolderIfNotExists } from '../utils/createFolder';
import { generatePrompt } from './prompt';
import { randomUUID } from 'crypto';
export async function generate() {
const prompt = `${generatePrompt()} ${process.env.PERSONA_PROMPT}`;
const result = (await makeRequest(prompt, Tool as BaseTool)) as Persona;
const validPersona = personaSchema.safeParse(result);
if (validPersona.error) {
throw Error(`Invalid Persona generated: ${validPersona.error.message}`);
}
const id = randomUUID();
await saveToJson(validPersona.data, id);
console.log(`Persona name: ${validPersona.data.core.name}`);
return id;
}
export async function saveToJson(persona: Persona, id: string) {
await createFolderIfNotExists(`personas/${id}/`);
const jsonName = `personas/${id}/${id}-persona.json`;
await fs.promises.writeFile(jsonName, JSON.stringify(persona), 'utf8');
console.log(`Persona '${persona.core.name}' saved as ${jsonName}`);
}

View File

@@ -1,82 +0,0 @@
import fs, { readFileSync } from 'fs';
import { PurchaseList, purchaseListSchema } from './types';
import { Tool } from './tool';
import { BaseTool, makeRequest } from '../utils/anthropicClient';
import { createFolderIfNotExists } from '../utils/createFolder';
import { generatePrompt } from './prompt';
import { Persona } from '../persona/types';
export async function generate(
personaId: string,
date: Date,
numWeeks: number
) {
try {
const jsonFile = readFileSync(
`personas/${personaId}/${personaId}-persona.json`,
'utf-8'
);
const persona: Persona = JSON.parse(jsonFile);
const personaPrompt = await generatePrompt(
persona,
parseInt(process.env.PURCHASE_REFLECTION_THRESHOLD ?? '50'),
date,
numWeeks
);
const result = (await makeRequest(
personaPrompt,
Tool as BaseTool
)) as PurchaseList;
const validPurchases = purchaseListSchema.safeParse(result);
if (validPurchases.error) {
throw Error(`Invalid purchases: ${validPurchases.error.message}`);
}
await saveToJson(validPurchases.data, personaId);
const totalPurchases = validPurchases.data.weeks.reduce(
(acc, week) => acc + week.purchases.length,
0
);
console.log(
`Generated ${totalPurchases} purchases for ${persona.core.name}`
);
return validPurchases.data;
} catch (error) {
console.error('Error generating purchases:', error);
throw error;
}
}
export async function saveToJson(purchaseList: PurchaseList, id: string) {
await createFolderIfNotExists(`personas/${id}/`);
const jsonName = `personas/${id}/${id}-purchases.json`;
await fs.promises.writeFile(
jsonName,
JSON.stringify(purchaseList, null, 2),
'utf8'
);
const purchaseStats = purchaseList.weeks.reduce(
(stats, week) => {
return {
total: stats.total + week.purchases.length,
planned: stats.planned + week.purchases.filter(p => p.isPlanned).length,
withReflections:
stats.withReflections +
week.purchases.filter(p => p.reflections?.length).length
};
},
{ total: 0, planned: 0, withReflections: 0 }
);
console.log(
`Saved ${purchaseStats.total} purchases (${purchaseStats.planned} planned, ${purchaseStats.withReflections} with reflections) as ${jsonName}`
);
}

View File

@@ -1,18 +0,0 @@
import { promises as fs } from 'fs';
export async function createFolderIfNotExists(
folderPath: string
): Promise<void> {
try {
await fs.access(folderPath);
console.log('Folder already exists');
} catch {
try {
await fs.mkdir(folderPath, { recursive: true });
console.log('Folder created successfully');
} catch (error) {
console.error('Error creating folder:', error);
throw error;
}
}
}

13
tailwind.config.ts Normal file
View File

@@ -0,0 +1,13 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}'
],
theme: {
extend: {}
},
plugins: [],
important: true // Add this line
};

View File

@@ -1,18 +1,31 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es2017", "target": "ES5",
"module": "commonjs", "lib": ["dom", "dom.iterable", "esnext"],
"lib": ["es6"],
"allowJs": true, "allowJs": true,
"outDir": "dist", "skipLibCheck": true,
"rootDir": "src",
"strict": true, "strict": true,
"noImplicitAny": true, "noEmit": true,
"esModuleInterop": true, "esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"types": ["node"], "isolatedModules": true,
"typeRoots": ["./node_modules/@types"] "jsx": "preserve",
}, "incremental": true,
"include": ["src/**/*", "utils/anthropicClient.ts"], "plugins": [
"exclude": ["node_modules", "dist"] {
"name": "next"
}
],
"paths": {
"@app/*": ["./app/*"],
"@components/*": ["./components/*"],
"@utils/*": ["./utils/*"],
"@consumer/*": ["./utils/consumer/*"],
"@purchases/*": ["./utils/purchases/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", ".yarn", ".next", ".vercel", ".vscode"]
} }

View File

@@ -13,15 +13,13 @@ export interface BaseTool {
export async function makeRequest<T extends BaseTool>(prompt: string, tool: T) { export async function makeRequest<T extends BaseTool>(prompt: string, tool: T) {
if (!process.env.ANTHROPIC_API_KEY) { if (!process.env.ANTHROPIC_API_KEY) {
throw Error('Anthropic API key missing.'); throw Error('No Anthropic API key found.');
} }
const anthropic = new Anthropic({ const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
apiKey: process.env.ANTHROPIC_API_KEY
});
try { try {
const response = await anthropic.messages.create({ const response = await client.messages.create({
model: 'claude-3-5-sonnet-20241022', model: 'claude-3-5-sonnet-20241022',
max_tokens: 8192, max_tokens: 8192,
temperature: 1, temperature: 1,

View File

@@ -1,18 +1,19 @@
import { generatePersonaSeed } from '../utils/generatePersonaSeed'; import { Moment } from 'moment';
export function generatePrompt(): string { export function generatePrompt(
const seed = generatePersonaSeed(); letters: string,
const [letters, year, postalCode] = seed.split(':'); birthday: Moment,
zipCode: string
): string {
return `You are tasked with creating a detailed consumer of an Italian individual based on the following seed information:
return `You are tasked with creating a detailed persona of an Italian individual based on the following seed information: <consumer_seed>
<persona_seed>
<name_letters>${letters}</name_letters> <name_letters>${letters}</name_letters>
<birth_year>${year}</birth_year> <birthday>${birthday.format('YYYY-MM-DD')}</birthday>
<postal_code>${postalCode}</postal_code> <zip_code>${zipCode}</zip_code>
</persona_seed> </consumer_seed>
Your goal is to generate a realistic and diverse persona that reflects the complexity of Italian society. Follow these steps to create the persona: Your goal is to generate a realistic and diverse consumer that reflects the complexity of Italian society. Follow these steps to create the consumer:
1. Analyze the demographic data: 1. Analyze the demographic data:
- Create a full name that includes ALL the letters provided in <name_letters>, though it may contain additional letters. - Create a full name that includes ALL the letters provided in <name_letters>, though it may contain additional letters.
@@ -52,7 +53,7 @@ export function generatePrompt(): string {
- Exercise routines or lack thereof - Exercise routines or lack thereof
- Social activities - Social activities
7. Add personal context: 7. Add consumerl context:
- Key stress triggers - Key stress triggers
- Reward behaviors - Reward behaviors
- Upcoming significant events - Upcoming significant events
@@ -66,9 +67,9 @@ export function generatePrompt(): string {
c) Professional sector norms c) Professional sector norms
d) Local cost of living d) Local cost of living
Before providing the final persona, wrap your analysis in <persona_creation_process> tags. For each major section: Before providing the final consumer, wrap your analysis in <consumer_creation_process> tags. For each major section:
1. Break down the postal code implications on the person's background and lifestyle. 1. Break down the postal code implications on the person's background and lifestyle.
2. Consider multiple options for each aspect (at least 2-3 choices). 2. Consider multiple options for each aspect (at least 2-3 choices).
3. Explain your reasoning for the final choice. 3. Explain your reasoning for the final choice.
This will help ensure a thorough and well-reasoned persona creation.`; This will help ensure a thorough and well-reasoned consumer creation.`;
} }

54
utils/consumer/store.ts Normal file
View File

@@ -0,0 +1,54 @@
import 'dotenv/config';
import { Consumer, consumerSchema } from './types';
import { Tool } from './tool';
import { BaseTool, makeRequest } from '../anthropicClient';
import { generatePrompt } from './prompt';
import { generateConsumerSeed } from '@utils/generateConsumerSeed';
import prisma from '../../prisma/prisma';
export async function generate() {
const { letters, birthday, zipCode } = generateConsumerSeed();
const newConsumer = await prisma.consumer.create({
data: {
letters,
birthday: birthday.toDate(),
zipCode
}
});
console.info(`New consumer being generated with id ${newConsumer.id}`);
const prompt = generatePrompt(letters, birthday, zipCode);
try {
const result = (await makeRequest(prompt, Tool as BaseTool)) as Consumer;
const validConsumer = consumerSchema.safeParse(result);
if (validConsumer.error) {
throw Error(`Invalid consumer generated: ${validConsumer.error.message}`);
}
console.info('Generated consumer by Anthropic', validConsumer.data);
await prisma.consumer.update({
where: {
id: newConsumer.id
},
data: {
value: validConsumer.data
}
});
console.info(`Consumer with id ${newConsumer.id} stored in database.`);
return {
id: newConsumer.id,
consumer: validConsumer.data
};
} catch (error) {
console.error('Error generating purchases:', error);
throw error;
}
}

View File

@@ -1,8 +1,8 @@
export const Tool = { export const Tool = {
name: 'PersonaSchema' as const, name: 'ConsumerSchema' as const,
input_schema: { input_schema: {
type: 'object' as const, type: 'object' as const,
description: 'User persona', description: 'User consumer',
properties: { properties: {
core: { core: {
type: 'object' as const, type: 'object' as const,

View File

@@ -126,7 +126,7 @@ const contextSchema = z.object({
upcoming_events: z.array(upcomingEventSchema) upcoming_events: z.array(upcomingEventSchema)
}); });
export const personaSchema = z.object({ export const consumerSchema = z.object({
core: coreSchema, core: coreSchema,
routines: routinesSchema, routines: routinesSchema,
preferences: preferencesSchema, preferences: preferencesSchema,
@@ -135,4 +135,4 @@ export const personaSchema = z.object({
context: contextSchema context: contextSchema
}); });
export type Persona = z.infer<typeof personaSchema>; export type Consumer = z.infer<typeof consumerSchema>;

View File

@@ -1,3 +1,5 @@
import moment, { Moment } from 'moment';
const PROVINCE_CODES = [ const PROVINCE_CODES = [
'00', // Roma '00', // Roma
'04', // Latina '04', // Latina
@@ -87,25 +89,27 @@ function generateLetters(): string {
.join(''); .join('');
} }
function generateBirthYear(): string { function generateBirthYear(): Moment {
const currentYear = new Date().getFullYear(); const currentYear = moment().year();
const minYear = currentYear - 50; const minYear = currentYear - 50;
const maxYear = currentYear - 20; const maxYear = currentYear - 20;
return Math.floor(Math.random() * (maxYear - minYear) + minYear).toString(); const year = Math.floor(Math.random() * (maxYear - minYear) + minYear);
const startDate = moment([year, 0, 1]); // January 1st
const endDate = moment([year, 11, 31]); // December 31st
const startTimestamp = startDate.valueOf();
const endTimestamp = endDate.valueOf();
const randomTimestamp =
startTimestamp + Math.random() * (endTimestamp - startTimestamp);
return moment(randomTimestamp).startOf('day');
} }
function formatPersonaSeed( export function generateConsumerSeed() {
letters: string,
year: string,
postalCode: string
): string {
return `${letters}:${year}:${postalCode}`;
}
export function generatePersonaSeed(): string {
const letters = generateLetters(); const letters = generateLetters();
const birthYear = generateBirthYear(); const birthday = generateBirthYear();
const postalCode = generateRandomCAP(); const zipCode = generateRandomCAP();
return formatPersonaSeed(letters, birthYear, postalCode); return { letters, birthday, zipCode };
} }

View File

@@ -1,5 +1,5 @@
import { ExerciseActivity, Persona, SocialActivity } from '../persona/types'; import { ExerciseActivity, Consumer, SocialActivity } from '../consumer/types';
import { getWeekRanges, isDateInRange } from '../utils/dateFunctions'; import { getWeekRanges, isDateInRange } from '../dateFunctions';
function formatCategories( function formatCategories(
categories?: Record< categories?: Record<
@@ -96,7 +96,7 @@ function formatContext(context: {
return sections.join('\n\n'); return sections.join('\n\n');
} }
function formatPersonaCore(core: Persona['core']): string { function formatConsumerCore(core: Consumer['core']): string {
const sections = [ const sections = [
`${core.name} is a ${core.age}-year-old ${core.occupation.title} at ${core.occupation.location}`, `${core.name} is a ${core.age}-year-old ${core.occupation.title} at ${core.occupation.location}`,
`Living: ${core.household.status}, ${core.home.ownership} ${core.home.type} in ${core.home.location}`, `Living: ${core.household.status}, ${core.home.ownership} ${core.home.type} in ${core.home.location}`,
@@ -106,7 +106,7 @@ function formatPersonaCore(core: Persona['core']): string {
return sections.filter(Boolean).join('\n'); return sections.filter(Boolean).join('\n');
} }
function formatHousehold(household: Persona['core']['household']): string { function formatHousehold(household: Consumer['core']['household']): string {
const sections = []; const sections = [];
if (household.members.length) { if (household.members.length) {
@@ -140,7 +140,7 @@ function formatDailySchedule(
.join('\n'); .join('\n');
} }
function formatCommute(commute: Persona['routines']['commute']): string { function formatCommute(commute: Consumer['routines']['commute']): string {
return `${commute.method} (${commute.route.join(' → ')}) return `${commute.method} (${commute.route.join(' → ')})
Regular stops: ${commute.regular_stops Regular stops: ${commute.regular_stops
.map(stop => `${stop.frequency} ${stop.purpose} at ${stop.location}`) .map(stop => `${stop.frequency} ${stop.purpose} at ${stop.location}`)
@@ -156,7 +156,7 @@ function formatPurchasingStyle(impulsiveScore: number): string {
} }
function formatSubscriptions( function formatSubscriptions(
subscriptions: Persona['finances']['subscriptions'] subscriptions: Consumer['finances']['subscriptions']
): string { ): string {
return subscriptions return subscriptions
.map( .map(
@@ -166,7 +166,7 @@ function formatSubscriptions(
.join('\n'); .join('\n');
} }
function formatBrands(brands: Persona['preferences']['brands']): string { function formatBrands(brands: Consumer['preferences']['brands']): string {
return brands return brands
.map(brand => `${brand.name} (loyalty: ${brand.loyalty_score}/10)`) .map(brand => `${brand.name} (loyalty: ${brand.loyalty_score}/10)`)
.join(', '); .join(', ');
@@ -175,18 +175,18 @@ function formatBrands(brands: Persona['preferences']['brands']): string {
function formatWeekSection( function formatWeekSection(
range: { start: Date; end: Date }, range: { start: Date; end: Date },
weekNum: number, weekNum: number,
persona: Persona consumer: Consumer
): string { ): string {
return `=== WEEK ${weekNum}: ${range.start.toISOString().split('T')[0]} to ${range.end.toISOString().split('T')[0]} === return `=== WEEK ${weekNum}: ${range.start.toISOString().split('T')[0]} to ${range.end.toISOString().split('T')[0]} ===
Context: Context:
${formatWeekContext(range, persona)} ${formatWeekContext(range, consumer)}
${formatPurchaseOpportunities(persona)}`; ${formatPurchaseOpportunities(consumer)}`;
} }
function formatWeekContext( function formatWeekContext(
range: { start: Date; end: Date }, range: { start: Date; end: Date },
persona: Persona consumer: Consumer
): string { ): string {
const contexts = []; const contexts = [];
@@ -194,7 +194,7 @@ function formatWeekContext(
contexts.push('Post-salary period'); contexts.push('Post-salary period');
} }
const events = persona.context.upcoming_events.filter(event => const events = consumer.context.upcoming_events.filter(event =>
isDateInRange(new Date(event.date), range.start, range.end) isDateInRange(new Date(event.date), range.start, range.end)
); );
@@ -205,28 +205,28 @@ function formatWeekContext(
return contexts.map(c => `- ${c}`).join('\n'); return contexts.map(c => `- ${c}`).join('\n');
} }
function formatPurchaseOpportunities(persona: Persona): string { function formatPurchaseOpportunities(consumer: Consumer): string {
const opportunities = []; const opportunities = [];
if (persona.routines.commute.regular_stops.length) { if (consumer.routines.commute.regular_stops.length) {
opportunities.push('Regular purchase points:'); opportunities.push('Regular purchase points:');
persona.routines.commute.regular_stops.forEach(stop => { consumer.routines.commute.regular_stops.forEach(stop => {
opportunities.push( opportunities.push(
`- ${stop.frequency} ${stop.purpose} at ${stop.location}` `- ${stop.frequency} ${stop.purpose} at ${stop.location}`
); );
}); });
} }
if (persona.habits.exercise.length) { if (consumer.habits.exercise.length) {
opportunities.push('Activity-related purchases:'); opportunities.push('Activity-related purchases:');
persona.habits.exercise.forEach(ex => { consumer.habits.exercise.forEach(ex => {
opportunities.push(`- ${ex.frequency} ${ex.activity} sessions`); opportunities.push(`- ${ex.frequency} ${ex.activity} sessions`);
}); });
} }
if (persona.habits.social.length) { if (consumer.habits.social.length) {
opportunities.push('Social spending occasions:'); opportunities.push('Social spending occasions:');
persona.habits.social.forEach(soc => { consumer.habits.social.forEach(soc => {
opportunities.push(`- ${soc.frequency} ${soc.activity}`); opportunities.push(`- ${soc.frequency} ${soc.activity}`);
}); });
} }
@@ -235,51 +235,51 @@ function formatPurchaseOpportunities(persona: Persona): string {
} }
export async function generatePrompt( export async function generatePrompt(
persona: Persona, consumer: Consumer,
reflectionThreshold: number, reflectionThreshold: number,
targetDate: Date, targetDate: Date,
numWeeks: number numWeeks: number
): Promise<string> { ): Promise<string> {
const weekRanges = getWeekRanges(targetDate, numWeeks); const weekRanges = getWeekRanges(targetDate, numWeeks);
return `PERSONA PROFILE: return `consumer PROFILE:
${formatPersonaCore(persona.core)} ${formatConsumerCore(consumer.core)}
Daily Schedule: Daily Schedule:
${formatDailySchedule(persona.routines.weekday)} ${formatDailySchedule(consumer.routines.weekday)}
Commute: ${formatCommute(persona.routines.commute)} Commute: ${formatCommute(consumer.routines.commute)}
Weekend Activities: Weekend Activities:
${persona.routines.weekend.map(activity => `- ${activity}`).join('\n')} ${consumer.routines.weekend.map(activity => `- ${activity}`).join('\n')}
${formatHabits(persona.habits)} ${formatHabits(consumer.habits)}
Financial Profile: Financial Profile:
- Income: ${persona.core.occupation.income.toLocaleString()}/year - Income: ${consumer.core.occupation.income.toLocaleString()}/year
- Payment Methods: ${persona.preferences.payment_methods.join(', ')} - Payment Methods: ${consumer.preferences.payment_methods.join(', ')}
- Price Sensitivity: ${persona.preferences.price_sensitivity}/10 - Price Sensitivity: ${consumer.preferences.price_sensitivity}/10
- Purchasing Style: ${formatPurchasingStyle(persona.finances.spending_patterns.impulsive_score)} - Purchasing Style: ${formatPurchasingStyle(consumer.finances.spending_patterns.impulsive_score)}
Monthly Fixed Expenses: Monthly Fixed Expenses:
${formatSubscriptions(persona.finances.subscriptions)} ${formatSubscriptions(consumer.finances.subscriptions)}
Spending Categories: Spending Categories:
${formatCategories(persona.finances.spending_patterns.categories)} ${formatCategories(consumer.finances.spending_patterns.categories)}
Brand Preferences: Brand Preferences:
${formatBrands(persona.preferences.brands)} ${formatBrands(consumer.preferences.brands)}
Dietary Preferences: Dietary Preferences:
${persona.preferences.diet.join(', ')} ${consumer.preferences.diet.join(', ')}
${formatContext(persona.context)} ${formatContext(consumer.context)}
PURCHASE GENERATION GUIDELINES: PURCHASE GENERATION GUIDELINES:
Generate ${numWeeks} weeks of purchases for ${persona.core.name}: Generate ${numWeeks} weeks of purchases for ${consumer.core.name}:
${weekRanges ${weekRanges
.map((range, i) => formatWeekSection(range, i + 1, persona)) .map((range, i) => formatWeekSection(range, i + 1, consumer))
.join('\n\n')} .join('\n\n')}
Purchase Format: Purchase Format:

83
utils/purchases/store.ts Normal file
View File

@@ -0,0 +1,83 @@
import { PurchaseList, purchaseListSchema } from './types';
import { Tool } from './tool';
import { BaseTool, makeRequest } from '../anthropicClient';
import { generatePrompt } from './prompt';
import { Consumer } from '../consumer/types';
import prisma from '../../prisma/prisma';
export async function generate(
id: number | undefined,
editedConsumer: Consumer,
date: Date
) {
const consumerPrompt = await generatePrompt(
editedConsumer,
parseInt(process.env.PURCHASE_REFLECTION_THRESHOLD ?? '50'),
date,
parseInt(process.env.NUMBER_OF_WEEKS ?? '4')
);
const consumer = id
? await prisma.consumer.update({
where: {
id
},
data: {
editedValue: editedConsumer
}
})
: await prisma.consumer.create({
data: {
editedValue: editedConsumer
}
});
const newPurchaseList = await prisma.purchaseList.create({
data: {
consumerId: consumer.id
}
});
console.info(
`Generating purchase list with id ${newPurchaseList.id} for consumer with id ${consumer.id}`
);
try {
const result = (await makeRequest(
consumerPrompt,
Tool as BaseTool
)) as PurchaseList;
const validPurchases = purchaseListSchema.safeParse(result);
if (validPurchases.error) {
throw Error(`Invalid purchases: ${validPurchases.error.message}`);
}
const totalPurchases = validPurchases.data.weeks.reduce(
(acc, week) => acc + week.purchases.length,
0
);
console.info(
`Generated ${totalPurchases} purchases for purchase list with id ${newPurchaseList.id} for consumer wth id ${id}`
);
await prisma.purchaseList.update({
where: {
id: newPurchaseList.id
},
data: {
value: validPurchases.data
}
});
console.info(
`Purchase list with id ${newPurchaseList.id} for consumer with id ${id} stored in database.`
);
return validPurchases.data;
} catch (error) {
console.error('Error generating purchases:', error);
throw error;
}
}

View File

@@ -47,7 +47,7 @@ export const Tool = {
category: { category: {
type: 'string' as const, type: 'string' as const,
description: description:
'Spending category (must match persona preferences)' 'Spending category (must match consumer preferences)'
}, },
isPlanned: { isPlanned: {
type: 'boolean' as const, type: 'boolean' as const,

View File

@@ -1,3 +1,4 @@
import { consumerSchema } from '@utils/consumer/types';
import { z } from 'zod'; import { z } from 'zod';
const isoDateTimeString = z.string().refine( const isoDateTimeString = z.string().refine(
@@ -44,8 +45,8 @@ const weekSchema = z
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Invalid date format'), endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Invalid date format'),
purchases: z purchases: z
.array(purchaseSchema) .array(purchaseSchema)
.min(12, 'Minimum 12 purchases required per week') .min(7, 'Minimum 7 purchases required per week')
.max(20, 'Maximum 20 purchases allowed per week'), .max(21, 'Maximum 21 purchases allowed per week'),
weekContext: weekContextSchema.optional() weekContext: weekContextSchema.optional()
}) })
.refine( .refine(
@@ -78,3 +79,8 @@ export const purchaseListSchema = z
); );
export type PurchaseList = z.infer<typeof purchaseListSchema>; export type PurchaseList = z.infer<typeof purchaseListSchema>;
export const purchasesRequestSchema = z.object({
id: z.number().optional(),
consumer: consumerSchema
});

27
utils/rateLimiter.ts Normal file
View File

@@ -0,0 +1,27 @@
import prisma from '../prisma/prisma';
export async function rateLimiter() {
if (!process.env.RATE_LIMIT) {
throw Error('Rate limit missing.');
}
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
const consumersCount = await prisma.consumer.count({
where: {
createdAt: {
gt: yesterday
}
}
});
const purchaseListsCount = await prisma.purchaseList.count({
where: {
createdAt: {
gt: yesterday
}
}
});
return consumersCount + purchaseListsCount > parseInt(process.env.RATE_LIMIT);
}

43
vercel.json Normal file
View File

@@ -0,0 +1,43 @@
{
"functions": {
"app/api/**/*": {
"maxDuration": 90
}
},
"headers": [
{
"source": "/api/(.*)",
"headers": [
{
"key": "Access-Control-Allow-Methods",
"value": "GET, POST, OPTIONS"
},
{
"key": "Access-Control-Allow-Headers",
"value": "Content-Type, Accept"
},
{
"key": "Strict-Transport-Security",
"value": "max-age=63072000; includeSubDomains; preload"
},
{
"key": "Content-Security-Policy",
"value": "default-src 'none'"
},
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "X-Frame-Options",
"value": "DENY"
},
{ "key": "Referrer-Policy", "value": "same-origin" },
{
"key": "X-XSS-Protection",
"value": "1; mode=block"
}
]
}
]
}

2928
yarn.lock

File diff suppressed because it is too large Load Diff