feat: add org landing page builder service

New service that generates static Astro landing pages for organizations
and deploys them to Cloudflare Pages at {slug}.mana.how.

Components:
- Landing Builder Service (NestJS, port 3030) with Astro template
- Admin UI in Manacore web dashboard at /organizations/[id]/landing
- TeamSection + ContactSection for shared-landing-ui
- Two org themes (classic dark, warm light)
- LandingPageConfig types in shared-types
- Docker + CI/CD integration for Mac Mini deployment

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-23 13:20:10 +01:00
parent da6dd4ecb8
commit df0b849408
39 changed files with 2171 additions and 4 deletions

View file

@ -0,0 +1,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,
}),
});
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,7 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
return {
orgId: params.id,
};
};

View file

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

View file

@ -0,0 +1,7 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
return {
orgId: params.id,
};
};