mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
feat: add org landing page builder service
New service that generates static Astro landing pages for organizations
and deploys them to Cloudflare Pages at {slug}.mana.how.
Components:
- Landing Builder Service (NestJS, port 3030) with Astro template
- Admin UI in Manacore web dashboard at /organizations/[id]/landing
- TeamSection + ContactSection for shared-landing-ui
- Two org themes (classic dark, warm light)
- LandingPageConfig types in shared-types
- Docker + CI/CD integration for Mac Mini deployment
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
da6dd4ecb8
commit
df0b849408
39 changed files with 2171 additions and 4 deletions
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-web" "apps/storage/apps/web/" "apps/storage/packages/"
|
||||
check_changes "matrix-mana-bot" "services/matrix-mana-bot/" "packages/matrix-bot-common/"
|
||||
check_changes "mana-landing-builder" "services/mana-landing-builder/" "packages/shared-types/" "packages/shared-landing-ui/"
|
||||
|
||||
# Check if anything needs deploying
|
||||
ANY="false"
|
||||
for svc in matrix-web mana-core-auth chat-backend chat-web todo-backend todo-web calendar-backend calendar-web clock-backend clock-web contacts-backend contacts-web mukke-backend mukke-web storage-backend storage-web matrix-mana-bot; do
|
||||
for svc in matrix-web mana-core-auth chat-backend chat-web todo-backend todo-web calendar-backend calendar-web clock-backend clock-web contacts-backend contacts-web mukke-backend mukke-web storage-backend storage-web matrix-mana-bot mana-landing-builder; do
|
||||
val=$(grep "^$svc=" $GITHUB_OUTPUT | tail -1 | cut -d= -f2)
|
||||
if [ "$val" == "true" ]; then
|
||||
ANY="true"
|
||||
|
|
@ -219,6 +220,7 @@ jobs:
|
|||
if [ "${{ needs.detect-changes.outputs.storage-backend }}" == "true" ]; then SERVICES="$SERVICES storage-backend"; fi
|
||||
if [ "${{ needs.detect-changes.outputs.storage-web }}" == "true" ]; then SERVICES="$SERVICES storage-web"; fi
|
||||
if [ "${{ needs.detect-changes.outputs.matrix-mana-bot }}" == "true" ]; then SERVICES="$SERVICES matrix-mana-bot"; fi
|
||||
if [ "${{ needs.detect-changes.outputs.mana-landing-builder }}" == "true" ]; then SERVICES="$SERVICES mana-landing-builder"; fi
|
||||
fi
|
||||
|
||||
echo "services=$SERVICES" >> $GITHUB_OUTPUT
|
||||
|
|
|
|||
31
CLAUDE.md
31
CLAUDE.md
|
|
@ -132,7 +132,15 @@ manacore-monorepo/
|
|||
│ ├── mana-core-auth/ # Central authentication service
|
||||
│ ├── mana-search/ # Central search & content extraction service
|
||||
│ ├── mana-crawler/ # Web crawler service
|
||||
│ └── mana-llm/ # Central LLM abstraction service
|
||||
│ ├── mana-llm/ # Central LLM abstraction service
|
||||
│ ├── mana-landing-builder/# Org landing page builder (Astro → Cloudflare Pages)
|
||||
│ ├── mana-media/ # Central media platform (CAS, thumbnails)
|
||||
│ ├── mana-api-gateway/ # API gateway with rate limiting
|
||||
│ ├── mana-notify/ # Notification service (push, email, in-app)
|
||||
│ ├── mana-image-gen/ # Local AI image generation (FLUX)
|
||||
│ ├── mana-stt/ # Speech-to-text service
|
||||
│ ├── mana-tts/ # Text-to-speech service
|
||||
│ └── mana-voice-bot/ # Voice interaction bot
|
||||
├── packages/ # Monorepo-wide shared packages
|
||||
└── docker/ # Docker configuration files
|
||||
```
|
||||
|
|
@ -648,6 +656,26 @@ pnpm cf:projects:list
|
|||
npx wrangler pages project add-domain chat-landing chat.mana.how
|
||||
```
|
||||
|
||||
### Organization Landing Pages
|
||||
|
||||
Organizations can have their own landing pages at `{slug}.mana.how`, built and deployed automatically by the **mana-landing-builder** service.
|
||||
|
||||
```bash
|
||||
# Start the builder service
|
||||
pnpm dev:landing-builder
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
1. Org admin configures landing page at `/organizations/{id}/landing` in the Manacore web dashboard
|
||||
2. Config is stored in `organizations.metadata.landingPage` (mana-core-auth)
|
||||
3. On publish, the builder service generates a static Astro site from the config
|
||||
4. Site is deployed to Cloudflare Pages as `org-{slug}` → `{slug}.mana.how`
|
||||
|
||||
**Available themes:** `classic` (dark, professional), `warm` (light, inviting)
|
||||
**Available sections:** Hero, About/Features, Team, Contact, Footer
|
||||
|
||||
See `services/mana-landing-builder/CLAUDE.md` for full documentation.
|
||||
|
||||
## Server Access
|
||||
|
||||
### Mac Mini Production Server
|
||||
|
|
@ -767,6 +795,7 @@ Each project has its own `CLAUDE.md` with detailed information:
|
|||
- `services/mana-search/CLAUDE.md` - Search & content extraction service
|
||||
- `services/mana-crawler/CLAUDE.md` - Web crawler service
|
||||
- `services/mana-llm/CLAUDE.md` - Central LLM abstraction service
|
||||
- `services/mana-landing-builder/CLAUDE.md` - Org landing page builder service
|
||||
|
||||
Navigate to the specific project directory to work on it.
|
||||
|
||||
|
|
|
|||
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
|
||||
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)
|
||||
# ============================================
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@
|
|||
"./themes/manadeck": "./src/themes/manadeck.css",
|
||||
"./themes/picture": "./src/themes/picture.css",
|
||||
"./themes/chat": "./src/themes/chat.css",
|
||||
"./themes/zitare": "./src/themes/zitare.css"
|
||||
"./themes/zitare": "./src/themes/zitare.css",
|
||||
"./themes/org-classic": "./src/themes/org-classic.css",
|
||||
"./themes/org-warm": "./src/themes/org-warm.css"
|
||||
},
|
||||
"files": [
|
||||
"src"
|
||||
|
|
|
|||
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
|
||||
export * from './contact';
|
||||
|
||||
// Landing page configuration types
|
||||
export * from './landing-config';
|
||||
|
||||
// API types
|
||||
export interface User {
|
||||
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':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-auth
|
||||
'@manacore/shared-logger':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-logger
|
||||
'@picture/design-tokens':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/design-tokens
|
||||
|
|
@ -4083,7 +4086,7 @@ importers:
|
|||
specifier: ~15.0.8
|
||||
version: 15.0.9(expo@54.0.12)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))
|
||||
nativewind:
|
||||
specifier: latest
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.3(react-native-reanimated@4.1.5(patch_hash=ja2p6dcgbdai4kr2slklwsqegq)(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.1))
|
||||
react:
|
||||
specifier: 19.1.0
|
||||
|
|
|
|||
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