From c1254a16b5b9324d4075a4268ad7e57a26415bc0 Mon Sep 17 00:00:00 2001 From: cr0ss Date: Thu, 9 Jul 2026 09:49:49 +0200 Subject: [PATCH 1/4] Replace public RAG chat with private networking-recall system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilds the site's AI feature as a personal "contact memory" (per the Ethically Ambiguous Networking guide), entirely on Vercel/Next.js native primitives β€” no Make/n8n/Zapier. Removed: - Public /chat UI, /api/chat, /api/ai/* reindex routes, RAG retrieval and blog/knowledge indexers, and the chat_embeddings table (dropped by the new setup script). Kept and repurposed lib/ai/embeddings.ts, rate limiting, and the auth/middleware helpers. The knowledgeBase CMS model is left intact (only its RAG indexing is removed). Added: - Contacts store on Neon pgvector: contacts + contact_embeddings tables (scripts/setup-contacts-db.ts), lib/db/contacts.ts, contact Zod schemas. - Portfolio: new portfolioProject Contentful model + seed (lib/contentful/setup-portfolio.ts), fetch layer, /portfolio and /portfolio/[slug] pages; nav repointed; /projects -> 301. - Public capture flow: capture-form + /api/capture (Zod, rate limit, honeypot) returning a pre-filled wa.me link. - Durable enrichment via Vercel Workflow (WDK): Apify start/poll -> embed -> store (lib/workflows/enrich-contact.ts); withWorkflow in next.config; hourly cron fallback. - Owner-only MCP server (/api/mcp/[transport]) exposing search_contacts via withMcpAuth; Eve agent scaffold in eve/ (connection + prompt + setup docs). New optional env: OWNER_WHATSAPP, APIFY_TOKEN, APIFY_ACTOR_ID, CRON_SECRET, MCP_BEARER_TOKEN. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 4 +- app/api/ai/reindex-blog/route.ts | 64 - app/api/ai/reindex-knowledge/route.ts | 64 - app/api/capture/route.ts | 74 + app/api/chat/route.ts | 122 - app/api/cron/enrich/route.ts | 35 + app/api/mcp/[transport]/route.ts | 63 + app/api/revalidate/route.ts | 56 +- app/chat/page.tsx | 21 - app/portfolio/[slug]/page.tsx | 85 + app/portfolio/page.tsx | 46 + app/projects/page.tsx | 113 +- components/capture/capture-form.tsx | 146 + components/chat/chat-interface.tsx | 290 -- components/chat/message.tsx | 63 - components/chat/suggested-questions.tsx | 25 - components/navigation.tsx | 25 +- env.ts | 12 + eslint.config.mjs | 1 + eve/README.md | 63 + eve/agents/recall.md | 18 + eve/connections/contacts.ts | 22 + lib/ai/index-blog-post.ts | 62 - lib/ai/index-knowledge-base.ts | 57 - lib/ai/llm.ts | 132 - lib/ai/retrieval.ts | 95 - lib/contentful/api/portfolio.ts | 69 + lib/contentful/api/props/portfolio.ts | 102 + lib/contentful/setup-portfolio.ts | 215 ++ lib/db/contacts.ts | 198 ++ lib/db/embeddings.ts | 192 -- lib/db/models.ts | 68 +- lib/workflows/enrich-contact.ts | 159 + next.config.mjs | 3 +- package.json | 8 +- pnpm-lock.yaml | 3662 ++++++++++++++++++++++- scripts/index-embeddings.ts | 239 -- scripts/setup-contacts-db.ts | 128 + scripts/setup-pgvector.ts | 182 -- scripts/update-embedding-dimension.ts | 57 - tsconfig.json | 3 +- vercel.json | 8 +- 42 files changed, 5145 insertions(+), 1906 deletions(-) delete mode 100644 app/api/ai/reindex-blog/route.ts delete mode 100644 app/api/ai/reindex-knowledge/route.ts create mode 100644 app/api/capture/route.ts delete mode 100644 app/api/chat/route.ts create mode 100644 app/api/cron/enrich/route.ts create mode 100644 app/api/mcp/[transport]/route.ts delete mode 100644 app/chat/page.tsx create mode 100644 app/portfolio/[slug]/page.tsx create mode 100644 app/portfolio/page.tsx create mode 100644 components/capture/capture-form.tsx delete mode 100644 components/chat/chat-interface.tsx delete mode 100644 components/chat/message.tsx delete mode 100644 components/chat/suggested-questions.tsx create mode 100644 eve/README.md create mode 100644 eve/agents/recall.md create mode 100644 eve/connections/contacts.ts delete mode 100644 lib/ai/index-blog-post.ts delete mode 100644 lib/ai/index-knowledge-base.ts delete mode 100644 lib/ai/llm.ts delete mode 100644 lib/ai/retrieval.ts create mode 100644 lib/contentful/api/portfolio.ts create mode 100644 lib/contentful/api/props/portfolio.ts create mode 100644 lib/contentful/setup-portfolio.ts create mode 100644 lib/db/contacts.ts delete mode 100644 lib/db/embeddings.ts create mode 100644 lib/workflows/enrich-contact.ts delete mode 100644 scripts/index-embeddings.ts create mode 100644 scripts/setup-contacts-db.ts delete mode 100644 scripts/setup-pgvector.ts delete mode 100644 scripts/update-embedding-dimension.ts diff --git a/.gitignore b/.gitignore index f0efb0f..4ade318 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,6 @@ next-env.d.ts .env # transformers.js model cache -.transformers-cache/ \ No newline at end of file +.transformers-cache/ +.env* +/.swc diff --git a/app/api/ai/reindex-blog/route.ts b/app/api/ai/reindex-blog/route.ts deleted file mode 100644 index ad0ea7b..0000000 --- a/app/api/ai/reindex-blog/route.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Node runtime for Transformers.js -export const runtime = "nodejs"; -export const maxDuration = 60; // 60 seconds for embedding generation - -import { hasValidSecret } from "@/lib/auth/secret"; -import { - createErrorResponse, - createSuccessResponse, -} from "@/lib/api/middleware"; -import { reindexBlogPost } from "@/lib/ai/index-blog-post"; -import { z } from "zod"; - -const ZReindexRequest = z.object({ - slug: z.string().min(1), -}); - -/** - * Re-index a single blog post for AI chat - * Called by the revalidate webhook when a blog post changes - * - * POST /api/ai/reindex-blog - * Body: { slug: "blog-post-slug" } - * Headers: x-vercel-revalidation-key: SECRET - */ -export async function POST(request: Request) { - // Check for valid secret - if (!hasValidSecret(request)) { - return createErrorResponse("Unauthorized", 401, undefined, "UNAUTHORIZED"); - } - - try { - const body = await request.json(); - const validation = ZReindexRequest.safeParse(body); - - if (!validation.success) { - return createErrorResponse( - "Invalid request", - 400, - validation.error.issues, - "VALIDATION_ERROR" - ); - } - - const { slug } = validation.data; - - // Re-index the blog post - const chunksIndexed = await reindexBlogPost(slug); - - return createSuccessResponse({ - success: true, - slug, - chunksIndexed, - timestamp: Date.now(), - }); - } catch (error) { - console.error("Blog post re-indexing error:", error); - return createErrorResponse( - "Failed to re-index blog post", - 500, - error instanceof Error ? error.message : undefined, - "REINDEX_ERROR" - ); - } -} diff --git a/app/api/ai/reindex-knowledge/route.ts b/app/api/ai/reindex-knowledge/route.ts deleted file mode 100644 index bb4e9ea..0000000 --- a/app/api/ai/reindex-knowledge/route.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Node runtime for Transformers.js -export const runtime = "nodejs"; -export const maxDuration = 60; // 60 seconds for embedding generation - -import { hasValidSecret } from "@/lib/auth/secret"; -import { - createErrorResponse, - createSuccessResponse, -} from "@/lib/api/middleware"; -import { reindexKnowledgeBase } from "@/lib/ai/index-knowledge-base"; -import { z } from "zod"; - -const ZReindexRequest = z.object({ - slug: z.string().min(1), -}); - -/** - * Re-index a single knowledge base entry for AI chat - * Called by the revalidate webhook when a knowledge base entry changes - * - * POST /api/ai/reindex-knowledge - * Body: { slug: "about-me" } - * Headers: x-vercel-revalidation-key: SECRET - */ -export async function POST(request: Request) { - // Check for valid secret - if (!hasValidSecret(request)) { - return createErrorResponse("Unauthorized", 401, undefined, "UNAUTHORIZED"); - } - - try { - const body = await request.json(); - const validation = ZReindexRequest.safeParse(body); - - if (!validation.success) { - return createErrorResponse( - "Invalid request", - 400, - validation.error.issues, - "VALIDATION_ERROR" - ); - } - - const { slug } = validation.data; - - // Re-index the knowledge base entry - const chunksIndexed = await reindexKnowledgeBase(slug); - - return createSuccessResponse({ - success: true, - slug, - chunksIndexed, - timestamp: Date.now(), - }); - } catch (error) { - console.error("Knowledge base re-indexing error:", error); - return createErrorResponse( - "Failed to re-index knowledge base", - 500, - error instanceof Error ? error.message : undefined, - "REINDEX_ERROR" - ); - } -} diff --git a/app/api/capture/route.ts b/app/api/capture/route.ts new file mode 100644 index 0000000..534034a --- /dev/null +++ b/app/api/capture/route.ts @@ -0,0 +1,74 @@ +export const runtime = "nodejs"; + +import { z } from "zod"; +import { start } from "workflow/api"; +import { rateLimit } from "@/lib/rate/limit"; +import { + createErrorResponse, + createSuccessResponse, + validateRequestBody, +} from "@/lib/api/middleware"; +import { ZContactSeed } from "@/lib/db/models"; +import { insertContactSeed } from "@/lib/db/contacts"; +import { enrichContactWorkflow } from "@/lib/workflows/enrich-contact"; + +// The public form also submits a honeypot field which must stay empty. +const ZCaptureRequest = ZContactSeed.extend({ + website: z.string().optional(), +}); + +/** + * Public capture endpoint for the portfolio NFC landing page. + * POST /api/capture + * + * Stores a contact seed, kicks off the durable enrichment workflow, and returns + * a pre-filled wa.me link so the visitor lands in WhatsApp. + */ +export async function POST(request: Request) { + // Rate limit: 5 submissions per hour per client (spam control). + const rl = await rateLimit(request, "capture", { windowSec: 3600, max: 5 }); + if (!rl.ok) { + return createErrorResponse( + "Too many submissions. Please try again later.", + 429, + { retryAfterSec: rl.retryAfterSec }, + "RATE_LIMIT_EXCEEDED" + ); + } + + const parsed = await validateRequestBody(request, ZCaptureRequest); + if (!parsed.success) return parsed.response; + + const { website, ...seed } = parsed.data; + + // Honeypot tripped β€” pretend success without storing anything. + if (website && website.trim().length > 0) { + return createSuccessResponse({ ok: true }); + } + + const ownerNumber = process.env.OWNER_WHATSAPP; + if (!ownerNumber) { + return createErrorResponse( + "Capture is not configured yet.", + 503, + undefined, + "NOT_CONFIGURED" + ); + } + + const id = await insertContactSeed(seed); + + // Fire the durable enrichment pipeline. Don't fail the capture if the + // workflow backend is unavailable β€” the cron fallback will pick it up. + try { + await start(enrichContactWorkflow, [id]); + } catch (error) { + console.error("Failed to start enrichment workflow:", error); + } + + // Build the pre-filled WhatsApp link (open-ended so they can add detail). + const text = `Hi! We just met β€” I'm ${seed.name}.`; + const waUrl = `https://wa.me/${ownerNumber}?text=${encodeURIComponent(text)}`; + + return createSuccessResponse({ id, waUrl }); +} diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts deleted file mode 100644 index b313331..0000000 --- a/app/api/chat/route.ts +++ /dev/null @@ -1,122 +0,0 @@ -// Vercel AI SDK - works on serverless -export const runtime = "nodejs"; -export const maxDuration = 60; - -import { NextResponse } from "next/server"; -import { z } from "zod"; -import { rateLimit } from "@/lib/rate/limit"; -import { createErrorResponse } from "@/lib/api/middleware"; -import { retrieveContext, createSystemPrompt } from "@/lib/ai/retrieval"; -import { generateResponse, getCurrentModel } from "@/lib/ai/llm"; - -const ZChatRequest = z.object({ - message: z.string().min(1).max(500), -}); - -/** - * Chat endpoint for AI assistant - * POST /api/chat - * Body: { message: "user question" } - * - * Uses openai/gpt-4o-mini via Vercel AI Gateway - * Rate limited to 10 requests per 12 hours per user - */ -export async function POST(request: Request) { - try { - // Rate limiting - 10 requests per 12 hours (to control AI costs) - const TWELVE_HOURS_SEC = 12 * 60 * 60; // 43200 seconds - const rl = await rateLimit(request, "ai-chat", { - windowSec: TWELVE_HOURS_SEC, - max: 10, - }); - - if (!rl.ok) { - return createErrorResponse( - "You've reached your chat limit. Please try again later.", - 429, - { retryAfterSec: rl.retryAfterSec, limit: 10, windowHours: 12 }, - "RATE_LIMIT_EXCEEDED" - ); - } - - // Parse and validate request - const body = await request.json(); - const validation = ZChatRequest.safeParse(body); - - if (!validation.success) { - return createErrorResponse( - "Invalid request", - 400, - validation.error.issues, - "VALIDATION_ERROR" - ); - } - - const { message } = validation.data; - - console.log(`\nπŸ€– Chat request: "${message}"`); - - // Retrieve relevant context using RAG - const { context, sources } = await retrieveContext(message, 5); - - // Generate system prompt - const systemPrompt = createSystemPrompt(); - - // Generate response using Vercel AI SDK - const responseText = await generateResponse(systemPrompt, context, message); - - // Get model info - const model = getCurrentModel(); - - // Filter sources to only include blog posts with URLs for the response - const blogSources = sources - .filter((s) => s.type === "blog" && s.url) - .map((s) => ({ title: s.title, url: s.url! })); - - // Return response - return NextResponse.json({ - response: responseText, - model: { - name: model.name, - provider: model.provider, - }, - sources: blogSources, - timestamp: Date.now(), - }); - } catch (error) { - console.error("Chat API error:", error); - - // Check for specific error types - if (error instanceof Error) { - // API key errors - if ( - error.message.includes("API key") || - error.message.includes("authentication") - ) { - return createErrorResponse( - "AI service configuration error. Please try again later.", - 503, - process.env.NODE_ENV === "development" ? error.message : undefined, - "API_CONFIG_ERROR" - ); - } - - // Rate limit from provider - if (error.message.includes("rate limit")) { - return createErrorResponse( - "AI service is busy. Please try again in a moment.", - 503, - process.env.NODE_ENV === "development" ? error.message : undefined, - "PROVIDER_RATE_LIMIT" - ); - } - } - - return createErrorResponse( - "Failed to generate response. Please try again.", - 500, - process.env.NODE_ENV === "development" ? error : undefined, - "GENERATION_ERROR" - ); - } -} diff --git a/app/api/cron/enrich/route.ts b/app/api/cron/enrich/route.ts new file mode 100644 index 0000000..dc22e18 --- /dev/null +++ b/app/api/cron/enrich/route.ts @@ -0,0 +1,35 @@ +export const runtime = "nodejs"; + +import { start } from "workflow/api"; +import { createErrorResponse, createSuccessResponse } from "@/lib/api/middleware"; +import { listPendingContacts } from "@/lib/db/contacts"; +import { enrichContactWorkflow } from "@/lib/workflows/enrich-contact"; + +/** + * Fallback enrichment sweep for any contacts left in `pending` + * (e.g. if a workflow failed to start at capture time). + * + * GET /api/cron/enrich β€” intended to run on a Vercel Cron schedule. + * Authenticated with the Vercel-provided `Authorization: Bearer `. + */ +export async function GET(request: Request) { + const secret = process.env.CRON_SECRET; + const auth = request.headers.get("authorization"); + if (!secret || auth !== `Bearer ${secret}`) { + return createErrorResponse("Unauthorized", 401, undefined, "UNAUTHORIZED"); + } + + const pending = await listPendingContacts(25); + const started: string[] = []; + + for (const contact of pending) { + try { + await start(enrichContactWorkflow, [contact.id]); + started.push(contact.id); + } catch (error) { + console.error(`Failed to start enrichment for ${contact.id}:`, error); + } + } + + return createSuccessResponse({ pending: pending.length, started: started.length }); +} diff --git a/app/api/mcp/[transport]/route.ts b/app/api/mcp/[transport]/route.ts new file mode 100644 index 0000000..789dc7a --- /dev/null +++ b/app/api/mcp/[transport]/route.ts @@ -0,0 +1,63 @@ +export const runtime = "nodejs"; +export const maxDuration = 60; + +import { createMcpHandler, withMcpAuth } from "mcp-handler"; +import { z } from "zod"; +import { generateEmbedding } from "@/lib/ai/embeddings"; +import { searchContacts } from "@/lib/db/contacts"; + +/** + * MCP server exposing the private "contact memory" as a single read-only tool. + * The Vercel Eve agent connects to this endpoint and calls `search_contacts`. + * + * Streamable HTTP endpoint: /api/mcp/mcp + */ +const handler = createMcpHandler( + (server) => { + server.registerTool( + "search_contacts", + { + title: "Search contacts", + description: + "Semantic recall over the people I've met. Ask in plain English, " + + 'e.g. "who was the fintech founder from the Shoreditch event".', + inputSchema: { + query: z + .string() + .describe("Plain-English description of who you are looking for"), + limit: z.number().int().min(1).max(20).optional(), + }, + }, + async ({ query, limit }) => { + const embedding = await generateEmbedding(query); + const results = await searchContacts(embedding, limit ?? 5); + return { + content: [{ type: "text", text: JSON.stringify(results, null, 2) }], + }; + } + ); + }, + {}, + { basePath: "/api/mcp", maxDuration: 60 } +); + +/** + * Owner-only gate: require a bearer token matching MCP_BEARER_TOKEN. + * This is the baseline hard gate; in production Vercel Connect / Sign in with + * Vercel brokers a scoped OIDC identity in front of it (see agent/ + docs). + */ +const verifyToken = async (_req: Request, bearerToken?: string) => { + const expected = process.env.MCP_BEARER_TOKEN; + if (!expected || !bearerToken || bearerToken !== expected) { + return undefined; + } + return { + token: bearerToken, + clientId: "owner", + scopes: ["contacts:read"], + }; +}; + +const authHandler = withMcpAuth(handler, verifyToken, { required: true }); + +export { authHandler as GET, authHandler as POST }; diff --git a/app/api/revalidate/route.ts b/app/api/revalidate/route.ts index 01c0b87..e72abd5 100644 --- a/app/api/revalidate/route.ts +++ b/app/api/revalidate/route.ts @@ -180,6 +180,16 @@ function getRevalidationTags(payload: ContentfulWebhookPayload): string[] { tags.push('knowledgeBase'); break; + case 'portfolioProject': + // Revalidate portfolio collection + tags.push('portfolioProjects'); + + // Revalidate specific project if slug is available + if (slug) { + tags.push(slug); + } + break; + default: console.warn(`Unknown content type: ${contentTypeId}`); } @@ -275,6 +285,16 @@ function getRevalidationPaths(payload: ContentfulWebhookPayload): string[] { paths.push(`/coffee/${slug}`); } break; + + case 'portfolioProject': + // Revalidate portfolio index + paths.push('/portfolio'); + + // Revalidate specific project detail page if slug is available + if (slug) { + paths.push(`/portfolio/${slug}`); + } + break; } return paths; @@ -325,47 +345,11 @@ export async function POST(request: Request) { algoliaUpdated = true; } - // Re-index content for AI chat - // Note: This runs async and doesn't block the response - let aiIndexed = false; - if (contentTypeId === 'blogPost' && slug) { - // Call the AI re-indexing endpoint (runs on Node runtime) - fetch(new URL('/api/ai/reindex-blog', request.url), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-vercel-revalidation-key': request.headers.get('x-vercel-revalidation-key') || '', - }, - body: JSON.stringify({ slug }), - }) - .then(() => console.log(`AI re-indexing triggered for blog: ${slug}`)) - .catch((error) => console.error(`AI re-indexing failed for blog ${slug}:`, error)); - - aiIndexed = true; - } - - // Re-index knowledge base entry for AI chat - if (contentTypeId === 'knowledgeBase' && slug) { - fetch(new URL('/api/ai/reindex-knowledge', request.url), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-vercel-revalidation-key': request.headers.get('x-vercel-revalidation-key') || '', - }, - body: JSON.stringify({ slug }), - }) - .then(() => console.log(`AI re-indexing triggered for knowledge base: ${slug}`)) - .catch((error) => console.error(`AI re-indexing failed for knowledge base ${slug}:`, error)); - - aiIndexed = true; - } - return createSuccessResponse({ revalidated: true, tags: tagsToRevalidate, paths: pathsToRevalidate, algoliaIndexed: algoliaUpdated, - aiIndexQueued: aiIndexed, timestamp: Date.now(), }); } catch (error) { diff --git a/app/chat/page.tsx b/app/chat/page.tsx deleted file mode 100644 index be5685b..0000000 --- a/app/chat/page.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import ChatInterface from '@/components/chat/chat-interface'; -import { createListMetadata } from '@/lib/metadata'; -import type { Metadata } from 'next'; - -export const metadata: Metadata = createListMetadata({ - title: 'Chat with AI | cr0ss.mind', - description: 'Chat with an AI assistant that knows about Simon KrΓΌger\'s professional background, expertise, and blog posts.', - path: '/chat', -}); - -export default function ChatPage() { - return ( -
-
-
- -
-
-
- ); -} diff --git a/app/portfolio/[slug]/page.tsx b/app/portfolio/[slug]/page.tsx new file mode 100644 index 0000000..cf02e34 --- /dev/null +++ b/app/portfolio/[slug]/page.tsx @@ -0,0 +1,85 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { ArrowUpRight } from 'lucide-react'; +import { documentToReactComponents } from '@contentful/rich-text-react-renderer'; +import { getAllProjects, getProject } from '@/lib/contentful/api/portfolio'; +import { + createRichTextOptions, + PAGE_STYLES, +} from '@/lib/contentful/rich-text-renderer'; +import { CaptureForm } from '@/components/capture/capture-form'; + +export const revalidate = 3600; + +export async function generateStaticParams() { + const { items } = await getAllProjects(); + return items.map((project) => ({ slug: project.slug })); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }>; +}): Promise { + const { slug } = await params; + const project = await getProject(slug); + if (!project) return { title: 'Project not found | cr0ss.mind' }; + return { + title: `${project.title} | cr0ss.mind`, + description: project.summary, + }; +} + +export default async function ProjectPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + const project = await getProject(slug); + if (!project) notFound(); + + const isExternal = Boolean(project.external); + + return ( +
+
+ + ← Portfolio + + +

+ {project.title} +

+

{project.summary}

+ + {project.url && ( + + Visit project + + + )} + + {project.description?.json && ( +
+ {documentToReactComponents( + project.description.json, + createRichTextOptions(project.description.links, PAGE_STYLES) + )} +
+ )} + + +
+
+ ); +} diff --git a/app/portfolio/page.tsx b/app/portfolio/page.tsx new file mode 100644 index 0000000..08b00b2 --- /dev/null +++ b/app/portfolio/page.tsx @@ -0,0 +1,46 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { ArrowUpRight } from 'lucide-react'; +import { getAllProjects } from '@/lib/contentful/api/portfolio'; + +export const revalidate = 3600; + +export const metadata: Metadata = { + title: 'Portfolio | cr0ss.mind', + description: 'Things I’ve been building.', +}; + +export default async function PortfolioPage() { + const { items } = await getAllProjects(); + + return ( +
+
+

+ Portfolio +

+

Things I've been building.

+ +
+ {items.map((project) => ( + +
+

{project.title}

+ +
+

{project.summary}

+ + ))} + + {items.length === 0 && ( +

No projects yet β€” check back soon.

+ )} +
+
+
+ ); +} diff --git a/app/projects/page.tsx b/app/projects/page.tsx index 575fdf0..b7f57b1 100644 --- a/app/projects/page.tsx +++ b/app/projects/page.tsx @@ -1,113 +1,6 @@ -import type { Metadata } from "next"; -import Link from "next/link"; -import { ArrowUpRight } from "lucide-react"; - -export const metadata: Metadata = { - title: "Projects | cr0ss.mind", - description: "Projects by cr0ss", -}; - -type Project = { - name: string; - description: string; - url: string; - external?: boolean; -}; - -const projects: Project[] = [ - { - name: "Signal Intelligence", - description: - "Signal Intelligence is a browser-based self-audit that reveals what a device and browser expose before a user logs into any website.", - url: "https://signal.cr0ss.org", - external: true, - }, - { - name: "404 Museum", - description: - "Every page refresh reveals a fake abandoned website from an alternate internet timeline. Each generated website feels like something that genuinely could have existed.", - url: "https://404.cr0ss.org", - external: true, - }, - { - name: "Latency Cathedral", - description: - "Uses live network timings (ping, resource load, packet jitter) to generate gothic structures in WebGL. Every network condition creates a different cathedral.", - url: "https://latency.cr0ss.org", - external: true, - }, - { - name: "EAVI", - description: - "An ephemeral audiovisual installation in the browser. Each visit becomes a one-off composition shaped by your environment, time and motion β€” then disappears, leaving no trace.", - url: "https://eavi.cr0ss.org", - external: true, - }, - { - name: "Migration Readiness Assessment", - description: - "A diagnostic framework for engineering leaders and CTOs to evaluate whether their organisation is ready for platform modernisation. It collects structured feedback from engineering, product, business, and leadership stakeholders, then surfaces misalignments across six readiness domains using statistical validation and NLP-driven qualitative analysis.", - url: "https://github.com/kayoslab/Migration-Readiness-Assessment", - external: true, - }, - { - name: "Dashboard", - description: - "A quantified-self dashboard that tracks daily habits, coffee and caffeine metabolism, workouts, running stats, and travel across countries β€” with an insights engine that discovers statistical correlations between metrics.", - url: "/dashboard", - }, - { - name: "Coffee", - description: - "A curated journal of specialty coffees documenting origin, roaster, processing method, variety, tasting notes, SCA scores, and brewing recipes β€” each with an interactive map pinpointing where the beans were grown.", - url: "/coffee", - }, -]; +import { permanentRedirect } from 'next/navigation'; +// Projects moved to the Contentful-backed /portfolio route. export default function ProjectsPage() { - return ( -
-
-

- Projects -

-

- Things I've been building. -

- -
- {projects.map((project) => - project.external ? ( - -
-

- {project.name} -

- -
-

{project.description}

-
- ) : ( - -

- {project.name} -

-

{project.description}

- - ) - )} -
-
-
- ); + permanentRedirect('/portfolio'); } diff --git a/components/capture/capture-form.tsx b/components/capture/capture-form.tsx new file mode 100644 index 0000000..9a6dc84 --- /dev/null +++ b/components/capture/capture-form.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +/** + * Public capture form shown on a portfolio project page (the NFC landing page). + * On success the server returns a pre-filled wa.me link and we send the visitor + * straight into WhatsApp. + */ +const ZCaptureForm = z.object({ + name: z.string().min(1, 'Please enter your name').max(200), + anchorType: z.enum(['linkedin', 'company']), + anchorValue: z.string().min(1, 'Add a LinkedIn URL or company').max(500), + message: z.string().max(1000).optional(), + // Honeypot β€” must stay empty; bots tend to fill every field. + website: z.string().max(0).optional(), +}); + +type CaptureFormValues = z.infer; + +export function CaptureForm() { + const [serverError, setServerError] = useState(null); + + const { + register, + handleSubmit, + watch, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(ZCaptureForm), + defaultValues: { anchorType: 'linkedin' }, + }); + + const anchorType = watch('anchorType'); + + const onSubmit = async (values: CaptureFormValues) => { + setServerError(null); + try { + const res = await fetch('/api/capture', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(values), + }); + + if (res.status === 429) { + setServerError('Too many submissions right now. Please try again later.'); + return; + } + if (!res.ok) { + setServerError('Something went wrong. Please try again.'); + return; + } + + const data = (await res.json()) as { waUrl?: string }; + if (data.waUrl) { + // Hand off to WhatsApp with the pre-filled message. + window.location.href = data.waUrl; + } + } catch { + setServerError('Network error. Please try again.'); + } + }; + + return ( +
+
+

Let's stay in touch

+

+ Leave your name and I'll pick it up on WhatsApp. +

+
+ +
+ + + {errors.name &&

{errors.name.message}

} +
+ +
+ How can I find you? +
+ + +
+ + {errors.anchorValue && ( +

{errors.anchorValue.message}

+ )} +
+ +
+ +