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/dashboard/layout.tsx b/app/dashboard/layout.tsx index 0f8252e..7e782cb 100644 --- a/app/dashboard/layout.tsx +++ b/app/dashboard/layout.tsx @@ -7,8 +7,7 @@ import { Separator } from "@/components/ui/separator"; const marketingLinks = [ { href: "/", label: "Home" }, { href: "/blog", label: "Blog" }, - { href: "/projects", label: "Projects" }, - { href: "/page/about", label: "About" }, + { href: "/portfolio", label: "Portfolio" }, { href: "/page/contact", label: "Contact" }, ]; diff --git a/app/portfolio/[slug]/page.tsx b/app/portfolio/[slug]/page.tsx new file mode 100644 index 0000000..53020a4 --- /dev/null +++ b/app/portfolio/[slug]/page.tsx @@ -0,0 +1,115 @@ +import type { Metadata } from 'next'; +import Image from 'next/image'; +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'; + +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.heroImage?.url && ( + {project.heroImage.title + )} + +
+ {project.url && ( + + Visit project + + + )} + {project.githubUrl && ( + + + View on GitHub + + )} +
+ + {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..6641d63 --- /dev/null +++ b/app/portfolio/page.tsx @@ -0,0 +1,79 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { ArrowUpRight } from 'lucide-react'; +import { getAllProjects } from '@/lib/contentful/api/portfolio'; +import { CaptureForm } from '@/components/capture/capture-form'; + +export const metadata: Metadata = { + title: 'Portfolio | cr0ss.mind', + description: 'Things I’ve been building.', +}; + +// Intro copy (repurposed from the retired About page). +const INTRO_PARAGRAPHS = [ + 'With over 15 years in software engineering and a decade of experience in online retail, I focus on bridging the gap between technology and business with clarity, empathy, and long-term impact. I work with prospects and clients to design modern solutions that are not just technically sound, but culturally sustainable, solutions people want to adopt, own and grow with.', + 'My roots are in engineering, but my strength lies in building meaningful relationships and translating complex architecture into human terms. I’ve worked as a solutions architect and requirements engineer, always with a focus on empowering teams, whether it’s a marketer seeing new opportunities for engagement or an engineer excited by how easily they can ship a change.', + 'I leverage my expertise in e-commerce, cloud, and MACH (microservices, API-first, cloud-native, and headless) solutions to guide and advise clients on their digital transformation journeys. At the same time I believe composable commerce isn’t a silver bullet, it’s a shift that succeeds only when your people are ready to lead the way. Beyond client work, I care deeply about how digital commerce evolves, particularly the tension between fast retail and the growing need for ethical, personalized, customer-centric experiences.', +]; + +/** Normalize a search param that may be a string, array, or undefined. */ +function firstParam(value: string | string[] | undefined): string | undefined { + if (Array.isArray(value)) return value[0]; + return value; +} + +export default async function PortfolioPage({ + searchParams, +}: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + const [{ items }, sp] = await Promise.all([getAllProjects(), searchParams]); + + // The "Let's stay in touch" box only appears when arriving via a campaign + // link (e.g. the NFC card β†’ /portfolio?campaign-id=shoreditch-meetup). + const campaignId = firstParam(sp['campaign-id']); + + return ( +
+
+ {/* Campaign visitors land straight on the capture box. */} + {campaignId && } + +
+

+ Who am I? +

+
+ {INTRO_PARAGRAPHS.map((paragraph, i) => ( +

{paragraph}

+ ))} +
+
+ +

+ 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/blog/blogarticle.tsx b/components/blog/blogarticle.tsx index b1a68c3..7b629a9 100644 --- a/components/blog/blogarticle.tsx +++ b/components/blog/blogarticle.tsx @@ -33,7 +33,7 @@ export const Blog = ({ blog, recommendations }: { blog: BlogProps, recommendatio
- By {"cr0ss"}{" published on "}{publishDate}{" in "} + By {"cr0ss"}{" published on "}{publishDate}{" in "} {blog.categoriesCollection.items.length > 0 ? "|" : ""} {blog.categoriesCollection.items.map((category: CategoryProps) => ( {category.title}| diff --git a/components/capture/capture-form.tsx b/components/capture/capture-form.tsx new file mode 100644 index 0000000..bfa0147 --- /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({ campaignId }: { campaignId?: string }) { + 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, campaignId }), + }); + + 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}

+ )} +
+ +
+ +