From 633b8ee2072df390acbc41cf94261eaf37730600 Mon Sep 17 00:00:00 2001 From: Riccardo Senica Date: Sat, 7 Dec 2024 07:45:24 +0100 Subject: [PATCH] feat: convert to nextjs --- .env.example | 14 +- .eslintrc.json | 16 +- .gitignore | 6 +- .husky/commit-msg | 4 + .husky/pre-commit | 8 + README.md | 21 +- app/api/consumer/route.ts | 28 + app/api/purchase-list/route.ts | 38 + app/globals.css | 44 + app/layout.tsx | 20 + app/page.tsx | 20 + components/Button.tsx | 39 + components/Content.tsx | 337 ++ components/Footer.tsx | 32 + components/Header.tsx | 32 + components/Spinner.tsx | 5 + components/Toast.tsx | 24 + context/toast/ToastContext.ts | 23 + context/toast/ToastProvider.tsx | 23 + next-env.d.ts | 5 + package.json | 56 +- postcss.config.js | 6 + .../migration.sql | 26 + .../migration.sql | 17 + .../migration.sql | 4 + prisma/migrations/migration_lock.toml | 3 + prisma/prisma.ts | 9 + prisma/schema.prisma | 34 + public/favicon.ico | Bin 0 -> 15086 bytes public/manifest.webmanifest | 16 + public/samples/consumer.json | 209 ++ public/samples/purchases.json | 480 +++ src/index.ts | 20 - src/persona/store.ts | 38 - src/purchase/store.ts | 82 - src/utils/createFolder.ts | 18 - tailwind.config.ts | 13 + tsconfig.json | 33 +- {src/utils => utils}/anthropicClient.ts | 8 +- {src/persona => utils/consumer}/prompt.ts | 29 +- utils/consumer/store.ts | 54 + {src/persona => utils/consumer}/tool.ts | 4 +- {src/persona => utils/consumer}/types.ts | 4 +- {src/utils => utils}/dateFunctions.ts | 0 .../generateConsumerSeed.ts | 34 +- {src/purchase => utils/purchases}/prompt.ts | 74 +- utils/purchases/store.ts | 83 + {src/purchase => utils/purchases}/tool.ts | 2 +- {src/purchase => utils/purchases}/types.ts | 10 +- utils/rateLimiter.ts | 27 + vercel.json | 43 + yarn.lock | 2928 +++++++++++++---- 52 files changed, 4121 insertions(+), 982 deletions(-) create mode 100755 .husky/commit-msg create mode 100755 .husky/pre-commit create mode 100644 app/api/consumer/route.ts create mode 100644 app/api/purchase-list/route.ts create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 components/Button.tsx create mode 100644 components/Content.tsx create mode 100644 components/Footer.tsx create mode 100644 components/Header.tsx create mode 100644 components/Spinner.tsx create mode 100644 components/Toast.tsx create mode 100644 context/toast/ToastContext.ts create mode 100644 context/toast/ToastProvider.tsx create mode 100644 next-env.d.ts create mode 100644 postcss.config.js create mode 100644 prisma/migrations/20241204155324_database_initialization/migration.sql create mode 100644 prisma/migrations/20241204164538_correction_to_value_fields/migration.sql create mode 100644 prisma/migrations/20241204172334_consumer_fields_nullable/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/prisma.ts create mode 100644 prisma/schema.prisma create mode 100644 public/favicon.ico create mode 100644 public/manifest.webmanifest create mode 100644 public/samples/consumer.json create mode 100644 public/samples/purchases.json delete mode 100644 src/index.ts delete mode 100644 src/persona/store.ts delete mode 100644 src/purchase/store.ts delete mode 100644 src/utils/createFolder.ts create mode 100644 tailwind.config.ts rename {src/utils => utils}/anthropicClient.ts (85%) rename {src/persona => utils/consumer}/prompt.ts (75%) create mode 100644 utils/consumer/store.ts rename {src/persona => utils/consumer}/tool.ts (99%) rename {src/persona => utils/consumer}/types.ts (96%) rename {src/utils => utils}/dateFunctions.ts (100%) rename src/utils/generatePersonaSeed.ts => utils/generateConsumerSeed.ts (73%) rename {src/purchase => utils/purchases}/prompt.ts (75%) create mode 100644 utils/purchases/store.ts rename {src/purchase => utils/purchases}/tool.ts (98%) rename {src/purchase => utils/purchases}/types.ts (88%) create mode 100644 utils/rateLimiter.ts create mode 100644 vercel.json diff --git a/.env.example b/.env.example index 2242f36..014ee46 100644 --- a/.env.example +++ b/.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= \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index 9e2006c..fe97958 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -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/"] } diff --git a/.gitignore b/.gitignore index 8e5b17d..6a2c8f6 100644 --- a/.gitignore +++ b/.gitignore @@ -131,7 +131,5 @@ dist .editorconfig -personas/ -purchases/ - -.DS_Store \ No newline at end of file +.DS_Store +.vercel diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000..1a089f4 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx --no-install commitlint --edit $1 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..1787b50 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,8 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +yarn audit +yarn format +yarn lint-staged +yarn typecheck +yarn build \ No newline at end of file diff --git a/README.md b/README.md index b0f9a2b..e88446a 100644 --- a/README.md +++ b/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. diff --git a/app/api/consumer/route.ts b/app/api/consumer/route.ts new file mode 100644 index 0000000..022239c --- /dev/null +++ b/app/api/consumer/route.ts @@ -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 } + ); + } +} diff --git a/app/api/purchase-list/route.ts b/app/api/purchase-list/route.ts new file mode 100644 index 0000000..5e7dbc3 --- /dev/null +++ b/app/api/purchase-list/route.ts @@ -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 } + ); + } +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..7b1b9ed --- /dev/null +++ b/app/globals.css @@ -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; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..9fbb6cd --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..354e37e --- /dev/null +++ b/app/page.tsx @@ -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 ( + +
+
+
+ +
+
+
+
+ ); +} diff --git a/components/Button.tsx b/components/Button.tsx new file mode 100644 index 0000000..672d65e --- /dev/null +++ b/components/Button.tsx @@ -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 ( + + ); +}; diff --git a/components/Content.tsx b/components/Content.tsx new file mode 100644 index 0000000..2c353b4 --- /dev/null +++ b/components/Content.tsx @@ -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(); + const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [consumer, setConsumer] = useState(null); + const [purchasesError, setPurchasesError] = useState(null); + const [editedConsumer, setEditedConsumer] = useState(''); + const [purchasesResult, setPurchasesResult] = useState( + 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 ( +
+
+
+ + +
+
+
+
+
+ +
+
+

+ Quick Start +

+

+ Generate synthetic data in minutes +

+
+
+ +
+
+
+
01
+

Generate Profile

+

+ Create a synthetic consumer profile +

+
+
+
02
+

Review Data

+

+ Check and modify the generated profile +

+
+
+
03
+

Get History

+

+ Generate purchase history data +

+
+
+
+
+ +
+
+
+
+
+ +
+

+ Consumer Data +

+
+
+
+