mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
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:
parent
da6dd4ecb8
commit
df0b849408
39 changed files with 2171 additions and 4 deletions
12
services/mana-landing-builder/.env.example
Normal file
12
services/mana-landing-builder/.env.example
Normal 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
|
||||
208
services/mana-landing-builder/CLAUDE.md
Normal file
208
services/mana-landing-builder/CLAUDE.md
Normal 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 |
|
||||
70
services/mana-landing-builder/Dockerfile
Normal file
70
services/mana-landing-builder/Dockerfile
Normal 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"]
|
||||
8
services/mana-landing-builder/nest-cli.json
Normal file
8
services/mana-landing-builder/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
38
services/mana-landing-builder/package.json
Normal file
38
services/mana-landing-builder/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
15
services/mana-landing-builder/src/app.module.ts
Normal file
15
services/mana-landing-builder/src/app.module.ts
Normal 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 {}
|
||||
|
|
@ -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' };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
224
services/mana-landing-builder/src/builder/builder.service.ts
Normal file
224
services/mana-landing-builder/src/builder/builder.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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',
|
||||
});
|
||||
37
services/mana-landing-builder/src/main.ts
Normal file
37
services/mana-landing-builder/src/main.ts
Normal 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();
|
||||
10
services/mana-landing-builder/template/astro.config.mjs
Normal file
10
services/mana-landing-builder/template/astro.config.mjs
Normal 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',
|
||||
},
|
||||
});
|
||||
17
services/mana-landing-builder/template/package.json
Normal file
17
services/mana-landing-builder/template/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
18
services/mana-landing-builder/template/src/data/config.json
Normal file
18
services/mana-landing-builder/template/src/data/config.json
Normal 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": {}
|
||||
}
|
||||
|
|
@ -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>
|
||||
69
services/mana-landing-builder/template/src/pages/index.astro
Normal file
69
services/mana-landing-builder/template/src/pages/index.astro
Normal 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>
|
||||
14
services/mana-landing-builder/template/src/styles/theme.css
Normal file
14
services/mana-landing-builder/template/src/styles/theme.css
Normal 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;
|
||||
}
|
||||
3
services/mana-landing-builder/template/tsconfig.json
Normal file
3
services/mana-landing-builder/template/tsconfig.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict"
|
||||
}
|
||||
25
services/mana-landing-builder/tsconfig.json
Normal file
25
services/mana-landing-builder/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue