diff --git a/apps/portal/README.md b/apps/portal/README.md new file mode 100644 index 00000000..d1399eb2 --- /dev/null +++ b/apps/portal/README.md @@ -0,0 +1,38 @@ +# CleanStart partner portal (clickable mock) + +A real Next.js app with seeded mock data and no backend, built so the CleanStart team can walk through the whole partner portal and review it screen by screen. Every name, number and email is made up. Nothing is sent anywhere. + +## Run it + +```bash +pnpm --filter @cleanstart/portal dev # http://localhost:3004 +pnpm --filter @cleanstart/portal build +``` + +If a build fails with a `next/font/google` import-map error right after using `dev`, delete `apps/portal/.next` and build again. + +## How to review + +Open the app and use the **Walkthrough** button (bottom left). It lists the steps in order, jumps to the right screen as the right person, and highlights what to look at. Sign-in has two shortcut buttons (partner or CleanStart staff), and the walkthrough panel has links to switch role at any time. + +The flows are live, so actions carry across screens: apply as a new company, approve the application as staff, register a deal as a partner, accept it as staff, then see the new status as the partner. Everything you do is also recorded in the Audit log. + +State is kept in the browser's local storage so a reload does not lose your place. **Reset demo data** in the walkthrough panel restores the seeds. + +## Hosting a review copy (Vercel) + +1. Create a Vercel project from this repository with **Root Directory** set to `apps/portal`. +2. Add the environment variable `PORTAL_REVIEW_PASSWORD` (any shared password). With it set, every page asks for it (HTTP Basic, username ignored). Leave it unset for an open copy. +3. Search engines are blocked in three ways: `robots.txt`, an `X-Robots-Tag` header and page metadata. + +## Where things live + +- `src/components/screens/`: one file per screen, ported from the design boards. +- `src/data/`: seeded mock data, one persisted slice per area (`src/lib/slice.ts`). +- `src/components/shell/`: sidebar, top bar, search (Ctrl K), notifications and help, shared by every signed-in screen. +- `src/tour/`: the walkthrough steps. A screen takes part by carrying a `data-tour` attribute. +- `src/styles/portal.css`: the whole design, taken from the design boards. + +## Not in this app + +No real authentication, no HubSpot connection, no file storage and no email. Those belong to the real portal build. diff --git a/apps/portal/biome.json b/apps/portal/biome.json new file mode 100644 index 00000000..5409aaba --- /dev/null +++ b/apps/portal/biome.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "extends": [ + "../../biome.json" + ], + "linter": { + "rules": { + "a11y": { + "noSvgWithoutTitle": "off", + "useKeyWithClickEvents": "off", + "useSemanticElements": "off", + "useFocusableInteractive": "off" + }, + "suspicious": { + "noArrayIndexKey": "off" + }, + "style": { + "noNonNullAssertion": "off" + } + } + } +} diff --git a/apps/portal/next.config.ts b/apps/portal/next.config.ts new file mode 100644 index 00000000..1b744eee --- /dev/null +++ b/apps/portal/next.config.ts @@ -0,0 +1,14 @@ +import path from "node:path"; +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + // lets a production build run beside a live dev server without sharing .next + distDir: process.env.NEXT_DIST_DIR ?? ".next", + turbopack: { root: path.resolve(process.cwd(), "..", "..") }, + poweredByHeader: false, + async headers() { + return [{ source: "/:path*", headers: [{ key: "X-Robots-Tag", value: "noindex, nofollow" }] }]; + }, +}; + +export default nextConfig; diff --git a/apps/portal/package.json b/apps/portal/package.json new file mode 100644 index 00000000..34feab15 --- /dev/null +++ b/apps/portal/package.json @@ -0,0 +1,27 @@ +{ + "name": "@cleanstart/portal", + "version": "0.0.1", + "description": "CleanStart partner portal: clickable mock with seeded data for team walkthroughs. No backend, no real data.", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev --port 3004", + "build": "next build", + "start": "next start --port 3004", + "lint": "biome lint src", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "driver.js": "^1.3.6", + "next": "16.2.5", + "react": "19.2.4", + "react-dom": "19.2.4" + }, + "devDependencies": { + "@cleanstart/config": "workspace:*", + "@types/node": "^22.10.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "^5.7.3" + } +} diff --git a/apps/portal/public/logo-mark.svg b/apps/portal/public/logo-mark.svg new file mode 100644 index 00000000..a1f43ae7 --- /dev/null +++ b/apps/portal/public/logo-mark.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/apps/portal/public/robots.txt b/apps/portal/public/robots.txt new file mode 100644 index 00000000..1f53798b --- /dev/null +++ b/apps/portal/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/apps/portal/src/app/(public)/accept-invite/page.tsx b/apps/portal/src/app/(public)/accept-invite/page.tsx new file mode 100644 index 00000000..1a34dfe0 --- /dev/null +++ b/apps/portal/src/app/(public)/accept-invite/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { AcceptInvite } from "@/components/screens/AcceptInvite"; + +export const metadata: Metadata = { title: "Set up your account" }; + +export default function Page(): React.JSX.Element { + return ; +} diff --git a/apps/portal/src/app/(public)/apply/done/page.tsx b/apps/portal/src/app/(public)/apply/done/page.tsx new file mode 100644 index 00000000..50c1bedd --- /dev/null +++ b/apps/portal/src/app/(public)/apply/done/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { ApplyDone } from "@/components/screens/ApplyDone"; + +export const metadata: Metadata = { title: "Application received" }; + +export default function Page(): React.JSX.Element { + return ; +} diff --git a/apps/portal/src/app/(public)/apply/page.tsx b/apps/portal/src/app/(public)/apply/page.tsx new file mode 100644 index 00000000..e99fde06 --- /dev/null +++ b/apps/portal/src/app/(public)/apply/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { Apply } from "@/components/screens/Apply"; + +export const metadata: Metadata = { title: "Apply to become a partner" }; + +export default function Page(): React.JSX.Element { + return ; +} diff --git a/apps/portal/src/app/(public)/layout.tsx b/apps/portal/src/app/(public)/layout.tsx new file mode 100644 index 00000000..bb74e47d --- /dev/null +++ b/apps/portal/src/app/(public)/layout.tsx @@ -0,0 +1,3 @@ +export default function PublicLayout({ children }: { children: React.ReactNode }): React.JSX.Element { + return
{children}
; +} diff --git a/apps/portal/src/app/(public)/sign-in/page.tsx b/apps/portal/src/app/(public)/sign-in/page.tsx new file mode 100644 index 00000000..403b4984 --- /dev/null +++ b/apps/portal/src/app/(public)/sign-in/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { SignIn } from "@/components/screens/SignIn"; + +export const metadata: Metadata = { title: "Sign in" }; + +export default function Page(): React.JSX.Element { + return ; +} diff --git a/apps/portal/src/app/admin/approvals/page.tsx b/apps/portal/src/app/admin/approvals/page.tsx new file mode 100644 index 00000000..54f05945 --- /dev/null +++ b/apps/portal/src/app/admin/approvals/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { AdminApprovals } from "@/components/screens/AdminApprovals"; + +export const metadata: Metadata = { title: "Deal approvals" }; + +export default function Page(): React.JSX.Element { + return ; +} diff --git a/apps/portal/src/app/admin/audit/page.tsx b/apps/portal/src/app/admin/audit/page.tsx new file mode 100644 index 00000000..1fd6902e --- /dev/null +++ b/apps/portal/src/app/admin/audit/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { AdminAudit } from "@/components/screens/AdminAudit"; + +export const metadata: Metadata = { title: "Audit log" }; + +export default function Page(): React.JSX.Element { + return ; +} diff --git a/apps/portal/src/app/admin/documents/page.tsx b/apps/portal/src/app/admin/documents/page.tsx new file mode 100644 index 00000000..a9678d02 --- /dev/null +++ b/apps/portal/src/app/admin/documents/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { AdminDocuments } from "@/components/screens/AdminDocuments"; + +export const metadata: Metadata = { title: "Manage documents" }; + +export default function Page(): React.JSX.Element { + return ; +} diff --git a/apps/portal/src/app/admin/layout.tsx b/apps/portal/src/app/admin/layout.tsx new file mode 100644 index 00000000..328e9eae --- /dev/null +++ b/apps/portal/src/app/admin/layout.tsx @@ -0,0 +1,5 @@ +import { Shell } from "@/components/shell/shell"; + +export default function AdminLayout({ children }: { children: React.ReactNode }): React.JSX.Element { + return {children}; +} diff --git a/apps/portal/src/app/admin/loading.tsx b/apps/portal/src/app/admin/loading.tsx new file mode 100644 index 00000000..3f062b08 --- /dev/null +++ b/apps/portal/src/app/admin/loading.tsx @@ -0,0 +1,5 @@ +import { LoadingSkeleton } from "@/components/screens/loading-skeleton"; + +export default function Loading(): React.JSX.Element { + return ; +} diff --git a/apps/portal/src/app/admin/page.tsx b/apps/portal/src/app/admin/page.tsx new file mode 100644 index 00000000..99295e69 --- /dev/null +++ b/apps/portal/src/app/admin/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; +import { ROUTES } from "@/lib/routes"; + +export default function AdminIndex(): never { + redirect(ROUTES.admin.approvals); +} diff --git a/apps/portal/src/app/admin/partners/page.tsx b/apps/portal/src/app/admin/partners/page.tsx new file mode 100644 index 00000000..ec1f66a5 --- /dev/null +++ b/apps/portal/src/app/admin/partners/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { AdminPartners } from "@/components/screens/AdminPartners"; + +export const metadata: Metadata = { title: "Partners" }; + +export default function Page(): React.JSX.Element { + return ( + + + + ); +} diff --git a/apps/portal/src/app/admin/reports/page.tsx b/apps/portal/src/app/admin/reports/page.tsx new file mode 100644 index 00000000..507520dc --- /dev/null +++ b/apps/portal/src/app/admin/reports/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { AdminReports } from "@/components/screens/AdminReports"; + +export const metadata: Metadata = { title: "Reports" }; + +export default function Page(): React.JSX.Element { + return ; +} diff --git a/apps/portal/src/app/admin/team/page.tsx b/apps/portal/src/app/admin/team/page.tsx new file mode 100644 index 00000000..54b526a0 --- /dev/null +++ b/apps/portal/src/app/admin/team/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { AdminTeam } from "@/components/screens/AdminTeam"; + +export const metadata: Metadata = { title: "CleanStart team" }; + +export default function Page(): React.JSX.Element { + return ; +} diff --git a/apps/portal/src/app/error.tsx b/apps/portal/src/app/error.tsx new file mode 100644 index 00000000..84dc2ff7 --- /dev/null +++ b/apps/portal/src/app/error.tsx @@ -0,0 +1,37 @@ +"use client"; + +import Link from "next/link"; +import { useEffect } from "react"; +import { ROUTES } from "@/lib/routes"; + +export default function ErrorPage({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}): React.JSX.Element { + useEffect(() => { + console.error(error); + }, [error]); + + return ( +
+
+ Something went wrong +

This screen could not load

+

+ Try again. If it keeps happening, reset the demo data from the Walkthrough panel or start from sign in. +

+
+ + + Sign in + +
+
+
+ ); +} diff --git a/apps/portal/src/app/icon.svg b/apps/portal/src/app/icon.svg new file mode 100644 index 00000000..a1f43ae7 --- /dev/null +++ b/apps/portal/src/app/icon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/apps/portal/src/app/layout.tsx b/apps/portal/src/app/layout.tsx new file mode 100644 index 00000000..103384d1 --- /dev/null +++ b/apps/portal/src/app/layout.tsx @@ -0,0 +1,28 @@ +import type { Metadata, Viewport } from "next"; +import { Manrope } from "next/font/google"; +import { ToastProvider } from "@/components/ui/toast"; +import { TourProvider } from "@/tour/tour-provider"; +import "@/styles/portal.css"; + +const manrope = Manrope({ subsets: ["latin"], weight: ["400", "500", "600", "700"], variable: "--font-manrope", display: "swap" }); + +export const metadata: Metadata = { + title: { default: "CleanStart Partner Portal (mock)", template: "%s | CleanStart Partner Portal (mock)" }, + description: "Clickable mock of the CleanStart partner portal with made-up data, for team review.", + robots: { index: false, follow: false }, +}; + +export const viewport: Viewport = { width: "device-width", initialScale: 1 }; + +export default function RootLayout({ children }: { children: React.ReactNode }): React.JSX.Element { + return ( + + + + {children} + + + + + ); +} diff --git a/apps/portal/src/app/not-found.tsx b/apps/portal/src/app/not-found.tsx new file mode 100644 index 00000000..fb941431 --- /dev/null +++ b/apps/portal/src/app/not-found.tsx @@ -0,0 +1,17 @@ +import Link from "next/link"; +import { ROUTES } from "@/lib/routes"; + +export default function NotFound(): React.JSX.Element { + return ( +
+
+ 404 +

This page does not exist

+

The link may be old, or the address may have a typo.

+ + Back to sign in + +
+
+ ); +} diff --git a/apps/portal/src/app/page.tsx b/apps/portal/src/app/page.tsx new file mode 100644 index 00000000..f9f8bdc9 --- /dev/null +++ b/apps/portal/src/app/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; +import { ROUTES } from "@/lib/routes"; + +export default function Home(): never { + redirect(ROUTES.signIn); +} diff --git a/apps/portal/src/app/partner/account/page.tsx b/apps/portal/src/app/partner/account/page.tsx new file mode 100644 index 00000000..76334969 --- /dev/null +++ b/apps/portal/src/app/partner/account/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { Account } from "@/components/screens/Account"; + +export const metadata: Metadata = { title: "Your account" }; + +export default function Page(): React.JSX.Element { + return ; +} diff --git a/apps/portal/src/app/partner/company/page.tsx b/apps/portal/src/app/partner/company/page.tsx new file mode 100644 index 00000000..181a78f3 --- /dev/null +++ b/apps/portal/src/app/partner/company/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { Company } from "@/components/screens/Company"; + +export const metadata: Metadata = { title: "Company" }; + +export default function Page(): React.JSX.Element { + return ; +} diff --git a/apps/portal/src/app/partner/deals/[id]/page.tsx b/apps/portal/src/app/partner/deals/[id]/page.tsx new file mode 100644 index 00000000..722950cb --- /dev/null +++ b/apps/portal/src/app/partner/deals/[id]/page.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from "next"; +import { DealDetail } from "@/components/screens/DealDetail"; + +export const metadata: Metadata = { title: "Deal detail" }; + +export default async function Page({ params }: { params: Promise<{ id: string }> }): Promise { + const { id } = await params; + return ; +} diff --git a/apps/portal/src/app/partner/deals/new/page.tsx b/apps/portal/src/app/partner/deals/new/page.tsx new file mode 100644 index 00000000..b16764b9 --- /dev/null +++ b/apps/portal/src/app/partner/deals/new/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { RegisterDeal } from "@/components/screens/RegisterDeal"; + +export const metadata: Metadata = { title: "Register a deal" }; + +export default function Page(): React.JSX.Element { + return ; +} diff --git a/apps/portal/src/app/partner/deals/page.tsx b/apps/portal/src/app/partner/deals/page.tsx new file mode 100644 index 00000000..b1acc064 --- /dev/null +++ b/apps/portal/src/app/partner/deals/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { Deals } from "@/components/screens/Deals"; + +export const metadata: Metadata = { title: "Deals" }; + +export default function Page(): React.JSX.Element { + return ; +} diff --git a/apps/portal/src/app/partner/documents/page.tsx b/apps/portal/src/app/partner/documents/page.tsx new file mode 100644 index 00000000..1114f657 --- /dev/null +++ b/apps/portal/src/app/partner/documents/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { Documents } from "@/components/screens/Documents"; + +export const metadata: Metadata = { title: "Documents" }; + +export default function Page(): React.JSX.Element { + return ; +} diff --git a/apps/portal/src/app/partner/layout.tsx b/apps/portal/src/app/partner/layout.tsx new file mode 100644 index 00000000..e9c6a597 --- /dev/null +++ b/apps/portal/src/app/partner/layout.tsx @@ -0,0 +1,5 @@ +import { Shell } from "@/components/shell/shell"; + +export default function PartnerLayout({ children }: { children: React.ReactNode }): React.JSX.Element { + return {children}; +} diff --git a/apps/portal/src/app/partner/loading.tsx b/apps/portal/src/app/partner/loading.tsx new file mode 100644 index 00000000..3f062b08 --- /dev/null +++ b/apps/portal/src/app/partner/loading.tsx @@ -0,0 +1,5 @@ +import { LoadingSkeleton } from "@/components/screens/loading-skeleton"; + +export default function Loading(): React.JSX.Element { + return ; +} diff --git a/apps/portal/src/app/partner/overview/page.tsx b/apps/portal/src/app/partner/overview/page.tsx new file mode 100644 index 00000000..1ac9cc35 --- /dev/null +++ b/apps/portal/src/app/partner/overview/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { PartnerOverview } from "@/components/screens/PartnerOverview"; + +export const metadata: Metadata = { title: "Overview" }; + +export default function Page(): React.JSX.Element { + return ; +} diff --git a/apps/portal/src/app/partner/page.tsx b/apps/portal/src/app/partner/page.tsx new file mode 100644 index 00000000..d330b49b --- /dev/null +++ b/apps/portal/src/app/partner/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; +import { ROUTES } from "@/lib/routes"; + +export default function PartnerIndex(): never { + redirect(ROUTES.partner.overview); +} diff --git a/apps/portal/src/app/partner/team/page.tsx b/apps/portal/src/app/partner/team/page.tsx new file mode 100644 index 00000000..6d47373f --- /dev/null +++ b/apps/portal/src/app/partner/team/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { Team } from "@/components/screens/Team"; + +export const metadata: Metadata = { title: "Team" }; + +export default function Page(): React.JSX.Element { + return ; +} diff --git a/apps/portal/src/app/partner/welcome/page.tsx b/apps/portal/src/app/partner/welcome/page.tsx new file mode 100644 index 00000000..31348786 --- /dev/null +++ b/apps/portal/src/app/partner/welcome/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { PartnerWelcome } from "@/components/screens/PartnerWelcome"; + +export const metadata: Metadata = { title: "Welcome" }; + +export default function Page(): React.JSX.Element { + return ; +} diff --git a/apps/portal/src/components/deals/ManagerMessageDrawer.tsx b/apps/portal/src/components/deals/ManagerMessageDrawer.tsx new file mode 100644 index 00000000..2b269acc --- /dev/null +++ b/apps/portal/src/components/deals/ManagerMessageDrawer.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useState } from "react"; +import { Icon } from "../icon"; +import type { Persona } from "../shell/personas"; +import { Drawer } from "../ui/overlay"; +import { useToast } from "../ui/toast"; + +/** The "Message your partner manager" drawer, opened from a screen with a subject already filled in. */ +export function ManagerMessageDrawer({ + persona, + initialSubject, + onClose, +}: { + persona: Persona; + initialSubject: string; + onClose: () => void; +}): React.JSX.Element | null { + const toast = useToast(); + const [subject, setSubject] = useState(initialSubject); + const [body, setBody] = useState(""); + const [tried, setTried] = useState(false); + const manager = persona.manager; + if (!manager) return null; + const invalid = tried && !body.trim(); + + const send = (event: React.FormEvent): void => { + event.preventDefault(); + if (!body.trim()) { + setTried(true); + return; + } + toast(`Message sent to ${manager.name}`); + onClose(); + }; + + return ( + {manager.initials}} + onSubmit={send} + footer={ + <> + + Sent from {persona.user.toLowerCase().replace(" ", ".")}@{persona.org.toLowerCase().replace(/\s+/g, "")}.com + + + + + } + > +
+ + setSubject(e.target.value)} + /> +
+
+ +