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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-23 13:20:10 +01:00
parent da6dd4ecb8
commit df0b849408
39 changed files with 2171 additions and 4 deletions

View file

@ -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

View file

@ -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.

View file

@ -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<ApiResult<any>> {
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<string, unknown> = {}
): Promise<ApiResult<any>> {
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<ApiResult<BuildResult>> {
return fetchWithRetry(`${BUILDER_URL}/api/v1/build`, {
method: 'POST',
body: JSON.stringify({
organizationId: orgId,
slug,
config,
}),
});
}

View file

@ -0,0 +1,514 @@
<script lang="ts">
import { Button, Card } from '@manacore/shared-ui';
import SectionEditor from './SectionEditor.svelte';
import RepeatableField from './RepeatableField.svelte';
import { saveLandingConfig, publishLanding } from '$lib/api/services/landing';
import type {
LandingPageConfig,
LandingAboutFeature,
LandingTeamMember,
LandingFooterLink,
LandingTheme,
} from '@manacore/shared-types';
let {
orgId,
orgSlug,
initialConfig,
existingMetadata = {},
}: {
orgId: string;
orgSlug: string;
initialConfig?: LandingPageConfig;
existingMetadata?: Record<string, unknown>;
} = $props();
// Default config
const defaultConfig: LandingPageConfig = {
enabled: true,
theme: 'warm',
sections: {
hero: { title: '', subtitle: '' },
about: { title: 'About', features: [] },
team: { title: 'Team', members: [] },
contact: { title: 'Contact' },
footer: {},
},
};
let config: LandingPageConfig = $state(
initialConfig ? structuredClone(initialConfig) : structuredClone(defaultConfig)
);
let saving = $state(false);
let publishing = $state(false);
let saveMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
async function handleSave() {
saving = true;
saveMessage = null;
const result = await saveLandingConfig(orgId, config, existingMetadata);
if (result.error) {
saveMessage = { type: 'error', text: result.error };
} else {
saveMessage = { type: 'success', text: 'Saved' };
}
saving = false;
}
async function handlePublish() {
if (!orgSlug) {
saveMessage = { type: 'error', text: 'Organization needs a slug to publish' };
return;
}
publishing = true;
saveMessage = null;
// Save first
const saveResult = await saveLandingConfig(orgId, config, existingMetadata);
if (saveResult.error) {
saveMessage = { type: 'error', text: saveResult.error };
publishing = false;
return;
}
// Then build
const buildResult = await publishLanding(orgId, orgSlug, config);
if (buildResult.error) {
saveMessage = { type: 'error', text: `Build failed: ${buildResult.error}` };
} else if (buildResult.data) {
config.publishedUrl = buildResult.data.url;
config.lastBuiltAt = new Date().toISOString();
config.lastBuildStatus = 'success';
// Save updated status
await saveLandingConfig(orgId, config, existingMetadata);
saveMessage = {
type: 'success',
text: `Published at ${buildResult.data.url} (${Math.round((buildResult.data.duration || 0) / 1000)}s)`,
};
}
publishing = false;
}
// Helper to add/remove items in arrays
function addFeature() {
config.sections.about.features = [
...config.sections.about.features,
{ icon: '', title: '', description: '' },
];
}
function removeFeature(index: number) {
config.sections.about.features = config.sections.about.features.filter((_, i) => i !== index);
}
function addMember() {
config.sections.team.members = [...config.sections.team.members, { name: '', role: '' }];
}
function removeMember(index: number) {
config.sections.team.members = config.sections.team.members.filter((_, i) => i !== index);
}
function addFooterLink() {
config.sections.footer.links = [
...(config.sections.footer.links || []),
{ label: '', href: '' },
];
}
function removeFooterLink(index: number) {
config.sections.footer.links = (config.sections.footer.links || []).filter(
(_, i) => i !== index
);
}
</script>
<div class="space-y-6">
<!-- Global Settings -->
<Card>
<div class="space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Settings</h3>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label
for="theme"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Theme
</label>
<select
id="theme"
bind:value={config.theme}
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"
>
<option value="warm">Warm (Light)</option>
<option value="classic">Classic (Dark)</option>
</select>
</div>
<div>
<label
for="primary-color"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Primary Color (optional)
</label>
<div class="flex gap-2">
<input
id="primary-color"
type="color"
value={config.customColors?.primary ||
(config.theme === 'warm' ? '#d97706' : '#64748b')}
oninput={(e) => {
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"
/>
<input
type="text"
value={config.customColors?.primary || ''}
placeholder="e.g. #3b82f6"
oninput={(e) => {
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"
/>
</div>
</div>
</div>
</div>
</Card>
<!-- Hero Section -->
<SectionEditor title="Hero" expanded={true}>
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Title</label>
<input
type="text"
bind:value={config.sections.hero.title}
placeholder="Your organization name"
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"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>Subtitle</label
>
<textarea
bind:value={config.sections.hero.subtitle}
placeholder="A short description of your organization"
rows="2"
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"
></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>Variant</label
>
<select
bind:value={config.sections.hero.variant}
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"
>
<option value="centered">Centered</option>
<option value="default">Split (Text + Image)</option>
<option value="fullwidth">Full Width</option>
</select>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>CTA Button Text</label
>
<input
type="text"
value={config.sections.hero.primaryCta?.text || ''}
oninput={(e) => {
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"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>CTA Button Link</label
>
<input
type="text"
value={config.sections.hero.primaryCta?.href || ''}
oninput={(e) => {
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"
/>
</div>
</div>
</div>
</SectionEditor>
<!-- About Section -->
<SectionEditor title="About / Features">
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>Section Title</label
>
<input
type="text"
bind:value={config.sections.about.title}
placeholder="What we offer"
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"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>Subtitle</label
>
<input
type="text"
bind:value={config.sections.about.subtitle}
placeholder="Optional subtitle"
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"
/>
</div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Features</label>
<RepeatableField
items={config.sections.about.features}
onAdd={addFeature}
onRemove={removeFeature}
addLabel="Add Feature"
>
{#snippet renderItem(feature: LandingAboutFeature, index: number)}
<div class="grid gap-2 pr-6">
<div class="grid gap-2 md:grid-cols-[60px_1fr]">
<input
type="text"
bind:value={config.sections.about.features[index].icon}
placeholder="Icon"
class="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-center"
/>
<input
type="text"
bind:value={config.sections.about.features[index].title}
placeholder="Feature title"
class="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
/>
</div>
<textarea
bind:value={config.sections.about.features[index].description}
placeholder="Description"
rows="2"
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"
></textarea>
</div>
{/snippet}
</RepeatableField>
</div>
</SectionEditor>
<!-- Team Section -->
<SectionEditor title="Team">
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>Section Title</label
>
<input
type="text"
bind:value={config.sections.team.title}
placeholder="Our Team"
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"
/>
</div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Members</label>
<RepeatableField
items={config.sections.team.members}
onAdd={addMember}
onRemove={removeMember}
addLabel="Add Member"
>
{#snippet renderItem(member: LandingTeamMember, index: number)}
<div class="grid gap-2 pr-6">
<div class="grid gap-2 md:grid-cols-2">
<input
type="text"
bind:value={config.sections.team.members[index].name}
placeholder="Name"
class="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
/>
<input
type="text"
bind:value={config.sections.team.members[index].role}
placeholder="Role"
class="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
/>
</div>
<input
type="text"
bind:value={config.sections.team.members[index].image}
placeholder="Image URL (optional)"
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"
/>
</div>
{/snippet}
</RepeatableField>
</div>
</SectionEditor>
<!-- Contact Section -->
<SectionEditor title="Contact">
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>Section Title</label
>
<input
type="text"
bind:value={config.sections.contact.title}
placeholder="Contact"
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"
/>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>E-Mail</label
>
<input
type="email"
bind:value={config.sections.contact.email}
placeholder="info@example.com"
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"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>Phone</label
>
<input
type="tel"
bind:value={config.sections.contact.phone}
placeholder="+49 123 456789"
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"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>Address</label
>
<textarea
bind:value={config.sections.contact.address}
placeholder="Street, City, ZIP"
rows="2"
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"
></textarea>
</div>
</div>
</SectionEditor>
<!-- Footer Section -->
<SectionEditor title="Footer">
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>Copyright Text</label
>
<input
type="text"
bind:value={config.sections.footer.copyright}
placeholder="e.g. 2024 My Organization. All rights reserved."
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"
/>
</div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Links</label>
<RepeatableField
items={config.sections.footer.links || []}
onAdd={addFooterLink}
onRemove={removeFooterLink}
addLabel="Add Link"
>
{#snippet renderItem(link: LandingFooterLink, index: number)}
<div class="grid gap-2 md:grid-cols-2 pr-6">
<input
type="text"
bind:value={config.sections.footer.links || [])[index].label}
placeholder="Label"
class="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
/>
<input
type="text"
bind:value={config.sections.footer.links || [])[index].href}
placeholder="URL"
class="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
/>
</div>
{/snippet}
</RepeatableField>
</div>
</SectionEditor>
<!-- Status Message -->
{#if saveMessage}
<div
class="rounded-lg px-4 py-3 text-sm {saveMessage.type === 'success'
? 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400'
: 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400'}"
>
{saveMessage.text}
</div>
{/if}
<!-- Action Buttons -->
<div class="flex items-center gap-3">
<Button variant="secondary" onclick={handleSave} disabled={saving || publishing}>
{saving ? 'Saving...' : 'Save'}
</Button>
<Button variant="primary" onclick={handlePublish} disabled={saving || publishing}>
{#if publishing}
<div class="flex items-center gap-2">
<div
class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
></div>
Building...
</div>
{:else}
Publish
{/if}
</Button>
{#if config.publishedUrl}
<a
href={config.publishedUrl}
target="_blank"
rel="noopener noreferrer"
class="ml-auto text-sm text-primary-600 hover:underline flex items-center gap-1"
>
{config.publishedUrl}
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
{/if}
</div>
</div>

View file

@ -0,0 +1,52 @@
<script lang="ts" generics="T">
import { Button } from '@manacore/shared-ui';
import type { Snippet } from 'svelte';
let {
items = [],
onAdd,
onRemove,
addLabel = 'Add',
renderItem,
}: {
items: T[];
onAdd: () => void;
onRemove: (index: number) => void;
addLabel?: string;
renderItem: Snippet<[T, number]>;
} = $props();
</script>
<div class="space-y-3">
{#each items as item, index}
<div class="relative border border-gray-200 dark:border-gray-700 rounded-lg p-3">
<button
type="button"
onclick={() => onRemove(index)}
class="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-500 transition-colors"
title="Remove"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{@render renderItem(item, index)}
</div>
{/each}
<button
type="button"
onclick={onAdd}
class="w-full flex items-center justify-center gap-2 py-2 px-4 border border-dashed border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-400 dark:hover:border-gray-500 transition-colors"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{addLabel}
</button>
</div>

View file

@ -0,0 +1,35 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
title,
expanded = false,
children,
}: { title: string; expanded?: boolean; children: Snippet } = $props();
let isExpanded = $state(expanded);
</script>
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
type="button"
onclick={() => (isExpanded = !isExpanded)}
class="w-full flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-800/50 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<span class="font-medium text-sm text-gray-900 dark:text-white">{title}</span>
<svg
class="h-5 w-5 text-gray-500 transition-transform {isExpanded ? 'rotate-180' : ''}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if isExpanded}
<div class="px-4 py-4 space-y-4">
{@render children()}
</div>
{/if}
</div>

View file

@ -0,0 +1,155 @@
<script lang="ts">
import { page } from '$app/stores';
import { Card, Button, PageHeader } from '@manacore/shared-ui';
import { getOrganization } from '$lib/api/services/landing';
import { onMount } from 'svelte';
let { data } = $props();
let org: any = $state(null);
let loading = $state(true);
let error = $state<string | null>(null);
const tabs = [
{ id: 'overview', label: 'Overview', icon: 'home' },
{ id: 'landing', label: 'Landing Page', icon: 'globe' },
{ id: 'members', label: 'Members', icon: 'users' },
];
let activeTab = $state('overview');
const icons: Record<string, string> = {
home: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />',
globe:
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />',
users:
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />',
};
onMount(async () => {
const result = await getOrganization(data.orgId);
if (result.data) {
org = result.data;
} else {
error = result.error;
}
loading = false;
});
</script>
{#if loading}
<div class="flex items-center justify-center py-20">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary-600 border-t-transparent"
></div>
</div>
{:else if error}
<Card>
<div class="py-12 text-center">
<p class="text-red-500">{error}</p>
<a href="/organizations" class="mt-4 inline-block text-sm text-primary-600 hover:underline">
Back to organizations
</a>
</div>
</Card>
{:else if org}
<div class="space-y-6">
<PageHeader title={org.name} description={org.slug ? `${org.slug}.mana.how` : 'Organization'}>
{#snippet actions()}
<a
href="/organizations"
class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Back
</a>
{/snippet}
</PageHeader>
<!-- Tabs -->
<nav class="flex gap-1 border-b pb-px">
{#each tabs as tab}
{@const active = activeTab === tab.id}
<button
onclick={() => {
if (tab.id === 'landing') {
window.location.href = `/organizations/${data.orgId}/landing`;
} else {
activeTab = tab.id;
}
}}
class="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-t-lg transition-colors
{active
? 'text-primary border-b-2 border-primary -mb-px bg-primary/5'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'}"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{@html icons[tab.id]}
</svg>
{tab.label}
</button>
{/each}
</nav>
<!-- Tab Content -->
{#if activeTab === 'overview'}
<div class="grid gap-6 md:grid-cols-2">
<Card>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Details</h3>
<dl class="space-y-3 text-sm">
<div>
<dt class="text-gray-500 dark:text-gray-400">Name</dt>
<dd class="font-medium text-gray-900 dark:text-white">{org.name}</dd>
</div>
{#if org.slug}
<div>
<dt class="text-gray-500 dark:text-gray-400">Slug</dt>
<dd class="font-medium text-gray-900 dark:text-white">{org.slug}</dd>
</div>
{/if}
<div>
<dt class="text-gray-500 dark:text-gray-400">Created</dt>
<dd class="font-medium text-gray-900 dark:text-white">
{new Date(org.createdAt).toLocaleDateString()}
</dd>
</div>
</dl>
</Card>
<Card>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Landing Page</h3>
{#if org.metadata?.landingPage?.enabled}
<div class="space-y-2">
<p class="text-sm text-green-600 dark:text-green-400 flex items-center gap-2">
<span class="h-2 w-2 rounded-full bg-green-500"></span>
Active
</p>
{#if org.metadata.landingPage.publishedUrl}
<a
href={org.metadata.landingPage.publishedUrl}
target="_blank"
rel="noopener noreferrer"
class="text-sm text-primary-600 hover:underline"
>
{org.metadata.landingPage.publishedUrl}
</a>
{/if}
</div>
{:else}
<p class="text-sm text-gray-500 dark:text-gray-400">Not configured yet</p>
{/if}
<div class="mt-4">
<a href="/organizations/{data.orgId}/landing">
<Button variant="primary" size="sm">Configure Landing Page</Button>
</a>
</div>
</Card>
</div>
{:else if activeTab === 'members'}
<Card>
<div class="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
Member management coming soon.
</div>
</Card>
{/if}
</div>
{/if}

View file

@ -0,0 +1,7 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
return {
orgId: params.id,
};
};

View file

@ -0,0 +1,60 @@
<script lang="ts">
import { PageHeader } from '@manacore/shared-ui';
import LandingEditor from '$lib/components/landing/LandingEditor.svelte';
import { getOrganization } from '$lib/api/services/landing';
import { onMount } from 'svelte';
let { data } = $props();
let org: any = $state(null);
let loading = $state(true);
let error = $state<string | null>(null);
onMount(async () => {
const result = await getOrganization(data.orgId);
if (result.data) {
org = result.data;
} else {
error = result.error;
}
loading = false;
});
</script>
{#if loading}
<div class="flex items-center justify-center py-20">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary-600 border-t-transparent"
></div>
</div>
{:else if error}
<div class="py-12 text-center">
<p class="text-red-500">{error}</p>
<a
href="/organizations/{data.orgId}"
class="mt-4 inline-block text-sm text-primary-600 hover:underline"
>
Back to organization
</a>
</div>
{:else if org}
<div class="space-y-6">
<PageHeader title="Landing Page" description="Configure the public landing page for {org.name}">
{#snippet actions()}
<a
href="/organizations/{data.orgId}"
class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Back to {org.name}
</a>
{/snippet}
</PageHeader>
<LandingEditor
orgId={data.orgId}
orgSlug={org.slug || ''}
initialConfig={org.metadata?.landingPage}
existingMetadata={org.metadata || {}}
/>
</div>
{/if}

View file

@ -0,0 +1,7 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
return {
orgId: params.id,
};
};

View file

@ -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)
# ============================================

View file

@ -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"

View file

@ -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;
---
<section id={id} class:list={['py-16 md:py-24', className]}>
<Container size="md">
<SectionHeader title={title} subtitle={subtitle} />
{
hasContactInfo && (
<div class="max-w-2xl mx-auto">
<Card variant="bordered" padding="lg">
<div class="space-y-6">
{email && (
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-lg bg-[var(--color-primary)] bg-opacity-10 flex items-center justify-center flex-shrink-0">
<svg
class="w-5 h-5 text-[var(--color-primary)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-[var(--color-text-muted)] mb-1">E-Mail</p>
<a
href={`mailto:${email}`}
class="text-[var(--color-text-primary)] hover:text-[var(--color-primary)] transition-colors"
>
{email}
</a>
</div>
</div>
)}
{phone && (
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-lg bg-[var(--color-primary)] bg-opacity-10 flex items-center justify-center flex-shrink-0">
<svg
class="w-5 h-5 text-[var(--color-primary)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-[var(--color-text-muted)] mb-1">Telefon</p>
<a
href={`tel:${phone.replace(/\s/g, '')}`}
class="text-[var(--color-text-primary)] hover:text-[var(--color-primary)] transition-colors"
>
{phone}
</a>
</div>
</div>
)}
{address && (
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-lg bg-[var(--color-primary)] bg-opacity-10 flex items-center justify-center flex-shrink-0">
<svg
class="w-5 h-5 text-[var(--color-primary)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-[var(--color-text-muted)] mb-1">Adresse</p>
<p class="text-[var(--color-text-primary)] whitespace-pre-line">{address}</p>
</div>
</div>
)}
</div>
</Card>
</div>
)
}
</Container>
</section>

View file

@ -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);
}
---
<section id={id} class:list={['py-16 md:py-24', className]}>
<Container>
<SectionHeader title={title} subtitle={subtitle} />
<div class:list={['grid gap-6 md:gap-8', gridCols[columns]]}>
{
members.map((member) => (
<Card variant="hover" padding="lg">
<div class="flex flex-col items-center text-center">
{member.image ? (
<img
src={member.image}
alt={member.name}
class="w-20 h-20 rounded-full object-cover mb-4"
loading="lazy"
/>
) : (
<div class="w-20 h-20 rounded-full bg-[var(--color-primary)] flex items-center justify-center mb-4">
<span class="text-xl font-bold text-white">{getInitials(member.name)}</span>
</div>
)}
<h3 class="text-lg font-semibold text-[var(--color-text-primary)]">{member.name}</h3>
<p class="text-sm text-[var(--color-primary)] font-medium mt-1">{member.role}</p>
{member.bio && (
<p class="text-sm text-[var(--color-text-secondary)] mt-3 leading-relaxed">
{member.bio}
</p>
)}
</div>
</Card>
))
}
</div>
</Container>
</section>

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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;
}

5
pnpm-lock.yaml generated
View file

@ -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

View file

@ -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

View file

@ -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 |

View file

@ -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"]

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -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"
}
}

View file

@ -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 {}

View file

@ -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<BuildResult> {
this.logger.log(`Build requested for org: ${dto.slug}`);
return this.builderService.build(dto);
}
@Get('health')
health() {
return { status: 'ok', service: 'mana-landing-builder' };
}
}

View file

@ -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 {}

View file

@ -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<string>();
// 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<BuildResult> {
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<string>('orgLandingDomain', 'mana.how');
const url = `https://${slug}.${domain}`;
if (this.configService.get<string>('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<string, Record<string, string>> = {
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<string>('cloudflare.accountId');
const apiToken = this.configService.get<string>('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();
}
}

View file

@ -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<string, unknown>;
}

View file

@ -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',
});

View file

@ -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<number>('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();

View file

@ -0,0 +1,10 @@
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
export default defineConfig({
integrations: [tailwind()],
output: 'static',
build: {
inlineStylesheets: 'auto',
},
});

View file

@ -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"
}
}

View file

@ -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": {}
}

View file

@ -0,0 +1,40 @@
---
interface Props {
title: string;
description?: string;
}
const { title, description = '' } = Astro.props;
---
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
{description && <meta name="description" content={description} />}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<style>
body {
font-family:
'Inter',
system-ui,
-apple-system,
sans-serif;
background-color: var(--color-background-page);
color: var(--color-text-primary);
margin: 0;
min-height: 100vh;
}
</style>
</head>
<body>
<slot />
</body>
</html>

View file

@ -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;
---
<Layout title={hero.title} description={hero.subtitle}>
<main>
<HeroSection
title={hero.title}
subtitle={hero.subtitle}
variant={hero.variant || 'centered'}
primaryCta={hero.primaryCta}
secondaryCta={hero.secondaryCta}
image={hero.image}
/>
{
hasAbout && (
<FeatureSection
id="about"
title={about.title}
subtitle={about.subtitle}
features={about.features}
columns={about.columns || 3}
variant="cards"
/>
)
}
{
hasTeam && (
<TeamSection id="team" title={team.title} subtitle={team.subtitle} members={team.members} />
)
}
{
hasContact && (
<ContactSection
id="contact"
title={contact.title}
subtitle={contact.subtitle}
email={contact.email}
phone={contact.phone}
address={contact.address}
/>
)
}
</main>
<Footer
brand={{ name: hero.title }}
copyright={footer.copyright}
socialLinks={footer.socialLinks}
sections={footer.links ? [{ title: 'Links', links: footer.links }] : []}
/>
</Layout>

View file

@ -0,0 +1,14 @@
/* This file is overwritten by the builder service with the org's theme */
:root {
--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;
}

View file

@ -0,0 +1,3 @@
{
"extends": "astro/tsconfigs/strict"
}

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2022",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "template"]
}