feat(contacts): add landing page + avatar upload and vCard import on server

New Astro landing page with hero, features, pricing sections.
Server: avatar upload with file validation, vCard import parser.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-01 14:56:08 +02:00
parent 90f6c0db39
commit 7b7a00a538
14 changed files with 949 additions and 3 deletions

View file

@ -0,0 +1,19 @@
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
// https://astro.build/config
export default defineConfig({
integrations: [tailwind()],
output: 'static',
build: {
inlineStylesheets: 'auto',
},
vite: {
resolve: {
alias: {
'@components': '/src/components',
'@layouts': '/src/layouts',
},
},
},
});

View file

@ -0,0 +1,34 @@
{
"name": "@contacts/landing",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev --port 4321",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro",
"type-check": "astro check",
"format": "prettier --write .",
"clean": "rm -rf dist .astro node_modules"
},
"dependencies": {
"@astrojs/check": "^0.9.0",
"@manacore/shared-landing-ui": "workspace:*",
"astro": "^5.16.0",
"typescript": "^5.9.2"
},
"devDependencies": {
"@astrojs/tailwind": "^6.0.2",
"@tailwindcss/typography": "^0.5.18",
"@types/node": "^20.0.0",
"eslint": "^9.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-astro": "^1.0.0",
"prettier": "^3.6.2",
"prettier-plugin-astro": "^0.14.1",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^3.4.0"
}
}

View file

@ -0,0 +1,60 @@
---
// Call to Action section
---
<section class="relative overflow-hidden bg-dark-bg">
<!-- Background gradient -->
<div class="absolute inset-0 bg-gradient-to-r from-primary-950/30 via-dark-bg to-primary-950/30">
</div>
<div class="container relative">
<div class="mx-auto max-w-3xl text-center">
<h2 class="mb-6 text-3xl font-bold md:text-4xl lg:text-5xl">
Bereit, deine Kontakte zu organisieren?
</h2>
<p class="mb-10 text-lg text-gray-400">
Starte kostenlos und importiere deine bestehenden Kontakte in Sekunden. Keine Kreditkarte
erforderlich.
</p>
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row">
<a href="#" class="btn btn-primary text-lg">
Jetzt kostenlos starten
<svg class="ml-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
</svg>
</a>
<a href="#features" class="btn btn-secondary"> Mehr erfahren </a>
</div>
<!-- Benefits list -->
<div class="mt-12 flex flex-wrap items-center justify-center gap-6 text-sm text-gray-500">
<div class="flex items-center gap-2">
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"
></path>
</svg>
<span>Kostenlos starten</span>
</div>
<div class="flex items-center gap-2">
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"
></path>
</svg>
<span>Google & vCard Import</span>
</div>
<div class="flex items-center gap-2">
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"
></path>
</svg>
<span>Offline verfügbar</span>
</div>
</div>
</div>
</div>
</section>

View file

@ -0,0 +1,85 @@
---
// Features section for Contacts landing page
const features = [
{
icon: `<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"></path>
</svg>`,
title: 'Tags & Gruppen',
description:
'Organisiere deine Kontakte mit farbigen Tags und Gruppen. Filtere und finde Kontakte blitzschnell.',
},
{
icon: `<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
</svg>`,
title: 'Import & Export',
description:
'Importiere Kontakte aus Google, CSV oder vCard. Exportiere jederzeit in gängige Formate.',
},
{
icon: `<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>`,
title: 'Duplikaterkennung',
description:
'Finde und merge doppelte Kontakte automatisch. Halte dein Adressbuch sauber und aktuell.',
},
{
icon: `<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>`,
title: 'Notizen & Aktivitäten',
description:
'Füge Notizen zu Kontakten hinzu und verfolge Aktivitäten. Vergiss nie wieder ein wichtiges Detail.',
},
{
icon: `<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>`,
title: 'Kontaktfotos',
description:
'Lade Profilfotos hoch oder synchronisiere sie automatisch. Erkenne Kontakte auf einen Blick.',
},
{
icon: `<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
</svg>`,
title: 'Offline-First',
description:
'Arbeite auch ohne Internet. Deine Kontakte werden lokal gespeichert und automatisch synchronisiert.',
},
];
---
<section id="features" class="bg-dark-surface">
<div class="container">
<!-- Section header -->
<div class="mx-auto mb-16 max-w-3xl text-center">
<span class="mb-4 inline-block text-sm font-medium uppercase tracking-wider text-primary-400">
Funktionen
</span>
<h2 class="mb-6 text-3xl font-bold md:text-4xl lg:text-5xl">Kontaktverwaltung neu gedacht</h2>
<p class="text-lg text-gray-400">
ManaContacts bietet alles, was du brauchst, um deine Kontakte effizient zu verwalten.
</p>
</div>
<!-- Features grid -->
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{
features.map((feature) => (
<div class="group rounded-xl border border-dark-border bg-dark-card p-6 transition-all duration-300 hover:border-primary-500/50 hover:bg-dark-card/80">
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-primary-500/10 text-primary-400 transition-colors group-hover:bg-primary-500/20">
<Fragment set:html={feature.icon} />
</div>
<h3 class="mb-3 text-xl font-semibold">{feature.title}</h3>
<p class="text-gray-400">{feature.description}</p>
</div>
))
}
</div>
</div>
</section>

View file

@ -0,0 +1,109 @@
---
// Footer component
const currentYear = new Date().getFullYear();
const links = {
product: [
{ name: 'Funktionen', href: '#features' },
{ name: 'Preise', href: '#pricing' },
{ name: 'Changelog', href: '/changelog' },
{ name: 'Roadmap', href: '/roadmap' },
],
legal: [
{ name: 'Impressum', href: '/impressum' },
{ name: 'Datenschutz', href: '/datenschutz' },
{ name: 'AGB', href: '/agb' },
],
support: [
{ name: 'FAQ', href: '/faq' },
{ name: 'Kontakt', href: '/kontakt' },
{ name: 'Status', href: '/status' },
],
};
---
<footer class="border-t border-dark-border bg-dark-bg py-12">
<div class="container">
<div class="grid gap-8 md:grid-cols-4">
<!-- Brand -->
<div class="md:col-span-1">
<div class="mb-4 flex items-center gap-2">
<svg
class="h-8 w-8 text-primary-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"
></path>
</svg>
<span class="text-xl font-bold">ManaContacts</span>
</div>
<p class="text-sm text-gray-500">Smart Contact Management für bessere Beziehungen.</p>
</div>
<!-- Links -->
<div>
<h4 class="mb-4 font-semibold">Produkt</h4>
<ul class="space-y-2 text-sm text-gray-400">
{
links.product.map((link) => (
<li>
<a href={link.href} class="transition-colors hover:text-white">
{link.name}
</a>
</li>
))
}
</ul>
</div>
<div>
<h4 class="mb-4 font-semibold">Rechtliches</h4>
<ul class="space-y-2 text-sm text-gray-400">
{
links.legal.map((link) => (
<li>
<a href={link.href} class="transition-colors hover:text-white">
{link.name}
</a>
</li>
))
}
</ul>
</div>
<div>
<h4 class="mb-4 font-semibold">Support</h4>
<ul class="space-y-2 text-sm text-gray-400">
{
links.support.map((link) => (
<li>
<a href={link.href} class="transition-colors hover:text-white">
{link.name}
</a>
</li>
))
}
</ul>
</div>
</div>
<!-- Bottom bar -->
<div
class="mt-12 flex flex-col items-center justify-between gap-4 border-t border-dark-border pt-8 md:flex-row"
>
<p class="text-sm text-gray-500">
&copy; {currentYear} ManaContacts. Alle Rechte vorbehalten.
</p>
<p class="text-sm text-gray-500">
Ein <a href="https://mana.how" class="text-primary-400 hover:underline">Manacore</a> Produkt
</p>
</div>
</div>
</footer>

View file

@ -0,0 +1,156 @@
---
// Hero section for Contacts landing page
---
<section class="relative overflow-hidden py-20 md:py-32">
<!-- Background gradient -->
<div class="absolute inset-0 bg-gradient-to-b from-primary-950/30 via-dark-bg to-dark-bg"></div>
<!-- Grid pattern -->
<div class="absolute inset-0 bg-[url('/grid.svg')] bg-center opacity-10"></div>
<div class="container relative">
<div class="mx-auto max-w-4xl text-center">
<!-- Badge -->
<div
class="mb-8 inline-flex items-center gap-2 rounded-full border border-primary-500/30 bg-primary-500/10 px-4 py-2 text-sm text-primary-400"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"
></path>
</svg>
<span>Smart Contact Management</span>
</div>
<!-- Headline -->
<h1 class="mb-6 text-4xl font-bold leading-tight md:text-6xl lg:text-7xl">
Alle Kontakte an
<span class="gradient-text">einem Ort</span>
</h1>
<!-- Subheadline -->
<p class="mx-auto mb-10 max-w-2xl text-lg text-gray-400 md:text-xl">
Verwalte deine Kontakte mit Tags, Gruppen und Notizen. Importiere aus Google, CSV oder vCard
und finde Duplikate automatisch.
</p>
<!-- CTA Buttons -->
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row">
<a href="#" class="btn btn-primary group text-lg">
Kostenlos starten
<svg
class="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
</svg>
</a>
<a href="#features" class="btn btn-secondary"> Funktionen entdecken </a>
</div>
<!-- Social proof -->
<div class="mt-16 flex flex-col items-center gap-4">
<div class="flex -space-x-2">
{
[1, 2, 3, 4, 5].map(() => (
<div class="h-10 w-10 rounded-full border-2 border-dark-bg bg-gradient-to-br from-primary-400 to-primary-600" />
))
}
</div>
<p class="text-sm text-gray-500">
<span class="font-semibold text-white">500+</span> Nutzer vertrauen ManaContacts
</p>
</div>
</div>
<!-- Preview mockup -->
<div class="relative mx-auto mt-16 max-w-5xl">
<div
class="absolute -inset-4 rounded-2xl bg-gradient-to-r from-primary-500/20 via-transparent to-primary-500/20 blur-3xl"
>
</div>
<div class="relative rounded-xl border border-dark-border bg-dark-card p-2 shadow-2xl">
<div class="flex gap-2 px-4 py-3">
<div class="h-3 w-3 rounded-full bg-red-500"></div>
<div class="h-3 w-3 rounded-full bg-yellow-500"></div>
<div class="h-3 w-3 rounded-full bg-green-500"></div>
</div>
<div class="aspect-[16/9] overflow-hidden rounded-lg bg-dark-surface">
<!-- Contact list preview -->
<div class="p-6">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-xl font-semibold">Kontakte</h3>
<div class="flex gap-2">
<button class="rounded-lg bg-dark-card px-3 py-1 text-sm text-gray-400">
Suchen...
</button>
<button class="rounded-lg bg-primary-500 px-3 py-1 text-sm">+ Neu</button>
</div>
</div>
<div class="space-y-3">
{
[
{
name: 'Anna Müller',
email: 'anna@example.com',
tags: ['Arbeit', 'Design'],
},
{
name: 'Max Schmidt',
email: 'max.schmidt@firma.de',
tags: ['Kunde'],
},
{
name: 'Sarah Weber',
email: 'sarah.w@startup.io',
tags: ['Arbeit', 'Dev'],
},
{
name: 'Tom Fischer',
email: 'tom@creative.de',
tags: ['Freelancer'],
},
{
name: 'Lisa Braun',
email: 'lisa.braun@uni.edu',
tags: ['Privat'],
},
].map((contact) => (
<div class="flex items-center gap-3 rounded-lg bg-dark-card p-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-500/20 text-sm font-semibold text-primary-400">
{contact.name
.split(' ')
.map((n) => n[0])
.join('')}
</div>
<div class="flex-1">
<span class="text-white">{contact.name}</span>
<span class="ml-2 text-sm text-gray-500">{contact.email}</span>
</div>
<div class="flex gap-1">
{contact.tags.map((tag) => (
<span class="rounded-full bg-primary-500/10 px-2 py-0.5 text-xs text-primary-400">
{tag}
</span>
))}
</div>
</div>
))
}
</div>
</div>
</div>
</div>
</div>
</div>
</section>

View file

@ -0,0 +1,58 @@
---
import '../styles/global.css';
import Analytics from '@manacore/shared-landing-ui/atoms/Analytics.astro';
interface Props {
title?: string;
description?: string;
}
const {
title = 'ManaContacts - Smart Contact Management',
description = 'Verwalte deine Kontakte intelligent. Gruppen, Tags, Import/Export, Duplikaterkennung und mehr. Kostenlos starten.',
} = Astro.props;
---
<!doctype html>
<html lang="de" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="description" content={description} />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<!-- SEO Meta Tags -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<!-- Preconnect to Google Fonts -->
<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"
/>
<!-- Umami Analytics -->
{
import.meta.env.PUBLIC_UMAMI_WEBSITE_ID && (
<script
defer
src="https://stats.mana.how/script.js"
data-website-id={import.meta.env.PUBLIC_UMAMI_WEBSITE_ID}
/>
)
}
<title>{title}</title>
</head>
<body class="antialiased">
<slot />
<Analytics />
</body>
</html>

View file

@ -0,0 +1,252 @@
---
import Layout from '@layouts/Layout.astro';
import Hero from '@components/Hero.astro';
import Features from '@components/Features.astro';
import CTA from '@components/CTA.astro';
import Footer from '@components/Footer.astro';
// Try to import shared components if available
let StepsSection: any = null;
let PricingSection: any = null;
try {
const shared = await import('@manacore/shared-landing-ui/sections/StepsSection.astro');
StepsSection = shared.default;
} catch {
// Shared component not available
}
try {
const shared = await import('@manacore/shared-landing-ui/sections/PricingSection.astro');
PricingSection = shared.default;
} catch {
// Shared component not available
}
// Steps data
const steps = [
{
number: '1',
title: 'Kontakte importieren',
description:
'Importiere deine bestehenden Kontakte aus Google, CSV oder vCard - oder starte von Null.',
image: '/screenshots/import.png',
},
{
number: '2',
title: 'Organisieren & Taggen',
description:
'Ordne Kontakte mit Tags und Gruppen. Finde Duplikate automatisch und halte alles sauber.',
image: '/screenshots/organize.png',
},
{
number: '3',
title: 'Immer griffbereit',
description: 'Greife offline auf alle Kontakte zu. Synchronisiere nahtlos zwischen Geräten.',
image: '/screenshots/access.png',
},
];
// Pricing data
const pricingPlans = [
{
name: 'Free',
price: '0',
period: '/Monat',
description: 'Perfekt zum Einstieg',
features: [
{ text: 'Bis zu 100 Kontakte', included: true },
{ text: 'Tags & Gruppen', included: true },
{ text: 'vCard Import/Export', included: true },
{ text: 'Web-App Zugang', included: true },
{ text: 'Google Import', included: false },
{ text: 'Duplikaterkennung', included: false },
],
cta: {
text: 'Kostenlos starten',
href: '#',
},
},
{
name: 'Pro',
price: '4,99',
period: '/Monat',
description: 'Für alle deine Kontakte',
features: [
{ text: 'Unbegrenzte Kontakte', included: true },
{ text: 'Google Import', included: true },
{ text: 'Duplikaterkennung', included: true },
{ text: 'Kontaktfotos', included: true },
{ text: 'Aktivitäten & Notizen', included: true },
{ text: 'Priority Support', included: true },
],
cta: {
text: 'Pro starten',
href: '#',
},
highlighted: true,
badge: 'Beliebt',
},
{
name: 'Team',
price: '9,99',
period: '/Monat',
description: 'Für Teams & Unternehmen',
features: [
{ text: 'Alles aus Pro', included: true },
{ text: 'Geteilte Kontakte', included: true },
{ text: 'Team-Verwaltung', included: true },
{ text: 'API-Zugang', included: true },
{ text: 'Admin-Dashboard', included: true },
{ text: 'SLA Garantie', included: true },
],
cta: {
text: 'Team erstellen',
href: '#',
},
},
];
---
<Layout
title="ManaContacts - Smart Contact Management"
description="Verwalte deine Kontakte intelligent mit Tags, Gruppen, Import/Export und automatischer Duplikaterkennung. Kostenlos starten."
>
<Hero />
<Features />
{
StepsSection && (
<StepsSection
id="how-it-works"
title="So einfach geht's"
subtitle="In drei Schritten zu organisierten Kontakten"
steps={steps}
showImages={false}
alternateLayout={true}
class="bg-dark-surface"
/>
)
}
{
!StepsSection && (
<section id="how-it-works" class="bg-dark-surface">
<div class="container">
<div class="mx-auto mb-16 max-w-3xl text-center">
<span class="mb-4 inline-block text-sm font-medium uppercase tracking-wider text-primary-400">
So funktioniert's
</span>
<h2 class="mb-6 text-3xl font-bold md:text-4xl">So einfach geht's</h2>
<p class="text-lg text-gray-400">In drei Schritten zu organisierten Kontakten</p>
</div>
<div class="grid gap-8 md:grid-cols-3">
{steps.map((step) => (
<div class="text-center">
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary-500/20 text-2xl font-bold text-primary-400">
{step.number}
</div>
<h3 class="mb-3 text-xl font-semibold">{step.title}</h3>
<p class="text-gray-400">{step.description}</p>
</div>
))}
</div>
</div>
</section>
)
}
{
PricingSection && (
<PricingSection
id="pricing"
title="Einfache, transparente Preise"
subtitle="Starte kostenlos, upgrade wenn du mehr brauchst"
plans={pricingPlans}
class="bg-dark-bg"
/>
)
}
{
!PricingSection && (
<section id="pricing" class="bg-dark-bg">
<div class="container">
<div class="mx-auto mb-16 max-w-3xl text-center">
<span class="mb-4 inline-block text-sm font-medium uppercase tracking-wider text-primary-400">
Preise
</span>
<h2 class="mb-6 text-3xl font-bold md:text-4xl">Einfache, transparente Preise</h2>
<p class="text-lg text-gray-400">Starte kostenlos, upgrade wenn du mehr brauchst</p>
</div>
<div class="mx-auto grid max-w-5xl gap-8 md:grid-cols-3">
{pricingPlans.map((plan) => (
<div
class={`relative rounded-xl border p-6 ${plan.highlighted ? 'border-primary-500 bg-primary-500/10' : 'border-dark-border bg-dark-card'}`}
>
{plan.badge && (
<div class="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-primary-500 px-3 py-1 text-xs font-medium text-white">
{plan.badge}
</div>
)}
<h3 class="mb-2 text-xl font-semibold">{plan.name}</h3>
<p class="mb-4 text-sm text-gray-400">{plan.description}</p>
<div class="mb-6">
<span class="text-4xl font-bold">{plan.price}&euro;</span>
<span class="text-gray-500">{plan.period}</span>
</div>
<ul class="mb-8 space-y-3">
{plan.features.map((feature) => (
<li
class={`flex items-center gap-2 text-sm ${feature.included ? 'text-white' : 'text-gray-600'}`}
>
{feature.included ? (
<svg
class="h-5 w-5 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
) : (
<svg
class="h-5 w-5 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
)}
{feature.text}
</li>
))}
</ul>
<a
href={plan.cta.href}
class={`btn w-full ${plan.highlighted ? 'btn-primary' : 'btn-secondary'}`}
>
{plan.cta.text}
</a>
</div>
))}
</div>
</div>
</section>
)
}
<CTA />
<Footer />
</Layout>

View file

@ -0,0 +1,78 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--color-background-page: #0a0a0a;
--color-background-card: #1a1a1a;
--color-background-card-hover: #242424;
--color-text-primary: #ffffff;
--color-text-secondary: #d1d5db;
--color-text-muted: #9ca3af;
--color-border: #262626;
--color-border-hover: #3f3f3f;
--color-primary: #3b82f6;
--color-primary-hover: #2563eb;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
@apply bg-dark-bg text-white;
margin: 0;
padding: 0;
overflow-x: hidden;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@apply bg-dark-bg;
}
::-webkit-scrollbar-thumb {
@apply bg-dark-border rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-gray-600;
}
/* Section padding */
section {
@apply py-16 md:py-24;
}
/* Container */
.container {
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
}
/* Gradient text */
.gradient-text {
@apply bg-gradient-to-r from-primary-400 to-primary-600 bg-clip-text text-transparent;
}
/* Button styles */
.btn {
@apply inline-flex items-center justify-center rounded-lg px-6 py-3 font-medium transition-all duration-200;
}
.btn-primary {
@apply bg-primary-500 text-white hover:bg-primary-600;
}
.btn-secondary {
@apply border border-dark-border bg-dark-card text-white hover:bg-dark-surface;
}

View file

@ -0,0 +1,53 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}',
'../../packages/shared-landing-ui/src/**/*.{astro,html,js,jsx,ts,tsx}',
],
theme: {
extend: {
colors: {
// Contacts app theme - blue
primary: {
DEFAULT: '#3b82f6',
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
950: '#172554',
},
dark: {
bg: '#0a0a0a',
surface: '#111111',
card: '#1a1a1a',
border: '#262626',
},
// CSS variable mappings for shared-landing-ui compatibility
background: {
page: 'var(--color-background-page, #0a0a0a)',
card: 'var(--color-background-card, #1a1a1a)',
'card-hover': 'var(--color-background-card-hover, #242424)',
},
text: {
primary: 'var(--color-text-primary, #ffffff)',
secondary: 'var(--color-text-secondary, #d1d5db)',
muted: 'var(--color-text-muted, #9ca3af)',
},
border: {
DEFAULT: 'var(--color-border, #262626)',
hover: 'var(--color-border-hover, #3f3f3f)',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
},
},
},
plugins: [require('@tailwindcss/typography')],
};

View file

@ -0,0 +1,10 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@components/*": ["src/components/*"],
"@layouts/*": ["src/layouts/*"]
}
}
}

View file

@ -0,0 +1,3 @@
name = "contacts-landing"
compatibility_date = "2024-12-01"
pages_build_output_dir = "dist"

View file

@ -6,17 +6,32 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { authMiddleware, healthRoute, errorHandler, notFoundHandler } from '@manacore/shared-hono';
import {
authMiddleware,
healthRoute,
errorHandler,
notFoundHandler,
rateLimitMiddleware,
} from '@manacore/shared-hono';
const PORT = parseInt(process.env.PORT || '3004', 10);
const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5173').split(',');
const ALLOWED_AVATAR_TYPES = new Set([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
]);
const app = new Hono();
app.onError(errorHandler);
app.notFound(notFoundHandler);
app.use('*', cors({ origin: CORS_ORIGINS, credentials: true }));
app.route('/health', healthRoute('contacts-server'));
app.use('/api/*', rateLimitMiddleware({ max: 100, windowMs: 60_000 }));
app.use('/api/*', authMiddleware());
// ─── Avatar Upload (server-only: S3) ─────────────────────────
@ -28,6 +43,9 @@ app.post('/api/v1/contacts/:id/avatar', async (c) => {
if (!file) return c.json({ error: 'No file' }, 400);
if (file.size > 5 * 1024 * 1024) return c.json({ error: 'Max 5MB' }, 400);
if (!ALLOWED_AVATAR_TYPES.has(file.type)) {
return c.json({ error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP, SVG' }, 400);
}
try {
const { createContactsStorage, generateUserFileKey, getContentType } = await import(

View file

@ -1,10 +1,21 @@
<!doctype html>
<html lang="en">
<html lang="de">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Kontakte</title>
<meta name="description" content="Verwalte deine Kontakte intelligent mit Tags, Gruppen, Import/Export und automatischer Duplikaterkennung." />
<meta name="application-name" content="ManaContacts" />
<meta name="theme-color" content="#3b82f6" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta property="og:title" content="ManaContacts - Kontaktverwaltung" />
<meta property="og:description" content="Verwalte deine Kontakte intelligent mit Tags, Gruppen, Import/Export und automatischer Duplikaterkennung." />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="ManaContacts - Kontaktverwaltung" />
<meta name="twitter:description" content="Verwalte deine Kontakte intelligent mit Tags, Gruppen, Import/Export und automatischer Duplikaterkennung." />
<title>ManaContacts</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">