├── src ├── lib │ ├── types.ts │ ├── db.ts │ ├── trpc │ │ ├── client.ts │ │ ├── server.ts │ │ ├── client.tsx │ │ └── react.tsx │ ├── zod │ │ └── userSchemas.ts │ ├── utils.ts │ ├── inngest.ts │ ├── email │ │ ├── templates │ │ │ └── WelcomeEmail.tsx │ │ └── sendEmail.ts │ ├── ai │ │ ├── openai.ts │ │ └── prompt.ts │ ├── storage.ts │ ├── api │ │ ├── root.ts │ │ ├── routers │ │ │ └── tasklist.ts │ │ └── trpc.ts │ ├── auth │ │ └── index.ts │ ├── cache.ts │ └── aiClient.ts ├── app │ ├── favicon.ico │ ├── api │ │ ├── auth │ │ │ └── [...nextauth] │ │ │ │ └── route.ts │ │ ├── inngest │ │ │ └── handler.ts │ │ ├── upload │ │ │ └── route.ts │ │ ├── transcribe │ │ │ └── route.ts │ │ └── trpc │ │ │ └── [trpc] │ │ │ └── route.ts │ ├── layout.tsx │ ├── auth │ │ ├── error │ │ │ └── page.tsx │ │ ├── verify │ │ │ └── page.tsx │ │ ├── signout │ │ │ └── page.tsx │ │ └── signin │ │ │ └── page.tsx │ ├── page.tsx │ └── globals.css ├── types │ └── idea.ts ├── hooks │ └── useQueryHooks.ts ├── stories │ ├── Page.stories.tsx │ ├── Kicker.stories.tsx │ ├── CopyButton.stories.tsx │ ├── Card.stories.tsx │ ├── IdeaDetails.stories.tsx │ ├── Spinner.stories.tsx │ ├── TasklistPanel.stories.tsx │ ├── MarkdownRenderer.stories.tsx │ ├── Button.tsx │ ├── IdeaSlotMachine.stories.tsx │ └── Button.stories.tsx ├── components │ ├── atoms │ │ ├── Kicker.tsx │ │ ├── Card.tsx │ │ ├── Spinner.tsx │ │ ├── CopyButton.tsx │ │ ├── Button.tsx │ │ └── MarkdownRenderer.tsx │ ├── theme │ │ ├── ThemeProvider.tsx │ │ └── ThemeAwareToast.tsx │ ├── ClientProvider.tsx │ ├── Button.tsx │ ├── organisms │ │ ├── TasklistPanel.tsx │ │ ├── IdeaDetails.tsx │ │ └── IdeaSlotMachine.tsx │ ├── ui │ │ └── button.tsx │ └── SpeechToTextArea.tsx └── data │ └── ideas.ts ├── vercel.json ├── screenshots └── button-stories.png ├── public ├── vercel.svg └── next.svg ├── postcss.config.mjs ├── next.config.ts ├── components.json ├── .storybook ├── main.ts └── preview.ts ├── tsconfig.json ├── .gitignore ├── LICENSE ├── .env.example ├── scripts ├── warm-cache.ts └── init.sh ├── .cursor-template.xml ├── .cursor-updates ├── inngest.config.ts ├── prisma └── schema.prisma ├── package.json ├── .cursorrules ├── .cursor-tasks.md ├── agent-helpers ├── uat.md ├── task-list.md ├── docs │ └── tasklist.md └── guide.md ├── README.md └── tailwind.config.ts /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drFabio/idea-roulette/main/src/app/favicon.ico -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "buildCommand": "npm run build", 3 | "installCommand": "npm install" 4 | } 5 | -------------------------------------------------------------------------------- /screenshots/button-stories.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drFabio/idea-roulette/main/screenshots/button-stories.png -------------------------------------------------------------------------------- /src/lib/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | export const prisma = new PrismaClient(); 4 | -------------------------------------------------------------------------------- /src/types/idea.ts: -------------------------------------------------------------------------------- 1 | export type Idea = { 2 | title: string; 3 | summary: string; 4 | one_pager: string; 5 | }; 6 | 7 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /src/lib/trpc/client.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCReact } from "@trpc/react-query"; 2 | import type { AppRouter } from "@/lib/api/root"; 3 | 4 | export const trpc = createTRPCReact(); 5 | -------------------------------------------------------------------------------- /src/lib/zod/userSchemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const userRegistrationSchema = z.object({ 4 | email: z.string().email(), 5 | password: z.string().min(8), 6 | }); 7 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth"; 2 | import { authOptions } from "@/lib/auth"; 3 | 4 | const handler = NextAuth(authOptions); 5 | export { handler as GET, handler as POST }; 6 | -------------------------------------------------------------------------------- /src/hooks/useQueryHooks.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | 3 | export function useExampleQuery() { 4 | return useQuery({ 5 | queryKey: ["example"], 6 | queryFn: async () => { 7 | return { data: "Hello from React Query" }; 8 | }, 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | images: { 5 | remotePatterns: [ 6 | { 7 | protocol: "https", 8 | hostname: "**", 9 | }, 10 | ], 11 | }, 12 | }; 13 | 14 | export default nextConfig; 15 | -------------------------------------------------------------------------------- /src/lib/inngest.ts: -------------------------------------------------------------------------------- 1 | import { inngest } from "../../inngest.config"; 2 | 3 | export const userRegistered = inngest.createFunction( 4 | { name: "User Registered", id: "user/registered" }, 5 | { event: "user/registered" }, 6 | async ({ event, step }) => { 7 | // Handle the event 8 | }, 9 | ); 10 | -------------------------------------------------------------------------------- /src/lib/email/templates/WelcomeEmail.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface WelcomeEmailProps { 4 | name: string; 5 | } 6 | 7 | export function WelcomeEmail({ name }: WelcomeEmailProps) { 8 | return ( 9 |
10 |

Welcome, {name}!

11 |

Thanks for joining us!

12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/ai/openai.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from "openai"; 2 | 3 | const OPENAI_MODEL = process.env.OPENAI_MODEL ?? "gpt-5"; 4 | 5 | export const getOpenAI = () => { 6 | if (!process.env.OPENAI_API_KEY) { 7 | throw new Error("OPENAI_API_KEY is not set"); 8 | } 9 | 10 | return new OpenAI({ apiKey: process.env.OPENAI_API_KEY, defaultQuery: { model: OPENAI_MODEL } }); 11 | }; 12 | -------------------------------------------------------------------------------- /src/stories/Page.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | import Page from "../app/page"; 3 | 4 | const meta: Meta = { 5 | title: "Page/Main", 6 | component: Page, 7 | tags: ["autodocs"], 8 | parameters: { 9 | layout: "fullscreen", 10 | }, 11 | }; 12 | 13 | export default meta; 14 | type Story = StoryObj; 15 | 16 | export const Default: Story = {}; 17 | -------------------------------------------------------------------------------- /src/components/atoms/Kicker.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from "clsx"; 2 | 3 | export type KickerProps = React.HTMLAttributes; 4 | 5 | export const Kicker = ({ className, ...props }: KickerProps) => ( 6 |

13 | ); 14 | 15 | -------------------------------------------------------------------------------- /src/data/ideas.ts: -------------------------------------------------------------------------------- 1 | import rawIdeas from "./ideas.json"; 2 | import { z } from "zod"; 3 | 4 | import type { Idea } from "@/types/idea"; 5 | 6 | const ideaSchema = z.object({ 7 | title: z.string().min(1), 8 | summary: z.string().min(1), 9 | one_pager: z.string().min(1), 10 | }); 11 | 12 | const ideasSchema = z.array(ideaSchema).length(20); 13 | 14 | export const ideas: Idea[] = ideasSchema.parse(rawIdeas); 15 | 16 | export type { Idea }; 17 | -------------------------------------------------------------------------------- /src/app/api/inngest/handler.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "inngest/next"; 2 | import { inngest } from "@/../inngest.config"; 3 | 4 | const sendFn = inngest.createFunction( 5 | { name: "inngest/send", id: "inngest/send" }, 6 | { event: "inngest/send" }, 7 | async ({ event }) => { 8 | console.log("inngest/send", event); 9 | }, 10 | ); 11 | 12 | export const { POST, GET } = serve({ 13 | client: inngest, 14 | functions: [sendFn], 15 | }); 16 | -------------------------------------------------------------------------------- /src/lib/email/sendEmail.ts: -------------------------------------------------------------------------------- 1 | import { Resend } from "resend"; 2 | import { WelcomeEmail } from "./templates/WelcomeEmail"; 3 | import { createElement } from "react"; 4 | 5 | const resend = new Resend(process.env.RESEND_API_KEY || ""); 6 | 7 | export async function sendWelcomeEmail(to: string, name: string) { 8 | await resend.emails.send({ 9 | from: process.env.EMAIL_FROM || "no-reply@yourdomain.com", 10 | to, 11 | subject: "Welcome!", 12 | react: createElement(WelcomeEmail, { name }), 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /src/stories/Kicker.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | import { Kicker } from "../components/atoms/Kicker"; 3 | 4 | const meta: Meta = { 5 | title: "Atoms/Kicker", 6 | component: Kicker, 7 | tags: ["autodocs"], 8 | parameters: { 9 | layout: "centered", 10 | }, 11 | }; 12 | 13 | export default meta; 14 | type Story = StoryObj; 15 | 16 | export const Default: Story = { 17 | args: { 18 | children: "Single-page prototype only", 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": false, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /src/components/theme/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ThemeProvider as NextThemeProvider } from "next-themes"; 4 | 5 | interface ThemeProviderProps { 6 | children: React.ReactNode; 7 | defaultTheme?: string; 8 | storageKey?: string; 9 | forcedTheme?: string; 10 | enableSystem?: boolean; 11 | disableTransitionOnChange?: boolean; 12 | } 13 | 14 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 15 | return ( 16 | 17 | {children} 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/theme/ThemeAwareToast.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTheme } from "next-themes"; 4 | import { ToastContainer } from "react-toastify"; 5 | 6 | export function ThemeAwareToast() { 7 | const { theme } = useTheme(); 8 | 9 | return ( 10 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/stories/CopyButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | import { action } from "@storybook/addon-actions"; 3 | import { CopyButton } from "../components/atoms/CopyButton"; 4 | 5 | const meta: Meta = { 6 | title: "Atoms/CopyButton", 7 | component: CopyButton, 8 | tags: ["autodocs"], 9 | parameters: { 10 | layout: "centered", 11 | }, 12 | }; 13 | 14 | export default meta; 15 | type Story = StoryObj; 16 | 17 | export const CopySuccess: Story = { 18 | args: { 19 | children: "Copy", 20 | getText: () => "Sample text", 21 | onCopied: action("copied"), 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /src/lib/storage.ts: -------------------------------------------------------------------------------- 1 | import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; 2 | 3 | const s3 = new S3Client({ 4 | region: process.env.AWS_REGION, 5 | credentials: { 6 | accessKeyId: process.env.AWS_ACCESS_KEY_ID!, 7 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, 8 | }, 9 | }); 10 | 11 | export async function uploadFile( 12 | key: string, 13 | body: Buffer | Uint8Array | Blob | string, 14 | ) { 15 | await s3.send( 16 | new PutObjectCommand({ 17 | Bucket: process.env.BUCKET_NAME!, 18 | Key: key, 19 | Body: body, 20 | }), 21 | ); 22 | return `https://${process.env.BUCKET_NAME}.s3.amazonaws.com/${key}`; 23 | } 24 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from "@storybook/nextjs"; 2 | 3 | const config: StorybookConfig = { 4 | stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], 5 | addons: [ 6 | "@storybook/addon-links", 7 | "@storybook/addon-essentials", 8 | "@storybook/addon-onboarding", 9 | "@storybook/addon-interactions", 10 | { 11 | name: "@storybook/addon-styling-webpack", 12 | options: { 13 | postCss: true, 14 | }, 15 | }, 16 | ], 17 | framework: { 18 | name: "@storybook/nextjs", 19 | options: {}, 20 | }, 21 | docs: { 22 | autodocs: "tag", 23 | }, 24 | }; 25 | export default config; 26 | -------------------------------------------------------------------------------- /src/lib/trpc/server.ts: -------------------------------------------------------------------------------- 1 | import "server-only"; 2 | 3 | import { headers } from "next/headers"; 4 | import { cache } from "react"; 5 | 6 | import { createCaller } from "@/lib/api/root"; 7 | import { createTRPCContext } from "@/lib/api/trpc"; 8 | 9 | /** 10 | * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when 11 | * handling a tRPC call from a React Server Component. 12 | */ 13 | const createContext = cache(async () => { 14 | const heads = new Headers(await headers()); 15 | heads.set("x-trpc-source", "rsc"); 16 | 17 | return createTRPCContext({ 18 | headers: heads, 19 | }); 20 | }); 21 | 22 | export const api = createCaller(createContext); 23 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from "@storybook/react"; 2 | import "../src/app/globals.css"; // Import your Tailwind CSS file 3 | 4 | const preview: Preview = { 5 | parameters: { 6 | actions: { argTypesRegex: "^on[A-Z].*" }, 7 | controls: { 8 | matchers: { 9 | color: /(background|color)$/i, 10 | date: /Date$/i, 11 | }, 12 | }, 13 | backgrounds: { 14 | default: "light", 15 | values: [ 16 | { 17 | name: "light", 18 | value: "#ffffff", 19 | }, 20 | { 21 | name: "dark", 22 | value: "#1a1a1a", 23 | }, 24 | ], 25 | }, 26 | }, 27 | }; 28 | 29 | export default preview; 30 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "@/app/globals.css"; 3 | import "react-toastify/dist/ReactToastify.css"; 4 | import { Metadata } from "next"; 5 | import { ClientProvider } from "@/components/ClientProvider"; 6 | 7 | export const metadata: Metadata = { 8 | title: "Idea Roulette", 9 | description: "Spin up startup ideas and generate AI task lists", 10 | icons: { 11 | icon: "/favicon.ico", 12 | }, 13 | }; 14 | 15 | export default function RootLayout({ children }: { children: React.ReactNode }) { 16 | return ( 17 | 18 | 19 | {children} 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /src/app/api/upload/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { uploadFile } from "@/lib/storage"; 3 | 4 | export async function POST(req: Request) { 5 | try { 6 | const formData = await req.formData(); 7 | const file = formData.get("file") as File | null; 8 | if (!file) return NextResponse.json({ error: "No file" }, { status: 400 }); 9 | 10 | const arrayBuffer = await file.arrayBuffer(); 11 | const buffer = Buffer.from(arrayBuffer); 12 | 13 | const url = await uploadFile(file.name, buffer); 14 | return NextResponse.json({ url }); 15 | } catch (error) { 16 | console.error("Upload error:", error); 17 | return NextResponse.json({ error: "Upload failed" }, { status: 500 }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/api/root.ts: -------------------------------------------------------------------------------- 1 | import { createCallerFactory, createTRPCRouter } from "./trpc"; 2 | import { tasklistRouter } from "./routers/tasklist"; 3 | // import all routers here 4 | 5 | /** 6 | * This is the primary router for your server. 7 | * 8 | * All routers added in /api/routers should be manually added here. 9 | */ 10 | export const appRouter = createTRPCRouter({ 11 | tasklist: tasklistRouter, 12 | }); 13 | 14 | // export type definition of API 15 | export type AppRouter = typeof appRouter; 16 | 17 | /** 18 | * Create a server-side caller for the tRPC API. 19 | * @example 20 | * const trpc = createCaller(createContext); 21 | * const res = await trpc.post.all(); 22 | * ^? Post[] 23 | */ 24 | export const createCaller = createCallerFactory(appRouter); 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env 35 | .env.local 36 | .env.staging 37 | .env.production 38 | 39 | # vercel 40 | .vercel 41 | 42 | # typescript 43 | *.tsbuildinfo 44 | next-env.d.ts 45 | 46 | .cursor-scratchpad 47 | 48 | *storybook.log 49 | -------------------------------------------------------------------------------- /src/stories/Card.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | import { Card } from "../components/atoms/Card"; 3 | 4 | const meta: Meta = { 5 | title: "Atoms/Card", 6 | component: Card, 7 | tags: ["autodocs"], 8 | parameters: { 9 | layout: "centered", 10 | }, 11 | }; 12 | 13 | export default meta; 14 | type Story = StoryObj; 15 | 16 | export const Default: Story = { 17 | args: { 18 | children: ( 19 |

20 |

Card Title

21 |

22 | Lightweight container with rounded corners and border for grouping content. 23 |

24 |
25 | ), 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /src/stories/IdeaDetails.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | import { IdeaDetails } from "../components/organisms/IdeaDetails"; 3 | import type { Idea } from "../types/idea"; 4 | 5 | const idea: Idea = { 6 | title: "Sample Idea", 7 | summary: "### Summary\nBrief markdown summary for the idea.", 8 | one_pager: "# One Pager\nDetailed plan for the sample idea.", 9 | }; 10 | 11 | const meta: Meta = { 12 | title: "Organisms/IdeaDetails", 13 | component: IdeaDetails, 14 | tags: ["autodocs"], 15 | args: { 16 | idea, 17 | }, 18 | }; 19 | 20 | export default meta; 21 | type Story = StoryObj; 22 | 23 | export const Collapsed: Story = {}; 24 | 25 | export const Expanded: Story = { 26 | args: { 27 | defaultExpanded: true, 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /src/lib/trpc/client.tsx: -------------------------------------------------------------------------------- 1 | import { createTRPCClient, httpBatchLink } from "@trpc/client"; 2 | import SuperJSON from "superjson"; 3 | import { type AppRouter } from "@/lib/api/root"; 4 | 5 | const trpcClient = createTRPCClient({ 6 | links: [ 7 | httpBatchLink({ 8 | url: `${getBaseUrl()}/api/trpc`, 9 | headers: () => { 10 | const headers = new Headers(); 11 | headers.set("x-trpc-source", "nextjs-react"); 12 | return headers; 13 | }, 14 | transformer: SuperJSON, 15 | }), 16 | ], 17 | }); 18 | 19 | function getBaseUrl() { 20 | if (typeof window !== "undefined") return window.location.origin; 21 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; 22 | return `http://localhost:${process.env.PORT ?? 3000}`; 23 | } 24 | 25 | export { trpcClient }; 26 | -------------------------------------------------------------------------------- /src/stories/Spinner.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | import { Spinner } from "../components/atoms/Spinner"; 3 | 4 | const meta: Meta = { 5 | title: "Atoms/Spinner", 6 | component: Spinner, 7 | tags: ["autodocs"], 8 | parameters: { 9 | layout: "centered", 10 | docs: { 11 | description: { 12 | component: "Accessible loading indicator respecting reduced motion preferences.", 13 | }, 14 | }, 15 | }, 16 | }; 17 | 18 | export default meta; 19 | type Story = StoryObj; 20 | 21 | export const Default: Story = { 22 | args: { 23 | size: "md", 24 | }, 25 | }; 26 | 27 | export const ReducedMotion: Story = { 28 | parameters: { 29 | backgrounds: { default: "dark" }, 30 | }, 31 | render: () => ( 32 |
33 | 34 |
35 | ), 36 | }; 37 | -------------------------------------------------------------------------------- /src/app/api/transcribe/route.ts: -------------------------------------------------------------------------------- 1 | import { type NextRequest, NextResponse } from "next/server"; 2 | import Groq from "groq-sdk"; 3 | 4 | const groq = new Groq({ 5 | apiKey: process.env.GROQ_API_KEY! ?? "", 6 | }); 7 | 8 | export async function POST(request: NextRequest) { 9 | try { 10 | const formData = await request.formData(); 11 | const file = formData.get("file") as File; 12 | 13 | if (!file) { 14 | return NextResponse.json({ error: "No file uploaded" }, { status: 400 }); 15 | } 16 | 17 | const transcription = await groq.audio.transcriptions.create({ 18 | file, 19 | model: "distil-whisper-large-v3-en", 20 | }); 21 | 22 | return NextResponse.json({ text: transcription.text }); 23 | } catch (error) { 24 | console.error("Transcription error:", error); 25 | return NextResponse.json( 26 | { error: "Transcription failed" }, 27 | { status: 500 }, 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/stories/TasklistPanel.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | import { action } from "@storybook/addon-actions"; 3 | import { TasklistPanel } from "../components/organisms/TasklistPanel"; 4 | 5 | const markdown = `1. **Prototype overview** — describe single page usage. 6 | 7 | - [ ] Build atoms in src/components/atoms. 8 | - [ ] Compose organisms in src/components/organisms. 9 | - [ ] Assemble page in app/page.tsx. 10 | - [ ] Write Storybook coverage. 11 | `; 12 | 13 | const meta: Meta = { 14 | title: "Organisms/TasklistPanel", 15 | component: TasklistPanel, 16 | tags: ["autodocs"], 17 | args: { 18 | markdown, 19 | onCopy: action("copy"), 20 | }, 21 | }; 22 | 23 | export default meta; 24 | type Story = StoryObj; 25 | 26 | export const Default: Story = {}; 27 | 28 | export const Empty: Story = { 29 | args: { 30 | markdown: "", 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /src/components/ClientProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 4 | import { useState } from "react"; 5 | import { ThemeProvider } from "next-themes"; 6 | 7 | import { TRPCReactProvider } from "@/lib/trpc/react"; 8 | import { ThemeAwareToast } from "@/components/theme/ThemeAwareToast"; 9 | 10 | const createQueryClient = () => new QueryClient(); 11 | 12 | export function ClientProvider({ children }: { children: React.ReactNode }) { 13 | const [queryClient] = useState(createQueryClient); 14 | 15 | return ( 16 | 17 | 18 | 24 | {children} 25 | 26 | 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/components/atoms/Card.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from "react"; 2 | import { clsx } from "clsx"; 3 | 4 | type CardTone = "default" | "inverted"; 5 | 6 | export type CardProps = React.HTMLAttributes & { 7 | tone?: CardTone; 8 | }; 9 | 10 | const toneStyles: Record = { 11 | default: 12 | "border-neutral-200 bg-white text-neutral-900 shadow-neutral-900/5 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-100", 13 | inverted: 14 | "border-neutral-800 bg-neutral-950 text-neutral-50 shadow-neutral-950/30 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-50", 15 | }; 16 | 17 | export const Card = forwardRef( 18 | ({ className, tone = "default", ...props }, ref) => ( 19 |
28 | ), 29 | ); 30 | 31 | Card.displayName = "Card"; 32 | 33 | -------------------------------------------------------------------------------- /src/stories/MarkdownRenderer.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | import { MarkdownRenderer } from "../components/atoms/MarkdownRenderer"; 3 | 4 | const meta: Meta = { 5 | title: "Atoms/MarkdownRenderer", 6 | component: MarkdownRenderer, 7 | tags: ["autodocs"], 8 | parameters: { 9 | layout: "centered", 10 | }, 11 | }; 12 | 13 | export default meta; 14 | type Story = StoryObj; 15 | 16 | const sampleMarkdown = `# Heading One 17 | 18 | ## Subheading Two 19 | 20 | Paragraph text with [link](https://example.com) and some \ 21 | 22 | snippet code. 23 | 24 | - List item one 25 | - List item two 26 | 27 | > Blockquote example. 28 | `; 29 | 30 | export const Default: Story = { 31 | args: { 32 | markdown: sampleMarkdown, 33 | className: "max-w-xl", 34 | }, 35 | }; 36 | 37 | export const LongContent: Story = { 38 | args: { 39 | markdown: `${sampleMarkdown.repeat(4)}`, 40 | className: "max-w-xl max-h-96 overflow-auto", 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Kevin Leneway 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/stories/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import './button.css'; 4 | 5 | export interface ButtonProps { 6 | /** Is this the principal call to action on the page? */ 7 | primary?: boolean; 8 | /** What background color to use */ 9 | backgroundColor?: string; 10 | /** How large should the button be? */ 11 | size?: 'small' | 'medium' | 'large'; 12 | /** Button contents */ 13 | label: string; 14 | /** Optional click handler */ 15 | onClick?: () => void; 16 | } 17 | 18 | /** Primary UI component for user interaction */ 19 | export const Button = ({ 20 | primary = false, 21 | size = 'medium', 22 | backgroundColor, 23 | label, 24 | ...props 25 | }: ButtonProps) => { 26 | const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary'; 27 | return ( 28 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/stories/IdeaSlotMachine.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | import { IdeaSlotMachine } from "../components/organisms/IdeaSlotMachine"; 3 | import type { Idea } from "../types/idea"; 4 | 5 | const ideas: Idea[] = [ 6 | { 7 | title: "Test Idea A", 8 | summary: "Summary A", 9 | one_pager: "Details for idea A", 10 | }, 11 | { 12 | title: "Test Idea B", 13 | summary: "Summary B", 14 | one_pager: "Details for idea B", 15 | }, 16 | { 17 | title: "Test Idea C", 18 | summary: "Summary C", 19 | one_pager: "Details for idea C", 20 | }, 21 | ]; 22 | 23 | const meta: Meta = { 24 | title: "Organisms/IdeaSlotMachine", 25 | component: IdeaSlotMachine, 26 | tags: ["autodocs"], 27 | parameters: { 28 | layout: "centered", 29 | }, 30 | }; 31 | 32 | export default meta; 33 | type Story = StoryObj; 34 | 35 | export const Default: Story = { 36 | args: { 37 | ideas, 38 | durationMs: 1500, 39 | }, 40 | }; 41 | 42 | export const ReducedMotion: Story = { 43 | args: { 44 | ideas, 45 | durationMs: 500, 46 | }, 47 | parameters: { 48 | controls: { exclude: ["onDone"] }, 49 | }, 50 | }; 51 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Database 2 | DATABASE_URL="postgresql://user:password@localhost:5432/facility-bids" 3 | DIRECT_URL="postgresql://user:password@localhost:5432/facility-bids" 4 | 5 | # NextAuth 6 | NEXTAUTH_URL="http://localhost:3000" 7 | NEXTAUTH_SECRET="your-secret-key-at-least-32-chars" 8 | 9 | # Email Provider 10 | EMAIL_SERVER_HOST="smtp.example.com" 11 | EMAIL_SERVER_PORT="587" 12 | EMAIL_SERVER_USER="your-email@example.com" 13 | EMAIL_SERVER_PASSWORD="your-email-password" 14 | EMAIL_FROM="noreply@example.com" 15 | 16 | NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co 17 | NEXT_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key 18 | RESEND_API_KEY=re_123456789 19 | EMAIL_SERVER=smtp.resend.com 20 | AWS_ACCESS_KEY_ID=your-aws-access-key-id 21 | AWS_SECRET_ACCESS_KEY=your-aws-secret-access-key 22 | AWS_REGION=us-west-2 23 | BUCKET_NAME=your-bucket-name 24 | OPENAI_API_KEY=sk-your-openai-api-key 25 | ANTHROPIC_API_KEY=sk-ant-your-anthropic-api-key 26 | IDEATION_DATABASE_URL=postgresql://user:password@host/database 27 | PERPLEXITY_API_KEY=pplx-your-perplexity-api-key 28 | INNGEST_EVENT_KEY=your-inngest-event-key 29 | PROXYCURL_API_KEY=your-proxycurl-api-key 30 | SES_FROM_EMAIL=your-email@example.com 31 | GROQ_API_KEY=your-groq-api-key 32 | -------------------------------------------------------------------------------- /src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface ButtonProps { 4 | children: React.ReactNode; 5 | variant?: "primary" | "secondary"; 6 | size?: "sm" | "md" | "lg"; 7 | onClick?: () => void; 8 | className?: string; 9 | } 10 | 11 | export const Button: React.FC = ({ 12 | children, 13 | variant = "primary", 14 | size = "md", 15 | onClick, 16 | className = "", 17 | }) => { 18 | const baseStyles = "rounded-lg font-medium transition-all duration-200"; 19 | 20 | const variantStyles = { 21 | primary: 22 | "bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white shadow-lg shadow-blue-500/20 hover:shadow-xl hover:shadow-blue-500/30", 23 | secondary: 24 | "bg-neutral-100 hover:bg-neutral-200 text-neutral-900 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:text-white", 25 | }; 26 | 27 | const sizeStyles = { 28 | sm: "px-4 py-2 text-sm", 29 | md: "px-6 py-3 text-base", 30 | lg: "px-8 py-4 text-lg", 31 | }; 32 | 33 | return ( 34 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/app/api/trpc/[trpc]/route.ts: -------------------------------------------------------------------------------- 1 | import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; 2 | import { type NextRequest } from "next/server"; 3 | 4 | import { appRouter } from "@/lib/api/root"; 5 | import { createTRPCContext } from "@/lib/api/trpc"; 6 | 7 | /** 8 | * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when 9 | * handling a HTTP request (e.g. when you make requests from Client Components). 10 | */ 11 | const createContext = async (req: NextRequest) => { 12 | return createTRPCContext({ 13 | headers: req.headers, 14 | }); 15 | }; 16 | 17 | const env = process.env; 18 | 19 | const handler = (req: NextRequest) => 20 | fetchRequestHandler({ 21 | endpoint: "/api/trpc", 22 | req, 23 | router: appRouter, 24 | createContext: () => createContext(req), 25 | onError: 26 | env.NODE_ENV === "development" 27 | ? ({ path, error }) => { 28 | console.error( 29 | `❌ tRPC failed on ${path ?? ""}: ${error.message}`, 30 | ); 31 | } 32 | : ({ path, error }) => { 33 | console.error(`tRPC failed on ${path ?? ""}`, error); 34 | }, 35 | }); 36 | 37 | export { handler as GET, handler as POST }; 38 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/atoms/Spinner.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { clsx } from "clsx"; 4 | 5 | type SpinnerSize = "sm" | "md"; 6 | 7 | export type SpinnerProps = React.HTMLAttributes & { 8 | size?: SpinnerSize; 9 | label?: string; 10 | }; 11 | 12 | const sizeClasses: Record = { 13 | sm: "h-3 w-3", 14 | md: "h-4 w-4", 15 | }; 16 | 17 | export const Spinner = ({ 18 | size = "sm", 19 | label = "Loading", 20 | className, 21 | ...props 22 | }: SpinnerProps) => ( 23 | 29 | 52 | {label} 53 | 54 | ); 55 | 56 | -------------------------------------------------------------------------------- /scripts/warm-cache.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | 3 | import { ideas } from "../src/data/ideas"; 4 | import { ensureTasklistCached } from "../src/lib/cache"; 5 | 6 | async function main() { 7 | const limit = 20; 8 | const targets = ideas.slice(0, limit); 9 | 10 | const BATCH_SIZE = 5; 11 | let ensured = 0; 12 | 13 | for (let i = 0; i < targets.length; i += BATCH_SIZE) { 14 | const batch = targets.slice(i, i + BATCH_SIZE); 15 | 16 | const results = await Promise.all( 17 | batch.map(async (idea) => { 18 | try { 19 | const { cached } = await ensureTasklistCached({ 20 | title: idea.title, 21 | summary: idea.summary, 22 | one_pager: idea.one_pager, 23 | }); 24 | return cached ? "already cached" : "generated"; 25 | } catch (err) { 26 | const msg = err instanceof Error ? err.message : String(err); 27 | return `FAILED (${msg})`; 28 | } 29 | }), 30 | ); 31 | 32 | results.forEach((status, idx) => { 33 | const idea = batch[idx]!; 34 | const ok = !status.startsWith("FAILED"); 35 | if (ok) ensured += 1; 36 | console.log(`• ${idea.title} ... ${status}`); 37 | }); 38 | } 39 | 40 | console.log(`\nDone. Ensured ${ensured}/${targets.length} ideas in cache.`); 41 | } 42 | 43 | main().catch((err) => { 44 | console.error(err); 45 | process.exit(1); 46 | }); 47 | -------------------------------------------------------------------------------- /src/lib/trpc/react.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { httpBatchLink } from "@trpc/client"; 4 | import { createTRPCReact } from "@trpc/react-query"; 5 | import { useState } from "react"; 6 | import SuperJSON from "superjson"; 7 | 8 | import { type AppRouter } from "@/lib/api/root"; 9 | import { QueryClient } from "@tanstack/react-query"; 10 | 11 | export const api = createTRPCReact(); 12 | 13 | type TRPCProviderProps = { 14 | children: React.ReactNode; 15 | queryClient: QueryClient; 16 | }; 17 | 18 | export function TRPCReactProvider({ children, queryClient }: TRPCProviderProps) { 19 | const [trpcClient] = useState(() => 20 | api.createClient({ 21 | links: [ 22 | httpBatchLink({ 23 | url: `${getBaseUrl()}/api/trpc`, 24 | transformer: SuperJSON, 25 | headers: () => { 26 | const headers = new Headers(); 27 | headers.set("x-trpc-source", "nextjs-react"); 28 | return headers; 29 | }, 30 | }), 31 | ], 32 | }), 33 | ); 34 | 35 | return ( 36 | 37 | {children} 38 | 39 | ); 40 | } 41 | 42 | function getBaseUrl() { 43 | if (typeof window !== "undefined") return window.location.origin; 44 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; 45 | return `http://localhost:${process.env.PORT ?? 3000}`; 46 | } 47 | -------------------------------------------------------------------------------- /.cursor-template.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.cursor-updates: -------------------------------------------------------------------------------- 1 | # Cursor Updates 2 | 3 | - Ran production build verification - build completed successfully with no TypeScript or compilation errors 4 | - Performed build check on Next.js app with tRPC and Tailwind configuration 5 | - Successfully ran production build with Prisma generation and Next.js compilation 6 | - Fixed dynamic route warning by adding force-dynamic config to root page 7 | - Added Storybook with Button component and stories, updated .cursorrules with Storybook guidelines 8 | - Captured screenshot of Button component stories in Storybook 9 | - Built idea roulette flow with atoms, organisms, tasklist API, and full Storybook coverage 10 | - Fixed runtime mutation error by restoring QueryClientProvider around TRPC provider and re-running build 11 | - Added inverted card tone for slot machine to fix white strip and verified build succeeds 12 | - Removed OpenAI temperature override so default-only models work and reran build 13 | - Wrapped tasklist OpenAI call in try/catch to surface TRPC errors cleanly and rebuilt 14 | - Improved tasklist router error messaging (API key hint, safe fallback) and reran build 15 | - Styled IdeaDetails one pager paragraphs by splitting newline-delimited text into clean sections and reran build 16 | - Added Tailwind Typography plugin and tasklist markdown styles; cleaned up LLM checklist rendering 17 | - Added simple file-backed LLM cache (src/data/cache.json) with prefetch on idea selection and TRPC cache lookup 18 | - Added warm-cache script using shared helpers; npm run warm-cache to precompute 20 ideas 19 | - Fixed cache race with a simple mutex to prevent concurrent write clobbering; warm-cache runs 5 concurrent 20 | -------------------------------------------------------------------------------- /src/stories/Button.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | import { Button, type ButtonProps } from "../components/atoms/Button"; 3 | import { action } from "@storybook/addon-actions"; 4 | 5 | const meta: Meta = { 6 | title: "Atoms/Button", 7 | component: Button, 8 | tags: ["autodocs"], 9 | parameters: { 10 | layout: "centered", 11 | docs: { 12 | description: { 13 | component: 14 | "Responsive button component supporting primary and outline variants with loading states.", 15 | }, 16 | }, 17 | }, 18 | argTypes: { 19 | size: { 20 | control: "radio", 21 | options: ["md", "lg"], 22 | }, 23 | variant: { 24 | control: "radio", 25 | options: ["primary", "outline"], 26 | }, 27 | loading: { 28 | control: "boolean", 29 | }, 30 | fullWidth: { 31 | control: "boolean", 32 | }, 33 | onClick: { action: "clicked" }, 34 | }, 35 | args: { 36 | size: "md", 37 | variant: "primary", 38 | children: "Click me", 39 | onClick: action("button-click"), 40 | }, 41 | }; 42 | 43 | export default meta; 44 | type Story = StoryObj; 45 | 46 | export const Default: Story = {}; 47 | 48 | export const Loading: Story = { 49 | args: { 50 | loading: true, 51 | children: "Loading", 52 | }, 53 | }; 54 | 55 | export const Disabled: Story = { 56 | args: { 57 | disabled: true, 58 | children: "Disabled", 59 | }, 60 | }; 61 | 62 | export const Outline: Story = { 63 | args: { 64 | variant: "outline", 65 | }, 66 | }; 67 | 68 | export const FullWidth: Story = { 69 | args: { 70 | fullWidth: true, 71 | }, 72 | }; 73 | -------------------------------------------------------------------------------- /inngest.config.ts: -------------------------------------------------------------------------------- 1 | import { Inngest } from "inngest"; 2 | import { serve } from "inngest/next"; 3 | 4 | // Define event types for better type safety 5 | export type AppEvents = { 6 | "user/registered": { 7 | data: { 8 | userId: string; 9 | email: string; 10 | name?: string; 11 | timestamp: string; 12 | }; 13 | }; 14 | "inngest/send": { 15 | data: { 16 | message: string; 17 | metadata?: Record; 18 | }; 19 | }; 20 | }; 21 | 22 | // Initialize Inngest with typed events 23 | export const inngest = new Inngest({ 24 | id: "newco", 25 | eventKey: "events", 26 | validateEvents: process.env.NODE_ENV === "development", 27 | }); 28 | 29 | // Define event handlers 30 | export const userRegisteredFn = inngest.createFunction( 31 | { id: "user-registered-handler" }, 32 | { event: "user/registered" }, 33 | async ({ event, step }) => { 34 | await step.run("Log registration", async () => { 35 | console.log(`New user registered: ${event.data.email}`); 36 | }); 37 | 38 | // Example: Send welcome email 39 | await step.run("Send welcome email", async () => { 40 | // Add your email sending logic here 41 | console.log(`Sending welcome email to ${event.data.email}`); 42 | }); 43 | }, 44 | ); 45 | 46 | export const messageHandlerFn = inngest.createFunction( 47 | { id: "message-handler" }, 48 | { event: "inngest/send" }, 49 | async ({ event, step }) => { 50 | await step.run("Process message", async () => { 51 | console.log(`Processing message: ${event.data.message}`); 52 | if (event.data.metadata) { 53 | console.log("Metadata:", event.data.metadata); 54 | } 55 | }); 56 | }, 57 | ); 58 | 59 | // Export the serve function for use in API routes 60 | export const serveInngest = serve({ 61 | client: inngest, 62 | functions: [userRegisteredFn, messageHandlerFn], 63 | }); 64 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("DATABASE_URL") 8 | directUrl = env("DIRECT_URL") 9 | } 10 | 11 | model User { 12 | id String @id @default(cuid()) 13 | name String? 14 | email String? @unique 15 | emailVerified DateTime? 16 | image String? 17 | hashedPassword String? 18 | createdAt DateTime @default(now()) 19 | updatedAt DateTime @updatedAt 20 | login String? 21 | role UserRole @default(user) 22 | isAdmin Boolean @default(false) 23 | accounts Account[] 24 | sessions Session[] 25 | } 26 | 27 | model Account { 28 | id String @id @default(cuid()) 29 | userId String 30 | type String 31 | provider String 32 | providerAccountId String 33 | refresh_token String? 34 | access_token String? 35 | expires_at Int? 36 | token_type String? 37 | scope String? 38 | id_token String? 39 | session_state String? 40 | User User @relation(fields: [userId], references: [id]) 41 | 42 | @@unique([provider, providerAccountId]) 43 | } 44 | 45 | model Session { 46 | id String @id @default(cuid()) 47 | sessionToken String @unique 48 | userId String 49 | expires DateTime 50 | 51 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 52 | } 53 | 54 | enum UserRole { 55 | user 56 | admin 57 | } 58 | 59 | model Allowlist { 60 | id String @id @default(cuid()) 61 | email String @unique 62 | createdAt DateTime @default(now()) 63 | } 64 | 65 | model VerificationToken { 66 | identifier String 67 | token String @unique 68 | expires DateTime 69 | 70 | @@unique([identifier, token]) 71 | } 72 | -------------------------------------------------------------------------------- /src/app/auth/error/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { Suspense } from "react"; 4 | import { useSearchParams } from "next/navigation"; 5 | import { ArrowLeft } from "lucide-react"; 6 | import Link from "next/link"; 7 | 8 | function ErrorContent() { 9 | const searchParams = useSearchParams(); 10 | const error = searchParams.get("error"); 11 | 12 | return ( 13 |
14 | 18 | 19 | Back 20 | 21 | 22 |
23 |
24 |

25 | Authentication Error 26 |

27 |

28 | {error === "AccessDenied" 29 | ? "Access denied." 30 | : "An error occurred during authentication. Please try again."} 31 |

32 |
33 | 34 |
35 | 39 | Try Again 40 | 41 |
42 |
43 |
44 | ); 45 | } 46 | 47 | export default function ErrorPage() { 48 | return ( 49 | Loading...
}> 50 | 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/lib/api/routers/tasklist.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { buildPrompt } from "@/lib/ai/prompt"; 4 | import { getOpenAI } from "@/lib/ai/openai"; 5 | import { publicProcedure, createTRPCRouter } from "../trpc"; 6 | import { TRPCError } from "@trpc/server"; 7 | import { ensureTasklistCached, generateTasklistWithCache } from "@/lib/cache"; 8 | 9 | export const tasklistRouter = createTRPCRouter({ 10 | ensure: publicProcedure 11 | .input( 12 | z.object({ 13 | title: z.string().min(1), 14 | summary: z.string().min(1), 15 | one_pager: z.string().min(1), 16 | }), 17 | ) 18 | .mutation(async ({ input }) => { 19 | try { 20 | const result = await ensureTasklistCached(input); 21 | return { cached: result.cached } as const; 22 | } catch (error) { 23 | console.error("Tasklist ensure cache failed", error); 24 | throw new TRPCError({ 25 | code: "INTERNAL_SERVER_ERROR", 26 | message: 27 | error instanceof Error ? error.message : "Failed to ensure cache", 28 | cause: error instanceof Error ? error : undefined, 29 | }); 30 | } 31 | }), 32 | generate: publicProcedure 33 | .input( 34 | z.object({ 35 | title: z.string().min(1), 36 | summary: z.string().min(1), 37 | one_pager: z.string().min(1), 38 | }), 39 | ) 40 | .mutation(async ({ input }) => { 41 | try { 42 | const markdown = await generateTasklistWithCache(input); 43 | return { markdown } as const; 44 | } catch (error) { 45 | console.error("Tasklist generation failed", error); 46 | 47 | const message = 48 | error instanceof Error && error.message.includes("OPENAI_API_KEY") 49 | ? "OpenAI API key is not configured. Set OPENAI_API_KEY in your environment." 50 | : error instanceof Error 51 | ? error.message 52 | : "Failed to generate task list"; 53 | 54 | throw new TRPCError({ 55 | code: "INTERNAL_SERVER_ERROR", 56 | message, 57 | cause: error instanceof Error ? error : undefined, 58 | }); 59 | } 60 | }), 61 | }); 62 | -------------------------------------------------------------------------------- /src/components/atoms/CopyButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCallback, useState } from "react"; 4 | import { toast } from "react-toastify"; 5 | 6 | import { Button, type ButtonProps } from "@/components/atoms/Button"; 7 | 8 | export type CopyButtonProps = Omit & { 9 | getText: () => string; 10 | successMessage?: string; 11 | errorMessage?: string; 12 | onCopied?: () => void; 13 | }; 14 | 15 | export const CopyButton = ({ 16 | getText, 17 | successMessage = "Task list copied", 18 | errorMessage = "Unable to copy", 19 | onCopied, 20 | disabled, 21 | loading, 22 | ...props 23 | }: CopyButtonProps) => { 24 | const [isCopying, setIsCopying] = useState(false); 25 | 26 | const copyText = useCallback(async () => { 27 | if (isCopying) return; 28 | setIsCopying(true); 29 | const text = getText(); 30 | 31 | const fallbackCopy = (value: string) => { 32 | try { 33 | const textarea = document.createElement("textarea"); 34 | textarea.value = value; 35 | textarea.setAttribute("readonly", "true"); 36 | textarea.style.position = "absolute"; 37 | textarea.style.left = "-9999px"; 38 | document.body.appendChild(textarea); 39 | textarea.select(); 40 | const ok = document.execCommand("copy"); 41 | document.body.removeChild(textarea); 42 | return ok; 43 | } catch { 44 | return false; 45 | } 46 | }; 47 | 48 | try { 49 | if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { 50 | await navigator.clipboard.writeText(text); 51 | } else if (!fallbackCopy(text)) { 52 | throw new Error("Clipboard not available"); 53 | } 54 | toast.success(successMessage); 55 | onCopied?.(); 56 | } catch (error) { 57 | console.error("Copy failed", error); 58 | toast.error(errorMessage); 59 | } finally { 60 | setIsCopying(false); 61 | } 62 | }, [errorMessage, getText, isCopying, onCopied, successMessage]); 63 | 64 | return ( 65 | 38 | )} 39 | markdown} 42 | onCopied={onCopy} 43 | disabled={!markdown} 44 | > 45 | Copy 46 | 47 | 48 | 49 |
53 | {markdown ? ( 54 | 55 | ) : ( 56 |

57 | Tasklist will appear here once generated. 58 |

59 | )} 60 |
61 | 62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /src/app/auth/verify/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import Link from "next/link"; 5 | import { ArrowLeft, Mail } from "lucide-react"; 6 | 7 | export default function VerifyRequest() { 8 | return ( 9 |
10 | 14 | 15 | Back 16 | 17 | 18 |
19 |
20 |
21 | 22 |
23 |

24 | Check your email 25 |

26 |

27 | A sign in link has been sent to your email address. Please check 28 | your inbox and click the link to continue. 29 |

30 |
31 | 32 |
33 |
34 |

35 | Didn't receive the email? 36 |

37 |
    38 |
  • Check your spam folder
  • 39 |
  • Make sure you entered the correct email address
  • 40 |
  • 41 | If you still haven't received it after a few minutes,{" "} 42 | 46 | try signing in again 47 | 48 |
  • 49 |
50 |
51 |
52 |
53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/components/atoms/Button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { forwardRef } from "react"; 4 | import { clsx } from "clsx"; 5 | 6 | import { Spinner } from "@/components/atoms/Spinner"; 7 | 8 | type ButtonSize = "md" | "lg"; 9 | type ButtonVariant = "primary" | "outline"; 10 | 11 | export type ButtonProps = React.ButtonHTMLAttributes & { 12 | size?: ButtonSize; 13 | loading?: boolean; 14 | fullWidth?: boolean; 15 | variant?: ButtonVariant; 16 | }; 17 | 18 | const sizeClasses: Record = { 19 | md: "px-4 py-2 text-sm", 20 | lg: "px-5 py-3 text-base", 21 | }; 22 | 23 | const variantClasses: Record = { 24 | primary: 25 | "border-transparent bg-neutral-900 text-white hover:bg-neutral-800 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200", 26 | outline: 27 | "border-neutral-300 bg-transparent text-neutral-800 hover:bg-neutral-100 dark:border-neutral-700 dark:text-neutral-100 dark:hover:bg-neutral-800", 28 | }; 29 | 30 | export const Button = forwardRef( 31 | ( 32 | { 33 | size = "md", 34 | loading = false, 35 | fullWidth = false, 36 | variant = "primary", 37 | className, 38 | children, 39 | disabled, 40 | ...props 41 | }, 42 | ref, 43 | ) => { 44 | const isDisabled = disabled ?? loading; 45 | 46 | return ( 47 | 72 | ); 73 | }, 74 | ); 75 | 76 | Button.displayName = "Button"; 77 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-neutral-900 text-neutral-50 hover:bg-neutral-900/90 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50/90", 14 | destructive: 15 | "bg-red-500 text-neutral-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-neutral-50 dark:hover:bg-red-900/90", 16 | outline: 17 | "border border-neutral-200 bg-white hover:bg-neutral-100 hover:text-neutral-900 dark:border-neutral-800 dark:bg-neutral-950 dark:hover:bg-neutral-800 dark:hover:text-neutral-50", 18 | secondary: 19 | "bg-neutral-100 text-neutral-900 hover:bg-neutral-100/80 dark:bg-neutral-800 dark:text-neutral-50 dark:hover:bg-neutral-800/80", 20 | ghost: 21 | "hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-neutral-800 dark:hover:text-neutral-50", 22 | link: "text-neutral-900 underline-offset-4 hover:underline dark:text-neutral-50", 23 | }, 24 | size: { 25 | default: "h-10 px-4 py-2", 26 | sm: "h-9 rounded-md px-3", 27 | lg: "h-11 rounded-md px-8", 28 | icon: "h-10 w-10", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | }, 36 | ); 37 | 38 | export interface ButtonProps 39 | extends React.ButtonHTMLAttributes, 40 | VariantProps { 41 | asChild?: boolean; 42 | } 43 | 44 | const Button = React.forwardRef( 45 | ({ className, variant, size, asChild = false, ...props }, ref) => { 46 | const Comp = asChild ? Slot : "button"; 47 | return ( 48 | 53 | ); 54 | }, 55 | ); 56 | Button.displayName = "Button"; 57 | 58 | export { Button, buttonVariants }; 59 | -------------------------------------------------------------------------------- /src/components/atoms/MarkdownRenderer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import ReactMarkdown from "react-markdown"; 4 | import remarkGfm from "remark-gfm"; 5 | import { clsx } from "clsx"; 6 | import type { Components } from "react-markdown"; 7 | import type { ComponentPropsWithoutRef, ReactNode } from "react"; 8 | 9 | export type MarkdownRendererProps = { 10 | markdown: string; 11 | className?: string; 12 | variant?: "default" | "tasklist"; 13 | }; 14 | 15 | const headingClassName = "font-semibold text-neutral-900 dark:text-neutral-50"; 16 | 17 | type CodeProps = ComponentPropsWithoutRef<"code"> & { 18 | inline?: boolean; 19 | children?: ReactNode; 20 | }; 21 | 22 | const Code = ({ className, children, inline, ...props }: CodeProps) => ( 23 | 31 | {children} 32 | 33 | ); 34 | 35 | const defaultComponents: Components = { 36 | h1: ({ className, ...props }) => ( 37 |

38 | ), 39 | h2: ({ className, ...props }) => ( 40 |

41 | ), 42 | h3: ({ className, ...props }) => ( 43 |

44 | ), 45 | h4: ({ className, ...props }) => ( 46 |

47 | ), 48 | code: Code, 49 | a: ({ className, ...props }) => ( 50 | 57 | ), 58 | }; 59 | 60 | const tasklistComponents: Components = { 61 | ...defaultComponents, 62 | }; 63 | 64 | export const MarkdownRenderer = ({ 65 | markdown, 66 | className, 67 | variant = "default", 68 | }: MarkdownRendererProps) => { 69 | const containerClassName = 70 | variant === "tasklist" 71 | ? "markdown-tasklist" 72 | : "prose prose-neutral dark:prose-invert"; 73 | 74 | const components = 75 | variant === "tasklist" ? tasklistComponents : defaultComponents; 76 | 77 | return ( 78 |
79 | 80 | {markdown} 81 | 82 |
83 | ); 84 | }; 85 | -------------------------------------------------------------------------------- /src/components/organisms/IdeaDetails.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { AnimatePresence, motion } from "framer-motion"; 5 | import { ChevronDown, ChevronUp } from "lucide-react"; 6 | 7 | import { Card } from "@/components/atoms/Card"; 8 | import { MarkdownRenderer } from "@/components/atoms/MarkdownRenderer"; 9 | import type { Idea } from "@/types/idea"; 10 | 11 | export type IdeaDetailsProps = { 12 | idea: Idea; 13 | defaultExpanded?: boolean; 14 | }; 15 | 16 | export const IdeaDetails = ({ 17 | idea, 18 | defaultExpanded = false, 19 | }: IdeaDetailsProps) => { 20 | const [expanded, setExpanded] = useState(defaultExpanded); 21 | const paragraphs = idea.one_pager 22 | .split(/\n+/) 23 | .map((paragraph) => paragraph.trim()) 24 | .filter(Boolean); 25 | 26 | return ( 27 | 28 |
29 |

30 | {idea.title} 31 |

32 | 33 |
34 | 35 |
36 | 54 | 55 | {expanded && ( 56 | 63 |
64 | {paragraphs.map((paragraph, index) => ( 65 |

{paragraph}

66 | ))} 67 |
68 |
69 | )} 70 |
71 |
72 |
73 | ); 74 | }; 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-template", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "next dev --turbopack", 8 | "build": "prisma generate && next build", 9 | "start": "next start", 10 | "lint": "next lint", 11 | "vercel-build": "prisma generate && next build", 12 | "postinstall": "prisma generate", 13 | "storybook": "storybook dev -p 6006", 14 | "build-storybook": "storybook build", 15 | "warm-cache": "tsx scripts/warm-cache.ts" 16 | }, 17 | "dependencies": { 18 | "@aws-sdk/client-s3": "^3.709.0", 19 | "@emotion/is-prop-valid": "^1.4.0", 20 | "@fortawesome/free-solid-svg-icons": "^6.7.2", 21 | "@fortawesome/react-fontawesome": "^0.2.2", 22 | "@google/generative-ai": "^0.21.0", 23 | "@next-auth/prisma-adapter": "^1.0.7", 24 | "@prisma/client": "^6.0.1", 25 | "@radix-ui/react-slot": "^1.1.0", 26 | "@shadcn/ui": "^0.0.4", 27 | "@supabase/supabase-js": "^2.47.3", 28 | "@tailwindcss/typography": "^0.5.16", 29 | "@tanstack/react-query": "^5.25.0", 30 | "@trpc/client": "next", 31 | "@trpc/next": "next", 32 | "@trpc/react-query": "next", 33 | "@trpc/server": "next", 34 | "@types/react-datepicker": "^6.2.0", 35 | "class-variance-authority": "^0.7.1", 36 | "clsx": "^2.1.1", 37 | "date-fns": "^4.1.0", 38 | "framer-motion": "^11.15.0", 39 | "groq-sdk": "^0.12.0", 40 | "inngest": "^3.27.5", 41 | "lucide-react": "^0.468.0", 42 | "next": "15.1.0", 43 | "next-auth": "^4.24.11", 44 | "next-themes": "^0.4.4", 45 | "nodemailer": "^6.9.16", 46 | "openai": "^4.80.1", 47 | "prisma": "^6.0.1", 48 | "react": "^19.0.0", 49 | "react-datepicker": "^7.6.0", 50 | "react-dom": "^19.0.0", 51 | "react-icons": "^5.4.0", 52 | "react-markdown": "^10.1.0", 53 | "react-toastify": "^11.0.3", 54 | "remark-gfm": "^4.0.1", 55 | "resend": "^4.0.1", 56 | "superjson": "^2.2.2", 57 | "tailwind-merge": "^2.5.5", 58 | "tailwindcss-animate": "^1.0.7", 59 | "zod": "^3.24.1" 60 | }, 61 | "devDependencies": { 62 | "@chromatic-com/storybook": "^3.2.4", 63 | "@storybook/addon-essentials": "^8.5.3", 64 | "@storybook/addon-interactions": "^8.5.3", 65 | "@storybook/addon-links": "^8.5.3", 66 | "@storybook/addon-onboarding": "^8.5.3", 67 | "@storybook/addon-styling-webpack": "^1.0.1", 68 | "@storybook/blocks": "^8.5.3", 69 | "@storybook/nextjs": "^8.5.3", 70 | "@storybook/react": "^8.5.3", 71 | "@storybook/test": "^8.5.3", 72 | "@types/node": "^20", 73 | "@types/react": "^19", 74 | "@types/react-dom": "^19", 75 | "dotenv": "^16.6.1", 76 | "postcss": "^8", 77 | "prisma": "^6.0.1", 78 | "storybook": "^8.5.3", 79 | "tailwindcss": "^3.4.1", 80 | "tsx": "^4.20.5", 81 | "typescript": "^5" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/components/organisms/IdeaSlotMachine.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useMemo } from "react"; 4 | import { motion, useReducedMotion } from "framer-motion"; 5 | 6 | import { Card } from "@/components/atoms/Card"; 7 | import type { Idea } from "@/types/idea"; 8 | 9 | export type IdeaSlotMachineProps = { 10 | ideas: Idea[]; 11 | durationMs?: number; 12 | onDone: (idea: Idea) => void; 13 | }; 14 | 15 | const ITEM_HEIGHT = 64; // px, matches h-16 16 | 17 | export const IdeaSlotMachine = ({ 18 | ideas, 19 | durationMs = 6500, 20 | onDone, 21 | }: IdeaSlotMachineProps) => { 22 | const shouldReduceMotion = useReducedMotion(); 23 | 24 | const finalIdea = useMemo(() => { 25 | if (!ideas.length) { 26 | throw new Error("IdeaSlotMachine requires at least one idea"); 27 | } 28 | const index = Math.floor(Math.random() * ideas.length); 29 | return ideas[index]; 30 | }, [ideas]); 31 | 32 | const sequence = useMemo(() => { 33 | const loops = shouldReduceMotion ? 1 : 4; 34 | const rolled: Idea[] = []; 35 | for (let loop = 0; loop < loops; loop += 1) { 36 | rolled.push(...ideas); 37 | } 38 | rolled.push(finalIdea); 39 | return rolled; 40 | }, [finalIdea, ideas, shouldReduceMotion]); 41 | 42 | const targetIndex = sequence.length - 1; 43 | const duration = shouldReduceMotion 44 | ? Math.min(Math.max(durationMs, 200), 800) 45 | : durationMs; 46 | 47 | useEffect(() => { 48 | const timeout = window.setTimeout(() => onDone(finalIdea), duration); 49 | return () => window.clearTimeout(timeout); 50 | }, [duration, finalIdea, onDone]); 51 | 52 | return ( 53 | 54 |
59 |
60 |
61 | 70 | {sequence.map((idea, index) => ( 71 |
75 | {idea.title} 76 |
77 | ))} 78 |
79 |
80 | 81 | ); 82 | }; 83 | 84 | -------------------------------------------------------------------------------- /src/app/auth/signout/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import { signOut } from "next-auth/react"; 5 | import Link from "next/link"; 6 | import { ArrowLeft, LogOut } from "lucide-react"; 7 | 8 | export default function SignOut() { 9 | const [isLoading, setIsLoading] = React.useState(false); 10 | 11 | const handleSignOut = async () => { 12 | setIsLoading(true); 13 | await signOut({ callbackUrl: "/" }); 14 | }; 15 | 16 | return ( 17 |
18 | 22 | 23 | Back 24 | 25 | 26 |
27 |
28 |
29 | 30 |
31 |

32 | Sign out 33 |

34 |

35 | Are you sure you want to sign out? 36 |

37 |
38 | 39 |
40 |
41 | 49 | 50 | 54 | Cancel 55 | 56 |
57 |
58 |
59 |
60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /.cursorrules: -------------------------------------------------------------------------------- 1 | # .cursorrules 2 | 3 | Components & Naming 4 | 5 | - Use functional components with `"use client"` if needed. 6 | - Name in PascalCase under `src/components/`. 7 | - Keep them small, typed with interfaces. 8 | - Use Tailwind for common UI components like textarea, button, etc. Never use radix or shadcn. 9 | 10 | Prisma 11 | 12 | - Manage DB logic with Prisma in `prisma/schema.prisma`, `src/lib/db.ts`. 13 | - snake_case table → camelCase fields. 14 | - No raw SQL; run `npx prisma migrate dev`, never use `npx prisma db push`. 15 | 16 | Icons 17 | 18 | - Prefer `lucide-react`; name icons in PascalCase. 19 | - Custom icons in `src/components/icons`. 20 | 21 | Toast Notifications 22 | 23 | - Use `react-toastify` in client components. 24 | - `toast.success()`, `toast.error()`, etc. 25 | 26 | Next.js Structure 27 | 28 | - Use App Router in `app/`. Server components by default, `"use client"` for client logic. 29 | - NextAuth + Prisma for auth. `.env` for secrets. 30 | 31 | tRPC Routers 32 | 33 | - Routers in `src/lib/api/routers`, compose in `src/lib/api/root.ts`. 34 | - `publicProcedure` or `protectedProcedure` with Zod. 35 | - Access from React via `@/lib/trpc/react`. 36 | 37 | TypeScript & Syntax 38 | 39 | - Strict mode. Avoid `any`. 40 | - Use optional chaining, union types (no enums). 41 | 42 | File & Folder Names 43 | 44 | - Next.js routes in kebab-case (e.g. `app/dashboard/page.tsx`). 45 | - Shared types in `src/lib/types.ts`. 46 | - Sort imports (external → internal → sibling → styles). 47 | 48 | Tailwind Usage 49 | 50 | - Use Tailwind (mobile-first, dark mode with dark:(class)). Extend brand tokens in `tailwind.config.ts`. 51 | - For animations, prefer Framer Motion. 52 | 53 | Inngest / Background Jobs 54 | 55 | - Use `inngest.config.ts` for Inngest configuration. 56 | - Use `src/app/api/inngest/route.ts` for Inngest API route. 57 | - Use polling to update the UI when Inngest events are received, not trpc success response. 58 | 59 | AI 60 | 61 | - Use `generateChatCompletion` in `src/lib/aiClient.ts` for all AI calls. 62 | - Prefer `gpt-5` model with high reasoning effort for all AI calls. 63 | 64 | Storybook 65 | 66 | - Place stories in `src/stories` with `.stories.tsx` extension. 67 | - One story file per component, matching component name. 68 | - Use autodocs for automatic documentation. 69 | - Include multiple variants and sizes in stories. 70 | - Test interactive features with actions. 71 | - Use relative imports from component directory. 72 | 73 | Additional 74 | 75 | - Keep code short; commits semantic. 76 | - Reusable logic in `src/lib/utils/shared.ts` or `src/lib/utils/server.ts`. 77 | - Use `tsx` scripts for migrations. 78 | 79 | IMPORTANT: 80 | 81 | - After all changes are made, ALWAYS build the project with `npm run build`. Ignore warnings, fix errors. 82 | - Always add a one-sentence summary of changes to `.cursor-updates` file in markdown format at the end of every agent interaction. 83 | - If you forget, the user can type the command "finish" and you will run the build and update `.cursor-updates`. 84 | - Finally, update git with `git add . && git commit -m "..."`. Don't push. 85 | -------------------------------------------------------------------------------- /src/lib/auth/index.ts: -------------------------------------------------------------------------------- 1 | import { PrismaAdapter } from "@next-auth/prisma-adapter"; 2 | import { prisma } from "@/lib/db"; 3 | import { 4 | getServerSession, 5 | type NextAuthOptions, 6 | type DefaultSession, 7 | } from "next-auth"; 8 | import EmailProvider from "next-auth/providers/email"; 9 | 10 | export enum UserRole { 11 | user = "user", 12 | admin = "admin", 13 | } 14 | 15 | /** 16 | * Module augmentation for `next-auth` types. Allows us to add custom properties to the `session` 17 | * object and keep type safety. 18 | * 19 | * @see https://next-auth.js.org/getting-started/typescript#module-augmentation 20 | */ 21 | declare module "next-auth/adapters" { 22 | interface AdapterUser { 23 | login?: string; 24 | role?: UserRole; 25 | dashboardEnabled?: boolean; 26 | isTeamAdmin?: boolean; 27 | } 28 | } 29 | 30 | declare module "next-auth" { 31 | interface Session extends DefaultSession { 32 | user: { 33 | id: string; 34 | name?: string | null; 35 | email?: string | null; 36 | image?: string | null; 37 | login?: string; 38 | role?: UserRole; 39 | dashboardEnabled?: boolean; 40 | isAdmin?: boolean; 41 | expires?: string; 42 | isTeamAdmin?: boolean; 43 | }; 44 | accessToken?: string; 45 | } 46 | 47 | export interface Profile { 48 | login: string; 49 | } 50 | 51 | interface User { 52 | role?: UserRole; 53 | login?: string; 54 | expires?: string; 55 | isTeamAdmin?: boolean; 56 | isAdmin?: boolean; 57 | } 58 | } 59 | 60 | export const authOptions: NextAuthOptions = { 61 | adapter: PrismaAdapter(prisma), 62 | providers: [ 63 | EmailProvider({ 64 | server: { 65 | host: "smtp.resend.com", 66 | port: 465, 67 | auth: { 68 | user: "resend", 69 | pass: process.env.EMAIL_SERVER_PASSWORD, 70 | }, 71 | }, 72 | from: process.env.EMAIL_FROM || "onboarding@resend.dev", 73 | }), 74 | ], 75 | session: { 76 | strategy: "database", 77 | }, 78 | secret: process.env.NEXTAUTH_SECRET, 79 | callbacks: { 80 | async signIn({ user }) { 81 | try { 82 | const email = user?.email; 83 | if (!email) return false; 84 | 85 | /* 86 | // Enable this to restrict sign-ins to certain domains or allowlist 87 | const domainCheck = ALLOWED_DOMAINS.some((d) => email.endsWith(d)); 88 | if (!domainCheck) { 89 | const inAllowlist = await prisma.allowlist.findUnique({ 90 | where: { email }, 91 | }); 92 | 93 | if (!inAllowlist) { 94 | return false; 95 | } 96 | } 97 | */ 98 | 99 | return true; 100 | } catch (error) { 101 | console.error("SignIn callback error:", error); 102 | return false; 103 | } 104 | }, 105 | async session({ session, user }) { 106 | try { 107 | return { 108 | ...session, 109 | user: { 110 | ...session.user, 111 | id: user.id, 112 | role: user.role, 113 | login: user.login, 114 | isAdmin: user.isAdmin, 115 | }, 116 | }; 117 | } catch (error) { 118 | console.error("Session callback error:", error); 119 | return session; 120 | } 121 | }, 122 | }, 123 | pages: { 124 | signIn: "/auth/signin", 125 | signOut: "/auth/signout", 126 | error: "/auth/error", 127 | verifyRequest: "/auth/verify", 128 | }, 129 | }; 130 | 131 | export const getServerAuthSession = () => getServerSession(authOptions); 132 | -------------------------------------------------------------------------------- /src/lib/cache.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { readFile, writeFile } from "fs/promises"; 3 | 4 | import { buildPrompt, type BuildPromptInput } from "./ai/prompt"; 5 | import { getOpenAI } from "./ai/openai"; 6 | 7 | const CACHE_FILE = path.join(process.cwd(), "src", "data", "cache.json"); 8 | 9 | // Simple in-process mutex to serialize cache writes 10 | let cacheLock: Promise = Promise.resolve(); 11 | async function withCacheLock(fn: () => Promise): Promise { 12 | const previous = cacheLock; 13 | let release: () => void; 14 | cacheLock = new Promise((res) => { 15 | release = res; 16 | }); 17 | await previous; 18 | try { 19 | return await fn(); 20 | } finally { 21 | // @ts-expect-error release is assigned above 22 | release(); 23 | } 24 | } 25 | 26 | export async function readCache(): Promise> { 27 | try { 28 | const raw = await readFile(CACHE_FILE, "utf8"); 29 | const data = JSON.parse(raw); 30 | return data && typeof data === "object" 31 | ? (data as Record) 32 | : {}; 33 | } catch { 34 | return {}; 35 | } 36 | } 37 | 38 | export async function writeCache(cache: Record): Promise { 39 | const serialized = JSON.stringify(cache, null, 2) + "\n"; 40 | await writeFile(CACHE_FILE, serialized, "utf8"); 41 | } 42 | 43 | export function cacheKeyFromInput( 44 | input: Pick, 45 | ): string { 46 | return input.title.trim().toLowerCase(); 47 | } 48 | 49 | export async function ensureTasklistCached( 50 | input: BuildPromptInput, 51 | ): Promise<{ cached: boolean }> { 52 | const key = cacheKeyFromInput(input); 53 | const cache = await readCache(); 54 | if (cache[key] && typeof cache[key] === "string" && cache[key].trim()) { 55 | return { cached: true } as const; 56 | } 57 | 58 | const openai = getOpenAI(); 59 | const prompt = buildPrompt(input); 60 | 61 | const completion = await openai.chat.completions.create({ 62 | model: process.env.OPENAI_MODEL ?? "gpt-5", 63 | messages: [ 64 | { role: "system", content: prompt.system }, 65 | { role: "user", content: prompt.user }, 66 | ], 67 | }); 68 | 69 | const markdown = completion.choices?.[0]?.message?.content ?? ""; 70 | 71 | if (!markdown.trim()) { 72 | throw new Error("OpenAI returned an empty response"); 73 | } 74 | 75 | // Serialize write and re-check to avoid lost updates under concurrency 76 | await withCacheLock(async () => { 77 | const latest = await readCache(); 78 | if (!latest[key] || !latest[key].trim()) { 79 | latest[key] = markdown; 80 | await writeCache(latest); 81 | } 82 | }); 83 | 84 | return { cached: false } as const; 85 | } 86 | 87 | export async function getCachedTasklist( 88 | input: BuildPromptInput, 89 | ): Promise { 90 | const key = cacheKeyFromInput(input); 91 | const cache = await readCache(); 92 | const value = cache[key]; 93 | return value && typeof value === "string" && value.trim() ? value : null; 94 | } 95 | 96 | export async function generateTasklistWithCache( 97 | input: BuildPromptInput, 98 | ): Promise { 99 | const key = cacheKeyFromInput(input); 100 | const cache = await readCache(); 101 | const cachedMarkdown = cache[key]; 102 | if ( 103 | cachedMarkdown && 104 | typeof cachedMarkdown === "string" && 105 | cachedMarkdown.trim() 106 | ) { 107 | return cachedMarkdown; 108 | } 109 | 110 | const openai = getOpenAI(); 111 | const prompt = buildPrompt(input); 112 | 113 | const completion = await openai.chat.completions.create({ 114 | model: process.env.OPENAI_MODEL ?? "gpt-5", 115 | messages: [ 116 | { role: "system", content: prompt.system }, 117 | { role: "user", content: prompt.user }, 118 | ], 119 | }); 120 | 121 | const markdown = completion.choices?.[0]?.message?.content ?? ""; 122 | 123 | if (!markdown.trim()) { 124 | throw new Error("OpenAI returned an empty response"); 125 | } 126 | 127 | await withCacheLock(async () => { 128 | const latest = await readCache(); 129 | if (!latest[key] || !latest[key].trim()) { 130 | latest[key] = markdown; 131 | await writeCache(latest); 132 | } 133 | }); 134 | 135 | return markdown; 136 | } 137 | -------------------------------------------------------------------------------- /src/lib/api/trpc.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS: 3 | * 1. You want to modify request context (see Part 1). 4 | * 2. You want to create a new middleware or type of procedure (see Part 3). 5 | * 6 | * TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will 7 | * need to use are documented accordingly near the end. 8 | */ 9 | import { initTRPC, TRPCError } from "@trpc/server"; 10 | import superjson from "superjson"; 11 | import { ZodError } from "zod"; 12 | 13 | import { getServerAuthSession, UserRole } from "../auth"; 14 | 15 | /** 16 | * 1. CONTEXT 17 | * 18 | * This section defines the "contexts" that are available in the backend API. 19 | * 20 | * These allow you to access things when processing a request, like the database, the session, etc. 21 | * 22 | * This helper generates the "internals" for a tRPC context. The API handler and RSC clients each 23 | * wrap this and provides the required context. 24 | * 25 | * @see https://trpc.io/docs/server/context 26 | */ 27 | export const createTRPCContext = async (opts: { headers: Headers }) => { 28 | const session = await getServerAuthSession(); 29 | 30 | return { 31 | session, 32 | ...opts, 33 | }; 34 | }; 35 | 36 | /** 37 | * 2. INITIALIZATION 38 | * 39 | * This is where the tRPC API is initialized, connecting the context and transformer. We also parse 40 | * ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation 41 | * errors on the backend. 42 | */ 43 | const t = initTRPC.context().create({ 44 | transformer: superjson, 45 | errorFormatter({ shape, error }) { 46 | return { 47 | ...shape, 48 | data: { 49 | ...shape.data, 50 | zodError: 51 | error.cause instanceof ZodError ? error.cause.flatten() : null, 52 | }, 53 | }; 54 | }, 55 | }); 56 | 57 | /** 58 | * Create a server-side caller. 59 | * 60 | * @see https://trpc.io/docs/server/server-side-calls 61 | */ 62 | export const createCallerFactory = t.createCallerFactory; 63 | 64 | /** 65 | * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) 66 | * 67 | * These are the pieces you use to build your tRPC API. You should import these a lot in the 68 | * "/src/server/api/routers" directory. 69 | */ 70 | 71 | /** 72 | * This is how you create new routers and sub-routers in your tRPC API. 73 | * 74 | * @see https://trpc.io/docs/router 75 | */ 76 | export const createTRPCRouter = t.router; 77 | 78 | /** 79 | * Public (unauthenticated) procedure 80 | * 81 | * This is the base piece you use to build new queries and mutations on your tRPC API. It does not 82 | * guarantee that a user querying is authorized, but you can still access user session data if they 83 | * are logged in. 84 | */ 85 | export const publicProcedure = t.procedure; 86 | 87 | /** 88 | * Protected (authenticated) procedure 89 | * 90 | * If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies 91 | * the session is valid and guarantees `ctx.session.user` is not null. 92 | * 93 | * @see https://trpc.io/docs/procedures 94 | */ 95 | export const protectedProcedure = t.procedure.use(({ ctx, next }) => { 96 | if (!ctx.session || !ctx.session.user) { 97 | throw new TRPCError({ 98 | code: "UNAUTHORIZED", 99 | message: "Session or user information is missing", 100 | }); 101 | } 102 | 103 | return next({ 104 | ctx: { 105 | session: { ...ctx.session, user: ctx.session.user }, 106 | }, 107 | }); 108 | }); 109 | 110 | // /** 111 | // * Protected (authenticated) procedure 112 | // * 113 | // * If you want a query or mutation to ONLY be accessible to logged in admins, use this. It verifies 114 | // * the session is valid and guarantees `ctx.session.user` has admin privileges. 115 | // * 116 | // * @see https://trpc.io/docs/procedures 117 | // */ 118 | // export const adminProcedure = t.procedure.use(({ ctx, next }) => { 119 | // if (!ctx.session?.user?.isAdmin) { 120 | // throw new TRPCError({ 121 | // code: "UNAUTHORIZED", 122 | // message: "Admin privileges required", 123 | // }); 124 | // } 125 | 126 | // return next({ 127 | // ctx: { 128 | // session: { ...ctx.session, user: ctx.session.user }, 129 | // }, 130 | // }); 131 | // }); 132 | -------------------------------------------------------------------------------- /src/app/auth/signin/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { Suspense } from "react"; 4 | import { signIn } from "next-auth/react"; 5 | import { useSearchParams } from "next/navigation"; 6 | import { ArrowLeft, Mail } from "lucide-react"; 7 | import Link from "next/link"; 8 | 9 | function SignInContent() { 10 | const [email, setEmail] = React.useState(""); 11 | const [isLoading, setIsLoading] = React.useState(false); 12 | const searchParams = useSearchParams(); 13 | const callbackUrl = searchParams.get("callbackUrl") || "/"; 14 | 15 | const handleSubmit = async (e: React.FormEvent) => { 16 | e.preventDefault(); 17 | setIsLoading(true); 18 | await signIn("email", { email, callbackUrl }); 19 | }; 20 | 21 | return ( 22 |
23 | 27 | 28 | Back 29 | 30 | 31 |
32 |
33 |

34 | 35 | Welcome 36 | 37 |

38 |

39 | Enter your email to sign in or create an account 40 |

41 |
42 | 43 |
44 |
45 |
46 | 52 |
53 | setEmail(e.target.value)} 61 | className="block w-full rounded-lg border border-neutral-300 dark:border-neutral-600 px-4 py-3 text-neutral-900 dark:text-white placeholder-neutral-500 dark:placeholder-neutral-400 shadow-sm dark:bg-neutral-800 focus:border-brandBlue-500 dark:focus:border-brandBlue-400 focus:ring-brandBlue-500 dark:focus:ring-brandBlue-400" 62 | placeholder="you@example.com" 63 | /> 64 |
65 |
66 | 67 | 75 |
76 | 77 |
78 |

79 | By signing in, you agree to our{" "} 80 | 84 | Privacy Policy 85 | 86 |

87 |
88 |
89 |
90 |
91 | ); 92 | } 93 | 94 | export default function SignIn() { 95 | return ( 96 | Loading...
}> 97 | 98 | 99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /.cursor-tasks.md: -------------------------------------------------------------------------------- 1 | # Example Tasks for a "Hello, World!" Project 2 | 3 | This file outlines a set of tasks for building a simple Next.js project. In this project, the user enters their name in a text box on the Home Page and is then greeted with "Hello, {name}" on a separate Greeting Page. 4 | 5 | Here's an example prompt to use to generate this. Note that you'll first want to either provide a detailed set of notes / prd of exactly what to build, or have a two-step process where you have the AI create the spec, then proceed with this step: 6 | Be sure to use an advanced thinking model with this, ideally "Deep Research" from OpenAI but o1-pro, o3-mini, flash-2-thinking, or (maybe?) DeepSeek R1 could work as well. 7 | 8 | ``` txt 9 | Create a very very very detailed markdown checklist of all of the stories for this project plan, with one-story-point tasks (with unchecked checkboxes) that break down each story. It is critically important that all of the details to implement this are in this list. Note that a very competent AI Coding Agent will be using this list to autonomously create this application, so be sure not to miss any details whatsoever, no matter how much time and thinking you must do to complete this very challenging but critically important task. 10 | ``` 11 | 12 | After you generate this task list, here is a prompt to use in cursor agent to kick this off (might be useful to put at the end of your cursorrules file as well?) 13 | Probably helpful to just @include the cursor-tasks.md file as well. 14 | ``` txt 15 | Go through each story and task in the .cursor-tasks.md file. Find the next story to work on. Review each unfinished task, correct any issues or ask for clarifications (only if absolutely needed!). Then proceed to create or edit files to complete each task. After you complete all the tasks in the story, update the file to check off any completed tasks. Run builds and commits after each story. Run all safe commands without asking for approval. Continue with each task until you have finished the story, then stop and wait for me to review. 16 | ``` 17 | 18 | --- 19 | 20 | ## 1. **Project Setup** 21 | 22 | 1. [ ] **Initialize the Next.js Project** 23 | - Use Create Next App to bootstrap the project. 24 | - Enable the App Router. 25 | - Configure Tailwind CSS for styling. 26 | - Set up TypeScript with strict mode. 27 | 28 | 2. [ ] **Configure Basic Routing** 29 | - Ensure the project has two main pages: 30 | - **Home Page** (`pages/index.tsx`) for user input. 31 | - **Greeting Page** (`pages/greeting.tsx`) to display the greeting. 32 | 33 | --- 34 | 35 | ## 2. **Home Page – Name Input** 36 | 37 | 1. [ ] **Create the Home Page (`pages/index.tsx`)** 38 | - Render a form containing: 39 | - A text input where the user enters their name. 40 | - A submit button labeled "Submit". 41 | - Use Tailwind CSS classes for styling (e.g., input borders, padding, and button colors). 42 | 43 | 2. [ ] **Implement Form Handling** 44 | - Use React’s `useState` hook to manage the input value. 45 | - Validate that the input is not empty before submission. 46 | - On form submission, navigate to the Greeting Page while passing the entered name (using query parameters or a simple state management solution). 47 | 48 | --- 49 | 50 | ## 3. **Greeting Page – Display the Message** 51 | 52 | 1. [ ] **Create the Greeting Page (`pages/greeting.tsx`)** 53 | - Retrieve the user's name from the query parameters or via a shared state. 54 | - Display a greeting message in the format: **"Hello, {name}"**. 55 | - Style the greeting message using Tailwind CSS (e.g., text size, color, and margin). 56 | 57 | 2. [ ] **Implement Navigation from Home Page** 58 | - Ensure that the Home Page form submission correctly routes to the Greeting Page with the user’s name attached. 59 | 60 | --- 61 | 62 | ## 4. **Basic Interactivity and Validation** 63 | 64 | 1. [ ] **Form Validation** 65 | - Prevent submission if the text input is empty. 66 | - Display a simple error message below the input (e.g., "Please enter your name.") when validation fails. 67 | 68 | 2. [ ] **Test the User Flow** 69 | - Manually test by entering a name and verifying that the Greeting Page shows the correct message. 70 | - Optionally, write unit tests for the form logic to ensure reliability. 71 | 72 | --- 73 | 74 | ## 5. **Documentation and Final Steps** 75 | 76 | 1. [ ] **Update the Project README** 77 | - Include instructions on how to install dependencies, run the development server, and build the project. 78 | - Provide a brief overview of the project’s purpose and structure. 79 | 80 | 2. [ ] **Final Review and Testing** 81 | - Ensure that all components render correctly and the navigation works as expected. 82 | - Test the app in both development and production modes to confirm proper behavior. 83 | -------------------------------------------------------------------------------- /agent-helpers/uat.md: -------------------------------------------------------------------------------- 1 | ```uat.md 2 | # UAT Script — Task‑List‑Creation Demo App 3 | 4 | **Test target:** One‑page Next.js app (Next AI Starter base) that selects a random idea, animates selection, renders idea markdown, generates an AI task list (markdown), and supports “Copy”. 5 | 6 | **Environment:** 7 | - Desktop Chrome (latest), Safari, Firefox. 8 | - Optional mobile Safari/Chrome. 9 | - Vercel preview or local `http://localhost:3000`. 10 | - `.env` has valid `OPENAI_API_KEY` and `OPENAI_MODEL=gpt-5`. 11 | 12 | --- 13 | 14 | ## 1) Smoke & Layout 15 | 16 | 1. **Load** the root page `/`. 17 | - **Expect:** Page title “AI Task‑List Creator”, sub‑caption about single‑page prototype. 18 | - **Expect:** Prominent button **“Pick a Random Startup Idea”**. 19 | 20 | 2. **No console errors** in DevTools. 21 | - **Pass if:** 0 red errors; only innocuous warnings allowed. 22 | 23 | --- 24 | 25 | ## 2) Random Idea & Animation 26 | 27 | 3. **Click** “Pick a Random Startup Idea”. 28 | - **Expect:** Slot‑style list of idea titles appears and begins cycling. 29 | 30 | 4. **Timing check:** 31 | - **Expect:** Animation lasts between **5 and 10 seconds** (default configured ≈6.5s). 32 | - **Accessibility:** With `prefers-reduced-motion` enabled (OS setting), re‑run: the animation should be near instant (≤ 800ms) without continuous motion. 33 | 34 | 5. **Finalization:** 35 | - **Expect:** Animation stops; the randomizer UI disappears; the chosen idea view is shown. 36 | 37 | --- 38 | 39 | ## 3) Idea Rendering 40 | 41 | 6. **Chosen idea view** shows: 42 | - **Title** as H1. 43 | - **Summary** rendered via markdown (headings/links styled). 44 | - **One‑pager** markdown is visible or behind an **Expand/Collapse** toggle (either default is fine if documented). 45 | - **CTA:** Big **“Create AI Agent Tasklist”** button. 46 | 47 | 7. **Formatting audit:** 48 | - Check that long paragraphs wrap; lists render properly; code spans use monospace. 49 | 50 | --- 51 | 52 | ## 4) Generate AI Task List 53 | 54 | 8. **Click** “Create AI Agent Tasklist”. 55 | - **Expect:** Button enters **loading/disabled** state and a spinner or “Generating…” indicator appears. 56 | 57 | 9. **Response handling:** 58 | - **Success path:** A markdown block appears as read‑only content. 59 | - **Error path (forced by temporarily breaking API key):** Inline error message appears; retry button or normal CTA becomes enabled again. 60 | 61 | 10. **Content validation (CRITICAL):** 62 | - The returned markdown contains **one detailed paragraph** describing the prototype **followed by** a **checklist of one‑story‑point items** grouped into these **exact** top‑level sections: 63 | - **Atoms** 64 | - **Organisms** 65 | - **Page** 66 | - **Storybook** 67 | - **Expect:** Each checklist item uses `- [ ]` and mentions what/where (file path or component) and minimal acceptance criteria. 68 | - **Expect:** The **Storybook** section includes stories for **every component and the page** (CSF3), including an **a11y** entry. 69 | 70 | --- 71 | 72 | ## 5) Copy Interaction 73 | 74 | 11. **Click** **Copy**. 75 | - **Expect:** The entire markdown task list is copied to clipboard. 76 | - **Expect:** A toast appears: **“Task list copied”** (disappears automatically). 77 | - **Manual check:** Paste into a text editor to confirm content matches what’s rendered. 78 | 79 | 12. **Clipboard fallback:** (If possible) temporarily block clipboard permissions and click **Copy** again. 80 | - **Expect:** Fallback strategy still copies (or shows a clear failure message if the browser forbids it). 81 | 82 | --- 83 | 84 | ## 6) Page State Management 85 | 86 | 13. **State transitions:** 87 | - **idle → spinning → chosen → generating → ready** is observed on the happy path. 88 | - Trigger a failure (bad API key) to observe **generating → error** and that the app recovers after re‑enabling a good key. 89 | 90 | 14. **Reset flow:** 91 | - If a **“Pick another idea”** link/button exists, click it. 92 | - **Expect:** App returns to **idle** with no stale state. 93 | 94 | --- 95 | 96 | ## 7) Storybook Verification 97 | 98 | 15. **Run Storybook** (`npm run storybook`) and open in the browser. 99 | - **Expect:** A sidebar section for **Atoms**, **Organisms**, and **Page**. 100 | 101 | 16. **Atoms** 102 | - `Button`: View Default/Loading/Disabled/FullWidth; interact with Controls to change `size`/`loading`. 103 | - `Spinner`: Renders visibly; a11y panel shows no violations. 104 | - `CopyButton`: Clicking triggers an action/console (mocked clipboard). 105 | - `MarkdownRenderer`: Displays headings/links/code; long text story works. 106 | 107 | 17. **Organisms** 108 | - `IdeaSlotMachine`: Short duration story (≈1.2s) animates and stops; “Reduced‑motion” story shows minimal motion. 109 | - `IdeaDetails`: Expanded/Collapsed variants render. 110 | - `TasklistPanel`: Long content scrolls; sticky header with **Copy** present. 111 | 112 | 18. **Page** 113 | - Story with **controls** to toggle states (`idle`, `spinning`, etc.) works; no console errors. 114 | 115 | --- 116 | 117 | ## 8) Cross‑Browser & Mobile 118 | 119 | 19. **Chrome, Safari, Firefox**: Happy path works identically. 120 | 20. **Mobile** (optional): Check layout does not overflow horizontally; buttons are large enough to tap. 121 | 122 | --- 123 | 124 | ## 9) Performance & A11y 125 | 126 | 21. **Perf quick check:** Page loads without significant layout shift; animation is smooth. 127 | 22. **A11y:** Keyboard tab order sane; focus ring visible; `aria-live="polite"` announces the generated result. 128 | 129 | --- 130 | 131 | ## 10) Deploy Smoke 132 | 133 | 23. **Vercel preview** with env vars set: Happy path (Pick → Generate → Copy) works. 134 | 24. **Logs:** No 500 errors for `POST /api/tasklist` during demo run. 135 | 136 | **Pass Criteria:** All expectations above are met, and the generated checklist clearly includes **Storybook work** and **Atomic (Atoms/Organisms/Page)** sections, reinforcing the demo’s teaching goals. 137 | ``` 138 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useMemo, useState } from "react"; 4 | import { toast } from "react-toastify"; 5 | 6 | import { Button } from "@/components/atoms/Button"; 7 | import { Card } from "@/components/atoms/Card"; 8 | import { Spinner } from "@/components/atoms/Spinner"; 9 | import { IdeaDetails } from "@/components/organisms/IdeaDetails"; 10 | import { IdeaSlotMachine } from "@/components/organisms/IdeaSlotMachine"; 11 | import { TasklistPanel } from "@/components/organisms/TasklistPanel"; 12 | import { ideas } from "@/data/ideas"; 13 | import type { Idea } from "@/types/idea"; 14 | import { api } from "@/lib/trpc/react"; 15 | 16 | type AppState = 17 | | "idle" 18 | | "spinning" 19 | | "chosen" 20 | | "generating" 21 | | "ready" 22 | | "error"; 23 | 24 | const selectIdea = () => { 25 | const index = Math.floor(Math.random() * ideas.length); 26 | return ideas[index]; 27 | }; 28 | 29 | export default function Page() { 30 | const [state, setState] = useState("idle"); 31 | const [chosenIdea, setChosenIdea] = useState(null); 32 | const [tasklist, setTasklist] = useState(""); 33 | 34 | const utils = api.useUtils(); 35 | const ensureCache = api.tasklist.ensure.useMutation({ 36 | onError: (error) => { 37 | console.error("Tasklist prefetch failed", error); 38 | }, 39 | }); 40 | const generateTasklist = api.tasklist.generate.useMutation({ 41 | onMutate: () => { 42 | setState("generating"); 43 | toast.info("Generating task list..."); 44 | }, 45 | onSuccess: (data) => { 46 | setTasklist(data.markdown); 47 | setState("ready"); 48 | toast.dismiss(); 49 | toast.success("Task list ready"); 50 | }, 51 | onError: (error) => { 52 | console.error("Tasklist generation failed", error); 53 | toast.dismiss(); 54 | toast.error("Failed to generate task list"); 55 | setState("error"); 56 | }, 57 | onSettled: () => { 58 | utils.tasklist.invalidate(); 59 | }, 60 | }); 61 | 62 | const reset = () => { 63 | setState("idle"); 64 | setChosenIdea(null); 65 | setTasklist(""); 66 | }; 67 | 68 | const handleSpin = () => { 69 | setState("spinning"); 70 | }; 71 | 72 | const handleDone = (idea: Idea) => { 73 | setChosenIdea(idea); 74 | setState("chosen"); 75 | // Prime cache in the background so Generate is instant if possible 76 | ensureCache.mutate(idea); 77 | }; 78 | 79 | const handleGenerate = () => { 80 | if (!chosenIdea) return; 81 | generateTasklist.mutate(chosenIdea); 82 | }; 83 | 84 | const fastMode = 85 | typeof window !== "undefined" && window.location.search.includes("fast=1"); 86 | const slotDuration = useMemo(() => (fastMode ? 1500 : 3000), [fastMode]); 87 | 88 | const primaryButtonLabel = (() => { 89 | if (state === "spinning") return "Spinning..."; 90 | if (state === "generating") return "Generating..."; 91 | if (state === "ready") return "Generate again"; 92 | if (state === "chosen") return "Create AI Agent Tasklist"; 93 | return "Pick a Random Startup Idea"; 94 | })(); 95 | 96 | return ( 97 |
98 |
99 |

100 | AI Task-List Creator 101 |

102 |

103 | Spin for a random startup idea, generate an atomic design task list, 104 | and copy it for your next build session. 105 |

106 |
107 | 108 |
109 | 118 | 119 | {state !== "idle" && ( 120 | 127 | )} 128 |
129 | 130 | {state === "spinning" && ( 131 | 136 | )} 137 | 138 | {(state === "chosen" || state === "generating" || state === "ready") && 139 | chosenIdea && ( 140 |
141 | 142 | 143 | {state === "generating" && ( 144 |
145 | Generating task list with GPT-5... 146 |
147 | )} 148 | 149 | {state === "ready" && ( 150 | toast.success("Task list copied")} 153 | onRegenerate={handleGenerate} 154 | /> 155 | )} 156 |
157 | )} 158 | 159 | {state === "error" && ( 160 | 161 |

Something went wrong

162 |

163 | We couldn't generate the task list. Please try again or pick 164 | another idea. 165 |

166 |
167 | 170 | 173 |
174 |
175 | )} 176 |
177 | ); 178 | } 179 | -------------------------------------------------------------------------------- /agent-helpers/task-list.md: -------------------------------------------------------------------------------- 1 | ```task-list.md 2 | # AI Agent Implementation Tasks (All 1 SP Each) 3 | 4 | > **Scope:** Single‑page demo on top of Next AI Starter. No auth, no DB, no payments, no routing beyond `/`. Atomic design (Atoms, Organisms, Page). **Storybook for every component.** 5 | > **Paths** assume a fresh clone of `kleneway/next-ai-starter`. :contentReference[oaicite:18]{index=18} 6 | 7 | ## Setup & Data 8 | - [ ] **Create data file** `src/data/ideas.json` with 20 `Idea` objects (`title`, `summary`, `one_pager`). Validate shape and export `Idea` type. 9 | - [ ] **Wire types** `src/types/idea.ts` (`export type Idea = { title: string; summary: string; one_pager: string }`); import where needed. 10 | - [ ] **Install markdown lib** `react-markdown` (+ `remark-gfm` optional). Add to `package.json`. :contentReference[oaicite:19]{index=19} 11 | - [ ] **Ensure Storybook runs** `npm run storybook` and commit the starter’s `.storybook/` config if missing. :contentReference[oaicite:20]{index=20} 12 | 13 | ## Atoms (`src/components/atoms/`) 14 | - [ ] **Button.tsx**: Props `{ size?: 'md'|'lg'; loading?: boolean; fullWidth?: boolean }`. Renders `