From 020d416d0d8af4d16a981ff4f4f6d90153b9c603 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sat, 18 Apr 2026 22:50:54 +0200 Subject: [PATCH] feat: add website foundation v0 --- .dockerignore | 3 + .github/agents/copilot-instructions.md | 4 +- .gitignore | 3 + .npmignore | 3 + apps/website/astro.config.mjs | 14 + apps/website/package.json | 11 +- apps/website/playwright.config.ts | 29 ++ apps/website/public/robots.txt | 1 + .../src/components/content/AudienceRow.astro | 35 +++ .../src/components/content/Callout.astro | 23 ++ .../src/components/content/ContactPanel.astro | 29 ++ .../src/components/content/DemoPrompt.astro | 18 ++ .../src/components/content/Eyebrow.astro | 11 + .../src/components/content/FeatureItem.astro | 32 ++ .../src/components/content/Headline.astro | 11 + .../components/content/IntegrationBadge.astro | 19 ++ .../website/src/components/content/Lead.astro | 11 + .../src/components/content/Metric.astro | 18 ++ .../src/components/content/PrimaryCTA.astro | 12 + .../src/components/content/RichText.astro | 29 ++ .../src/components/content/SecondaryCTA.astro | 12 + .../content/TrustPrincipleCard.astro | 16 + .../src/components/layout/Footer.astro | 59 ++++ .../src/components/layout/Navbar.astro | 105 +++++++ .../src/components/layout/PageShell.astro | 39 +++ .../src/components/primitives/Badge.astro | 25 ++ .../src/components/primitives/Button.astro | 56 ++++ .../src/components/primitives/Card.astro | 23 ++ .../src/components/primitives/Cluster.astro | 13 + .../src/components/primitives/Container.astro | 14 + .../src/components/primitives/Grid.astro | 18 ++ .../src/components/primitives/Input.astro | 32 ++ .../src/components/primitives/Section.astro | 22 ++ .../components/primitives/SectionHeader.astro | 27 ++ .../src/components/primitives/Stack.astro | 20 ++ .../src/components/primitives/Textarea.astro | 31 ++ .../src/components/sections/CTASection.astro | 33 +++ .../src/components/sections/FeatureGrid.astro | 28 ++ .../src/components/sections/LogoStrip.astro | 44 +++ .../src/components/sections/PageHero.astro | 96 ++++++ .../src/components/sections/TrustGrid.astro | 28 ++ apps/website/src/content.config.ts | 24 ++ apps/website/src/content/pages/contact.ts | 65 +++++ apps/website/src/content/pages/home.ts | 145 +++++++++ .../website/src/content/pages/integrations.ts | 86 ++++++ apps/website/src/content/pages/legal.ts | 44 +++ apps/website/src/content/pages/privacy.ts | 60 ++++ apps/website/src/content/pages/product.ts | 110 +++++++ .../src/content/pages/security-trust.ts | 78 +++++ apps/website/src/content/pages/solutions.ts | 90 ++++++ apps/website/src/content/pages/terms.ts | 60 ++++ apps/website/src/layouts/BaseLayout.astro | 26 +- apps/website/src/lib/seo.ts | 27 ++ apps/website/src/lib/site.ts | 76 +++++ apps/website/src/pages/contact.astro | 98 +++++++ apps/website/src/pages/index.astro | 119 ++++---- apps/website/src/pages/integrations.astro | 55 ++++ apps/website/src/pages/legal.astro | 75 +++++ apps/website/src/pages/privacy.astro | 31 ++ apps/website/src/pages/product.astro | 61 ++++ apps/website/src/pages/security-trust.astro | 59 ++++ apps/website/src/pages/sitemap.xml.ts | 20 ++ apps/website/src/pages/solutions.astro | 55 ++++ apps/website/src/pages/terms.astro | 31 ++ apps/website/src/styles/global.css | 274 +++++++----------- apps/website/src/styles/tokens.css | 19 ++ apps/website/src/types/site.ts | 105 +++++++ .../website/tests/smoke/contact-legal.spec.ts | 57 ++++ apps/website/tests/smoke/home-product.spec.ts | 39 +++ apps/website/tests/smoke/smoke-helpers.ts | 45 +++ .../solutions-trust-integrations.spec.ts | 37 +++ apps/website/tsconfig.json | 18 ++ pnpm-lock.yaml | 79 ++++- .../checklists/requirements.md | 39 +++ .../contracts/public-site.openapi.yaml | 168 +++++++++++ specs/213-website-foundation-v0/data-model.md | 158 ++++++++++ specs/213-website-foundation-v0/plan.md | 194 +++++++++++++ specs/213-website-foundation-v0/quickstart.md | 85 ++++++ specs/213-website-foundation-v0/research.md | 64 ++++ specs/213-website-foundation-v0/spec.md | 161 ++++++++++ specs/213-website-foundation-v0/tasks.md | 201 +++++++++++++ 81 files changed, 4046 insertions(+), 249 deletions(-) create mode 100644 apps/website/playwright.config.ts create mode 100644 apps/website/src/components/content/AudienceRow.astro create mode 100644 apps/website/src/components/content/Callout.astro create mode 100644 apps/website/src/components/content/ContactPanel.astro create mode 100644 apps/website/src/components/content/DemoPrompt.astro create mode 100644 apps/website/src/components/content/Eyebrow.astro create mode 100644 apps/website/src/components/content/FeatureItem.astro create mode 100644 apps/website/src/components/content/Headline.astro create mode 100644 apps/website/src/components/content/IntegrationBadge.astro create mode 100644 apps/website/src/components/content/Lead.astro create mode 100644 apps/website/src/components/content/Metric.astro create mode 100644 apps/website/src/components/content/PrimaryCTA.astro create mode 100644 apps/website/src/components/content/RichText.astro create mode 100644 apps/website/src/components/content/SecondaryCTA.astro create mode 100644 apps/website/src/components/content/TrustPrincipleCard.astro create mode 100644 apps/website/src/components/layout/Footer.astro create mode 100644 apps/website/src/components/layout/Navbar.astro create mode 100644 apps/website/src/components/layout/PageShell.astro create mode 100644 apps/website/src/components/primitives/Badge.astro create mode 100644 apps/website/src/components/primitives/Button.astro create mode 100644 apps/website/src/components/primitives/Card.astro create mode 100644 apps/website/src/components/primitives/Cluster.astro create mode 100644 apps/website/src/components/primitives/Container.astro create mode 100644 apps/website/src/components/primitives/Grid.astro create mode 100644 apps/website/src/components/primitives/Input.astro create mode 100644 apps/website/src/components/primitives/Section.astro create mode 100644 apps/website/src/components/primitives/SectionHeader.astro create mode 100644 apps/website/src/components/primitives/Stack.astro create mode 100644 apps/website/src/components/primitives/Textarea.astro create mode 100644 apps/website/src/components/sections/CTASection.astro create mode 100644 apps/website/src/components/sections/FeatureGrid.astro create mode 100644 apps/website/src/components/sections/LogoStrip.astro create mode 100644 apps/website/src/components/sections/PageHero.astro create mode 100644 apps/website/src/components/sections/TrustGrid.astro create mode 100644 apps/website/src/content.config.ts create mode 100644 apps/website/src/content/pages/contact.ts create mode 100644 apps/website/src/content/pages/home.ts create mode 100644 apps/website/src/content/pages/integrations.ts create mode 100644 apps/website/src/content/pages/legal.ts create mode 100644 apps/website/src/content/pages/privacy.ts create mode 100644 apps/website/src/content/pages/product.ts create mode 100644 apps/website/src/content/pages/security-trust.ts create mode 100644 apps/website/src/content/pages/solutions.ts create mode 100644 apps/website/src/content/pages/terms.ts create mode 100644 apps/website/src/lib/seo.ts create mode 100644 apps/website/src/lib/site.ts create mode 100644 apps/website/src/pages/contact.astro create mode 100644 apps/website/src/pages/integrations.astro create mode 100644 apps/website/src/pages/legal.astro create mode 100644 apps/website/src/pages/privacy.astro create mode 100644 apps/website/src/pages/product.astro create mode 100644 apps/website/src/pages/security-trust.astro create mode 100644 apps/website/src/pages/sitemap.xml.ts create mode 100644 apps/website/src/pages/solutions.astro create mode 100644 apps/website/src/pages/terms.astro create mode 100644 apps/website/src/styles/tokens.css create mode 100644 apps/website/src/types/site.ts create mode 100644 apps/website/tests/smoke/contact-legal.spec.ts create mode 100644 apps/website/tests/smoke/home-product.spec.ts create mode 100644 apps/website/tests/smoke/smoke-helpers.ts create mode 100644 apps/website/tests/smoke/solutions-trust-integrations.spec.ts create mode 100644 apps/website/tsconfig.json create mode 100644 specs/213-website-foundation-v0/checklists/requirements.md create mode 100644 specs/213-website-foundation-v0/contracts/public-site.openapi.yaml create mode 100644 specs/213-website-foundation-v0/data-model.md create mode 100644 specs/213-website-foundation-v0/plan.md create mode 100644 specs/213-website-foundation-v0/quickstart.md create mode 100644 specs/213-website-foundation-v0/research.md create mode 100644 specs/213-website-foundation-v0/spec.md create mode 100644 specs/213-website-foundation-v0/tasks.md diff --git a/.dockerignore b/.dockerignore index a5b9aa4d..76a7fbc6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,9 @@ apps/platform/node_modules/ apps/website/node_modules/ apps/website/.astro/ apps/website/dist/ +apps/website/playwright-report/ +apps/website/test-results/ +apps/website/blob-report/ dist/ build/ vendor/ diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 7d76cbd8..803a6b60 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -205,6 +205,8 @@ ## Active Technologies - Repository-owned markdown and contract artifacts under `.specify/`, `specs/212-test-authoring-guardrails/`, and root documentation files; no product database persistence (212-test-authoring-guardrails) - PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `WorkspaceContext`, `OperateHubShell`, `EnsureFilamentTenantSelected`, `WorkspaceRedirectResolver`, `WorkspaceIntendedUrl`, `TenantPageCategory`, and `ResolvesPanelTenantContext` (199-global-context-shell-contract) - PostgreSQL unchanged plus existing Laravel session keys `current_workspace_id`, `workspace_intended_url`, and `workspace_last_tenant_ids`; no schema change planned (199-global-context-shell-contract) +- Astro 6.0.0 templates + TypeScript 5.x (explicit setup in `apps/website`) + Astro 6, Tailwind CSS v4, custom Astro component primitives (shadcn-inspired), lightweight Playwright browser smoke tests (213-website-foundation-v0) +- Static filesystem content, styles, and assets under `apps/website/src` and `apps/website/public`; no database (213-website-foundation-v0) - PHP 8.4.15 (feat/005-bulk-operations) @@ -239,8 +241,8 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 213-website-foundation-v0: Added Astro 6.0.0 templates + TypeScript 5.x (explicit setup in `apps/website`) + Astro 6, Tailwind CSS v4, custom Astro component primitives (shadcn-inspired), lightweight Playwright browser smoke tests - 199-global-context-shell-contract: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `WorkspaceContext`, `OperateHubShell`, `EnsureFilamentTenantSelected`, `WorkspaceRedirectResolver`, `WorkspaceIntendedUrl`, `TenantPageCategory`, and `ResolvesPanelTenantContext` - 212-test-authoring-guardrails: Added Markdown for repository governance artifacts, JSON Schema plus logical OpenAPI for planning contracts, and Bash-backed SpecKit scripts already present in the repo + `.specify/memory/constitution.md`, `.specify/templates/spec-template.md`, `.specify/templates/plan-template.md`, `.specify/templates/tasks-template.md`, `.specify/templates/checklist-template.md`, `.specify/README.md`, `README.md`, and the existing Specs 206 through 211 governance vocabulary -- 211-runtime-trend-recalibration: Added PHP 8.4.15 for repo-truth governance logic, Bash for repo-root wrappers, GitHub-compatible Gitea Actions workflow YAML under `.gitea/workflows/`, plus JSON Schema and logical OpenAPI for repository contracts + Laravel 12, Pest v4, PHPUnit 12, Filament v5, Livewire v4, Laravel Sail, Gitea Actions backed by `act_runner`, uploaded artifact bundles, and the existing `Tests\Support\TestLaneManifest`, `TestLaneBudget`, and `TestLaneReport` seams diff --git a/.gitignore b/.gitignore index 5d7c8605..d2ec0721 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ /apps/website/node_modules /.pnpm-store /apps/website/.astro +/apps/website/playwright-report +/apps/website/test-results +/apps/website/blob-report dist/ build/ coverage/ diff --git a/.npmignore b/.npmignore index 113b9ae7..fff9dbff 100644 --- a/.npmignore +++ b/.npmignore @@ -7,6 +7,9 @@ apps/platform/node_modules/ apps/website/node_modules/ apps/website/.astro/ apps/website/dist/ +apps/website/playwright-report/ +apps/website/test-results/ +apps/website/blob-report/ vendor/ apps/platform/vendor/ *.log diff --git a/apps/website/astro.config.mjs b/apps/website/astro.config.mjs index 25edb0f3..165f7ed8 100644 --- a/apps/website/astro.config.mjs +++ b/apps/website/astro.config.mjs @@ -1,9 +1,23 @@ +import { fileURLToPath } from 'node:url'; + +import tailwindcss from '@tailwindcss/vite'; import { defineConfig } from 'astro/config'; +const publicSiteUrl = process.env.PUBLIC_SITE_URL ?? 'https://tenantatlas.example'; + export default defineConfig({ output: 'static', + site: publicSiteUrl, server: { host: true, port: 4321, }, + vite: { + plugins: [tailwindcss()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + }, }); diff --git a/apps/website/package.json b/apps/website/package.json index abb3a3a2..5594266e 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -9,9 +9,18 @@ "scripts": { "dev": "astro dev --host 0.0.0.0 --port ${WEBSITE_PORT:-4321}", "build": "astro build", - "preview": "astro preview --host 0.0.0.0" + "preview": "astro preview --host 0.0.0.0", + "test": "playwright test", + "test:smoke": "playwright test" }, "dependencies": { "astro": "^6.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.59.1", + "@tailwindcss/vite": "^4.2.2", + "@types/node": "^24.7.2", + "tailwindcss": "^4.2.2", + "typescript": "^5.9.3" } } diff --git a/apps/website/playwright.config.ts b/apps/website/playwright.config.ts new file mode 100644 index 00000000..39e96dc3 --- /dev/null +++ b/apps/website/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from '@playwright/test'; + +const port = Number(process.env.WEBSITE_PORT ?? '4321'); +const baseURL = `http://127.0.0.1:${port}`; + +export default defineConfig({ + testDir: './tests/smoke', + fullyParallel: true, + retries: process.env.CI ? 2 : 0, + reporter: [['list']], + use: { + baseURL, + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], + webServer: { + command: `WEBSITE_PORT=${port} corepack pnpm dev`, + port, + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, +}); diff --git a/apps/website/public/robots.txt b/apps/website/public/robots.txt index c2a49f4f..b27b184a 100644 --- a/apps/website/public/robots.txt +++ b/apps/website/public/robots.txt @@ -1,2 +1,3 @@ User-agent: * Allow: / +Sitemap: /sitemap.xml diff --git a/apps/website/src/components/content/AudienceRow.astro b/apps/website/src/components/content/AudienceRow.astro new file mode 100644 index 00000000..bc41ecff --- /dev/null +++ b/apps/website/src/components/content/AudienceRow.astro @@ -0,0 +1,35 @@ +--- +import SecondaryCTA from '@/components/content/SecondaryCTA.astro'; +import Card from '@/components/primitives/Card.astro'; +import type { AudienceRowContent } from '@/types/site'; + +interface Props { + item: AudienceRowContent; +} + +const { item } = Astro.props; +--- + + +

+ {item.audience} +

+

+ {item.title} +

+

{item.description}

+ + {item.cta && ( +
+ +
+ )} +
diff --git a/apps/website/src/components/content/Callout.astro b/apps/website/src/components/content/Callout.astro new file mode 100644 index 00000000..b80f48fd --- /dev/null +++ b/apps/website/src/components/content/Callout.astro @@ -0,0 +1,23 @@ +--- +import Card from '@/components/primitives/Card.astro'; +import type { CalloutContent } from '@/types/site'; + +interface Props { + content: CalloutContent; +} + +const { content } = Astro.props; +const variant = content.tone === 'accent' ? 'accent' : content.tone === 'subtle' ? 'subtle' : 'default'; +--- + + + {content.eyebrow && ( +

+ {content.eyebrow} +

+ )} +

+ {content.title} +

+

{content.description}

+
diff --git a/apps/website/src/components/content/ContactPanel.astro b/apps/website/src/components/content/ContactPanel.astro new file mode 100644 index 00000000..60f67c38 --- /dev/null +++ b/apps/website/src/components/content/ContactPanel.astro @@ -0,0 +1,29 @@ +--- +import Button from '@/components/primitives/Button.astro'; +import Card from '@/components/primitives/Card.astro'; +import type { CtaLink } from '@/types/site'; + +interface Props { + cta: CtaLink; + points: string[]; + title: string; +} + +const { cta, points, title } = Astro.props; +--- + + +

{title}

+ +
+ +
+
diff --git a/apps/website/src/components/content/DemoPrompt.astro b/apps/website/src/components/content/DemoPrompt.astro new file mode 100644 index 00000000..4b5c72b2 --- /dev/null +++ b/apps/website/src/components/content/DemoPrompt.astro @@ -0,0 +1,18 @@ +--- +import Card from '@/components/primitives/Card.astro'; + +interface Props { + description: string; + title: string; +} + +const { description, title } = Astro.props; +--- + + +

+ Conversation focus +

+

{title}

+

{description}

+
diff --git a/apps/website/src/components/content/Eyebrow.astro b/apps/website/src/components/content/Eyebrow.astro new file mode 100644 index 00000000..93795711 --- /dev/null +++ b/apps/website/src/components/content/Eyebrow.astro @@ -0,0 +1,11 @@ +--- +interface Props { + class?: string; +} + +const { class: className = '' } = Astro.props; +--- + +

+ +

diff --git a/apps/website/src/components/content/FeatureItem.astro b/apps/website/src/components/content/FeatureItem.astro new file mode 100644 index 00000000..02c8b273 --- /dev/null +++ b/apps/website/src/components/content/FeatureItem.astro @@ -0,0 +1,32 @@ +--- +import Card from '@/components/primitives/Card.astro'; +import type { FeatureItemContent } from '@/types/site'; + +interface Props { + item: FeatureItemContent; +} + +const { item } = Astro.props; +--- + + + {item.eyebrow && ( +

+ {item.eyebrow} +

+ )} +

+ {item.title} +

+

{item.description}

+ {(item.meta || item.href) && ( +
+ {item.meta && {item.meta}} + {item.href && ( + + Learn more + + )} +
+ )} +
diff --git a/apps/website/src/components/content/Headline.astro b/apps/website/src/components/content/Headline.astro new file mode 100644 index 00000000..b167c7b2 --- /dev/null +++ b/apps/website/src/components/content/Headline.astro @@ -0,0 +1,11 @@ +--- +interface Props { + class?: string; +} + +const { class: className = '' } = Astro.props; +--- + +

+ +

diff --git a/apps/website/src/components/content/IntegrationBadge.astro b/apps/website/src/components/content/IntegrationBadge.astro new file mode 100644 index 00000000..aeeafb34 --- /dev/null +++ b/apps/website/src/components/content/IntegrationBadge.astro @@ -0,0 +1,19 @@ +--- +import Badge from '@/components/primitives/Badge.astro'; +import type { IntegrationEntry } from '@/types/site'; + +interface Props { + item: IntegrationEntry; +} + +const { item } = Astro.props; +--- + +
+
+ {item.category} +

{item.name}

+
+

{item.summary}

+ {item.note &&

{item.note}

} +
diff --git a/apps/website/src/components/content/Lead.astro b/apps/website/src/components/content/Lead.astro new file mode 100644 index 00000000..3f0f3027 --- /dev/null +++ b/apps/website/src/components/content/Lead.astro @@ -0,0 +1,11 @@ +--- +interface Props { + class?: string; +} + +const { class: className = '' } = Astro.props; +--- + +

+ +

diff --git a/apps/website/src/components/content/Metric.astro b/apps/website/src/components/content/Metric.astro new file mode 100644 index 00000000..f1b00255 --- /dev/null +++ b/apps/website/src/components/content/Metric.astro @@ -0,0 +1,18 @@ +--- +import Card from '@/components/primitives/Card.astro'; +import type { MetricItem } from '@/types/site'; + +interface Props { + item: MetricItem; +} + +const { item } = Astro.props; +--- + + +

{item.value}

+

+ {item.label} +

+

{item.description}

+
diff --git a/apps/website/src/components/content/PrimaryCTA.astro b/apps/website/src/components/content/PrimaryCTA.astro new file mode 100644 index 00000000..d2043771 --- /dev/null +++ b/apps/website/src/components/content/PrimaryCTA.astro @@ -0,0 +1,12 @@ +--- +import Button from '@/components/primitives/Button.astro'; +import type { CtaLink } from '@/types/site'; + +interface Props { + cta: CtaLink; +} + +const { cta } = Astro.props; +--- + + diff --git a/apps/website/src/components/content/RichText.astro b/apps/website/src/components/content/RichText.astro new file mode 100644 index 00000000..fa813d45 --- /dev/null +++ b/apps/website/src/components/content/RichText.astro @@ -0,0 +1,29 @@ +--- +import type { LegalSection } from '@/types/site'; + +interface Props { + sections: LegalSection[]; +} + +const { sections } = Astro.props; +--- + +
+ { + sections.map((section) => ( +
+

+ {section.title} +

+ +
+ )) + } +
diff --git a/apps/website/src/components/content/SecondaryCTA.astro b/apps/website/src/components/content/SecondaryCTA.astro new file mode 100644 index 00000000..78f8c40a --- /dev/null +++ b/apps/website/src/components/content/SecondaryCTA.astro @@ -0,0 +1,12 @@ +--- +import Button from '@/components/primitives/Button.astro'; +import type { CtaLink } from '@/types/site'; + +interface Props { + cta: CtaLink; +} + +const { cta } = Astro.props; +--- + + diff --git a/apps/website/src/components/content/TrustPrincipleCard.astro b/apps/website/src/components/content/TrustPrincipleCard.astro new file mode 100644 index 00000000..043fea74 --- /dev/null +++ b/apps/website/src/components/content/TrustPrincipleCard.astro @@ -0,0 +1,16 @@ +--- +import Card from '@/components/primitives/Card.astro'; +import type { TrustPrincipleContent } from '@/types/site'; + +interface Props { + item: TrustPrincipleContent; +} + +const { item } = Astro.props; +--- + + +

{item.title}

+

{item.description}

+ {item.note &&

{item.note}

} +
diff --git a/apps/website/src/components/layout/Footer.astro b/apps/website/src/components/layout/Footer.astro new file mode 100644 index 00000000..9b81361a --- /dev/null +++ b/apps/website/src/components/layout/Footer.astro @@ -0,0 +1,59 @@ +--- +import Button from '@/components/primitives/Button.astro'; +import Container from '@/components/primitives/Container.astro'; +import { contactCta, footerNavigationGroups, siteMetadata } from '@/lib/site'; + +interface Props { + currentPath: string; +} + +const { currentPath: _currentPath } = Astro.props; +const currentYear = new Date().getFullYear(); +--- + + diff --git a/apps/website/src/components/layout/Navbar.astro b/apps/website/src/components/layout/Navbar.astro new file mode 100644 index 00000000..8ebc6240 --- /dev/null +++ b/apps/website/src/components/layout/Navbar.astro @@ -0,0 +1,105 @@ +--- +import Button from '@/components/primitives/Button.astro'; +import Container from '@/components/primitives/Container.astro'; +import { contactCta, isActiveNavigationPath, primaryNavigation, siteMetadata } from '@/lib/site'; + +interface Props { + currentPath: string; +} + +const { currentPath } = Astro.props; +--- + +
+ +
+ + + TA + + + + {siteMetadata.siteName} + + + {siteMetadata.siteTagline} + + + + + + + + +
+ + Open navigation menu + + + + + + +
+ +
+
+
+
+
diff --git a/apps/website/src/components/layout/PageShell.astro b/apps/website/src/components/layout/PageShell.astro new file mode 100644 index 00000000..32d1b239 --- /dev/null +++ b/apps/website/src/components/layout/PageShell.astro @@ -0,0 +1,39 @@ +--- +import Footer from '@/components/layout/Footer.astro'; +import Navbar from '@/components/layout/Navbar.astro'; +import { resolveSeo } from '@/lib/seo'; +import BaseLayout from '@/layouts/BaseLayout.astro'; + +interface Props { + currentPath: string; + description?: string; + title?: string; +} + +const { currentPath, description, title } = Astro.props; +const seo = + title && description + ? resolveSeo({ description, path: currentPath, title }) + : undefined; +--- + + +
+
+
+ +
+ +
+
+
+
diff --git a/apps/website/src/components/primitives/Badge.astro b/apps/website/src/components/primitives/Badge.astro new file mode 100644 index 00000000..5aaac124 --- /dev/null +++ b/apps/website/src/components/primitives/Badge.astro @@ -0,0 +1,25 @@ +--- +interface Props { + class?: string; + tone?: 'accent' | 'neutral' | 'signal' | 'warm'; +} + +const { class: className = '', tone = 'accent' } = Astro.props; + +const toneClasses = { + accent: 'bg-[var(--color-brand-soft)] text-[var(--color-brand)]', + neutral: 'bg-white/75 text-[var(--color-ink-800)]', + signal: 'bg-[rgba(59,139,120,0.14)] text-[var(--color-signal)]', + warm: 'bg-[rgba(175,109,67,0.14)] text-[var(--color-warm)]', +}; +--- + + + + diff --git a/apps/website/src/components/primitives/Button.astro b/apps/website/src/components/primitives/Button.astro new file mode 100644 index 00000000..72c29afa --- /dev/null +++ b/apps/website/src/components/primitives/Button.astro @@ -0,0 +1,56 @@ +--- +import type { ButtonVariant } from '@/types/site'; + +interface Props { + ariaLabel?: string; + class?: string; + href?: string; + rel?: string; + size?: 'lg' | 'md' | 'sm'; + target?: '_blank' | '_self'; + type?: 'button' | 'reset' | 'submit'; + variant?: ButtonVariant; +} + +const { + ariaLabel, + class: className = '', + href, + rel, + size = 'md', + target, + type = 'button', + variant = 'primary', +} = Astro.props; + +const baseClass = + 'inline-flex items-center justify-center rounded-full border font-semibold tracking-[-0.01em] transition duration-150 focus-visible:outline-none'; + +const sizeClasses = { + sm: 'min-h-10 px-4 text-sm', + md: 'min-h-11 px-5 text-sm sm:text-[0.95rem]', + lg: 'min-h-12 px-6 text-[0.95rem]', +}; + +const variantClasses = { + primary: + 'border-transparent bg-[var(--color-ink-900)] text-white shadow-[0_18px_38px_rgba(17,36,58,0.16)] hover:bg-[var(--color-brand)]', + secondary: + 'border-[color:var(--color-line)] bg-white/85 text-[var(--color-ink-900)] hover:border-[var(--color-ink-900)] hover:bg-white', + ghost: 'border-transparent bg-transparent text-[var(--color-ink-800)] hover:bg-white/70', +}; + +const classes = [baseClass, sizeClasses[size], variantClasses[variant], className]; +--- + +{ + href ? ( + + + + ) : ( + + ) +} diff --git a/apps/website/src/components/primitives/Card.astro b/apps/website/src/components/primitives/Card.astro new file mode 100644 index 00000000..9be30d22 --- /dev/null +++ b/apps/website/src/components/primitives/Card.astro @@ -0,0 +1,23 @@ +--- +interface Props { + as?: keyof HTMLElementTagNameMap; + class?: string; + variant?: 'accent' | 'default' | 'subtle'; +} + +const { as = 'article', class: className = '', variant = 'default' } = Astro.props; + +const variantClasses = { + default: 'glass-panel border border-[color:var(--color-line)] bg-[var(--color-panel)]', + accent: + 'border border-[rgba(47,111,183,0.18)] bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(238,245,252,0.94))] shadow-[var(--shadow-soft)]', + subtle: + 'border border-[rgba(17,36,58,0.08)] bg-[linear-gradient(180deg,rgba(255,255,255,0.78),rgba(255,255,255,0.56))]', +}; + +const Tag = as; +--- + + + + diff --git a/apps/website/src/components/primitives/Cluster.astro b/apps/website/src/components/primitives/Cluster.astro new file mode 100644 index 00000000..a60eba3b --- /dev/null +++ b/apps/website/src/components/primitives/Cluster.astro @@ -0,0 +1,13 @@ +--- +interface Props { + as?: keyof HTMLElementTagNameMap; + class?: string; +} + +const { as = 'div', class: className = '' } = Astro.props; +const Tag = as; +--- + + + + diff --git a/apps/website/src/components/primitives/Container.astro b/apps/website/src/components/primitives/Container.astro new file mode 100644 index 00000000..2663cd79 --- /dev/null +++ b/apps/website/src/components/primitives/Container.astro @@ -0,0 +1,14 @@ +--- +interface Props { + as?: keyof HTMLElementTagNameMap; + class?: string; + wide?: boolean; +} + +const { as = 'div', class: className = '', wide = false } = Astro.props; +const Tag = as; +--- + + + + diff --git a/apps/website/src/components/primitives/Grid.astro b/apps/website/src/components/primitives/Grid.astro new file mode 100644 index 00000000..b7a439f8 --- /dev/null +++ b/apps/website/src/components/primitives/Grid.astro @@ -0,0 +1,18 @@ +--- +interface Props { + class?: string; + cols?: '2' | '3' | '4'; +} + +const { class: className = '', cols = '3' } = Astro.props; + +const colClasses = { + '2': 'grid-cols-1 md:grid-cols-2', + '3': 'grid-cols-1 md:grid-cols-2 xl:grid-cols-3', + '4': 'grid-cols-1 md:grid-cols-2 xl:grid-cols-4', +}; +--- + +
+ +
diff --git a/apps/website/src/components/primitives/Input.astro b/apps/website/src/components/primitives/Input.astro new file mode 100644 index 00000000..306bd13d --- /dev/null +++ b/apps/website/src/components/primitives/Input.astro @@ -0,0 +1,32 @@ +--- +interface Props { + class?: string; + name?: string; + placeholder?: string; + readonly?: boolean; + type?: string; + value?: string; +} + +const { + class: className = '', + name, + placeholder, + readonly = false, + type = 'text', + value, +} = Astro.props; +--- + + diff --git a/apps/website/src/components/primitives/Section.astro b/apps/website/src/components/primitives/Section.astro new file mode 100644 index 00000000..0d2f51a6 --- /dev/null +++ b/apps/website/src/components/primitives/Section.astro @@ -0,0 +1,22 @@ +--- +interface Props { + as?: keyof HTMLElementTagNameMap; + class?: string; + id?: string; + muted?: boolean; +} + +const { as = 'section', class: className = '', id, muted = false } = Astro.props; +const Tag = as; +--- + + + + diff --git a/apps/website/src/components/primitives/SectionHeader.astro b/apps/website/src/components/primitives/SectionHeader.astro new file mode 100644 index 00000000..ac59db6f --- /dev/null +++ b/apps/website/src/components/primitives/SectionHeader.astro @@ -0,0 +1,27 @@ +--- +import Eyebrow from '@/components/content/Eyebrow.astro'; +import Headline from '@/components/content/Headline.astro'; +import Lead from '@/components/content/Lead.astro'; + +interface Props { + align?: 'center' | 'left'; + class?: string; + description?: string; + eyebrow?: string; + title: string; +} + +const { + align = 'left', + class: className = '', + description, + eyebrow, + title, +} = Astro.props; +--- + +
+ {eyebrow && {eyebrow}} + {title} + {description && {description}} +
diff --git a/apps/website/src/components/primitives/Stack.astro b/apps/website/src/components/primitives/Stack.astro new file mode 100644 index 00000000..262da047 --- /dev/null +++ b/apps/website/src/components/primitives/Stack.astro @@ -0,0 +1,20 @@ +--- +interface Props { + as?: keyof HTMLElementTagNameMap; + class?: string; + gap?: 'lg' | 'md' | 'sm' | 'xl'; +} + +const { as = 'div', class: className = '', gap = 'md' } = Astro.props; +const gapClasses = { + sm: 'flex flex-col gap-3', + md: 'flex flex-col gap-5', + lg: 'flex flex-col gap-7', + xl: 'flex flex-col gap-10', +}; +const Tag = as; +--- + + + + diff --git a/apps/website/src/components/primitives/Textarea.astro b/apps/website/src/components/primitives/Textarea.astro new file mode 100644 index 00000000..7c9d30bc --- /dev/null +++ b/apps/website/src/components/primitives/Textarea.astro @@ -0,0 +1,31 @@ +--- +interface Props { + class?: string; + name?: string; + placeholder?: string; + readonly?: boolean; + rows?: number; + value?: string; +} + +const { + class: className = '', + name, + placeholder, + readonly = false, + rows = 5, + value, +} = Astro.props; +--- + + diff --git a/apps/website/src/components/sections/CTASection.astro b/apps/website/src/components/sections/CTASection.astro new file mode 100644 index 00000000..e5960581 --- /dev/null +++ b/apps/website/src/components/sections/CTASection.astro @@ -0,0 +1,33 @@ +--- +import SecondaryCTA from '@/components/content/SecondaryCTA.astro'; +import PrimaryCTA from '@/components/content/PrimaryCTA.astro'; +import Card from '@/components/primitives/Card.astro'; +import Container from '@/components/primitives/Container.astro'; +import Section from '@/components/primitives/Section.astro'; +import SectionHeader from '@/components/primitives/SectionHeader.astro'; +import type { CtaLink } from '@/types/site'; + +interface Props { + description: string; + eyebrow?: string; + primary: CtaLink; + secondary?: CtaLink; + title: string; +} + +const { description, eyebrow, primary, secondary, title } = Astro.props; +--- + +
+ + +
+ +
+ + {secondary && } +
+
+
+
+
diff --git a/apps/website/src/components/sections/FeatureGrid.astro b/apps/website/src/components/sections/FeatureGrid.astro new file mode 100644 index 00000000..feb17db6 --- /dev/null +++ b/apps/website/src/components/sections/FeatureGrid.astro @@ -0,0 +1,28 @@ +--- +import FeatureItem from '@/components/content/FeatureItem.astro'; +import Container from '@/components/primitives/Container.astro'; +import Grid from '@/components/primitives/Grid.astro'; +import Section from '@/components/primitives/Section.astro'; +import SectionHeader from '@/components/primitives/SectionHeader.astro'; +import type { FeatureItemContent } from '@/types/site'; + +interface Props { + description?: string; + eyebrow?: string; + items: FeatureItemContent[]; + title: string; +} + +const { description, eyebrow, items, title } = Astro.props; +--- + +
+ +
+ + + {items.map((item) => )} + +
+
+
diff --git a/apps/website/src/components/sections/LogoStrip.astro b/apps/website/src/components/sections/LogoStrip.astro new file mode 100644 index 00000000..0fae3555 --- /dev/null +++ b/apps/website/src/components/sections/LogoStrip.astro @@ -0,0 +1,44 @@ +--- +import IntegrationBadge from '@/components/content/IntegrationBadge.astro'; +import Badge from '@/components/primitives/Badge.astro'; +import Container from '@/components/primitives/Container.astro'; +import Section from '@/components/primitives/Section.astro'; +import type { IntegrationEntry, LogoStripItem } from '@/types/site'; + +interface Props { + eyebrow?: string; + items: (IntegrationEntry | LogoStripItem)[]; + title: string; +} + +const { eyebrow, items, title } = Astro.props; +--- + +
+ +
+
+
+ {eyebrow && {eyebrow}} +

+ {title} +

+
+
+ { + items.map((item) => ( + + )) + } +
+
+
+
+
diff --git a/apps/website/src/components/sections/PageHero.astro b/apps/website/src/components/sections/PageHero.astro new file mode 100644 index 00000000..9d80758c --- /dev/null +++ b/apps/website/src/components/sections/PageHero.astro @@ -0,0 +1,96 @@ +--- +import Badge from '@/components/primitives/Badge.astro'; +import Button from '@/components/primitives/Button.astro'; +import Card from '@/components/primitives/Card.astro'; +import Cluster from '@/components/primitives/Cluster.astro'; +import Container from '@/components/primitives/Container.astro'; +import type { HeroContent, MetricItem } from '@/types/site'; + +interface Props { + calloutDescription?: string; + calloutTitle?: string; + hero: HeroContent; + metrics?: MetricItem[]; +} + +const { calloutDescription, calloutTitle, hero, metrics = [] } = Astro.props; +--- + +
+ +
+ +
+ {hero.eyebrow} +
+

+ {hero.title} +

+

+ {hero.description} +

+
+ {(hero.primaryCta || hero.secondaryCta) && ( + + + {hero.secondaryCta && ( + + )} + + )} + {hero.highlights && hero.highlights.length > 0 && ( +
    + { + hero.highlights.map((highlight) => ( +
  • + {highlight} +
  • + )) + } +
+ )} +
+
+ +
+ {(calloutTitle || calloutDescription) && ( + +

+ Trust-first launch surface +

+ {calloutTitle && ( +

+ {calloutTitle} +

+ )} + {calloutDescription && ( +

{calloutDescription}

+ )} +
+ )} + + {metrics.length > 0 && ( +
+ { + metrics.map((metric) => ( + +

+ {metric.value} +

+

+ {metric.label} +

+

{metric.description}

+
+ )) + } +
+ )} +
+
+
+
diff --git a/apps/website/src/components/sections/TrustGrid.astro b/apps/website/src/components/sections/TrustGrid.astro new file mode 100644 index 00000000..3ec89675 --- /dev/null +++ b/apps/website/src/components/sections/TrustGrid.astro @@ -0,0 +1,28 @@ +--- +import TrustPrincipleCard from '@/components/content/TrustPrincipleCard.astro'; +import Container from '@/components/primitives/Container.astro'; +import Grid from '@/components/primitives/Grid.astro'; +import Section from '@/components/primitives/Section.astro'; +import SectionHeader from '@/components/primitives/SectionHeader.astro'; +import type { TrustPrincipleContent } from '@/types/site'; + +interface Props { + description?: string; + eyebrow?: string; + items: TrustPrincipleContent[]; + title: string; +} + +const { description, eyebrow, items, title } = Astro.props; +--- + +
+ +
+ + + {items.map((item) => )} + +
+
+
diff --git a/apps/website/src/content.config.ts b/apps/website/src/content.config.ts new file mode 100644 index 00000000..0841f9a8 --- /dev/null +++ b/apps/website/src/content.config.ts @@ -0,0 +1,24 @@ +import { glob } from 'astro/loaders'; +import { defineCollection, z } from 'astro:content'; + +const futureContentSchema = z.object({ + title: z.string(), + description: z.string(), + publishedAt: z.coerce.date().optional(), + draft: z.boolean().default(false), +}); + +export const collections = { + articles: defineCollection({ + loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/articles' }), + schema: futureContentSchema, + }), + changelog: defineCollection({ + loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/changelog' }), + schema: futureContentSchema, + }), + resources: defineCollection({ + loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/resources' }), + schema: futureContentSchema, + }), +}; diff --git a/apps/website/src/content/pages/contact.ts b/apps/website/src/content/pages/contact.ts new file mode 100644 index 00000000..c1d68724 --- /dev/null +++ b/apps/website/src/content/pages/contact.ts @@ -0,0 +1,65 @@ +import type { HeroContent, LegalSection, PageSeo } from '@/types/site'; + +export const contactSeo: PageSeo = { + title: 'TenantAtlas | Contact', + description: + 'TenantAtlas uses a qualified working-session path instead of a generic demo pitch so serious buyers can frame the right conversation early.', + path: '/contact', +}; + +export const contactHero: HeroContent = { + eyebrow: 'Contact / Demo', + title: 'Start a qualified working session instead of a generic demo request.', + description: + 'The contact path should help serious buyers explain who they are, what governance questions they are trying to solve, and what kind of follow-up would actually be useful.', + primaryCta: { + href: 'mailto:hello@tenantatlas.example?subject=TenantAtlas%20working%20session', + label: 'Email the TenantAtlas team', + variant: 'primary', + }, + secondaryCta: { + href: '/legal', + label: 'Read the legal surface', + variant: 'secondary', + }, + highlights: [ + 'Use the page to qualify the conversation, not to force a form funnel.', + 'Set expectations for what the session covers and what happens next.', + 'Keep privacy and terms visible before anyone shares evaluation details.', + ], +}; + +export const contactTopics = [ + 'Evaluation of backup, restore, or version-governance workflows for Intune and Microsoft tenant operations.', + 'Questions about MSP fit, customer-facing evidence, or multi-tenant operational discipline.', + 'Internal enterprise review of change history, drift visibility, evidence collection, or restore posture.', +]; + +export const contactPrompts = [ + { + title: 'Good first conversation', + description: + 'Explain the current operating model, where version history breaks down today, and which changes feel hardest to review or restore safely.', + }, + { + title: 'Useful context to share', + description: + 'Team shape, tenant count, policy complexity, change frequency, and whether the first concern is restore safety, auditability, or review evidence.', + }, +]; + +export const contactPreview = { + message: + 'We operate Microsoft tenant governance across multiple environments and want to understand how TenantAtlas approaches version history, safer restore flows, drift visibility, and review evidence.', + topic: 'Environment and operating model summary', +}; + +export const contactLegalSections: LegalSection[] = [ + { + title: 'Before you reach out', + body: [ + 'Use the legal links below before sharing evaluation details so the contact path stays trustworthy and unsurprising.', + 'The legal hub, privacy page, and public website terms remain reachable from the contact flow and the global footer.', + ], + }, +]; diff --git a/apps/website/src/content/pages/home.ts b/apps/website/src/content/pages/home.ts new file mode 100644 index 00000000..68d60136 --- /dev/null +++ b/apps/website/src/content/pages/home.ts @@ -0,0 +1,145 @@ +import type { + CalloutContent, + FeatureItemContent, + HeroContent, + IntegrationEntry, + MetricItem, + PageSeo, +} from '@/types/site'; + +export const homeSeo: PageSeo = { + title: 'TenantAtlas | Governance of record for Microsoft tenant operations', + description: + 'Trust-first public framing for a Microsoft tenant governance product that connects backup, restore, version history, drift, findings, evidence, and reviews.', + path: '/', +}; + +export const homeHero: HeroContent = { + eyebrow: 'Public website v0', + title: 'TenantAtlas is the trust-first public site for Microsoft tenant change history, drift visibility, and safer operations.', + description: + 'TenantAtlas gives MSP and enterprise teams one clear operating model for understanding what changed, what drifted, what needs review, and what can be restored without turning governance into a loose collection of disconnected tools.', + primaryCta: { + href: '/product', + label: 'See the product model', + }, + secondaryCta: { + href: '/security-trust', + label: 'Review the trust posture', + variant: 'secondary', + }, + highlights: [ + 'Static, readable, and separate from app runtime concerns.', + 'Built for trust conversations before a demo ever starts.', + 'Designed to scale into docs, changelog, and deeper resources later.', + ], +}; + +export const homeMetrics: MetricItem[] = [ + { + value: '1', + label: 'Connected model', + description: 'Inventory, snapshots, review evidence, and restore posture stay in one narrative.', + }, + { + value: '7+', + label: 'Core public surfaces', + description: 'Visitors can move from explanation to trust and contact without dead ends.', + }, + { + value: '0', + label: 'Runtime coupling', + description: 'The website stays independent from platform auth, session, and API behavior.', + }, +]; + +export const homePillars: FeatureItemContent[] = [ + { + eyebrow: 'Inventory', + title: 'Normalize what the tenant really looks like right now.', + description: + 'Start with the observed state so teams can inspect the current configuration baseline before they talk about restore or enforcement.', + meta: 'Last observed truth', + }, + { + eyebrow: 'Snapshots', + title: 'Keep immutable history instead of vague memory.', + description: + 'Version history stays queryable by tenant, operator, and moment in time so teams can explain what changed and why.', + meta: 'Reproducible versions', + }, + { + eyebrow: 'Drift & findings', + title: 'Surface drift, exceptions, and review needs in the same language.', + description: + 'Operational questions move from “what broke?” to “what changed, what matters, and what review is due?”', + meta: 'Review-oriented visibility', + }, + { + eyebrow: 'Restore', + title: 'Treat rollback and restore as governed actions, not panic buttons.', + description: + 'Preview, validation, and operator confirmation stay central so risky changes are reversible without becoming casual.', + meta: 'Safer execution', + }, + { + eyebrow: 'Evidence', + title: 'Connect reviews, findings, and evidence without a second reporting layer.', + description: + 'Teams can show why a configuration is acceptable, where exceptions exist, and how review decisions stay attributable.', + meta: 'Audit-ready context', + }, + { + eyebrow: 'Operations', + title: 'Keep velocity without hiding risk.', + description: + 'The product is built for admins who need speed and auditability at the same time, not for dashboards that only summarize after the fact.', + meta: 'Operator-first workflows', + }, +]; + +export const homeProofBlocks: CalloutContent[] = [ + { + eyebrow: 'Positioning', + title: 'Governance of record for Microsoft tenant operations.', + description: + 'The site makes the category legible up front: not just backup, not just reporting, and not a second admin portal trying to mirror every Microsoft screen.', + tone: 'accent', + }, + { + eyebrow: 'Why it matters now', + title: 'Microsoft tenant change volume keeps climbing while operator certainty keeps shrinking.', + description: + 'When policy history, restore posture, findings, and evidence live in separate conversations, teams lose time exactly when they need clarity.', + }, + { + eyebrow: 'Public promise', + title: 'No inflated compliance or automation claims.', + description: + 'The public story stays grounded in what the product can honestly support at launch: version truth, safer restore flows, drift visibility, and review support.', + tone: 'subtle', + }, +]; + +export const homeEcosystem: IntegrationEntry[] = [ + { + category: 'Microsoft', + name: 'Microsoft Graph', + summary: 'Graph-backed inventory and restore direction without pretending the website depends on live tenant access.', + }, + { + category: 'Identity', + name: 'Entra ID', + summary: 'Identity and access context remain part of the governance narrative where they matter to change control.', + }, + { + category: 'Endpoint', + name: 'Intune', + summary: 'Configuration state, backup, and restore posture stay central to the public product story.', + }, + { + category: 'Review', + name: 'Evidence workflows', + summary: 'Review packs, exceptions, and evidence stay connected to operational reality instead of becoming detached reporting artifacts.', + }, +]; diff --git a/apps/website/src/content/pages/integrations.ts b/apps/website/src/content/pages/integrations.ts new file mode 100644 index 00000000..7071ddf3 --- /dev/null +++ b/apps/website/src/content/pages/integrations.ts @@ -0,0 +1,86 @@ +import type { + FeatureItemContent, + HeroContent, + IntegrationEntry, + PageSeo, +} from '@/types/site'; + +export const integrationsSeo: PageSeo = { + title: 'TenantAtlas | Integrations', + description: + 'TenantAtlas describes the Microsoft-centric ecosystem it fits today without turning the page into a public wishlist.', + path: '/integrations', +}; + +export const integrationsHero: HeroContent = { + eyebrow: 'Ecosystem fit', + title: 'Stay clear about the ecosystem fit without turning the page into a wishlist.', + description: + 'This page should show the real systems TenantAtlas is built around, the workflows it expects to support, and the deliberate boundaries where speculative integration language would create more noise than trust.', + primaryCta: { + href: '/contact', + label: 'Plan the working session', + }, + secondaryCta: { + href: '/product', + label: 'Revisit the product model', + variant: 'secondary', + }, + highlights: [ + 'Microsoft-first and governance-led.', + 'Real direction only, no catalog-padding.', + 'Explains fit without implying runtime coupling to the public site.', + ], +}; + +export const integrationEntries: IntegrationEntry[] = [ + { + category: 'Microsoft core', + name: 'Microsoft Graph', + summary: + 'Graph remains the primary contract path for inventory, history, and restore-oriented product behavior in the platform.', + note: 'Core product contract', + }, + { + category: 'Identity', + name: 'Entra ID', + summary: + 'Identity context matters where tenant change, privileged access, or governance reviews intersect with Microsoft tenant administration.', + note: 'Operational context', + }, + { + category: 'Endpoint', + name: 'Intune', + summary: + 'Intune configuration state is central to the current product story, especially for version history, backup, restore, and drift visibility.', + note: 'Current release truth', + }, + { + category: 'Evidence', + name: 'Review & evidence workflows', + summary: + 'Exports, review packs, and evidence-linked conversations should remain grounded in the actual tenant object and its change history.', + note: 'Governance workflow fit', + }, +]; + +export const integrationRules: FeatureItemContent[] = [ + { + eyebrow: 'Direction', + title: 'Integrations should reinforce the governance model.', + description: + 'The page is not a marketplace list. It should show which systems matter because they change the product workflow, evidence story, or operator context.', + }, + { + eyebrow: 'Boundaries', + title: 'Do not imply shared public-site runtime dependencies.', + description: + 'The website stays static and independent even while the product story references Graph, Intune, and Microsoft tenant governance flows.', + }, + { + eyebrow: 'Credibility', + title: 'Speculative wishlist entries reduce trust instead of creating momentum.', + description: + 'The public integrations page should stay shorter and sharper than a catalog full of future ideas that are not relevant to launch truth.', + }, +]; diff --git a/apps/website/src/content/pages/legal.ts b/apps/website/src/content/pages/legal.ts new file mode 100644 index 00000000..9082183d --- /dev/null +++ b/apps/website/src/content/pages/legal.ts @@ -0,0 +1,44 @@ +import type { HeroContent, LegalSection, PageSeo } from '@/types/site'; + +export const legalSeo: PageSeo = { + title: 'TenantAtlas | Legal', + description: + 'The TenantAtlas legal surface keeps privacy, website terms, and public legal-notice routing accessible before or during buyer conversations.', + path: '/legal', +}; + +export const legalHero: HeroContent = { + eyebrow: 'Legal surface', + title: 'Legal access should stay one click away from the contact path.', + description: + 'The legal hub keeps privacy, website terms, and public legal-notice information discoverable from the footer and the conversion flow so visitors do not have to guess where those basics live.', + primaryCta: { + href: '/privacy', + label: 'Privacy', + }, + secondaryCta: { + href: '/terms', + label: 'Terms', + variant: 'secondary', + }, + highlights: [ + 'Public legal basics stay reachable before a visitor shares evaluation context.', + 'The site separates website disclosures from future product-commercial paperwork.', + 'Jurisdiction-specific notice has a dedicated home in the legal surface.', + ], +}; + +export const legalNoticeSections: LegalSection[] = [ + { + title: 'Public legal notice', + body: [ + 'This v0 legal surface reserves the public location for operating-entity, registration, address, and jurisdiction-specific disclosure details that must be published before a broad public launch.', + 'During controlled evaluation, legal and privacy inquiries can be routed through the public contact path while those final publisher details are being finalized.', + ], + bullets: [ + 'Operating entity and jurisdictional disclosure fields belong in this legal hub before launch.', + 'Privacy and website terms stay published as standalone routes now.', + 'The legal hub remains the single public path for future launch-required disclosures.', + ], + }, +]; diff --git a/apps/website/src/content/pages/privacy.ts b/apps/website/src/content/pages/privacy.ts new file mode 100644 index 00000000..8d353a05 --- /dev/null +++ b/apps/website/src/content/pages/privacy.ts @@ -0,0 +1,60 @@ +import type { HeroContent, LegalSection, PageSeo } from '@/types/site'; + +export const privacySeo: PageSeo = { + title: 'TenantAtlas | Privacy', + description: + 'Public-site privacy overview for TenantAtlas inquiries, including how contact details and evaluation context are handled on the public website.', + path: '/privacy', +}; + +export const privacyHero: HeroContent = { + eyebrow: 'Privacy', + title: 'Public-site privacy overview for TenantAtlas inquiries.', + description: + 'This page explains the privacy expectations for the public website and the contact path, rather than promising a full product-tenant data processing agreement from a static marketing surface.', + primaryCta: { + href: '/contact', + label: 'Return to contact', + }, + secondaryCta: { + href: '/terms', + label: 'Review website terms', + variant: 'secondary', + }, + highlights: [ + 'The public site should only request information that supports a useful follow-up.', + 'Contact details and evaluation context should be handled carefully and minimally.', + 'Future product-processing details belong in product/legal agreements, not hidden marketing copy.', + ], +}; + +export const privacySections: LegalSection[] = [ + { + title: 'Scope', + body: [ + 'This privacy overview applies to the public TenantAtlas website and to information a visitor intentionally shares through the public contact path.', + 'It does not describe tenant data processing inside the product itself, which belongs in product-specific legal and contractual materials.', + ], + }, + { + title: 'Information you choose to send', + body: [ + 'If you contact the team, the information you provide may include your name, company, role, email address, and the evaluation or governance questions you want to discuss.', + 'The site should not ask for unnecessary secrets, production credentials, or tenant data through the public contact path.', + ], + }, + { + title: 'Use and retention', + body: [ + 'Information shared through the public contact path is used to understand the inquiry, respond to the request, and coordinate a relevant follow-up conversation.', + 'Public-site inquiry information should be retained only for as long as needed to manage the evaluation discussion and related follow-up.', + ], + }, + { + title: 'Questions and updates', + body: [ + 'Privacy questions can be routed through the public contact path until the final launch legal notice publishes the full operating-entity details for privacy correspondence.', + 'If the public-site data handling model changes materially, this page should be updated before or at the same time as the change.', + ], + }, +]; diff --git a/apps/website/src/content/pages/product.ts b/apps/website/src/content/pages/product.ts new file mode 100644 index 00000000..7f50acea --- /dev/null +++ b/apps/website/src/content/pages/product.ts @@ -0,0 +1,110 @@ +import type { + CalloutContent, + FeatureItemContent, + HeroContent, + MetricItem, + PageSeo, +} from '@/types/site'; + +export const productSeo: PageSeo = { + title: 'TenantAtlas | Product', + description: + 'TenantAtlas connects inventory, snapshots, restore safety, drift visibility, findings, exceptions, and evidence into one governance model.', + path: '/product', +}; + +export const productHero: HeroContent = { + eyebrow: 'Product model', + title: 'One operating model for change history, drift visibility, and review readiness.', + description: + 'TenantAtlas treats Microsoft tenant governance as one connected system: observe the current state, preserve immutable history, detect meaningful change, and support reviews or restores with the context operators actually need.', + primaryCta: { + href: '/solutions', + label: 'See audience fit', + }, + secondaryCta: { + href: '/contact', + label: 'Talk through your current operating model', + variant: 'secondary', + }, + highlights: [ + 'Inventory first, snapshots second.', + 'Restore flows stay previewable and attributable.', + 'Evidence and review posture stay connected to real change history.', + ], +}; + +export const productMetrics: MetricItem[] = [ + { + value: '4', + label: 'Operator questions', + description: 'What changed? Why does it matter? What can be restored? What needs review now?', + }, + { + value: '100%', + label: 'Queryable versions', + description: 'Version semantics stay tied to who changed what, when, and in which tenant context.', + }, +]; + +export const productModelBlocks: FeatureItemContent[] = [ + { + eyebrow: 'Connected governance model', + title: 'Inventory creates the starting point for every other decision.', + description: + 'The product begins with the last observed tenant state so teams can compare real configuration truth instead of relying on partial memory or exported spreadsheets.', + }, + { + eyebrow: 'Connected governance model', + title: 'Snapshots add immutable history without replacing current truth.', + description: + 'Backups and versions are explicit artifacts. They preserve what was seen at a point in time while keeping the present-tense inventory readable.', + }, + { + eyebrow: 'Connected governance model', + title: 'Restore is handled as a governed operation, not as a blind push.', + description: + 'Preview, validation, selective scope, and confirmation reduce the risk of turning a recovery step into a new incident.', + }, + { + eyebrow: 'Drift visibility', + title: 'Differences become reviewable signals instead of noisy raw deltas.', + description: + 'Human-readable summaries and structured differences help operators and reviewers decide what changed and what needs action.', + }, + { + eyebrow: 'Exceptions & evidence', + title: 'Findings, exceptions, and evidence stay anchored to operational truth.', + description: + 'Governance discussions stay attached to the real object, version, and review context instead of drifting into separate manual trackers.', + }, + { + eyebrow: 'Operator safety', + title: 'Auditability is part of the product shape, not a later add-on.', + description: + 'The product is built so teams can explain actions afterward, not just execute them quickly in the moment.', + }, +]; + +export const productNarrative: CalloutContent[] = [ + { + eyebrow: 'Why it is not a feature list', + title: 'The point is not “backup plus reporting plus restore.”', + description: + 'The point is to reduce operator uncertainty by keeping those capabilities connected through the same source material and the same decision flow.', + tone: 'accent', + }, + { + eyebrow: 'What teams get', + title: 'A calmer path from observation to action.', + description: + 'Teams can move from understanding the current tenant state to comparing history, planning remediation, or reviewing restore options without leaving the product model behind.', + }, + { + eyebrow: 'What teams avoid', + title: 'No generic dashboard theater.', + description: + 'The product story avoids pretending that another alerting page or compliance badge alone solves governance discipline.', + tone: 'subtle', + }, +]; diff --git a/apps/website/src/content/pages/security-trust.ts b/apps/website/src/content/pages/security-trust.ts new file mode 100644 index 00000000..891aca99 --- /dev/null +++ b/apps/website/src/content/pages/security-trust.ts @@ -0,0 +1,78 @@ +import type { + CalloutContent, + HeroContent, + PageSeo, + TrustPrincipleContent, +} from '@/types/site'; + +export const securityTrustSeo: PageSeo = { + title: 'TenantAtlas | Security & Trust', + description: + 'TenantAtlas frames trust through substantiated product posture, safer restore discipline, and operational clarity rather than inflated guarantees.', + path: '/security-trust', +}; + +export const securityTrustHero: HeroContent = { + eyebrow: 'Security & Trust', + title: 'Explain the trust posture in the language of operational controls, not marketing claims.', + description: + 'The public trust page should set realistic expectations: what the product helps teams observe, preserve, review, and restore, and where launch claims intentionally stay narrow until they can be substantiated further.', + primaryCta: { + href: '/legal', + label: 'Read the legal surface', + }, + secondaryCta: { + href: '/contact', + label: 'Discuss trust requirements', + variant: 'secondary', + }, + highlights: [ + 'Preview, confirmation, and auditability remain part of the restore story.', + 'Public claims stay narrower than internal ambition.', + 'No fake compliance theater for launch.', + ], +}; + +export const securityPrinciples: TrustPrincipleContent[] = [ + { + title: 'Safer changes are a product rule, not an afterthought.', + description: + 'Destructive or high-risk flows are framed around preview, validation, and explicit confirmation instead of one-click confidence theater.', + note: 'Trust starts with operator discipline.', + }, + { + title: 'Operational evidence stays tied to the real object and version.', + description: + 'Findings, exceptions, and reviews remain anchored to observable tenant state so the trust story is defensible when someone asks for proof.', + note: 'Evidence should stay attributable.', + }, + { + title: 'Launch messaging stays within substantiated boundaries.', + description: + 'The public site avoids promising certifications, guarantees, or full automation outcomes that are not yet appropriate to claim.', + note: 'Restraint is part of credibility.', + }, +]; + +export const securityTrustNotes: CalloutContent[] = [ + { + eyebrow: 'Substantiated public posture', + title: 'Substantiated public posture', + description: + 'The launch story focuses on product shape and operator safeguards: inventory truth, immutable snapshots, safer restore flows, drift visibility, and review support.', + tone: 'accent', + }, + { + eyebrow: 'Sensitive connections', + title: 'Sensitive Microsoft connections should be explained carefully.', + description: + 'The public site acknowledges Graph- and tenant-facing access without pretending the site itself is part of the runtime trust boundary.', + }, + { + eyebrow: 'What we will not say', + title: 'No blanket assurances and no vague “fully automated governance” language.', + description: + 'Trust pages lose credibility quickly when they substitute slogans for the actual controls and workflows a buyer will later inspect.', + tone: 'subtle', + }, +]; diff --git a/apps/website/src/content/pages/solutions.ts b/apps/website/src/content/pages/solutions.ts new file mode 100644 index 00000000..c1768e52 --- /dev/null +++ b/apps/website/src/content/pages/solutions.ts @@ -0,0 +1,90 @@ +import type { + AudienceRowContent, + FeatureItemContent, + HeroContent, + PageSeo, +} from '@/types/site'; + +export const solutionsSeo: PageSeo = { + title: 'TenantAtlas | Solutions', + description: + 'TenantAtlas fits MSP and enterprise IT teams differently, and the public story should make those operating-model differences explicit.', + path: '/solutions', +}; + +export const solutionsHero: HeroContent = { + eyebrow: 'Audience fit', + title: 'Show how TenantAtlas fits MSP delivery teams and enterprise operators without collapsing them into one generic story.', + description: + 'The product helps different organizations answer similar governance questions, but the surrounding workflow, accountability, and evidence needs are not identical. The site should acknowledge that directly.', + primaryCta: { + href: '/integrations', + label: 'Review the ecosystem fit', + }, + secondaryCta: { + href: '/contact', + label: 'Talk through your evaluation path', + variant: 'secondary', + }, + highlights: [ + 'Separate MSP and enterprise language on purpose.', + 'Keep the product story stable while the buying context changes.', + 'Avoid forcing every visitor through the same generic motion.', + ], +}; + +export const solutionsAudiences: AudienceRowContent[] = [ + { + audience: 'MSP', + title: 'MSP operating model', + description: + 'Managed service providers need tenant-scoped operational truth, repeatable review workflows, and a way to explain change history to customers without drowning in manual evidence gathering.', + bullets: [ + 'Keep per-tenant history and restore posture reviewable during service delivery.', + 'Support a higher tempo of customer change while preserving a clean audit story.', + 'Give account and delivery teams a shared language for exceptions, findings, and follow-up.', + ], + cta: { + href: '/contact', + label: 'Discuss MSP delivery fit', + variant: 'secondary', + }, + }, + { + audience: 'Enterprise IT', + title: 'Enterprise IT operating model', + description: + 'Internal IT and security teams need durable version truth, change visibility, and review evidence that can stand up to operational leadership, audit, and cross-team scrutiny.', + bullets: [ + 'Reduce uncertainty around who changed what and when across the Microsoft tenant surface.', + 'Support internal review packs, exception handling, and evidence collection without fragmented tooling.', + 'Keep restore and remediation conversations grounded in the current tenant state and the relevant history.', + ], + cta: { + href: '/security-trust', + label: 'Inspect the trust posture', + variant: 'secondary', + }, + }, +]; + +export const solutionsSignals: FeatureItemContent[] = [ + { + eyebrow: 'Why buyers care', + title: 'The product is serious about both velocity and control.', + description: + 'Teams can move quickly without giving up visibility, confirmation discipline, or explainability when a risky change needs review.', + }, + { + eyebrow: 'Where it lands', + title: 'The product belongs in the operating layer, not just the reporting layer.', + description: + 'Visitors should understand that TenantAtlas helps teams make safer decisions about configuration state rather than merely summarize activity afterward.', + }, + { + eyebrow: 'How it reads', + title: 'The story changes by audience, but the product truth does not.', + description: + 'MSP and enterprise readers see their own operating concerns reflected without the site inventing two different products.', + }, +]; diff --git a/apps/website/src/content/pages/terms.ts b/apps/website/src/content/pages/terms.ts new file mode 100644 index 00000000..2dc14c25 --- /dev/null +++ b/apps/website/src/content/pages/terms.ts @@ -0,0 +1,60 @@ +import type { HeroContent, LegalSection, PageSeo } from '@/types/site'; + +export const termsSeo: PageSeo = { + title: 'TenantAtlas | Terms', + description: + 'Website terms for the public TenantAtlas surface, covering informational use of the site and the limits of public product statements.', + path: '/terms', +}; + +export const termsHero: HeroContent = { + eyebrow: 'Website terms', + title: 'Website terms for the public TenantAtlas surface.', + description: + 'These terms describe the public website itself: informational use of the content, basic conduct expectations, and the fact that a public product site is not the same thing as a signed service agreement.', + primaryCta: { + href: '/contact', + label: 'Return to contact', + }, + secondaryCta: { + href: '/privacy', + label: 'Review privacy', + variant: 'secondary', + }, + highlights: [ + 'Public copy explains the product but does not replace commercial agreements.', + 'The site is for evaluation and information, not operational control of a tenant.', + 'Any future service commitment belongs in explicit signed terms.', + ], +}; + +export const termsSections: LegalSection[] = [ + { + title: 'Informational website use', + body: [ + 'The public TenantAtlas website is provided to explain the product category, trust posture, integrations direction, and contact path for evaluation conversations.', + 'Nothing on the public site should be interpreted as a guarantee of product availability, certification, or commercial commitment unless it is later confirmed in signed agreements.', + ], + }, + { + title: 'Reasonable reliance', + body: [ + 'Visitors may use the public site to understand the product and decide whether to start a conversation, but they should not rely on public marketing pages as the sole source of contractual or implementation truth.', + 'Detailed service commitments, security terms, and procurement obligations belong in later commercial and legal documentation.', + ], + }, + { + title: 'Acceptable conduct', + body: [ + 'Visitors should use the public website lawfully and should not attempt to interfere with the availability or integrity of the public site.', + 'The public website is not a runtime administration surface and should not be treated as one.', + ], + }, + { + title: 'Questions', + body: [ + 'Questions about the public website terms, privacy, or future product legal materials can be routed through the public contact path.', + 'The legal hub remains the public anchor for later launch-ready legal disclosures.', + ], + }, +]; diff --git a/apps/website/src/layouts/BaseLayout.astro b/apps/website/src/layouts/BaseLayout.astro index 6919dbd4..562c50b8 100644 --- a/apps/website/src/layouts/BaseLayout.astro +++ b/apps/website/src/layouts/BaseLayout.astro @@ -1,15 +1,26 @@ --- import '../styles/global.css'; +import { siteMetadata } from '@/lib/site'; + interface Props { + canonicalUrl?: string; description?: string; + openGraphDescription?: string; + openGraphTitle?: string; + robots?: string; title?: string; } const { - description = 'TenantPilot keeps Intune governance observable, reviewable, and safe to operate.', - title = 'TenantPilot', + canonicalUrl, + description = siteMetadata.siteDescription, + robots = 'index,follow', + title = `${siteMetadata.siteName} | ${siteMetadata.siteTagline}`, } = Astro.props; + +const openGraphTitle = Astro.props.openGraphTitle ?? title; +const openGraphDescription = Astro.props.openGraphDescription ?? description; --- @@ -17,11 +28,22 @@ const { + + + + + + + + + + {canonicalUrl && } {title} + diff --git a/apps/website/src/lib/seo.ts b/apps/website/src/lib/seo.ts new file mode 100644 index 00000000..74d9f594 --- /dev/null +++ b/apps/website/src/lib/seo.ts @@ -0,0 +1,27 @@ +import { coreRoutes, siteMetadata } from '@/lib/site'; +import type { PageSeo } from '@/types/site'; + +export interface ResolvedSeo extends PageSeo { + canonicalUrl: string; + ogDescription: string; + ogTitle: string; + robots: string; +} + +export function buildCanonicalUrl(path: string): string { + return new URL(path, siteMetadata.siteUrl).toString(); +} + +export function resolveSeo(seo: PageSeo): ResolvedSeo { + return { + ...seo, + canonicalUrl: buildCanonicalUrl(seo.path), + ogDescription: seo.ogDescription ?? seo.description, + ogTitle: seo.ogTitle ?? seo.title, + robots: 'index,follow', + }; +} + +export function sitemapEntries(): string[] { + return [...coreRoutes].map((path) => buildCanonicalUrl(path)); +} diff --git a/apps/website/src/lib/site.ts b/apps/website/src/lib/site.ts new file mode 100644 index 00000000..ee0f1575 --- /dev/null +++ b/apps/website/src/lib/site.ts @@ -0,0 +1,76 @@ +import type { + CtaLink, + FooterNavigationGroup, + NavigationItem, + SiteMetadata, +} from '@/types/site'; + +export const siteMetadata: SiteMetadata = { + siteName: 'TenantAtlas', + siteTagline: 'Governance of record for Microsoft tenant operations.', + siteDescription: + 'TenantAtlas helps MSP and enterprise teams keep Microsoft tenant change history observable, reviewable, and safer to operate.', + siteUrl: import.meta.env.PUBLIC_SITE_URL ?? 'https://tenantatlas.example', +}; + +export const primaryNavigation: NavigationItem[] = [ + { href: '/product', label: 'Product', description: 'Understand the operating model.' }, + { href: '/solutions', label: 'Solutions', description: 'See the fit for MSP and enterprise teams.' }, + { href: '/security-trust', label: 'Security & Trust', description: 'Review the product posture.' }, + { href: '/integrations', label: 'Integrations', description: 'Inspect the real ecosystem fit.' }, + { href: '/contact', label: 'Contact', description: 'Reach the team for a working session.' }, +]; + +export const footerNavigationGroups: FooterNavigationGroup[] = [ + { + title: 'Explore', + items: [ + { href: '/', label: 'Home' }, + { href: '/product', label: 'Product' }, + { href: '/solutions', label: 'Solutions' }, + { href: '/security-trust', label: 'Security & Trust' }, + { href: '/integrations', label: 'Integrations' }, + ], + }, + { + title: 'Next step', + items: [ + { href: '/contact', label: 'Contact / Demo' }, + { href: '/legal', label: 'Legal' }, + ], + }, + { + title: 'Legal', + items: [ + { href: '/privacy', label: 'Privacy' }, + { href: '/terms', label: 'Terms' }, + ], + }, +]; + +export const contactCta: CtaLink = { + href: '/contact', + label: 'Request a working session', + helper: 'Bring your governance questions, rollout concerns, or evaluation goals.', + variant: 'primary', +}; + +export const coreRoutes = [ + '/', + '/product', + '/solutions', + '/security-trust', + '/integrations', + '/contact', + '/legal', + '/privacy', + '/terms', +] as const; + +export function isActiveNavigationPath(currentPath: string, href: string): boolean { + if (href === '/') { + return currentPath === '/'; + } + + return currentPath === href || currentPath.startsWith(`${href}/`); +} diff --git a/apps/website/src/pages/contact.astro b/apps/website/src/pages/contact.astro new file mode 100644 index 00000000..5e35fd46 --- /dev/null +++ b/apps/website/src/pages/contact.astro @@ -0,0 +1,98 @@ +--- +import ContactPanel from '@/components/content/ContactPanel.astro'; +import DemoPrompt from '@/components/content/DemoPrompt.astro'; +import RichText from '@/components/content/RichText.astro'; +import PageShell from '@/components/layout/PageShell.astro'; +import Card from '@/components/primitives/Card.astro'; +import Cluster from '@/components/primitives/Cluster.astro'; +import Container from '@/components/primitives/Container.astro'; +import Grid from '@/components/primitives/Grid.astro'; +import Input from '@/components/primitives/Input.astro'; +import Section from '@/components/primitives/Section.astro'; +import SectionHeader from '@/components/primitives/SectionHeader.astro'; +import Textarea from '@/components/primitives/Textarea.astro'; +import CTASection from '@/components/sections/CTASection.astro'; +import PageHero from '@/components/sections/PageHero.astro'; +import { + contactHero, + contactLegalSections, + contactPreview, + contactPrompts, + contactSeo, + contactTopics, +} from '@/content/pages/contact'; +--- + + + + +
+ + + + {contactPrompts.map((prompt) => )} + + +
+ +
+ +
+ + +
+ +