diff --git a/.gitignore b/.gitignore index 00735b6c..c17af8c4 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,4 @@ apps/*/dist/ *.logs .cursor/ +.agents diff --git a/apps/www/package.json b/apps/www/package.json index 8492b36f..849ac112 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -21,18 +21,25 @@ "@portabletext/react": "^5.0.0", "@portabletext/types": "^3.0.0", "@sanity/client": "^7.13.0", + "@sanity/code-input": "^6.0.4", "@sanity/image-url": "^1.2.0", + "@sanity/table": "^2.0.1", "@vercel/analytics": "^1.5.0", "csv-parse": "^6.1.0", + "easymde": "^2.20.0", "framer-motion": "^12.23.24", "jsdom": "^27.2.0", - "next": "^16.0.7", + "next": "^16.1.6", "next-sanity": "^11.6.9", "papaparse": "^5.5.3", "react": "19.2.0", "react-dom": "19.2.0", + "react-markdown": "^10.1.0", + "react-syntax-highlighter": "^16.1.0", + "remark-gfm": "^4.0.1", "resend": "^6.6.0", "sanity": "^4.18.0", + "sanity-plugin-markdown": "^7.0.4", "styled-components": "^6.1.19" }, "devDependencies": { @@ -44,6 +51,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/react-syntax-highlighter": "^15.5.13", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", "typescript": "^5" diff --git a/apps/www/sanity.config.ts b/apps/www/sanity.config.ts index 4db703f5..812e1ee7 100644 --- a/apps/www/sanity.config.ts +++ b/apps/www/sanity.config.ts @@ -1,4 +1,7 @@ import { defineConfig } from "sanity"; +import { codeInput } from "@sanity/code-input"; +import { table } from "@sanity/table"; +import { markdownSchema } from "sanity-plugin-markdown"; import { structureTool } from "sanity/structure"; import { schemaTypes } from "./schemaTypes"; @@ -11,7 +14,7 @@ export default defineConfig({ basePath: "/studio", - plugins: [structureTool()], + plugins: [structureTool(), codeInput(), table(), markdownSchema()], schema: { types: schemaTypes, diff --git a/apps/www/schemaTypes/blogType.ts b/apps/www/schemaTypes/blogType.ts index 90a33870..286ed843 100644 --- a/apps/www/schemaTypes/blogType.ts +++ b/apps/www/schemaTypes/blogType.ts @@ -35,6 +35,45 @@ export const blogType = defineType({ { type: "block", }, + { + type: "code", + options: { + language: "typescript", + languageAlternatives: [ + { title: "TypeScript", value: "typescript" }, + { title: "JavaScript", value: "javascript" }, + { title: "HTML", value: "html" }, + { title: "CSS", value: "css" }, + { title: "Bash", value: "sh" }, + { title: "Python", value: "python" }, + { title: "Markdown", value: "markdown" }, + { title: "YAML", value: "yaml" }, + { title: "JSON", value: "json" }, + { title: "XML", value: "xml" }, + { title: "SQL", value: "sql" }, + { title: "Shell", value: "shell" }, + { title: "PowerShell", value: "powershell" }, + { title: "Batch", value: "batch" }, + ], + withFilename: true, + }, + }, + { + type: "object", + name: "markdownBlock", + title: "Markdown", + fields: [ + { + name: "markdown", + title: "Markdown", + type: "markdown", + description: "Markdown content with preview and image uploads", + }, + ], + }, + { + type: "table", + }, { type: "image", fields: [ diff --git a/apps/www/src/app/api/contact/route.ts b/apps/www/src/app/api/contact/route.ts index 6c80bf6f..cdb8484c 100644 --- a/apps/www/src/app/api/contact/route.ts +++ b/apps/www/src/app/api/contact/route.ts @@ -1,10 +1,10 @@ -import { Resend } from 'resend'; -import { NextResponse } from 'next/server'; +import { Resend } from "resend"; +import { NextResponse } from "next/server"; function getResendClient() { const apiKey = process.env.RESEND_API_KEY; if (!apiKey) { - throw new Error('RESEND_API_KEY environment variable is not set'); + throw new Error("RESEND_API_KEY environment variable is not set"); } return new Resend(apiKey); } @@ -17,18 +17,24 @@ interface ContactPayload { lookingFor: string; } -function buildProperties(company?: string, lookingFor?: string): Record | undefined { +function buildProperties( + company?: string, + lookingFor?: string, +): Record | undefined { const properties: Record = {}; if (company) properties.company_name = company; if (lookingFor) properties.looking_for = lookingFor; return Object.keys(properties).length > 0 ? properties : undefined; } -function isDuplicateError(error: { message?: string; statusCode?: number | null }): boolean { - const errorMessage = error.message?.toLowerCase() || ''; +function isDuplicateError(error: { + message?: string; + statusCode?: number | null; +}): boolean { + const errorMessage = error.message?.toLowerCase() || ""; return ( - errorMessage.includes('already exists') || - errorMessage.includes('duplicate') || + errorMessage.includes("already exists") || + errorMessage.includes("duplicate") || error.statusCode === 409 ); } @@ -38,7 +44,7 @@ function createContactPayload( firstName: string, lastName: string, company?: string, - lookingFor?: string + lookingFor?: string, ) { const properties = buildProperties(company, lookingFor); return { @@ -53,50 +59,56 @@ function createContactPayload( export async function POST(req: Request) { try { const body = await req.json(); - const { firstName, lastName, email, company, lookingFor }: ContactPayload = body; + const { firstName, lastName, email, company, lookingFor }: ContactPayload = + body; if (!email || !firstName || !lastName || !lookingFor) { return NextResponse.json( - { error: 'Missing required fields' }, - { status: 400 } + { error: "Missing required fields" }, + { status: 400 }, ); } - const contactPayload = createContactPayload(email, firstName, lastName, company, lookingFor); + const contactPayload = createContactPayload( + email, + firstName, + lastName, + company, + lookingFor, + ); const resend = getResendClient(); const { data, error } = await resend.contacts.create(contactPayload); if (error) { if (isDuplicateError(error)) { - const { data: updateData, error: updateError } = await resend.contacts.update( - contactPayload - ); + const { data: updateData, error: updateError } = + await resend.contacts.update(contactPayload); if (updateError) { - console.error('Resend update error:', updateError); + console.error("Resend update error:", updateError); return NextResponse.json( - { error: updateError.message || 'Failed to update contact' }, - { status: 500 } + { error: updateError.message || "Failed to update contact" }, + { status: 500 }, ); } return NextResponse.json({ success: true, data: updateData }); } - console.error('Resend create error:', error); + console.error("Resend create error:", error); return NextResponse.json( - { error: error.message || 'Failed to create contact' }, - { status: error.statusCode || 500 } + { error: error.message || "Failed to create contact" }, + { status: error.statusCode || 500 }, ); } return NextResponse.json({ success: true, data }); } catch (error) { - console.error('Unexpected error:', error); + console.error("Unexpected error:", error); return NextResponse.json( - { error: error instanceof Error ? error.message : 'Unknown error' }, - { status: 500 } + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 }, ); } } diff --git a/apps/www/src/app/blog/[slug]/page.tsx b/apps/www/src/app/blog/[slug]/page.tsx index d2a53dc5..49c55955 100644 --- a/apps/www/src/app/blog/[slug]/page.tsx +++ b/apps/www/src/app/blog/[slug]/page.tsx @@ -1,9 +1,11 @@ import { client, urlFor } from "@/lib/sanity"; +import type { Metadata } from "next"; import Image from "next/image"; import Link from "next/link"; import { PortableText } from "@/components/PortableText"; import { notFound } from "next/navigation"; import { UnlockPotentialSection } from "@/components/UnlockPotentialSection"; +import { createBlogPostMetadata } from "@/lib/metadata"; interface BlogPost { _id: string; @@ -67,6 +69,30 @@ export async function generateStaticParams() { return slugs.map((slug) => ({ slug })); } +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }>; +}): Promise { + const { slug } = await params; + const post = await getBlogPost(slug); + + if (!post) { + return { + title: "Post Not Found | Plano Blog", + description: "The requested blog post could not be found.", + }; + } + + return createBlogPostMetadata({ + title: post.title, + description: post.summary, + slug: post.slug.current, + publishedAt: post.publishedAt, + author: post.author?.name, + }); +} + export default async function BlogPostPage({ params, }: { diff --git a/apps/www/src/app/blog/page.tsx b/apps/www/src/app/blog/page.tsx index 63d586bc..9b13c5e5 100644 --- a/apps/www/src/app/blog/page.tsx +++ b/apps/www/src/app/blog/page.tsx @@ -5,10 +5,9 @@ import { BlogHeader } from "@/components/BlogHeader"; import { FeaturedBlogCard } from "@/components/FeaturedBlogCard"; import { BlogCard } from "@/components/BlogCard"; import { BlogSectionHeader } from "@/components/BlogSectionHeader"; -export const metadata: Metadata = { - title: "Blog - Plano", - description: "Latest insights, updates, and stories from Plano", -}; +import { pageMetadata } from "@/lib/metadata"; + +export const metadata: Metadata = pageMetadata.blog; interface BlogPost { _id: string; diff --git a/apps/www/src/app/contact/ContactPageClient.tsx b/apps/www/src/app/contact/ContactPageClient.tsx new file mode 100644 index 00000000..45117f98 --- /dev/null +++ b/apps/www/src/app/contact/ContactPageClient.tsx @@ -0,0 +1,299 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@katanemo/ui"; +import { MessageSquare, Building2, MessagesSquare } from "lucide-react"; + +export default function ContactPageClient() { + const [formData, setFormData] = useState({ + firstName: "", + lastName: "", + email: "", + company: "", + lookingFor: "", + message: "", + }); + const [status, setStatus] = useState< + "idle" | "submitting" | "success" | "error" + >("idle"); + const [errorMessage, setErrorMessage] = useState(""); + + const handleChange = ( + e: React.ChangeEvent, + ) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setStatus("submitting"); + setErrorMessage(""); + + try { + const res = await fetch("/api/contact", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "Something went wrong"); + } + + setStatus("success"); + setFormData({ + firstName: "", + lastName: "", + email: "", + company: "", + lookingFor: "", + message: "", + }); + } catch (error) { + setStatus("error"); + setErrorMessage( + error instanceof Error ? error.message : "Failed to submit form", + ); + } + }; + + return ( +
+ {/* Hero / Header Section */} +
+
+

+ Let's start a + + conversation + +

+

+ Whether you're an enterprise looking for a custom solution or a + developer building cool agents, we'd love to hear from you. +

+
+
+ + {/* Main Content - Split Layout */} +
+
+
+ {/* Left Side: Community (Discord) */} +
+ {/* Background icon */} +
+ +
+ +
+
+
+ + Community +
+

+ Join Our Discord +

+
+

+ Connect with other developers, ask questions, share what + you're building, and stay updated on the latest features by + joining our Discord server. +

+
+ + +
+ + {/* Right Side: Enterprise Contact */} +
+ {/* Subtle background pattern */} +
+ + {/* Background icon */} +
+ +
+ +
+
+ + Enterprise +
+

+ Contact Us +

+
+ +
+ {status === "success" ? ( +
+
+ + + +
+
+ Message Sent! +
+

+ Thank you for reaching out. We'll be in touch shortly. +

+ +
+ ) : ( +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ +