diff --git a/.github/workflows/pr-checks.yaml b/.github/workflows/pr-checks.yaml index f322884..0238e40 100644 --- a/.github/workflows/pr-checks.yaml +++ b/.github/workflows/pr-checks.yaml @@ -34,6 +34,9 @@ jobs: - name: Install Dependencies run: npm ci --legacy-peer-deps + - name: Mock Portfolio Template Library for CI + run: node scripts/mock-template-library.mjs + - name: Linting & Formatting run: | npm run lint diff --git a/.hallmark/log.json b/.hallmark/log.json new file mode 100644 index 0000000..68525aa --- /dev/null +++ b/.hallmark/log.json @@ -0,0 +1,18 @@ +[ + { + "date": "2026-05-30", + "scope": "app", + "macrostructure": "Readable portfolio tour + Tabbed studio", + "theme": "VeriWorkly platform", + "enrichment": "Interactive full-template preview", + "brief": "Corrective portfolio makeover: readable landing page, compact studio, complete billing decision page, populated demos, and retuned templates." + }, + { + "date": "2026-05-30", + "scope": "app", + "macrostructure": "Editorial product tour + Workbench", + "theme": "VeriWorkly platform", + "enrichment": "Tier-A CSS grid surface", + "brief": "Full production makeover for the portfolio builder, editor, billing view, gallery, and public templates." + } +] diff --git a/apps/blog-platform/package.json b/apps/blog-platform/package.json index e873156..c975c6f 100644 --- a/apps/blog-platform/package.json +++ b/apps/blog-platform/package.json @@ -1,6 +1,6 @@ { "name": "@veriworkly/blog-platform", - "version": "3.11.0", + "version": "3.12.0", "private": true, "scripts": { "dev": "next dev", diff --git a/apps/docs-platform/package.json b/apps/docs-platform/package.json index 41be754..5348794 100644 --- a/apps/docs-platform/package.json +++ b/apps/docs-platform/package.json @@ -1,6 +1,6 @@ { "name": "@veriworkly/docs-platform", - "version": "3.11.0", + "version": "3.12.0", "private": true, "scripts": { "dev": "next dev", diff --git a/apps/portfolio/.env.example b/apps/portfolio/.env.example new file mode 100644 index 0000000..6b8eed3 --- /dev/null +++ b/apps/portfolio/.env.example @@ -0,0 +1,2 @@ +NEXT_PUBLIC_BACKEND_URL=http://localhost:8080/api/v1 +BACKEND_INTERNAL_URL=http://localhost:8080/api/v1 diff --git a/apps/portfolio/.gitignore b/apps/portfolio/.gitignore new file mode 100644 index 0000000..0df97fb --- /dev/null +++ b/apps/portfolio/.gitignore @@ -0,0 +1,51 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# except example file +!.env.example +!.env.docker.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# fumadocs generated files +.source + +# testing +/app/og-generator diff --git a/apps/portfolio/README.md b/apps/portfolio/README.md new file mode 100644 index 0000000..45c61aa --- /dev/null +++ b/apps/portfolio/README.md @@ -0,0 +1,41 @@ +# VeriWorkly Portfolio + +The public portfolio platform. Private template implementations are mounted as a Git submodule at `template-library/`. + +## Setup + +Clone with the private templates: + +```bash +git clone --recurse-submodules git@github.com:VeriWorkly/veriworkly.git +``` + +For an existing checkout: + +```bash +git submodule update --init --recursive +``` + +The private repository uses GitHub SSH access. The machine running development or deployment must trust GitHub's SSH host key and have access to `VeriWorkly/portfolio-templates`. + +## Add A Template + +1. Add a folder in `template-library/` with its own React component and optional scoped stylesheet. +2. Add one dynamic loader entry in `template-library/registry.ts`. +3. Add public gallery metadata in `templates/catalog/templates.ts`. +4. Commit and push the private repository first. +5. Commit the updated submodule pointer in this repository. + +Do not import template styles from `app/globals.css`. Template modules own their styles so Next.js can emit per-template assets. + +## Production Deployment + +Portfolio publishing requires: + +1. Point `portfolio.veriworkly.com` and `*.veriworkly.com` at the portfolio Next.js deployment. +2. Provision TLS coverage for `portfolio.veriworkly.com` and `*.veriworkly.com`. +3. Configure `NEXT_PUBLIC_BACKEND_URL` and `BACKEND_INTERNAL_URL`. +4. Configure the server Dodo Payments and Cloudflare R2 variables documented in `apps/server/.env.example`. +5. Set the auth cookie domain to `.veriworkly.com` so Studio and Portfolio share the signed-in session. + +Only VeriWorkly subdomains are supported at launch. Custom-domain routing and certificate automation are intentionally out of scope. diff --git a/apps/portfolio/app/api/render/route.ts b/apps/portfolio/app/api/render/route.ts new file mode 100644 index 0000000..be3b97d --- /dev/null +++ b/apps/portfolio/app/api/render/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; + +import { parsePortfolioContent } from "@/lib/portfolio"; + +import { renderTemplate } from "@/templates/runtime/registry"; + +export async function POST(request: Request) { + try { + const project = parsePortfolioContent(await request.json()); + + const element = await renderTemplate(project); + + const { renderToString } = await import("react-dom/server"); + const html = renderToString(element); + + return new NextResponse(html, { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store, max-age=0", + }, + }); + } catch (error) { + console.error("Failed to render preview template:", error); + return new NextResponse("Error rendering template", { status: 500 }); + } +} diff --git a/apps/portfolio/app/api/revalidate/route.ts b/apps/portfolio/app/api/revalidate/route.ts new file mode 100644 index 0000000..5404ee1 --- /dev/null +++ b/apps/portfolio/app/api/revalidate/route.ts @@ -0,0 +1,41 @@ +import { revalidatePath, revalidateTag } from "next/cache"; +import { NextResponse } from "next/server"; + +export async function POST(request: Request) { + let body: { paths?: string[]; tags?: string[]; secret?: string }; + + try { + body = (await request.json()) as { paths?: string[]; tags?: string[]; secret?: string }; + } catch { + return NextResponse.json({ message: "Invalid JSON body" }, { status: 400 }); + } + + const { paths, tags, secret } = body; + const expectedSecret = process.env.PORTFOLIO_REVALIDATE_SECRET || "dev-revalidate-secret"; + + if (secret !== expectedSecret) { + return NextResponse.json({ message: "Invalid secret" }, { status: 401 }); + } + + if (tags && Array.isArray(tags)) { + for (const tag of tags) { + try { + (revalidateTag as unknown as (tag: string) => void)(tag); + } catch (err) { + console.error(`Failed to revalidate tag: ${tag}`, err); + } + } + } + + if (paths && Array.isArray(paths)) { + for (const path of paths) { + try { + revalidatePath(path); + } catch (err) { + console.error(`Failed to revalidate path: ${path}`, err); + } + } + } + + return NextResponse.json({ revalidated: true, paths, tags }); +} diff --git a/apps/portfolio/app/billing/page.tsx b/apps/portfolio/app/billing/page.tsx new file mode 100644 index 0000000..4fe1a56 --- /dev/null +++ b/apps/portfolio/app/billing/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { BillingWorkspace } from "@/components/BillingWorkspace"; + +export const metadata: Metadata = { + title: "Portfolio Pro billing", + robots: { index: false, follow: false }, +}; +export default function BillingPage() { + return ; +} diff --git a/apps/portfolio/app/dashboard/page.tsx b/apps/portfolio/app/dashboard/page.tsx new file mode 100644 index 0000000..8d8fbd4 --- /dev/null +++ b/apps/portfolio/app/dashboard/page.tsx @@ -0,0 +1,7 @@ +import type { Metadata } from "next"; +import { DashboardWorkspace } from "@/components/DashboardWorkspace"; + +export const metadata: Metadata = { title: "Dashboard", robots: { index: false, follow: false } }; +export default function DashboardPage() { + return ; +} diff --git a/apps/portfolio/app/error.tsx b/apps/portfolio/app/error.tsx new file mode 100644 index 0000000..479655a --- /dev/null +++ b/apps/portfolio/app/error.tsx @@ -0,0 +1,24 @@ +"use client"; + +export default function ErrorPage({ reset }: { reset: () => void }) { + return ( +
+
+

+ Temporary issue +

+

This page could not load.

+

+ Your work has not been lost. Try the request again, or return after the service connection + recovers. +

+ +
+
+ ); +} diff --git a/apps/portfolio/app/globals.css b/apps/portfolio/app/globals.css new file mode 100644 index 0000000..cc1938b --- /dev/null +++ b/apps/portfolio/app/globals.css @@ -0,0 +1,96 @@ +/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V5 + * genre: modern-minimal · macrostructure: Editorial product tour + Workbench · tone: structured confidence · anchor hue: blue + * contrast: pass (46-50) · nav: N1 · footer: Ft1 · tokens: pass (58) · mobile: pass (36, 59, 61-69) + */ +@import "tailwindcss"; +@import "./tokens.css"; + +* { + box-sizing: border-box; +} +html, +body { + margin: 0; + overflow-x: clip; + scroll-behavior: smooth; +} +body { + background: var(--color-paper); + color: var(--color-ink); + font-family: var(--font-body); +} +.surface-grid { + background-image: + linear-gradient(to right, var(--color-line) 1px, transparent 1px), + linear-gradient(to bottom, var(--color-line) 1px, transparent 1px); + background-size: 28px 28px; +} +.paper-noise { + background: + radial-gradient(circle at 10% 0%, var(--color-accent-soft), transparent 26rem), + var(--color-paper); +} +.reveal { + animation: reveal var(--dur-slow) var(--ease-out) both; +} +.reveal-delay { + animation-delay: 120ms; +} +@keyframes reveal { + from { + opacity: 0; + transform: translateY(14px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +a { + color: inherit; + text-decoration: none; +} +button, +input, +textarea { + font: inherit; +} +button, +a { + -webkit-tap-highlight-color: transparent; +} +button:focus-visible, +a:focus-visible, +input:focus-visible, +textarea:focus-visible { + outline: 3px solid var(--color-accent); + outline-offset: 3px; +} +button:active, +a:active { + opacity: 0.72; +} +button:disabled, +input:disabled, +textarea:disabled { + cursor: not-allowed; + opacity: 0.55; +} +h1, +h2 { + min-width: 0; + overflow-wrap: anywhere; +} +@media (prefers-reduced-motion: reduce) { + html, + body { + scroll-behavior: auto; + } + *, + *::before, + *::after { + animation-duration: 1ms !important; + animation-iteration-count: 1 !important; + transition-duration: 1ms !important; + } +} diff --git a/apps/portfolio/app/layout.tsx b/apps/portfolio/app/layout.tsx new file mode 100644 index 0000000..3a0b1f9 --- /dev/null +++ b/apps/portfolio/app/layout.tsx @@ -0,0 +1,48 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import { portfolioSiteConfig } from "@/config/site"; + +export const metadata: Metadata = { + metadataBase: new URL(portfolioSiteConfig.url), + title: { + default: "Professional Portfolio Builder | VeriWorkly", + template: "%s | VeriWorkly Portfolio", + }, + description: portfolioSiteConfig.description, + keywords: [...portfolioSiteConfig.keywords], + authors: [{ name: "VeriWorkly" }], + creator: "VeriWorkly", + publisher: "VeriWorkly", + openGraph: { + type: "website", + url: portfolioSiteConfig.url, + title: "Professional Portfolio Builder | VeriWorkly", + description: portfolioSiteConfig.description, + siteName: portfolioSiteConfig.name, + }, + twitter: { + card: "summary", + title: "Professional Portfolio Builder | VeriWorkly", + description: portfolioSiteConfig.description, + }, + robots: { + index: true, + follow: true, + googleBot: { + index: true, + follow: true, + "max-image-preview": "large", + "max-snippet": -1, + "max-video-preview": -1, + }, + }, + alternates: { canonical: "/" }, +}; + +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return ( + + {children} + + ); +} diff --git a/apps/portfolio/app/page.tsx b/apps/portfolio/app/page.tsx new file mode 100644 index 0000000..6def580 --- /dev/null +++ b/apps/portfolio/app/page.tsx @@ -0,0 +1,188 @@ +import Image from "next/image"; +import Link from "next/link"; +import { ArrowRight, Check, CirclePlay, LayoutTemplate, MoveUpRight, Sparkles } from "lucide-react"; +import { TemplateFrame } from "@/components/TemplateFrame"; + +const shell = "mx-auto w-[min(1240px,calc(100%-48px))] max-sm:w-[min(100%-32px,1240px)]"; +const cta = + "inline-flex min-h-11 items-center gap-2 whitespace-nowrap rounded-full bg-[var(--color-accent)] px-5 text-sm font-extrabold text-[var(--color-accent-ink)] transition-transform duration-150 hover:-translate-y-0.5"; + +export default function HomePage() { + return ( +
+ + +
+
+

+ Portfolio studio +

+

+ Your work. +
+ Presented with intent. +

+

+ Build a portfolio that reads clearly, looks considered, and stays easy to update. Start + with a complete template, then shape every section in one focused workspace. +

+
+ + Create your portfolio + + + Explore templates + +
+
+ +
+ +
+ +
+ +
+
+
+

+ A focused workflow +

+

+ Edit less. +
+ Show more. +

+

+ The studio keeps the work visible and the forms compact. You always know which part of + the portfolio you are shaping. +

+
+
+ {[ + { + Icon: LayoutTemplate, + title: "Pick a complete direction", + copy: "Preview realistic templates with projects, experience, services, skills, writing, and testimonials already represented.", + }, + { + Icon: Sparkles, + title: "Work section by section", + copy: "Use a compact studio with dedicated tabs instead of one endless form.", + }, + { + Icon: MoveUpRight, + title: "Publish when it reads well", + copy: "Preview privately, then publish the finished portfolio on your VeriWorkly subdomain.", + }, + ].map(({ Icon, title, copy }) => ( +
+ + + +
+

{title}

+

+ {copy} +

+
+
+ ))} +
+
+
+ +
+
+
+

+ Draft for free +

+

+ Build privately. Publish when it feels right. +

+
+
+

Portfolio Pro

+

+ $12 + + / month + +

+

+ One published subdomain and privacy-first portfolio analytics. +

+ + See plans + +
+
+
+ +
+ + VeriWorkly Portfolio + +

A sharper home for your work.

+ + Open studio + +
+
+ ); +} diff --git a/apps/portfolio/app/portfolios/[username]/page.tsx b/apps/portfolio/app/portfolios/[username]/page.tsx new file mode 100644 index 0000000..9b3dd07 --- /dev/null +++ b/apps/portfolio/app/portfolios/[username]/page.tsx @@ -0,0 +1,82 @@ +import { notFound } from "next/navigation"; +import type { Metadata } from "next"; +import { portfolioPublicUrl } from "@/config/site"; + +import { getPublishedPortfolio } from "@/lib/published-portfolio"; +import { renderTemplate } from "@/templates/runtime/registry"; +import { PublicViewTracker } from "@/components/PublicViewTracker"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ username: string }>; +}): Promise { + const { username } = await params; + const publication = await getPublishedPortfolio(username); + if (!publication) + return { title: "Portfolio not found", robots: { index: false, follow: false } }; + const project = publication.snapshot; + const url = portfolioPublicUrl(publication.subdomain); + const title = project.seo.title || `${project.identity.name} | Portfolio`; + const images = project.seo.socialImage ? [{ url: project.seo.socialImage.url }] : undefined; + return { + title, + description: project.seo.description, + alternates: { canonical: url }, + robots: { + index: true, + follow: true, + googleBot: { + index: true, + follow: true, + "max-image-preview": "large", + "max-snippet": -1, + "max-video-preview": -1, + }, + }, + openGraph: { + type: "profile", + url, + title, + description: project.seo.description, + siteName: "VeriWorkly Portfolio", + images, + }, + twitter: { + card: images ? "summary_large_image" : "summary", + title, + description: project.seo.description, + images, + }, + }; +} + +export default async function Portfolio({ params }: { params: Promise<{ username: string }> }) { + const { username } = await params; + const publication = await getPublishedPortfolio(username); + if (!publication) notFound(); + const project = publication.snapshot; + const jsonLd = { + "@context": "https://schema.org", + "@type": "ProfilePage", + name: `${project.identity.name} portfolio`, + url: portfolioPublicUrl(publication.subdomain), + mainEntity: { + "@type": "Person", + name: project.identity.name, + jobTitle: project.identity.headline, + email: project.identity.email, + address: project.identity.location, + }, + }; + return ( + <> +