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

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