mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21: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
4
.github/workflows/cd-macmini.yml
vendored
4
.github/workflows/cd-macmini.yml
vendored
|
|
@ -138,10 +138,11 @@ jobs:
|
||||||
check_changes "storage-backend" "apps/storage/apps/backend/" "apps/storage/packages/"
|
check_changes "storage-backend" "apps/storage/apps/backend/" "apps/storage/packages/"
|
||||||
check_changes "storage-web" "apps/storage/apps/web/" "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 "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
|
# Check if anything needs deploying
|
||||||
ANY="false"
|
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)
|
val=$(grep "^$svc=" $GITHUB_OUTPUT | tail -1 | cut -d= -f2)
|
||||||
if [ "$val" == "true" ]; then
|
if [ "$val" == "true" ]; then
|
||||||
ANY="true"
|
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-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.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.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
|
fi
|
||||||
|
|
||||||
echo "services=$SERVICES" >> $GITHUB_OUTPUT
|
echo "services=$SERVICES" >> $GITHUB_OUTPUT
|
||||||
|
|
|
||||||
31
CLAUDE.md
31
CLAUDE.md
|
|
@ -132,7 +132,15 @@ manacore-monorepo/
|
||||||
│ ├── mana-core-auth/ # Central authentication service
|
│ ├── mana-core-auth/ # Central authentication service
|
||||||
│ ├── mana-search/ # Central search & content extraction service
|
│ ├── mana-search/ # Central search & content extraction service
|
||||||
│ ├── mana-crawler/ # Web crawler 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
|
├── packages/ # Monorepo-wide shared packages
|
||||||
└── docker/ # Docker configuration files
|
└── docker/ # Docker configuration files
|
||||||
```
|
```
|
||||||
|
|
@ -648,6 +656,26 @@ pnpm cf:projects:list
|
||||||
npx wrangler pages project add-domain chat-landing chat.mana.how
|
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
|
## Server Access
|
||||||
|
|
||||||
### Mac Mini Production Server
|
### 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-search/CLAUDE.md` - Search & content extraction service
|
||||||
- `services/mana-crawler/CLAUDE.md` - Web crawler service
|
- `services/mana-crawler/CLAUDE.md` - Web crawler service
|
||||||
- `services/mana-llm/CLAUDE.md` - Central LLM abstraction 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.
|
Navigate to the specific project directory to work on it.
|
||||||
|
|
||||||
|
|
|
||||||
63
apps/manacore/apps/web/src/lib/api/services/landing.ts
Normal file
63
apps/manacore/apps/web/src/lib/api/services/landing.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ params }) => {
|
||||||
|
return {
|
||||||
|
orgId: params.id,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ params }) => {
|
||||||
|
return {
|
||||||
|
orgId: params.id,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -299,6 +299,32 @@ services:
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
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)
|
# Tier 3: App Backends (Ports 3030-3049)
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,9 @@
|
||||||
"./themes/manadeck": "./src/themes/manadeck.css",
|
"./themes/manadeck": "./src/themes/manadeck.css",
|
||||||
"./themes/picture": "./src/themes/picture.css",
|
"./themes/picture": "./src/themes/picture.css",
|
||||||
"./themes/chat": "./src/themes/chat.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": [
|
"files": [
|
||||||
"src"
|
"src"
|
||||||
|
|
|
||||||
127
packages/shared-landing-ui/src/sections/ContactSection.astro
Normal file
127
packages/shared-landing-ui/src/sections/ContactSection.astro
Normal 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>
|
||||||
78
packages/shared-landing-ui/src/sections/TeamSection.astro
Normal file
78
packages/shared-landing-ui/src/sections/TeamSection.astro
Normal 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>
|
||||||
24
packages/shared-landing-ui/src/themes/org-classic.css
Normal file
24
packages/shared-landing-ui/src/themes/org-classic.css
Normal 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;
|
||||||
|
}
|
||||||
24
packages/shared-landing-ui/src/themes/org-warm.css
Normal file
24
packages/shared-landing-ui/src/themes/org-warm.css
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,9 @@ export * from './common';
|
||||||
// Contact types for cross-app integration
|
// Contact types for cross-app integration
|
||||||
export * from './contact';
|
export * from './contact';
|
||||||
|
|
||||||
|
// Landing page configuration types
|
||||||
|
export * from './landing-config';
|
||||||
|
|
||||||
// API types
|
// API types
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
90
packages/shared-types/src/landing-config.ts
Normal file
90
packages/shared-types/src/landing-config.ts
Normal 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
5
pnpm-lock.yaml
generated
|
|
@ -4013,6 +4013,9 @@ importers:
|
||||||
'@manacore/shared-auth':
|
'@manacore/shared-auth':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../../../packages/shared-auth
|
version: link:../../../../packages/shared-auth
|
||||||
|
'@manacore/shared-logger':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../../../packages/shared-logger
|
||||||
'@picture/design-tokens':
|
'@picture/design-tokens':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/design-tokens
|
version: link:../../packages/design-tokens
|
||||||
|
|
@ -4083,7 +4086,7 @@ importers:
|
||||||
specifier: ~15.0.8
|
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))
|
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:
|
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))
|
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:
|
react:
|
||||||
specifier: 19.1.0
|
specifier: 19.1.0
|
||||||
|
|
|
||||||
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