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

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