From df0b849408011f39a9458da2e4e6e2047bf4d89d Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 23 Mar 2026 13:20:10 +0100 Subject: [PATCH] feat: add org landing page builder service New service that generates static Astro landing pages for organizations and deploys them to Cloudflare Pages at {slug}.mana.how. Components: - Landing Builder Service (NestJS, port 3030) with Astro template - Admin UI in Manacore web dashboard at /organizations/[id]/landing - TeamSection + ContactSection for shared-landing-ui - Two org themes (classic dark, warm light) - LandingPageConfig types in shared-types - Docker + CI/CD integration for Mac Mini deployment Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/cd-macmini.yml | 4 +- CLAUDE.md | 31 +- .../apps/web/src/lib/api/services/landing.ts | 63 +++ .../components/landing/LandingEditor.svelte | 514 ++++++++++++++++++ .../components/landing/RepeatableField.svelte | 52 ++ .../components/landing/SectionEditor.svelte | 35 ++ .../(app)/organizations/[id]/+page.svelte | 155 ++++++ .../routes/(app)/organizations/[id]/+page.ts | 7 + .../organizations/[id]/landing/+page.svelte | 60 ++ .../(app)/organizations/[id]/landing/+page.ts | 7 + docker-compose.macmini.yml | 26 + packages/shared-landing-ui/package.json | 4 +- .../src/sections/ContactSection.astro | 127 +++++ .../src/sections/TeamSection.astro | 78 +++ .../src/themes/org-classic.css | 24 + .../shared-landing-ui/src/themes/org-warm.css | 24 + packages/shared-types/src/index.ts | 3 + packages/shared-types/src/landing-config.ts | 90 +++ pnpm-lock.yaml | 5 +- services/mana-landing-builder/.env.example | 12 + services/mana-landing-builder/CLAUDE.md | 208 +++++++ services/mana-landing-builder/Dockerfile | 70 +++ services/mana-landing-builder/nest-cli.json | 8 + services/mana-landing-builder/package.json | 38 ++ .../mana-landing-builder/src/app.module.ts | 15 + .../src/builder/builder.controller.ts | 22 + .../src/builder/builder.module.ts | 9 + .../src/builder/builder.service.ts | 224 ++++++++ .../src/builder/dto/build-landing.dto.ts | 18 + .../src/config/configuration.ts | 9 + services/mana-landing-builder/src/main.ts | 37 ++ .../template/astro.config.mjs | 10 + .../template/package.json | 17 + .../template/src/data/config.json | 18 + .../template/src/layouts/Layout.astro | 40 ++ .../template/src/pages/index.astro | 69 +++ .../template/src/styles/theme.css | 14 + .../template/tsconfig.json | 3 + services/mana-landing-builder/tsconfig.json | 25 + 39 files changed, 2171 insertions(+), 4 deletions(-) create mode 100644 apps/manacore/apps/web/src/lib/api/services/landing.ts create mode 100644 apps/manacore/apps/web/src/lib/components/landing/LandingEditor.svelte create mode 100644 apps/manacore/apps/web/src/lib/components/landing/RepeatableField.svelte create mode 100644 apps/manacore/apps/web/src/lib/components/landing/SectionEditor.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/organizations/[id]/+page.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/organizations/[id]/+page.ts create mode 100644 apps/manacore/apps/web/src/routes/(app)/organizations/[id]/landing/+page.svelte create mode 100644 apps/manacore/apps/web/src/routes/(app)/organizations/[id]/landing/+page.ts create mode 100644 packages/shared-landing-ui/src/sections/ContactSection.astro create mode 100644 packages/shared-landing-ui/src/sections/TeamSection.astro create mode 100644 packages/shared-landing-ui/src/themes/org-classic.css create mode 100644 packages/shared-landing-ui/src/themes/org-warm.css create mode 100644 packages/shared-types/src/landing-config.ts create mode 100644 services/mana-landing-builder/.env.example create mode 100644 services/mana-landing-builder/CLAUDE.md create mode 100644 services/mana-landing-builder/Dockerfile create mode 100644 services/mana-landing-builder/nest-cli.json create mode 100644 services/mana-landing-builder/package.json create mode 100644 services/mana-landing-builder/src/app.module.ts create mode 100644 services/mana-landing-builder/src/builder/builder.controller.ts create mode 100644 services/mana-landing-builder/src/builder/builder.module.ts create mode 100644 services/mana-landing-builder/src/builder/builder.service.ts create mode 100644 services/mana-landing-builder/src/builder/dto/build-landing.dto.ts create mode 100644 services/mana-landing-builder/src/config/configuration.ts create mode 100644 services/mana-landing-builder/src/main.ts create mode 100644 services/mana-landing-builder/template/astro.config.mjs create mode 100644 services/mana-landing-builder/template/package.json create mode 100644 services/mana-landing-builder/template/src/data/config.json create mode 100644 services/mana-landing-builder/template/src/layouts/Layout.astro create mode 100644 services/mana-landing-builder/template/src/pages/index.astro create mode 100644 services/mana-landing-builder/template/src/styles/theme.css create mode 100644 services/mana-landing-builder/template/tsconfig.json create mode 100644 services/mana-landing-builder/tsconfig.json diff --git a/.github/workflows/cd-macmini.yml b/.github/workflows/cd-macmini.yml index ea03ad106..6b098b644 100644 --- a/.github/workflows/cd-macmini.yml +++ b/.github/workflows/cd-macmini.yml @@ -138,10 +138,11 @@ jobs: check_changes "storage-backend" "apps/storage/apps/backend/" "apps/storage/packages/" check_changes "storage-web" "apps/storage/apps/web/" "apps/storage/packages/" check_changes "matrix-mana-bot" "services/matrix-mana-bot/" "packages/matrix-bot-common/" + check_changes "mana-landing-builder" "services/mana-landing-builder/" "packages/shared-types/" "packages/shared-landing-ui/" # Check if anything needs deploying ANY="false" - for svc in matrix-web mana-core-auth chat-backend chat-web todo-backend todo-web calendar-backend calendar-web clock-backend clock-web contacts-backend contacts-web mukke-backend mukke-web storage-backend storage-web matrix-mana-bot; do + for svc in matrix-web mana-core-auth chat-backend chat-web todo-backend todo-web calendar-backend calendar-web clock-backend clock-web contacts-backend contacts-web mukke-backend mukke-web storage-backend storage-web matrix-mana-bot mana-landing-builder; do val=$(grep "^$svc=" $GITHUB_OUTPUT | tail -1 | cut -d= -f2) if [ "$val" == "true" ]; then ANY="true" @@ -219,6 +220,7 @@ jobs: if [ "${{ needs.detect-changes.outputs.storage-backend }}" == "true" ]; then SERVICES="$SERVICES storage-backend"; fi if [ "${{ needs.detect-changes.outputs.storage-web }}" == "true" ]; then SERVICES="$SERVICES storage-web"; fi if [ "${{ needs.detect-changes.outputs.matrix-mana-bot }}" == "true" ]; then SERVICES="$SERVICES matrix-mana-bot"; fi + if [ "${{ needs.detect-changes.outputs.mana-landing-builder }}" == "true" ]; then SERVICES="$SERVICES mana-landing-builder"; fi fi echo "services=$SERVICES" >> $GITHUB_OUTPUT diff --git a/CLAUDE.md b/CLAUDE.md index 55090ab50..bd74f5104 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,7 +132,15 @@ manacore-monorepo/ │ ├── mana-core-auth/ # Central authentication service │ ├── mana-search/ # Central search & content extraction service │ ├── mana-crawler/ # Web crawler service -│ └── mana-llm/ # Central LLM abstraction service +│ ├── mana-llm/ # Central LLM abstraction service +│ ├── mana-landing-builder/# Org landing page builder (Astro → Cloudflare Pages) +│ ├── mana-media/ # Central media platform (CAS, thumbnails) +│ ├── mana-api-gateway/ # API gateway with rate limiting +│ ├── mana-notify/ # Notification service (push, email, in-app) +│ ├── mana-image-gen/ # Local AI image generation (FLUX) +│ ├── mana-stt/ # Speech-to-text service +│ ├── mana-tts/ # Text-to-speech service +│ └── mana-voice-bot/ # Voice interaction bot ├── packages/ # Monorepo-wide shared packages └── docker/ # Docker configuration files ``` @@ -648,6 +656,26 @@ pnpm cf:projects:list npx wrangler pages project add-domain chat-landing chat.mana.how ``` +### Organization Landing Pages + +Organizations can have their own landing pages at `{slug}.mana.how`, built and deployed automatically by the **mana-landing-builder** service. + +```bash +# Start the builder service +pnpm dev:landing-builder +``` + +**How it works:** +1. Org admin configures landing page at `/organizations/{id}/landing` in the Manacore web dashboard +2. Config is stored in `organizations.metadata.landingPage` (mana-core-auth) +3. On publish, the builder service generates a static Astro site from the config +4. Site is deployed to Cloudflare Pages as `org-{slug}` → `{slug}.mana.how` + +**Available themes:** `classic` (dark, professional), `warm` (light, inviting) +**Available sections:** Hero, About/Features, Team, Contact, Footer + +See `services/mana-landing-builder/CLAUDE.md` for full documentation. + ## Server Access ### Mac Mini Production Server @@ -767,6 +795,7 @@ Each project has its own `CLAUDE.md` with detailed information: - `services/mana-search/CLAUDE.md` - Search & content extraction service - `services/mana-crawler/CLAUDE.md` - Web crawler service - `services/mana-llm/CLAUDE.md` - Central LLM abstraction service +- `services/mana-landing-builder/CLAUDE.md` - Org landing page builder service Navigate to the specific project directory to work on it. diff --git a/apps/manacore/apps/web/src/lib/api/services/landing.ts b/apps/manacore/apps/web/src/lib/api/services/landing.ts new file mode 100644 index 000000000..2b7eb479d --- /dev/null +++ b/apps/manacore/apps/web/src/lib/api/services/landing.ts @@ -0,0 +1,63 @@ +/** + * Landing Page API Service + * + * Handles saving landing config to org metadata and triggering builds. + */ + +import { fetchWithRetry, type ApiResult } from '../base-client'; +import { getManaAuthUrl } from '../config'; +import type { LandingPageConfig } from '@manacore/shared-types'; + +const BUILDER_URL = 'http://localhost:3030'; + +interface BuildResult { + success: boolean; + url?: string; + duration?: number; +} + +/** + * Fetch organization details including metadata + */ +export async function getOrganization(orgId: string): Promise> { + const authUrl = getManaAuthUrl(); + return fetchWithRetry(`${authUrl}/api/v1/auth/organizations/${orgId}`); +} + +/** + * Save landing page config to organization metadata + */ +export async function saveLandingConfig( + orgId: string, + config: LandingPageConfig, + existingMetadata: Record = {} +): Promise> { + const authUrl = getManaAuthUrl(); + return fetchWithRetry(`${authUrl}/api/v1/auth/organizations/${orgId}`, { + method: 'PUT', + body: JSON.stringify({ + metadata: { + ...existingMetadata, + landingPage: config, + }, + }), + }); +} + +/** + * Trigger a build of the landing page + */ +export async function publishLanding( + orgId: string, + slug: string, + config: LandingPageConfig +): Promise> { + return fetchWithRetry(`${BUILDER_URL}/api/v1/build`, { + method: 'POST', + body: JSON.stringify({ + organizationId: orgId, + slug, + config, + }), + }); +} diff --git a/apps/manacore/apps/web/src/lib/components/landing/LandingEditor.svelte b/apps/manacore/apps/web/src/lib/components/landing/LandingEditor.svelte new file mode 100644 index 000000000..8e91e3c3d --- /dev/null +++ b/apps/manacore/apps/web/src/lib/components/landing/LandingEditor.svelte @@ -0,0 +1,514 @@ + + +
+ + +
+

Settings

+ +
+
+ + +
+ +
+ +
+ { + if (!config.customColors) config.customColors = {}; + config.customColors.primary = (e.target as HTMLInputElement).value; + }} + class="h-10 w-14 rounded border border-gray-300 dark:border-gray-600 cursor-pointer" + /> + { + if (!config.customColors) config.customColors = {}; + config.customColors.primary = (e.target as HTMLInputElement).value; + }} + class="flex-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm" + /> +
+
+
+
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + { + if (!config.sections.hero.primaryCta) + config.sections.hero.primaryCta = { text: '', href: '#contact' }; + config.sections.hero.primaryCta.text = (e.target as HTMLInputElement).value; + }} + placeholder="e.g. Contact us" + class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm" + /> +
+
+ + { + if (!config.sections.hero.primaryCta) + config.sections.hero.primaryCta = { text: '', href: '' }; + config.sections.hero.primaryCta.href = (e.target as HTMLInputElement).value; + }} + placeholder="e.g. #contact or https://..." + class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm" + /> +
+
+
+
+ + + +
+
+ + +
+
+ + +
+ + + + {#snippet renderItem(feature: LandingAboutFeature, index: number)} +
+
+ + +
+ +
+ {/snippet} +
+
+
+ + + +
+
+ + +
+ + + + {#snippet renderItem(member: LandingTeamMember, index: number)} +
+
+ + +
+ +
+ {/snippet} +
+
+
+ + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + + +
+
+ + +
+ + + + {#snippet renderItem(link: LandingFooterLink, index: number)} +
+ + +
+ {/snippet} +
+
+
+ + + {#if saveMessage} +
+ {saveMessage.text} +
+ {/if} + + +
+ + + + {#if config.publishedUrl} + + {config.publishedUrl} + + + + + {/if} +
+
diff --git a/apps/manacore/apps/web/src/lib/components/landing/RepeatableField.svelte b/apps/manacore/apps/web/src/lib/components/landing/RepeatableField.svelte new file mode 100644 index 000000000..f8aa4ed43 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/components/landing/RepeatableField.svelte @@ -0,0 +1,52 @@ + + +
+ {#each items as item, index} +
+ + {@render renderItem(item, index)} +
+ {/each} + + +
diff --git a/apps/manacore/apps/web/src/lib/components/landing/SectionEditor.svelte b/apps/manacore/apps/web/src/lib/components/landing/SectionEditor.svelte new file mode 100644 index 000000000..2d20ec52e --- /dev/null +++ b/apps/manacore/apps/web/src/lib/components/landing/SectionEditor.svelte @@ -0,0 +1,35 @@ + + +
+ + + {#if isExpanded} +
+ {@render children()} +
+ {/if} +
diff --git a/apps/manacore/apps/web/src/routes/(app)/organizations/[id]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/organizations/[id]/+page.svelte new file mode 100644 index 000000000..42b4ace11 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/organizations/[id]/+page.svelte @@ -0,0 +1,155 @@ + + +{#if loading} +
+
+
+{:else if error} + + + +{:else if org} +
+ + {#snippet actions()} + + Back + + {/snippet} + + + + + + + {#if activeTab === 'overview'} +
+ +

Details

+
+
+
Name
+
{org.name}
+
+ {#if org.slug} +
+
Slug
+
{org.slug}
+
+ {/if} +
+
Created
+
+ {new Date(org.createdAt).toLocaleDateString()} +
+
+
+
+ + +

Landing Page

+ {#if org.metadata?.landingPage?.enabled} +
+

+ + Active +

+ {#if org.metadata.landingPage.publishedUrl} + + {org.metadata.landingPage.publishedUrl} + + {/if} +
+ {:else} +

Not configured yet

+ {/if} + +
+
+ {:else if activeTab === 'members'} + +
+ Member management coming soon. +
+
+ {/if} +
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/organizations/[id]/+page.ts b/apps/manacore/apps/web/src/routes/(app)/organizations/[id]/+page.ts new file mode 100644 index 000000000..80c4573b0 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/organizations/[id]/+page.ts @@ -0,0 +1,7 @@ +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ params }) => { + return { + orgId: params.id, + }; +}; diff --git a/apps/manacore/apps/web/src/routes/(app)/organizations/[id]/landing/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/organizations/[id]/landing/+page.svelte new file mode 100644 index 000000000..40a1fbd28 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/organizations/[id]/landing/+page.svelte @@ -0,0 +1,60 @@ + + +{#if loading} +
+
+
+{:else if error} + +{:else if org} +
+ + {#snippet actions()} + + Back to {org.name} + + {/snippet} + + + +
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/organizations/[id]/landing/+page.ts b/apps/manacore/apps/web/src/routes/(app)/organizations/[id]/landing/+page.ts new file mode 100644 index 000000000..80c4573b0 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/organizations/[id]/landing/+page.ts @@ -0,0 +1,7 @@ +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ params }) => { + return { + orgId: params.id, + }; +}; diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index e84031e38..779232a3b 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -299,6 +299,32 @@ services: retries: 3 start_period: 40s + mana-landing-builder: + build: + context: . + dockerfile: services/mana-landing-builder/Dockerfile + image: mana-landing-builder:local + container_name: mana-core-landing-builder + restart: always + depends_on: + mana-auth: + condition: service_healthy + environment: + NODE_ENV: production + PORT: 3030 + MANA_CORE_AUTH_URL: http://mana-auth:3001 + CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN:-} + CLOUDFLARE_ACCOUNT_ID: ${CLOUDFLARE_ACCOUNT_ID:-} + ORG_LANDING_DOMAIN: mana.how + ports: + - "3030:3030" + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3030/api/v1/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + # ============================================ # Tier 3: App Backends (Ports 3030-3049) # ============================================ diff --git a/packages/shared-landing-ui/package.json b/packages/shared-landing-ui/package.json index fcb62a4c1..1aef8bbaf 100644 --- a/packages/shared-landing-ui/package.json +++ b/packages/shared-landing-ui/package.json @@ -20,7 +20,9 @@ "./themes/manadeck": "./src/themes/manadeck.css", "./themes/picture": "./src/themes/picture.css", "./themes/chat": "./src/themes/chat.css", - "./themes/zitare": "./src/themes/zitare.css" + "./themes/zitare": "./src/themes/zitare.css", + "./themes/org-classic": "./src/themes/org-classic.css", + "./themes/org-warm": "./src/themes/org-warm.css" }, "files": [ "src" diff --git a/packages/shared-landing-ui/src/sections/ContactSection.astro b/packages/shared-landing-ui/src/sections/ContactSection.astro new file mode 100644 index 000000000..15db8c9a3 --- /dev/null +++ b/packages/shared-landing-ui/src/sections/ContactSection.astro @@ -0,0 +1,127 @@ +--- +/** + * Shared Contact Section component + * Displays contact information (email, phone, address) in a clean layout. + */ +import Container from '../atoms/Container.astro'; +import SectionHeader from '../atoms/SectionHeader.astro'; +import Card from '../atoms/Card.astro'; + +interface Props { + title: string; + subtitle?: string; + email?: string; + phone?: string; + address?: string; + class?: string; + id?: string; +} + +const { title, subtitle, email, phone, address, class: className = '', id } = Astro.props; + +const hasContactInfo = email || phone || address; +--- + +
+ + + + { + hasContactInfo && ( +
+ +
+ {email && ( +
+
+ + + +
+
+

E-Mail

+ + {email} + +
+
+ )} + + {phone && ( +
+
+ + + +
+
+

Telefon

+ + {phone} + +
+
+ )} + + {address && ( +
+
+ + + + +
+
+

Adresse

+

{address}

+
+
+ )} +
+
+
+ ) + } +
+
diff --git a/packages/shared-landing-ui/src/sections/TeamSection.astro b/packages/shared-landing-ui/src/sections/TeamSection.astro new file mode 100644 index 000000000..7fc1c680b --- /dev/null +++ b/packages/shared-landing-ui/src/sections/TeamSection.astro @@ -0,0 +1,78 @@ +--- +/** + * Shared Team Section component + * Displays team/board members in a responsive grid with avatar, name, role, and optional bio. + */ +import Container from '../atoms/Container.astro'; +import SectionHeader from '../atoms/SectionHeader.astro'; +import Card from '../atoms/Card.astro'; + +interface TeamMember { + name: string; + role: string; + image?: string; + bio?: string; +} + +interface Props { + title: string; + subtitle?: string; + members: TeamMember[]; + columns?: 2 | 3 | 4; + class?: string; + id?: string; +} + +const { title, subtitle, members, columns = 3, class: className = '', id } = Astro.props; + +const gridCols = { + 2: 'md:grid-cols-2', + 3: 'md:grid-cols-2 lg:grid-cols-3', + 4: 'md:grid-cols-2 lg:grid-cols-4', +}; + +function getInitials(name: string): string { + return name + .split(' ') + .map((part) => part[0]) + .join('') + .toUpperCase() + .slice(0, 2); +} +--- + +
+ + + +
+ { + members.map((member) => ( + +
+ {member.image ? ( + {member.name} + ) : ( +
+ {getInitials(member.name)} +
+ )} +

{member.name}

+

{member.role}

+ {member.bio && ( +

+ {member.bio} +

+ )} +
+
+ )) + } +
+
+
diff --git a/packages/shared-landing-ui/src/themes/org-classic.css b/packages/shared-landing-ui/src/themes/org-classic.css new file mode 100644 index 000000000..b43c000f3 --- /dev/null +++ b/packages/shared-landing-ui/src/themes/org-classic.css @@ -0,0 +1,24 @@ +/** + * Org Classic Theme - Professional Dark + * Neutral slate tones, clean and serious. Ideal for institutions, associations, businesses. + */ +:root { + /* Primary colors - Slate Blue */ + --color-primary: #64748b; + --color-primary-hover: #475569; + --color-primary-glow: rgba(100, 116, 139, 0.3); + + /* Text colors */ + --color-text-primary: #f1f5f9; + --color-text-secondary: #cbd5e1; + --color-text-muted: #64748b; + + /* Background colors */ + --color-background-page: #0f172a; + --color-background-card: #1e293b; + --color-background-card-hover: #334155; + + /* Border colors */ + --color-border: #334155; + --color-border-hover: #475569; +} diff --git a/packages/shared-landing-ui/src/themes/org-warm.css b/packages/shared-landing-ui/src/themes/org-warm.css new file mode 100644 index 000000000..44d76ebaf --- /dev/null +++ b/packages/shared-landing-ui/src/themes/org-warm.css @@ -0,0 +1,24 @@ +/** + * Org Warm Theme - Inviting Light + * Warm amber tones on light backgrounds. Ideal for schools, social clubs, community orgs. + */ +:root { + /* Primary colors - Amber */ + --color-primary: #d97706; + --color-primary-hover: #b45309; + --color-primary-glow: rgba(217, 119, 6, 0.2); + + /* Text colors */ + --color-text-primary: #1c1917; + --color-text-secondary: #44403c; + --color-text-muted: #78716c; + + /* Background colors */ + --color-background-page: #fafaf9; + --color-background-card: #ffffff; + --color-background-card-hover: #f5f5f4; + + /* Border colors */ + --color-border: #e7e5e4; + --color-border-hover: #d6d3d1; +} diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index dd2f4de13..8b37fba74 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -19,6 +19,9 @@ export * from './common'; // Contact types for cross-app integration export * from './contact'; +// Landing page configuration types +export * from './landing-config'; + // API types export interface User { id: string; diff --git a/packages/shared-types/src/landing-config.ts b/packages/shared-types/src/landing-config.ts new file mode 100644 index 000000000..67abf583c --- /dev/null +++ b/packages/shared-types/src/landing-config.ts @@ -0,0 +1,90 @@ +/** + * Landing Page Configuration Types + * + * Used by the Admin UI (Manacore Web) and the Landing Builder Service + * to configure and generate static Astro landing pages for organizations. + */ + +// --- Section Configs --- + +export interface LandingHeroConfig { + title: string; + subtitle: string; + variant?: 'default' | 'centered' | 'fullwidth'; + primaryCta?: { text: string; href: string }; + secondaryCta?: { text: string; href: string }; + image?: { src: string; alt: string }; +} + +export interface LandingAboutFeature { + icon: string; + title: string; + description: string; +} + +export interface LandingAboutConfig { + title: string; + subtitle?: string; + features: LandingAboutFeature[]; + columns?: 2 | 3; +} + +export interface LandingTeamMember { + name: string; + role: string; + image?: string; + bio?: string; +} + +export interface LandingTeamConfig { + title: string; + subtitle?: string; + members: LandingTeamMember[]; +} + +export interface LandingContactConfig { + title: string; + subtitle?: string; + email?: string; + phone?: string; + address?: string; +} + +export interface LandingFooterLink { + label: string; + href: string; +} + +export interface LandingFooterConfig { + copyright?: string; + links?: LandingFooterLink[]; + socialLinks?: Array<{ platform: string; href: string }>; +} + +// --- Main Config --- + +export type LandingTheme = 'classic' | 'warm'; + +export type LandingBuildStatus = 'success' | 'failed' | 'building'; + +export interface LandingCustomColors { + primary?: string; + primaryHover?: string; + primaryGlow?: string; +} + +export interface LandingPageConfig { + enabled: boolean; + theme: LandingTheme; + customColors?: LandingCustomColors; + sections: { + hero: LandingHeroConfig; + about: LandingAboutConfig; + team: LandingTeamConfig; + contact: LandingContactConfig; + footer: LandingFooterConfig; + }; + lastBuiltAt?: string; + lastBuildStatus?: LandingBuildStatus; + publishedUrl?: string; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6102753e1..4bcb37325 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4013,6 +4013,9 @@ importers: '@manacore/shared-auth': specifier: workspace:* version: link:../../../../packages/shared-auth + '@manacore/shared-logger': + specifier: workspace:* + version: link:../../../../packages/shared-logger '@picture/design-tokens': specifier: workspace:* version: link:../../packages/design-tokens @@ -4083,7 +4086,7 @@ importers: specifier: ~15.0.8 version: 15.0.9(expo@54.0.12)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)) nativewind: - specifier: latest + specifier: ^4.2.1 version: 4.2.3(react-native-reanimated@4.1.5(patch_hash=ja2p6dcgbdai4kr2slklwsqegq)(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.1)) react: specifier: 19.1.0 diff --git a/services/mana-landing-builder/.env.example b/services/mana-landing-builder/.env.example new file mode 100644 index 000000000..b10b114cc --- /dev/null +++ b/services/mana-landing-builder/.env.example @@ -0,0 +1,12 @@ +# Landing Builder Service +PORT=3030 + +# Mana Core Auth (for JWT validation) +MANA_CORE_AUTH_URL=http://localhost:3001 + +# Cloudflare Pages deployment +CLOUDFLARE_API_TOKEN=your-cloudflare-api-token +CLOUDFLARE_ACCOUNT_ID=your-cloudflare-account-id + +# Base domain for org landing pages +ORG_LANDING_DOMAIN=mana.how diff --git a/services/mana-landing-builder/CLAUDE.md b/services/mana-landing-builder/CLAUDE.md new file mode 100644 index 000000000..444b1bd82 --- /dev/null +++ b/services/mana-landing-builder/CLAUDE.md @@ -0,0 +1,208 @@ +# Mana Landing Builder Service + +Static landing page builder for organizations. Takes a JSON config, generates an Astro site, and deploys it to Cloudflare Pages. + +## Overview + +- **Port**: 3030 +- **Technology**: NestJS + Astro + Cloudflare Pages +- **Purpose**: Build and deploy static landing pages for organizations under `{slug}.mana.how` + +## Architecture + +``` +Admin Dashboard (Manacore Web) + │ + │ POST /api/v1/build { slug, config } + ▼ +Landing Builder Service (Port 3030) + │ + ├── 1. Copy Astro template to temp dir + ├── 2. Write config.json (section data) + ├── 3. Generate theme.css (colors) + ├── 4. pnpm install + astro build + └── 5. wrangler pages deploy → Cloudflare Pages + │ + ▼ + {slug}.mana.how +``` + +## Quick Start + +```bash +# Start the builder service +pnpm dev:landing-builder + +# Or directly +pnpm --filter @mana-landing-builder/service start:dev +``` + +## API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/v1/build` | Build and deploy a landing page | +| GET | `/api/v1/health` | Health check | + +### Build Request + +```bash +curl -X POST http://localhost:3030/api/v1/build \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "organizationId": "org-id", + "slug": "chorverein-harmonie", + "config": { + "enabled": true, + "theme": "warm", + "sections": { + "hero": { "title": "Chorverein Harmonie", "subtitle": "Seit 1952" }, + "about": { "title": "About", "features": [] }, + "team": { "title": "Team", "members": [] }, + "contact": { "title": "Contact", "email": "info@example.com" }, + "footer": { "copyright": "2024 Chorverein Harmonie" } + } + } + }' +``` + +### Response + +```json +{ + "success": true, + "url": "https://chorverein-harmonie.mana.how", + "duration": 15000 +} +``` + +## Configuration + +The landing page config is stored in `organizations.metadata.landingPage` in mana-core-auth. The Admin UI in Manacore Web writes this config, then triggers the builder. + +### Config Structure (LandingPageConfig) + +Types are defined in `packages/shared-types/src/landing-config.ts`. + +```typescript +interface LandingPageConfig { + enabled: boolean; + theme: 'classic' | 'warm'; + customColors?: { primary?, primaryHover?, primaryGlow? }; + sections: { + hero: { title, subtitle, variant?, primaryCta?, image? }; + about: { title, subtitle?, features[] }; + team: { title, subtitle?, members[] }; + contact: { title, subtitle?, email?, phone?, address? }; + footer: { copyright?, links?, socialLinks? }; + }; +} +``` + +### Available Themes + +| Theme | Style | Best For | +|-------|-------|----------| +| `classic` | Professional dark (slate tones) | Businesses, institutions | +| `warm` | Inviting light (amber tones) | Schools, clubs, community orgs | + +Both themes support `customColors` overrides for the primary color. + +## Available Sections + +All sections use shared components from `@manacore/shared-landing-ui`: + +| Section | Component | Description | +|---------|-----------|-------------| +| Hero | `HeroSection.astro` | Title, subtitle, CTA button, image | +| About | `FeatureSection.astro` | Feature cards in a grid | +| Team | `TeamSection.astro` | Team member cards with avatars | +| Contact | `ContactSection.astro` | Email, phone, address display | +| Footer | `Footer.astro` | Copyright, links, social links | + +## Project Structure + +``` +services/mana-landing-builder/ +├── src/ +│ ├── main.ts # NestJS bootstrap +│ ├── app.module.ts +│ ├── builder/ +│ │ ├── builder.controller.ts # POST /api/v1/build +│ │ ├── builder.service.ts # Core build logic +│ │ └── dto/ +│ │ └── build-landing.dto.ts +│ └── config/ +│ └── configuration.ts +└── template/ # Astro template project + ├── astro.config.mjs + ├── package.json + ├── src/ + │ ├── layouts/Layout.astro # HTML shell + │ ├── pages/index.astro # Reads config.json, renders sections + │ ├── styles/theme.css # Overwritten per build + │ └── data/config.json # Overwritten per build + └── public/ +``` + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | 3030 | Service port | +| `MANA_CORE_AUTH_URL` | http://localhost:3001 | Auth service URL | +| `CLOUDFLARE_API_TOKEN` | - | Cloudflare API token (Pages + DNS permissions) | +| `CLOUDFLARE_ACCOUNT_ID` | - | Cloudflare account ID | +| `ORG_LANDING_DOMAIN` | mana.how | Base domain for org landing pages | + +## Admin UI + +The landing page editor lives in the Manacore web dashboard: + +- **Route**: `/organizations/[id]/landing` +- **Components**: `apps/manacore/apps/web/src/lib/components/landing/` +- **API Client**: `apps/manacore/apps/web/src/lib/api/services/landing.ts` + +The editor provides a form-based interface where org admins can: +1. Select a theme (classic/warm) +2. Optionally override the primary color +3. Fill in content for each section (Hero, About, Team, Contact, Footer) +4. Save (stores config in org metadata) or Publish (save + build + deploy) + +## Build Process Details + +1. Template directory is copied to a temporary working directory +2. `config.json` is written with the section data from the landing config +3. `theme.css` is generated from the selected theme + any custom color overrides +4. `pnpm install` runs to resolve `@manacore/shared-landing-ui` from the workspace +5. `astro build` generates static HTML/CSS/JS in `dist/` +6. `wrangler pages deploy` pushes to Cloudflare Pages as project `org-{slug}` +7. Custom domain `{slug}.mana.how` is configured (if Cloudflare token is set) +8. Temp directory is cleaned up + +Concurrent builds for the same org are rejected (409 Conflict). + +## Development Commands + +```bash +# Start service +pnpm dev:landing-builder + +# Build for production +pnpm --filter @mana-landing-builder/service build + +# Type check +pnpm --filter @mana-landing-builder/service type-check +``` + +## Related Files + +| Location | Purpose | +|----------|---------| +| `packages/shared-types/src/landing-config.ts` | TypeScript types for landing config | +| `packages/shared-landing-ui/src/sections/TeamSection.astro` | Team member grid component | +| `packages/shared-landing-ui/src/sections/ContactSection.astro` | Contact info component | +| `packages/shared-landing-ui/src/themes/org-classic.css` | Classic dark theme | +| `packages/shared-landing-ui/src/themes/org-warm.css` | Warm light theme | +| `apps/manacore/apps/web/src/lib/components/landing/` | Admin UI components | diff --git a/services/mana-landing-builder/Dockerfile b/services/mana-landing-builder/Dockerfile new file mode 100644 index 000000000..506840e82 --- /dev/null +++ b/services/mana-landing-builder/Dockerfile @@ -0,0 +1,70 @@ +# syntax=docker/dockerfile:1 +# ================================ +# Build Stage (Monorepo-aware) +# ================================ +FROM node:20-alpine AS base +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +# Astro build needs git for some operations +RUN apk add --no-cache git +WORKDIR /app + +# Install dependencies +FROM base AS deps +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY services/mana-landing-builder/package.json ./services/mana-landing-builder/ +COPY packages/shared-types/package.json ./packages/shared-types/ +# Template needs shared-landing-ui at build time +COPY packages/shared-landing-ui/package.json ./packages/shared-landing-ui/ +COPY services/mana-landing-builder/template/package.json ./services/mana-landing-builder/template/ +RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --filter @mana-landing-builder/service... + +# Build the NestJS application +FROM base AS builder +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/services/mana-landing-builder/node_modules ./services/mana-landing-builder/node_modules +COPY --from=deps /app/packages/shared-types/node_modules ./packages/shared-types/node_modules 2>/dev/null || true +COPY --from=deps /app/packages/shared-landing-ui/node_modules ./packages/shared-landing-ui/node_modules 2>/dev/null || true +COPY services/mana-landing-builder ./services/mana-landing-builder +COPY packages/shared-types ./packages/shared-types +COPY packages/shared-landing-ui ./packages/shared-landing-ui +WORKDIR /app/services/mana-landing-builder +RUN pnpm build + +# Deploy standalone version (resolves pnpm symlinks) +RUN pnpm deploy --filter @mana-landing-builder/service --prod /app/deploy + +# ================================ +# Production Stage +# ================================ +FROM node:20-alpine AS runner +ENV NODE_ENV=production + +# Need pnpm + git for astro builds at runtime +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +RUN apk add --no-cache git + +# Create non-root user +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nestjs + +WORKDIR /app + +# Copy deployed standalone application +COPY --from=builder --chown=nestjs:nodejs /app/deploy ./ +COPY --from=builder --chown=nestjs:nodejs /app/services/mana-landing-builder/dist ./dist +# Copy template (needed at runtime for builds) +COPY --from=builder --chown=nestjs:nodejs /app/services/mana-landing-builder/template ./template +# Copy shared-landing-ui (needed by template at astro build time) +COPY --from=builder --chown=nestjs:nodejs /app/packages/shared-landing-ui ./packages/shared-landing-ui + +# Create builds directory with correct permissions +RUN mkdir -p /app/.builds && chown nestjs:nodejs /app/.builds + +USER nestjs + +EXPOSE 3030 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3030/api/v1/health || exit 1 + +CMD ["node", "dist/main"] diff --git a/services/mana-landing-builder/nest-cli.json b/services/mana-landing-builder/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/services/mana-landing-builder/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/services/mana-landing-builder/package.json b/services/mana-landing-builder/package.json new file mode 100644 index 000000000..7fe02af99 --- /dev/null +++ b/services/mana-landing-builder/package.json @@ -0,0 +1,38 @@ +{ + "name": "@mana-landing-builder/service", + "version": "0.1.0", + "description": "Landing page builder service - generates static Astro sites for organizations", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "dev": "nest start --watch", + "start": "node dist/main", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,test}/**/*.ts\" --fix", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@nestjs/common": "^10.4.17", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.17", + "@nestjs/platform-express": "^10.4.17", + "@manacore/shared-types": "workspace:*", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "fs-extra": "^11.2.0" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.9", + "@nestjs/schematics": "^10.2.3", + "@nestjs/testing": "^10.4.17", + "@types/express": "^5.0.0", + "@types/fs-extra": "^11.0.4", + "@types/node": "^22.10.5", + "typescript": "^5.7.2" + } +} diff --git a/services/mana-landing-builder/src/app.module.ts b/services/mana-landing-builder/src/app.module.ts new file mode 100644 index 000000000..c37ff49ab --- /dev/null +++ b/services/mana-landing-builder/src/app.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { BuilderModule } from './builder/builder.module'; +import configuration from './config/configuration'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + load: [configuration], + isGlobal: true, + }), + BuilderModule, + ], +}) +export class AppModule {} diff --git a/services/mana-landing-builder/src/builder/builder.controller.ts b/services/mana-landing-builder/src/builder/builder.controller.ts new file mode 100644 index 000000000..4649a43e4 --- /dev/null +++ b/services/mana-landing-builder/src/builder/builder.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Post, Body, Get, Logger, HttpCode } from '@nestjs/common'; +import { BuilderService, type BuildResult } from './builder.service'; +import { BuildLandingDto } from './dto/build-landing.dto'; + +@Controller() +export class BuilderController { + private readonly logger = new Logger(BuilderController.name); + + constructor(private readonly builderService: BuilderService) {} + + @Post('build') + @HttpCode(200) + async buildLanding(@Body() dto: BuildLandingDto): Promise { + this.logger.log(`Build requested for org: ${dto.slug}`); + return this.builderService.build(dto); + } + + @Get('health') + health() { + return { status: 'ok', service: 'mana-landing-builder' }; + } +} diff --git a/services/mana-landing-builder/src/builder/builder.module.ts b/services/mana-landing-builder/src/builder/builder.module.ts new file mode 100644 index 000000000..26a9cb638 --- /dev/null +++ b/services/mana-landing-builder/src/builder/builder.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { BuilderController } from './builder.controller'; +import { BuilderService } from './builder.service'; + +@Module({ + controllers: [BuilderController], + providers: [BuilderService], +}) +export class BuilderModule {} diff --git a/services/mana-landing-builder/src/builder/builder.service.ts b/services/mana-landing-builder/src/builder/builder.service.ts new file mode 100644 index 000000000..5ed31c529 --- /dev/null +++ b/services/mana-landing-builder/src/builder/builder.service.ts @@ -0,0 +1,224 @@ +import { + Injectable, + Logger, + ConflictException, + InternalServerErrorException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { BuildLandingDto } from './dto/build-landing.dto'; +import * as path from 'path'; +import * as fs from 'fs-extra'; +import { execSync } from 'child_process'; +import type { LandingPageConfig } from '@manacore/shared-types'; + +export interface BuildResult { + success: boolean; + url?: string; + error?: string; + duration?: number; +} + +@Injectable() +export class BuilderService { + private readonly logger = new Logger(BuilderService.name); + private readonly activeBuilds = new Set(); + + // Path to the template project inside the service + private readonly templateDir = path.join(__dirname, '..', '..', 'template'); + + // Temp builds go into a .builds directory at service root + private readonly buildsDir = path.join(__dirname, '..', '..', '.builds'); + + constructor(private readonly configService: ConfigService) {} + + async build(dto: BuildLandingDto): Promise { + const { slug, config } = dto; + const landingConfig = config as unknown as LandingPageConfig; + + // Prevent concurrent builds for same org + if (this.activeBuilds.has(slug)) { + throw new ConflictException(`Build already in progress for ${slug}`); + } + + this.activeBuilds.add(slug); + const startTime = Date.now(); + const workDir = path.join(this.buildsDir, `org-${slug}-${Date.now()}`); + + try { + // 1. Copy template to work directory + this.logger.log(`[${slug}] Copying template to ${workDir}`); + await fs.ensureDir(this.buildsDir); + await fs.copy(this.templateDir, workDir); + + // 2. Write config.json + const configPath = path.join(workDir, 'src', 'data', 'config.json'); + await fs.ensureDir(path.dirname(configPath)); + await fs.writeJson(configPath, { + hero: landingConfig.sections.hero, + about: landingConfig.sections.about, + team: landingConfig.sections.team, + contact: landingConfig.sections.contact, + footer: landingConfig.sections.footer, + }); + this.logger.log(`[${slug}] Config written`); + + // 3. Generate theme.css + const themeCss = this.generateThemeCss(landingConfig); + const themePath = path.join(workDir, 'src', 'styles', 'theme.css'); + await fs.ensureDir(path.dirname(themePath)); + await fs.writeFile(themePath, themeCss); + this.logger.log(`[${slug}] Theme CSS generated (${landingConfig.theme})`); + + // 4. Install dependencies + this.logger.log(`[${slug}] Installing dependencies...`); + this.exec('pnpm install --frozen-lockfile', workDir); + + // 5. Build Astro site + this.logger.log(`[${slug}] Building Astro site...`); + this.exec('npx astro build', workDir); + + const distDir = path.join(workDir, 'dist'); + if (!(await fs.pathExists(distDir))) { + throw new Error('Astro build did not produce a dist directory'); + } + + // 6. Deploy to Cloudflare Pages + const projectName = `org-${slug}`; + const domain = this.configService.get('orgLandingDomain', 'mana.how'); + const url = `https://${slug}.${domain}`; + + if (this.configService.get('cloudflare.apiToken')) { + this.logger.log(`[${slug}] Deploying to Cloudflare Pages...`); + this.deployToCloudflare(distDir, projectName, slug, domain); + } else { + this.logger.warn( + `[${slug}] Skipping Cloudflare deploy (no API token configured). ` + + `Output at: ${distDir}` + ); + } + + const duration = Date.now() - startTime; + this.logger.log(`[${slug}] Build complete in ${duration}ms`); + + return { + success: true, + url, + duration, + }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + this.logger.error(`[${slug}] Build failed: ${message}`); + throw new InternalServerErrorException(`Build failed: ${message}`); + } finally { + this.activeBuilds.delete(slug); + + // Clean up work directory + try { + await fs.remove(workDir); + } catch { + this.logger.warn(`[${slug}] Failed to clean up ${workDir}`); + } + } + } + + private generateThemeCss(config: LandingPageConfig): string { + // Load base theme colors + const themes: Record> = { + classic: { + '--color-primary': '#64748b', + '--color-primary-hover': '#475569', + '--color-primary-glow': 'rgba(100, 116, 139, 0.3)', + '--color-text-primary': '#f1f5f9', + '--color-text-secondary': '#cbd5e1', + '--color-text-muted': '#64748b', + '--color-background-page': '#0f172a', + '--color-background-card': '#1e293b', + '--color-background-card-hover': '#334155', + '--color-border': '#334155', + '--color-border-hover': '#475569', + }, + warm: { + '--color-primary': '#d97706', + '--color-primary-hover': '#b45309', + '--color-primary-glow': 'rgba(217, 119, 6, 0.2)', + '--color-text-primary': '#1c1917', + '--color-text-secondary': '#44403c', + '--color-text-muted': '#78716c', + '--color-background-page': '#fafaf9', + '--color-background-card': '#ffffff', + '--color-background-card-hover': '#f5f5f4', + '--color-border': '#e7e5e4', + '--color-border-hover': '#d6d3d1', + }, + }; + + const colors = { ...themes[config.theme] }; + + // Apply custom color overrides + if (config.customColors?.primary) { + colors['--color-primary'] = config.customColors.primary; + } + if (config.customColors?.primaryHover) { + colors['--color-primary-hover'] = config.customColors.primaryHover; + } + if (config.customColors?.primaryGlow) { + colors['--color-primary-glow'] = config.customColors.primaryGlow; + } + + const cssVars = Object.entries(colors) + .map(([key, value]) => ` ${key}: ${value};`) + .join('\n'); + + return `:root {\n${cssVars}\n}\n`; + } + + private deployToCloudflare( + distDir: string, + projectName: string, + slug: string, + domain: string + ): void { + const accountId = this.configService.get('cloudflare.accountId'); + const apiToken = this.configService.get('cloudflare.apiToken'); + + const env = { + ...process.env, + CLOUDFLARE_API_TOKEN: apiToken, + CLOUDFLARE_ACCOUNT_ID: accountId, + }; + + // Create project if it doesn't exist (ignore error if already exists) + try { + this.exec( + `npx wrangler pages project create ${projectName} --production-branch=main`, + distDir, + env + ); + } catch { + // Project likely already exists, continue + } + + // Deploy + this.exec(`npx wrangler pages deploy . --project-name=${projectName}`, distDir, env); + + // Add custom domain (idempotent) + try { + this.exec( + `npx wrangler pages project add-domain ${projectName} ${slug}.${domain}`, + distDir, + env + ); + } catch { + // Domain might already be configured + } + } + + private exec(command: string, cwd: string, env?: NodeJS.ProcessEnv): string { + return execSync(command, { + cwd, + env: env || process.env, + stdio: 'pipe', + timeout: 120_000, // 2 minute timeout + }).toString(); + } +} diff --git a/services/mana-landing-builder/src/builder/dto/build-landing.dto.ts b/services/mana-landing-builder/src/builder/dto/build-landing.dto.ts new file mode 100644 index 000000000..519ddbcf1 --- /dev/null +++ b/services/mana-landing-builder/src/builder/dto/build-landing.dto.ts @@ -0,0 +1,18 @@ +import { IsString, IsNotEmpty, IsObject, Matches } from 'class-validator'; + +export class BuildLandingDto { + @IsString() + @IsNotEmpty() + organizationId: string; + + @IsString() + @IsNotEmpty() + @Matches(/^[a-z0-9-]+$/, { + message: 'slug must contain only lowercase letters, numbers, and hyphens', + }) + slug: string; + + @IsObject() + @IsNotEmpty() + config: Record; +} diff --git a/services/mana-landing-builder/src/config/configuration.ts b/services/mana-landing-builder/src/config/configuration.ts new file mode 100644 index 000000000..02d301364 --- /dev/null +++ b/services/mana-landing-builder/src/config/configuration.ts @@ -0,0 +1,9 @@ +export default () => ({ + port: parseInt(process.env.PORT || '3030', 10), + cloudflare: { + apiToken: process.env.CLOUDFLARE_API_TOKEN, + accountId: process.env.CLOUDFLARE_ACCOUNT_ID, + }, + orgLandingDomain: process.env.ORG_LANDING_DOMAIN || 'mana.how', + manaCoreAuthUrl: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001', +}); diff --git a/services/mana-landing-builder/src/main.ts b/services/mana-landing-builder/src/main.ts new file mode 100644 index 000000000..c84ec6232 --- /dev/null +++ b/services/mana-landing-builder/src/main.ts @@ -0,0 +1,37 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const logger = new Logger('Bootstrap'); + + const app = await NestFactory.create(AppModule); + + const configService = app.get(ConfigService); + const port = configService.get('port', 3030); + + // Global prefix + app.setGlobalPrefix('api/v1'); + + // CORS + app.enableCors({ + origin: ['http://localhost:5173', 'http://localhost:5174'], + credentials: true, + }); + + // Global pipes + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }) + ); + + await app.listen(port); + logger.log(`Landing Builder Service running on port ${port}`); + logger.log(`Health check: http://localhost:${port}/api/v1/health`); +} + +bootstrap(); diff --git a/services/mana-landing-builder/template/astro.config.mjs b/services/mana-landing-builder/template/astro.config.mjs new file mode 100644 index 000000000..467069aa9 --- /dev/null +++ b/services/mana-landing-builder/template/astro.config.mjs @@ -0,0 +1,10 @@ +import { defineConfig } from 'astro/config'; +import tailwind from '@astrojs/tailwind'; + +export default defineConfig({ + integrations: [tailwind()], + output: 'static', + build: { + inlineStylesheets: 'auto', + }, +}); diff --git a/services/mana-landing-builder/template/package.json b/services/mana-landing-builder/template/package.json new file mode 100644 index 000000000..4bfd39055 --- /dev/null +++ b/services/mana-landing-builder/template/package.json @@ -0,0 +1,17 @@ +{ + "name": "@mana-landing-builder/template", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview" + }, + "dependencies": { + "@manacore/shared-landing-ui": "workspace:*", + "astro": "^5.16.0", + "@astrojs/tailwind": "^6.0.0", + "tailwindcss": "^3.4.0" + } +} diff --git a/services/mana-landing-builder/template/src/data/config.json b/services/mana-landing-builder/template/src/data/config.json new file mode 100644 index 000000000..9119ac35f --- /dev/null +++ b/services/mana-landing-builder/template/src/data/config.json @@ -0,0 +1,18 @@ +{ + "hero": { + "title": "Placeholder", + "subtitle": "This file is overwritten by the builder service." + }, + "about": { + "title": "About", + "features": [] + }, + "team": { + "title": "Team", + "members": [] + }, + "contact": { + "title": "Contact" + }, + "footer": {} +} diff --git a/services/mana-landing-builder/template/src/layouts/Layout.astro b/services/mana-landing-builder/template/src/layouts/Layout.astro new file mode 100644 index 000000000..ca24a0b15 --- /dev/null +++ b/services/mana-landing-builder/template/src/layouts/Layout.astro @@ -0,0 +1,40 @@ +--- +interface Props { + title: string; + description?: string; +} + +const { title, description = '' } = Astro.props; +--- + + + + + + + {title} + {description && } + + + + + + + + + diff --git a/services/mana-landing-builder/template/src/pages/index.astro b/services/mana-landing-builder/template/src/pages/index.astro new file mode 100644 index 000000000..1790311a1 --- /dev/null +++ b/services/mana-landing-builder/template/src/pages/index.astro @@ -0,0 +1,69 @@ +--- +import config from '../data/config.json'; +import '../styles/theme.css'; + +import Layout from '../layouts/Layout.astro'; +import HeroSection from '@manacore/shared-landing-ui/sections/HeroSection.astro'; +import FeatureSection from '@manacore/shared-landing-ui/sections/FeatureSection.astro'; +import TeamSection from '@manacore/shared-landing-ui/sections/TeamSection.astro'; +import ContactSection from '@manacore/shared-landing-ui/sections/ContactSection.astro'; +import Footer from '@manacore/shared-landing-ui/layouts/Footer.astro'; + +const { hero, about, team, contact, footer } = config; + +const hasAbout = about?.features && about.features.length > 0; +const hasTeam = team?.members && team.members.length > 0; +const hasContact = contact?.email || contact?.phone || contact?.address; +--- + + +
+ + + { + hasAbout && ( + + ) + } + + { + hasTeam && ( + + ) + } + + { + hasContact && ( + + ) + } +
+ +