feat: convert to nextjs
This commit is contained in:
14
.env.example
14
.env.example
@@ -1,3 +1,13 @@
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5433/persona?schema=public&connect_timeout=300"
|
||||
PURCHASE_REFLECTION_THRESHOLD=50
|
||||
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=
|
||||
@@ -1,22 +1,32 @@
|
||||
{
|
||||
"env": {
|
||||
"node": true,
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"rules": {
|
||||
"@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
4
.gitignore
vendored
@@ -131,7 +131,5 @@ dist
|
||||
|
||||
.editorconfig
|
||||
|
||||
personas/
|
||||
purchases/
|
||||
|
||||
.DS_Store
|
||||
.vercel
|
||||
|
||||
4
.husky/commit-msg
Executable file
4
.husky/commit-msg
Executable 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
8
.husky/pre-commit
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
yarn audit
|
||||
yarn format
|
||||
yarn lint-staged
|
||||
yarn typecheck
|
||||
yarn build
|
||||
21
README.md
21
README.md
@@ -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
|
||||
|
||||
- Generate detailed fictional personas including:
|
||||
- Personal demographics and household details
|
||||
- Generate detailed synthetic consumers including:
|
||||
- Consumer demographics and household details
|
||||
- Daily routines and activities
|
||||
- Shopping preferences and brand loyalties
|
||||
- Financial patterns and spending habits
|
||||
- Contextual behaviors and upcoming events
|
||||
- Create realistic weekly purchase histories that match persona profiles
|
||||
- Store generated data in JSON files for easy data portability
|
||||
- Create realistic weekly purchase histories that match consumer profiles
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
@@ -48,7 +47,15 @@ yarn dev
|
||||
- `yarn lint` - Run ESLint with automatic fixes
|
||||
- `yarn format` - Format code using Prettier
|
||||
- `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
|
||||
|
||||
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
28
app/api/consumer/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
38
app/api/purchase-list/route.ts
Normal file
38
app/api/purchase-list/route.ts
Normal 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
44
app/globals.css
Normal 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
20
app/layout.tsx
Normal 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
20
app/page.tsx
Normal 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
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>
|
||||
);
|
||||
};
|
||||
23
context/toast/ToastContext.ts
Normal file
23
context/toast/ToastContext.ts
Normal 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;
|
||||
};
|
||||
23
context/toast/ToastProvider.tsx
Normal file
23
context/toast/ToastProvider.tsx
Normal 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
5
next-env.d.ts
vendored
Normal 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.
|
||||
56
package.json
56
package.json
@@ -1,41 +1,67 @@
|
||||
{
|
||||
"name": "purchases-personas",
|
||||
"version": "1.1.0",
|
||||
"description": "Generate realistic fictional personas and their weekly purchase behaviors using AI",
|
||||
"name": "synthetic-consumer-data",
|
||||
"version": "1.0.0",
|
||||
"description": "Generate realistic synthetic consumers and their weekly purchase behaviors using AI",
|
||||
"author": "riccardo@frompixels.com",
|
||||
"scripts": {
|
||||
"start": "node dist/index.js",
|
||||
"dev": "nodemon src/index.ts",
|
||||
"build": "tsc",
|
||||
"lint": "eslint . --fix",
|
||||
"format": "prettier --config .prettierrc '**/*.{ts,json,md}' --write",
|
||||
"dev": "next dev",
|
||||
"build": "prisma generate && next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint && eslint . --fix",
|
||||
"format": "prettier --config .prettierrc '**/*.{ts,tsx,json,md}' --write",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepare": "husky install",
|
||||
"audit": "audit-ci",
|
||||
"generate": "prisma generate",
|
||||
"migrate": "prisma migrate dev"
|
||||
"vercel:link": "vercel link",
|
||||
"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": {
|
||||
"@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",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^18.4.3",
|
||||
"@commitlint/config-conventional": "^18.4.3",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^6.12.0",
|
||||
"@typescript-eslint/parser": "^6.12.0",
|
||||
"audit-ci": "^6.6.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "^15.0.3",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"husky": "^8.0.3",
|
||||
"lint-staged": "^15.1.0",
|
||||
"nodemon": "^3.0.2",
|
||||
"postcss": "^8.4.49",
|
||||
"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"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx}": [
|
||||
"eslint --quiet --fix"
|
||||
],
|
||||
"*.{json,ts,tsx}": [
|
||||
"prettier --write --ignore-unknown"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
9
prisma/prisma.ts
Normal 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
34
prisma/schema.prisma
Normal 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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
16
public/manifest.webmanifest
Normal file
16
public/manifest.webmanifest
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
209
public/samples/consumer.json
Normal file
209
public/samples/consumer.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
480
public/samples/purchases.json
Normal file
480
public/samples/purchases.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
20
src/index.ts
20
src/index.ts
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
@@ -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
13
tailwind.config.ts
Normal 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
|
||||
};
|
||||
@@ -1,18 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2017",
|
||||
"module": "commonjs",
|
||||
"lib": ["es6"],
|
||||
"target": "ES5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"],
|
||||
"typeRoots": ["./node_modules/@types"]
|
||||
},
|
||||
"include": ["src/**/*", "utils/anthropicClient.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
|
||||
@@ -13,15 +13,13 @@ export interface BaseTool {
|
||||
|
||||
export async function makeRequest<T extends BaseTool>(prompt: string, tool: T) {
|
||||
if (!process.env.ANTHROPIC_API_KEY) {
|
||||
throw Error('Anthropic API key missing.');
|
||||
throw Error('No Anthropic API key found.');
|
||||
}
|
||||
|
||||
const anthropic = new Anthropic({
|
||||
apiKey: process.env.ANTHROPIC_API_KEY
|
||||
});
|
||||
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
||||
|
||||
try {
|
||||
const response = await anthropic.messages.create({
|
||||
const response = await client.messages.create({
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
max_tokens: 8192,
|
||||
temperature: 1,
|
||||
@@ -1,18 +1,19 @@
|
||||
import { generatePersonaSeed } from '../utils/generatePersonaSeed';
|
||||
import { Moment } from 'moment';
|
||||
|
||||
export function generatePrompt(): string {
|
||||
const seed = generatePersonaSeed();
|
||||
const [letters, year, postalCode] = seed.split(':');
|
||||
export function generatePrompt(
|
||||
letters: string,
|
||||
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:
|
||||
|
||||
<persona_seed>
|
||||
<consumer_seed>
|
||||
<name_letters>${letters}</name_letters>
|
||||
<birth_year>${year}</birth_year>
|
||||
<postal_code>${postalCode}</postal_code>
|
||||
</persona_seed>
|
||||
<birthday>${birthday.format('YYYY-MM-DD')}</birthday>
|
||||
<zip_code>${zipCode}</zip_code>
|
||||
</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:
|
||||
- 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
|
||||
- Social activities
|
||||
|
||||
7. Add personal context:
|
||||
7. Add consumerl context:
|
||||
- Key stress triggers
|
||||
- Reward behaviors
|
||||
- Upcoming significant events
|
||||
@@ -66,9 +67,9 @@ export function generatePrompt(): string {
|
||||
c) Professional sector norms
|
||||
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.
|
||||
2. Consider multiple options for each aspect (at least 2-3 choices).
|
||||
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
54
utils/consumer/store.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
export const Tool = {
|
||||
name: 'PersonaSchema' as const,
|
||||
name: 'ConsumerSchema' as const,
|
||||
input_schema: {
|
||||
type: 'object' as const,
|
||||
description: 'User persona',
|
||||
description: 'User consumer',
|
||||
properties: {
|
||||
core: {
|
||||
type: 'object' as const,
|
||||
@@ -126,7 +126,7 @@ const contextSchema = z.object({
|
||||
upcoming_events: z.array(upcomingEventSchema)
|
||||
});
|
||||
|
||||
export const personaSchema = z.object({
|
||||
export const consumerSchema = z.object({
|
||||
core: coreSchema,
|
||||
routines: routinesSchema,
|
||||
preferences: preferencesSchema,
|
||||
@@ -135,4 +135,4 @@ export const personaSchema = z.object({
|
||||
context: contextSchema
|
||||
});
|
||||
|
||||
export type Persona = z.infer<typeof personaSchema>;
|
||||
export type Consumer = z.infer<typeof consumerSchema>;
|
||||
@@ -1,3 +1,5 @@
|
||||
import moment, { Moment } from 'moment';
|
||||
|
||||
const PROVINCE_CODES = [
|
||||
'00', // Roma
|
||||
'04', // Latina
|
||||
@@ -87,25 +89,27 @@ function generateLetters(): string {
|
||||
.join('');
|
||||
}
|
||||
|
||||
function generateBirthYear(): string {
|
||||
const currentYear = new Date().getFullYear();
|
||||
function generateBirthYear(): Moment {
|
||||
const currentYear = moment().year();
|
||||
const minYear = currentYear - 50;
|
||||
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(
|
||||
letters: string,
|
||||
year: string,
|
||||
postalCode: string
|
||||
): string {
|
||||
return `${letters}:${year}:${postalCode}`;
|
||||
}
|
||||
|
||||
export function generatePersonaSeed(): string {
|
||||
export function generateConsumerSeed() {
|
||||
const letters = generateLetters();
|
||||
const birthYear = generateBirthYear();
|
||||
const postalCode = generateRandomCAP();
|
||||
const birthday = generateBirthYear();
|
||||
const zipCode = generateRandomCAP();
|
||||
|
||||
return formatPersonaSeed(letters, birthYear, postalCode);
|
||||
return { letters, birthday, zipCode };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ExerciseActivity, Persona, SocialActivity } from '../persona/types';
|
||||
import { getWeekRanges, isDateInRange } from '../utils/dateFunctions';
|
||||
import { ExerciseActivity, Consumer, SocialActivity } from '../consumer/types';
|
||||
import { getWeekRanges, isDateInRange } from '../dateFunctions';
|
||||
|
||||
function formatCategories(
|
||||
categories?: Record<
|
||||
@@ -96,7 +96,7 @@ function formatContext(context: {
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
function formatPersonaCore(core: Persona['core']): string {
|
||||
function formatConsumerCore(core: Consumer['core']): string {
|
||||
const sections = [
|
||||
`${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}`,
|
||||
@@ -106,7 +106,7 @@ function formatPersonaCore(core: Persona['core']): string {
|
||||
return sections.filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function formatHousehold(household: Persona['core']['household']): string {
|
||||
function formatHousehold(household: Consumer['core']['household']): string {
|
||||
const sections = [];
|
||||
|
||||
if (household.members.length) {
|
||||
@@ -140,7 +140,7 @@ function formatDailySchedule(
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function formatCommute(commute: Persona['routines']['commute']): string {
|
||||
function formatCommute(commute: Consumer['routines']['commute']): string {
|
||||
return `${commute.method} (${commute.route.join(' → ')})
|
||||
Regular stops: ${commute.regular_stops
|
||||
.map(stop => `${stop.frequency} ${stop.purpose} at ${stop.location}`)
|
||||
@@ -156,7 +156,7 @@ function formatPurchasingStyle(impulsiveScore: number): string {
|
||||
}
|
||||
|
||||
function formatSubscriptions(
|
||||
subscriptions: Persona['finances']['subscriptions']
|
||||
subscriptions: Consumer['finances']['subscriptions']
|
||||
): string {
|
||||
return subscriptions
|
||||
.map(
|
||||
@@ -166,7 +166,7 @@ function formatSubscriptions(
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function formatBrands(brands: Persona['preferences']['brands']): string {
|
||||
function formatBrands(brands: Consumer['preferences']['brands']): string {
|
||||
return brands
|
||||
.map(brand => `${brand.name} (loyalty: ${brand.loyalty_score}/10)`)
|
||||
.join(', ');
|
||||
@@ -175,18 +175,18 @@ function formatBrands(brands: Persona['preferences']['brands']): string {
|
||||
function formatWeekSection(
|
||||
range: { start: Date; end: Date },
|
||||
weekNum: number,
|
||||
persona: Persona
|
||||
consumer: Consumer
|
||||
): string {
|
||||
return `=== WEEK ${weekNum}: ${range.start.toISOString().split('T')[0]} to ${range.end.toISOString().split('T')[0]} ===
|
||||
|
||||
Context:
|
||||
${formatWeekContext(range, persona)}
|
||||
${formatPurchaseOpportunities(persona)}`;
|
||||
${formatWeekContext(range, consumer)}
|
||||
${formatPurchaseOpportunities(consumer)}`;
|
||||
}
|
||||
|
||||
function formatWeekContext(
|
||||
range: { start: Date; end: Date },
|
||||
persona: Persona
|
||||
consumer: Consumer
|
||||
): string {
|
||||
const contexts = [];
|
||||
|
||||
@@ -194,7 +194,7 @@ function formatWeekContext(
|
||||
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)
|
||||
);
|
||||
|
||||
@@ -205,28 +205,28 @@ function formatWeekContext(
|
||||
return contexts.map(c => `- ${c}`).join('\n');
|
||||
}
|
||||
|
||||
function formatPurchaseOpportunities(persona: Persona): string {
|
||||
function formatPurchaseOpportunities(consumer: Consumer): string {
|
||||
const opportunities = [];
|
||||
|
||||
if (persona.routines.commute.regular_stops.length) {
|
||||
if (consumer.routines.commute.regular_stops.length) {
|
||||
opportunities.push('Regular purchase points:');
|
||||
persona.routines.commute.regular_stops.forEach(stop => {
|
||||
consumer.routines.commute.regular_stops.forEach(stop => {
|
||||
opportunities.push(
|
||||
`- ${stop.frequency} ${stop.purpose} at ${stop.location}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (persona.habits.exercise.length) {
|
||||
if (consumer.habits.exercise.length) {
|
||||
opportunities.push('Activity-related purchases:');
|
||||
persona.habits.exercise.forEach(ex => {
|
||||
consumer.habits.exercise.forEach(ex => {
|
||||
opportunities.push(`- ${ex.frequency} ${ex.activity} sessions`);
|
||||
});
|
||||
}
|
||||
|
||||
if (persona.habits.social.length) {
|
||||
if (consumer.habits.social.length) {
|
||||
opportunities.push('Social spending occasions:');
|
||||
persona.habits.social.forEach(soc => {
|
||||
consumer.habits.social.forEach(soc => {
|
||||
opportunities.push(`- ${soc.frequency} ${soc.activity}`);
|
||||
});
|
||||
}
|
||||
@@ -235,51 +235,51 @@ function formatPurchaseOpportunities(persona: Persona): string {
|
||||
}
|
||||
|
||||
export async function generatePrompt(
|
||||
persona: Persona,
|
||||
consumer: Consumer,
|
||||
reflectionThreshold: number,
|
||||
targetDate: Date,
|
||||
numWeeks: number
|
||||
): Promise<string> {
|
||||
const weekRanges = getWeekRanges(targetDate, numWeeks);
|
||||
|
||||
return `PERSONA PROFILE:
|
||||
${formatPersonaCore(persona.core)}
|
||||
return `consumer PROFILE:
|
||||
${formatConsumerCore(consumer.core)}
|
||||
|
||||
Daily Schedule:
|
||||
${formatDailySchedule(persona.routines.weekday)}
|
||||
Commute: ${formatCommute(persona.routines.commute)}
|
||||
${formatDailySchedule(consumer.routines.weekday)}
|
||||
Commute: ${formatCommute(consumer.routines.commute)}
|
||||
|
||||
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:
|
||||
- Income: €${persona.core.occupation.income.toLocaleString()}/year
|
||||
- Payment Methods: ${persona.preferences.payment_methods.join(', ')}
|
||||
- Price Sensitivity: ${persona.preferences.price_sensitivity}/10
|
||||
- Purchasing Style: ${formatPurchasingStyle(persona.finances.spending_patterns.impulsive_score)}
|
||||
- Income: €${consumer.core.occupation.income.toLocaleString()}/year
|
||||
- Payment Methods: ${consumer.preferences.payment_methods.join(', ')}
|
||||
- Price Sensitivity: ${consumer.preferences.price_sensitivity}/10
|
||||
- Purchasing Style: ${formatPurchasingStyle(consumer.finances.spending_patterns.impulsive_score)}
|
||||
|
||||
Monthly Fixed Expenses:
|
||||
${formatSubscriptions(persona.finances.subscriptions)}
|
||||
${formatSubscriptions(consumer.finances.subscriptions)}
|
||||
|
||||
Spending Categories:
|
||||
${formatCategories(persona.finances.spending_patterns.categories)}
|
||||
${formatCategories(consumer.finances.spending_patterns.categories)}
|
||||
|
||||
Brand Preferences:
|
||||
${formatBrands(persona.preferences.brands)}
|
||||
${formatBrands(consumer.preferences.brands)}
|
||||
|
||||
Dietary Preferences:
|
||||
${persona.preferences.diet.join(', ')}
|
||||
${consumer.preferences.diet.join(', ')}
|
||||
|
||||
${formatContext(persona.context)}
|
||||
${formatContext(consumer.context)}
|
||||
|
||||
PURCHASE GENERATION GUIDELINES:
|
||||
|
||||
Generate ${numWeeks} weeks of purchases for ${persona.core.name}:
|
||||
Generate ${numWeeks} weeks of purchases for ${consumer.core.name}:
|
||||
|
||||
${weekRanges
|
||||
.map((range, i) => formatWeekSection(range, i + 1, persona))
|
||||
.map((range, i) => formatWeekSection(range, i + 1, consumer))
|
||||
.join('\n\n')}
|
||||
|
||||
Purchase Format:
|
||||
83
utils/purchases/store.ts
Normal file
83
utils/purchases/store.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export const Tool = {
|
||||
category: {
|
||||
type: 'string' as const,
|
||||
description:
|
||||
'Spending category (must match persona preferences)'
|
||||
'Spending category (must match consumer preferences)'
|
||||
},
|
||||
isPlanned: {
|
||||
type: 'boolean' as const,
|
||||
@@ -1,3 +1,4 @@
|
||||
import { consumerSchema } from '@utils/consumer/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
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'),
|
||||
purchases: z
|
||||
.array(purchaseSchema)
|
||||
.min(12, 'Minimum 12 purchases required per week')
|
||||
.max(20, 'Maximum 20 purchases allowed per week'),
|
||||
.min(7, 'Minimum 7 purchases required per week')
|
||||
.max(21, 'Maximum 21 purchases allowed per week'),
|
||||
weekContext: weekContextSchema.optional()
|
||||
})
|
||||
.refine(
|
||||
@@ -78,3 +79,8 @@ export const purchaseListSchema = z
|
||||
);
|
||||
|
||||
export type PurchaseList = z.infer<typeof purchaseListSchema>;
|
||||
|
||||
export const purchasesRequestSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
consumer: consumerSchema
|
||||
});
|
||||
27
utils/rateLimiter.ts
Normal file
27
utils/rateLimiter.ts
Normal 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
43
vercel.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user