Compare commits

..

3 Commits

65 changed files with 5083 additions and 408 deletions

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
lts/*

61
CLAUDE.md Normal file
View File

@@ -0,0 +1,61 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
npm start # Dev server at http://localhost:4200
npm run build # Production build (SSR)
npm test # Run unit tests with Vitest
ng generate component features/my-feature/components/my-comp # Scaffold component (SCSS, type=component)
node dist/hurler-webdesign-saas/server/server.mjs # Run SSR server after build
```
## Architecture
Angular 21 app with SSR (`@angular/ssr`) and Vitest for testing. The backend is a **Directus CMS** at `https://backend.hurler-webdesign.de` — all blog content is fetched via `BlogService` using Angular's `HttpClient`.
### Folder structure
```
src/app/
core/ # Services, models, directives — singleton, app-wide
features/ # Route-level modules (landing, blog, dashboard)
landing/ # Landingpage with Navigation, Hero, Features, Pricing, Projects, Footer
blog/ # BlogList + BlogDetail pages, fetched from Directus
shared/ # Reusable UI components (Button, NavMenu, ToggleTheme)
src/environments/ # Config constants (Directus URL, OpenPanel credentials)
src/styles/ # Global SCSS: abstracts (functions, mixins), base (tokens, typography), layout
```
### Path aliases (tsconfig)
| Alias | Resolves to |
|---|---|
| `@core/*` | `src/app/core/*` |
| `@features/*` | `src/app/features/*` |
| `@shared/*` | `src/app/shared/*` |
Routes use lazy-loaded components via `loadComponent`.
### Styling conventions
- **SCSS** everywhere; `src/styles/abstracts/` is globally included via `stylePreprocessorOptions.includePaths`
- Use `@use 'abstracts'` to access mixins/functions
- Design tokens live in `src/styles/base/_tokens.scss` as CSS custom properties (OKLCH color space, fluid typography with `clamp()`)
- Dark mode via `[data-theme="dark"]` attribute on `<html>`, managed by `ThemeService` (uses Angular Signals + `localStorage`)
- Breakpoints: `sm` (400px), `md` (700px), `lg` (1200px) — use `@include breakpoint('md')` mixin
- Max content width: 1200px — use `@mixin container-wrapper`
### Analytics
OpenPanel is configured in `src/environments/openpanel.ts` and provided app-wide via `provideOpenPanel()` in `app.config.ts`. The `OpenpanelDirective` (`@core/directives`) enables declarative event tracking.
### Angular patterns used
- Standalone components throughout (no NgModules)
- `inject()` function instead of constructor injection
- Angular Signals for reactive state (`ThemeService`, etc.)
- `provideHttpClient(withFetch())` + `provideClientHydration(withEventReplay())` for SSR hydration
- German locale (`de`) registered and set as `LOCALE_ID`

View File

@@ -53,8 +53,8 @@
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
"maximumWarning": "4kB", "maximumWarning": "6kB",
"maximumError": "8kB" "maximumError": "10kB"
} }
], ],
"outputHashing": "all" "outputHashing": "all"

780
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -34,9 +34,11 @@
"@angular/ssr": "^21.0.3", "@angular/ssr": "^21.0.3",
"@ng-icons/core": "^33.1.0", "@ng-icons/core": "^33.1.0",
"@ng-icons/css.gg": "^33.1.0", "@ng-icons/css.gg": "^33.1.0",
"@openpanel/web": "^1.2.0", "@openpanel/web": "^1.3.0",
"express": "^5.1.0", "express": "^5.1.0",
"marked": "^17.0.5",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"shiki": "^4.0.2",
"tslib": "^2.3.0" "tslib": "^2.3.0"
}, },
"devDependencies": { "devDependencies": {
@@ -44,6 +46,7 @@
"@angular/cli": "^21.2.3", "@angular/cli": "^21.2.3",
"@angular/compiler-cli": "^21.2.5", "@angular/compiler-cli": "^21.2.5",
"@types/express": "^5.0.1", "@types/express": "^5.0.1",
"@types/marked": "^5.0.2",
"@types/node": "^20.19.37", "@types/node": "^20.19.37",
"jsdom": "^27.1.0", "jsdom": "^27.1.0",
"typescript": "~5.9.2", "typescript": "~5.9.2",

13
public/images/text1.svg Normal file
View File

@@ -0,0 +1,13 @@
<svg
width="244.1156"
height="183.29309"
viewBox="0 0 64.588919 48.496297"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<g>
<path
d="m 64.423989,1.7255188 q -0.05975,0.1211937 -0.896238,1.9390997 -0.836489,1.817906 -2.150973,4.6659588 -1.314483,2.8480527 -2.808214,6.0596867 -1.49373,3.211634 -2.867963,6.120284 1.135236,0.0606 1.971725,0 0.836489,-0.121194 1.254734,-0.424178 0.477994,-0.363581 0.77674,-0.363581 0.298746,0 0.298746,0.302984 0,0.424178 -0.657242,1.030147 -0.597492,0.545372 -1.792477,1.211937 -1.194984,0.605969 -2.927712,0.666566 -1.015737,2.060293 -2.389969,4.908346 -1.374233,2.787456 -2.628967,5.99909 -0.238997,0.605968 -0.477994,1.393728 -0.179247,0.787759 -0.179247,1.514921 0,0.848356 0.358495,1.454325 0.418245,0.605969 1.374232,0.605969 1.792477,0 3.584955,-1.211938 1.852226,-1.272534 3.644703,-3.211634 1.792477,-1.999696 3.465455,-4.24178 0.298747,-0.363581 0.537744,-0.363581 0.298746,0 0.298746,0.484774 0,0.363582 -0.238997,0.666566 -1.732728,2.302681 -3.584954,4.484168 -1.852227,2.12089 -3.883701,3.454022 -2.031474,1.333131 -4.361694,1.333131 -2.449718,0 -3.525204,-1.211938 -1.015737,-1.272534 -1.015737,-3.211634 0,-0.908953 0.179247,-1.878503 0.238997,-1.030146 0.537743,-2.060293 0.77674,-2.423875 1.971725,-4.847749 1.194985,-2.484472 2.031474,-4.181184 -1.015737,-0.121194 -2.808214,-0.424178 -1.792477,-0.302985 -3.8837,-0.545372 -2.091223,-0.242387 -4.182447,-0.181791 -1.493731,3.635812 -3.584954,7.514012 -2.091223,3.817602 -4.779938,7.332221 -2.628968,3.514618 -5.915176,6.302074 -3.226459,2.787456 -7.110159,4.302377 -3.8837,1.575519 -8.4246419,1.393728 -2.6289663,-0.121193 -4.839688,-1.454324 -2.2107217,-1.272535 -3.525205,-3.635812 -1.25473392,-2.363278 -1.25473392,-5.574912 0,-1.514922 0.29874618,-3.09044 0.29874617,-1.636116 0.95598774,-3.393425 1.3144833,-3.454021 3.8239511,-6.241477 2.5094679,-2.848053 5.6164278,-4.484168 3.10696,-1.696713 6.094422,-1.696713 2.628967,0 4.481193,1.75731 1.911975,1.757309 1.911975,5.090136 0,1.575519 -0.537743,3.514619 -1.075486,3.696408 -3.10696,5.696105 -2.031474,1.999697 -4.062948,2.848053 -1.493731,0.545372 -2.867963,0.545372 -1.075486,0 -1.852226,-0.363582 -0.716991,-0.363581 -0.8364898,-1.030146 v -0.181791 q 0,-0.605969 0.4779938,-0.605969 0.597492,0 0.657242,0.727163 0.05975,0.18179 0.418244,0.424178 0.418245,0.18179 1.135236,0.18179 0.477994,0 1.015737,-0.121193 0.597492,-0.121194 1.194984,-0.363581 1.553481,-0.666566 3.16671,-2.545069 1.672978,-1.878503 2.628966,-5.21133 0.597493,-1.999697 0.597493,-3.757006 0,-2.787456 -1.374233,-4.181184 -1.374232,-1.393728 -3.226458,-1.393728 -2.808215,0 -5.317682,1.757309 -2.449719,1.757309 -4.3019452,4.484168 -1.7924771,2.726859 -2.8679633,5.696106 -0.6572416,1.817906 -0.9559878,3.514618 -0.2987461,1.696712 -0.2987461,3.211634 0,3.999393 1.9717247,6.423268 1.9717247,2.423875 5.3774307,2.423875 2.987462,0 5.795676,-1.454325 2.808214,-1.393728 5.377431,-3.757006 2.569217,-2.302681 4.779939,-5.090137 2.270471,-2.848052 4.062949,-5.696105 1.852227,-2.90865 3.226459,-5.393121 1.374232,-2.484472 2.210722,-4.120587 -2.628967,0.424178 -4.540942,1.15134 -1.911977,0.666566 -3.047212,3.151037 -0.119499,0.302985 -0.537744,0.666566 -0.358495,0.302984 -0.657241,0.363581 -0.05975,0 -0.119499,0.0606 0,0 -0.05975,0 -0.418244,0 -0.418244,-0.484775 0,-0.424178 0.477993,-1.393728 1.792477,-3.029843 4.540943,-4.484168 2.748465,-1.514922 5.795676,-1.9391 1.015737,-1.9391 2.091223,-4.120587 1.075487,-2.181487 2.38997,-4.362974 1.194984,-2.120891 2.569217,-4.2417813 1.374232,-2.1814872 2.748465,-3.8176026 -0.597493,-0.060597 -1.194985,-0.060597 -0.597492,-0.060597 -1.254734,-0.060597 -3.166709,0 -6.333419,0.7877593 -3.10696,0.7877593 -5.43718,2.6056653 -2.270471,1.7573091 -2.987463,4.8477493 -0.05975,0.242388 -0.119499,0.545372 0,0.242388 0,0.545372 0,1.514922 0.89624,2.484472 0.955988,0.96955 2.270471,0.96955 1.194985,0 2.210722,-0.908953 1.075486,-0.908953 1.732728,-2.181488 0.657241,-1.272534 0.657241,-2.2420843 0,-0.7271624 0.418245,-0.7271624 0.358495,0 0.358495,0.6059687 0,2.060294 -0.955988,3.454022 -0.896238,1.333131 -2.27047,1.999697 -1.374233,0.666565 -2.867964,0.666565 -2.031474,0 -3.644704,-1.333131 -1.55348,-1.333131 -1.254734,-3.757006 0.358495,-2.848053 2.150972,-4.665959 1.852228,-1.817906 4.421445,-2.7874559 2.569217,-1.0301467 5.377431,-1.3937279 2.808214,-0.3635812 5.138434,-0.3635812 1.314483,0 2.38997,0.1211937 1.075486,0.060597 1.672978,0.1817906 0.179248,0.060597 0.179248,0.3029844 0,0.3029843 -0.298746,0.7877592 -0.238997,0.484775 -0.358496,0.6059687 -2.210721,3.6964089 -3.823951,7.5140114 -1.613229,3.817603 -3.345957,7.816997 1.732728,0.0606 3.823951,0.424178 2.150973,0.302984 4.062948,0.666565 1.911976,0.302984 2.987462,0.484775 1.971724,-3.635812 3.823951,-6.96864 1.852226,-3.393424 3.345957,-6.1202838 1.493731,-2.726859 2.389969,-4.3629744 0.83649,-1.5149217 1.55348,-2.18148716 0.716991,-0.7271624 1.075487,-0.78775927 h 0.238997 q 0.358495,0 0.477993,0.30298433 0.179248,0.24238747 0.179248,0.5453718 0,0.4847749 -0.179248,0.8483561 z"
class="logo_h"
aria-label="H" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -1,16 +1,23 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { ApplicationConfig, provideBrowserGlobalErrorListeners, LOCALE_ID } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { provideNgIconsConfig } from '@ng-icons/core'; import { provideNgIconsConfig } from '@ng-icons/core';
import { environment } from '../environments/openpanel'; import { environment } from '../environments/openpanel';
import { provideOpenPanel } from '@core/provider/openpanel.provider'; import { provideOpenPanel } from '@core/provider/openpanel.provider';
import { registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
registerLocaleData(localeDe);
import { routes } from './app.routes'; import { routes } from './app.routes';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser'; import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
{ provide: LOCALE_ID, useValue: 'de' },
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
provideRouter(routes), provideRouter(routes),
provideHttpClient(withFetch()),
provideClientHydration(withEventReplay()), provideClientHydration(withEventReplay()),
provideNgIconsConfig({}), provideNgIconsConfig({}),
provideOpenPanel({ provideOpenPanel({

View File

@@ -3,10 +3,18 @@ import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [ export const serverRoutes: ServerRoute[] = [
{ {
path: '', path: '',
renderMode: RenderMode.Prerender renderMode: RenderMode.Prerender,
},
{
path: 'blog',
renderMode: RenderMode.Prerender,
},
{
path: 'blog/:slug',
renderMode: RenderMode.Server,
}, },
{ {
path: '**', path: '**',
renderMode: RenderMode.Prerender renderMode: RenderMode.Server,
} },
]; ];

View File

@@ -2,7 +2,23 @@ import { Routes } from '@angular/router';
export const routes: Routes = [ export const routes: Routes = [
{ {
path: "", path: '',
loadComponent: () => import('@features/landing/').then(m => m.LandingpageComponent) loadComponent: () => import('@features/landing/').then(m => m.LandingpageComponent),
data: { trackName: 'Home' }
},
{
path: 'blog',
loadComponent: () => import('@features/blog').then(m => m.BlogListComponent),
data: { trackName: 'Blog' }
},
{
path: 'blog/:slug',
loadComponent: () => import('@features/blog').then(m => m.BlogDetailComponent),
data: { trackName: 'BlogDetail' }
},
{
path: 'projekt/:slug',
loadComponent: () => import('@features/landing/pages/project-detail/project-detail.component').then(m => m.ProjectDetailComponent),
data: { trackName: 'ProjectDetail' }
} }
]; ];

View File

@@ -1,9 +1,9 @@
import { Component, signal, OnInit } from '@angular/core'; import { Component, signal, inject } from '@angular/core';
import { RouterOutlet, Router, NavigationEnd } from '@angular/router'; import { RouterOutlet, Router, NavigationEnd } from '@angular/router';
import {provideIcons} from "@ng-icons/core"; import {provideIcons} from "@ng-icons/core";
import { filter } from 'rxjs/operators'; import { filter } from 'rxjs/operators';
import {cssMenu} from "@ng-icons/css.gg"; import {cssMenu} from "@ng-icons/css.gg";
import { UmamiService } from '@core/services/umami.service'; import { OpenPanelService } from '@core/services/openpanel.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -12,19 +12,17 @@ import { UmamiService } from '@core/services/umami.service';
styleUrl: './app.scss', styleUrl: './app.scss',
viewProviders: [provideIcons({cssMenu})] viewProviders: [provideIcons({cssMenu})]
}) })
export class App implements OnInit { export class App {
protected readonly title = signal('hurler-webdesign-saas'); protected readonly title = signal('hurler-webdesign-saas');
private readonly router = inject(Router);
private readonly opService = inject(OpenPanelService);
constructor( constructor() {
private router: Router, // Optional: Manuelles Tracking von Seitenaufrufen, falls nicht automatisch in OpenPanelService konfiguriert
private umami: UmamiService
) {}
ngOnInit(): void {
this.router.events.pipe( this.router.events.pipe(
filter(event => event instanceof NavigationEnd) filter(event => event instanceof NavigationEnd)
).subscribe(() => { ).subscribe((event) => {
this.umami.trackPageview(); this.opService.trackScreenView((event as NavigationEnd).urlAfterRedirects);
}); });
} }
} }

View File

@@ -0,0 +1,24 @@
// src/app/blog/models/blog-post.model.ts
export interface Tag {
tags_id: {
id: string;
name: string;
};
}
export interface BlogPost {
id: string;
title: string;
slug: string;
summary: string;
content: string;
cover_image: string | null;
published_at: string;
status: 'Entwurf' | 'Veröffentlicht' | 'Archiviert';
tags: Tag[];
}
export interface DirectusResponse<T> {
data: T;
}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { BlogService } from './blog.service';
describe('BlogService', () => {
let service: BlogService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(BlogService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,52 @@
// src/app/blog/services/blog.service.ts
import { inject, Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, map } from 'rxjs';
import { environment } from '../../../environments/environment.directus';
import { BlogPost, DirectusResponse } from '@core/models/blog-posts.model';
@Injectable({ providedIn: 'root' })
export class BlogService {
private http = inject(HttpClient);
private baseUrl = environment.directusUrl;
private readonly defaultFields = [
'id',
'title',
'slug',
'summary',
'cover_image',
'published_at',
'tags.tags_id.id',
'tags.tags_id.name'
].join(',');
getPosts(): Observable<BlogPost[]> {
const params = new HttpParams()
.set('sort', '-published_at')
.set('fields', this.defaultFields)
.set('filter', JSON.stringify({ status: { _eq: 'published' } }));
return this.http
.get<DirectusResponse<BlogPost[]>>(`${this.baseUrl}/items/blog_posts`, { params })
.pipe(map(res => res.data));
}
getPostBySlug(slug: string): Observable<BlogPost | null> {
const params = new HttpParams()
.set('filter', JSON.stringify({ slug: { _eq: slug }, status: { _eq: 'published' } }))
.set('fields', `${this.defaultFields},content`);
return this.http
.get<DirectusResponse<BlogPost[]>>(`${this.baseUrl}/items/blog_posts`, { params })
.pipe(map(res => res.data[0] ?? null));
}
getAssetUrl(assetId: string, params?: { width?: number; height?: number; quality?: number }): string {
const url = new URL(`${this.baseUrl}/assets/${assetId}`);
if (params?.width) url.searchParams.set('width', String(params.width));
if (params?.height) url.searchParams.set('height', String(params.height));
if (params?.quality) url.searchParams.set('quality', String(params.quality));
return url.toString();
}
}

View File

@@ -15,14 +15,14 @@ export class NavigationService {
{ label: 'Projekte', type: 'anchor', target: 'projects' }, { label: 'Projekte', type: 'anchor', target: 'projects' },
{ label: 'Pricing', type: 'anchor', target: 'pricing' }, { label: 'Pricing', type: 'anchor', target: 'pricing' },
// Route-Links ( andere Pages) // Route-Links
{ label: 'Blog', type: 'route', target: '/blog' },
{ label: 'Login', type: 'route', target: '/login' }, { label: 'Login', type: 'route', target: '/login' },
{ {
label: 'Dashboard', label: 'Dashboard',
type: 'route', type: 'route',
target: '/dashboard', target: '/dashboard',
icon: 'layout', icon: 'layout',
// Geschützte Route - wird später gefiltert
} }
]); ]);
@@ -32,7 +32,7 @@ export class NavigationService {
// Gefiltert nach Kontext (Landingpage vs. App) // Gefiltert nach Kontext (Landingpage vs. App)
readonly landingNavigation = computed(() => readonly landingNavigation = computed(() =>
this._navigationItems().filter(item => this._navigationItems().filter(item =>
isAnchor(item) || item.target === '/login' isAnchor(item) || item.target === '/blog'
) )
); );

View File

@@ -48,21 +48,23 @@ export class OpenPanelService implements OnDestroy {
} }
private setupRouteTracking(): void { private setupRouteTracking(): void {
// Nur einmalig subscriben, vorherige Sub zerstören
this.routerSubscription?.unsubscribe(); this.routerSubscription?.unsubscribe();
this.routerSubscription = this.router.events this.routerSubscription = this.router.events.pipe(
.pipe(
filter((event) => event instanceof NavigationEnd), filter((event) => event instanceof NavigationEnd),
// Ersten initialNavigation-Event überspringen SSR hat ihn schon getriggert ).subscribe(() => {
skip(1), const route = this.getActiveRoute();
) const trackName = route.snapshot.data['trackName'] ?? this.router.url;
.subscribe((event) => { this.trackScreenView(trackName);
const navEvent = event as NavigationEnd;
this.trackScreenView(navEvent.urlAfterRedirects);
}); });
} }
private getActiveRoute() {
let route = this.router.routerState.root;
while (route.firstChild) route = route.firstChild;
return route;
}
// ─── Public API ──────────────────────────────────────────────────────────── // ─── Public API ────────────────────────────────────────────────────────────
/** /**
@@ -121,7 +123,7 @@ export class OpenPanelService implements OnDestroy {
/** /**
* Decrements a numeric property on the user profile. * Decrements a numeric property on the user profile.
* @example opService.decrement('credits', 5); * @example opService.decrement('credits');
*/ */
decrement(property: string): void { decrement(property: string): void {
if (!this.op) return; if (!this.op) return;

View File

@@ -0,0 +1,20 @@
<div class="blog-nav">
<div class="blog-nav__inner">
<a routerLink="/" class="blog-nav__logo">
<span class="blog-nav__logo-icon">H</span>
<span><span class="blog-nav__logo-accent">Hurler</span> Webdesign</span>
</a>
<div class="blog-nav__actions">
@if (showBack) {
<a routerLink="/blog" class="blog-nav__back">
← Alle Artikel
</a>
} @else {
<a routerLink="/" class="blog-nav__back">
← Zurück zur Startseite
</a>
}
</div>
</div>
</div>

View File

@@ -0,0 +1,57 @@
@use 'abstracts';
.blog-nav {
height: var(--nav-height);
background-color: var(--nav-bg);
backdrop-filter: var(--nav-backdrop);
box-shadow: var(--nav-shadow);
border-radius: 0 0 10px 10px;
position: sticky;
top: 0;
z-index: var(--z-index-sticky);
&__inner {
@include abstracts.container-wrapper;
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
&__logo {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-size-base);
font-weight: 700;
color: var(--text-main);
}
&__logo-icon {
display: flex;
align-items: center;
justify-content: center;
width: abstracts.rem(30);
height: abstracts.rem(30);
background-color: var(--accent);
color: var(--text-on-accent);
border-radius: 5px;
font-weight: 700;
font-size: 1.1rem;
flex-shrink: 0;
}
&__logo-accent {
color: var(--accent);
}
&__back {
font-size: var(--font-size-base);
color: var(--text-muted);
transition: color 0.15s ease;
&:hover {
color: var(--accent);
}
}
}

View File

@@ -0,0 +1,12 @@
import { Component, Input } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({
selector: 'app-blog-nav',
imports: [RouterModule],
templateUrl: './blog-nav.component.html',
styleUrl: './blog-nav.component.scss',
})
export class BlogNavComponent {
@Input() showBack = false;
}

View File

@@ -0,0 +1,2 @@
export { BlogListComponent } from './pages/blog-list/blog-list.component';
export { BlogDetailComponent } from './pages/blog-detail/blog-detail.component';

View File

@@ -0,0 +1,87 @@
<app-blog-nav [showBack]="true"></app-blog-nav>
<main class="blog-detail">
@if (loading()) {
<div class="blog-detail__skeleton">
<div class="blog-detail__cover-skeleton"></div>
<div class="blog-detail__wrapper">
<div class="skeleton-line skeleton-line--tag"></div>
<div class="skeleton-line skeleton-line--title"></div>
<div class="skeleton-line skeleton-line--meta"></div>
<div class="skeleton-line skeleton-line--body"></div>
<div class="skeleton-line skeleton-line--body"></div>
<div class="skeleton-line skeleton-line--body skeleton-line--short"></div>
</div>
</div>
} @else if (notFound()) {
<div class="blog-detail__wrapper blog-detail__not-found">
<h1>Artikel nicht gefunden</h1>
<p class="text-muted">Dieser Artikel existiert nicht oder wurde entfernt.</p>
</div>
} @else if (post(); as post) {
@if (getCoverUrl(post); as coverUrl) {
<div class="blog-detail__cover">
<img [src]="coverUrl" [alt]="post.title" />
<div class="blog-detail__cover-overlay"></div>
</div>
}
<div class="blog-detail__wrapper">
<header class="blog-detail__header">
@if (post.tags.length > 0) {
<div class="blog-detail__tags">
@for (tag of post.tags; track tag.tags_id.id) {
<span class="blog-detail__tag">{{ tag.tags_id.name }}</span>
}
</div>
}
<h1 class="blog-detail__title">{{ post.title }}</h1>
<div class="blog-detail__meta">
<time class="blog-detail__date" [dateTime]="post.published_at">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
{{ post.published_at | date:'d. MMMM yyyy':'':'de' }}
</time>
<span class="blog-detail__read-time">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
5 Min. Lesezeit
</span>
</div>
<p class="blog-detail__summary">{{ post.summary }}</p>
</header>
<article class="blog-detail__content" [innerHTML]="parsedContent()"></article>
<footer class="blog-detail__footer">
<div class="blog-detail__share">
<span class="blog-detail__share-label">Artikel teilen:</span>
<a href="#" class="blog-detail__share-btn" aria-label="Auf Twitter teilen">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
</a>
<a href="#" class="blog-detail__share-btn" aria-label="Auf LinkedIn teilen">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
</a>
</div>
</footer>
</div>
}
</main>

View File

@@ -0,0 +1,294 @@
@use 'abstracts';
@keyframes skeleton-shimmer {
from {
background-position: -400px 0;
}
to {
background-position: 400px 0;
}
}
.blog-detail {
min-height: calc(100vh - var(--nav-height));
padding-bottom: calc(var(--space-4) * 3);
&__wrapper {
@include abstracts.container-wrapper;
max-width: 740px;
}
&__cover {
position: relative;
display: flex;
width: 100%;
height: clamp(300px, 50vh, 500px);
overflow: hidden;
margin-bottom: calc(var(--space-4) * 1.5);
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
&__cover-overlay {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 50%;
background: linear-gradient(to top, var(--bg-surface) 0%, transparent 100%);
pointer-events: none;
}
&__header {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-bottom: calc(var(--space-4) * 1.5);
}
&__tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
&__tag {
font-size: 0.7rem;
font-weight: 700;
padding: 4px 12px;
border-radius: 999px;
background: linear-gradient(135deg, oklch(from var(--accent) l c h / 0.12) 0%, oklch(from var(--accent) l c h / 0.08) 100%);
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.05em;
}
&__title {
font-size: clamp(var(--font-size-xl), 4vw, 3rem);
font-weight: 800;
line-height: 1.15;
letter-spacing: -0.02em;
}
&__meta {
display: flex;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
}
&__date,
&__read-time {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: 0.875rem;
color: var(--text-muted);
svg {
opacity: 0.7;
}
}
&__summary {
font-size: var(--font-size-lg);
color: var(--text-muted);
line-height: 1.65;
font-weight: 400;
padding-top: var(--space-3);
border-top: 1px solid var(--border-color);
}
&__content {
font-size: var(--font-size-base);
line-height: 1.8;
color: var(--text-main);
h2 {
font-size: var(--font-size-xl);
font-weight: 700;
margin-top: calc(var(--space-4) * 1.5);
margin-bottom: var(--space-3);
line-height: 1.25;
}
h3 {
font-size: var(--font-size-lg);
font-weight: 700;
margin-top: var(--space-4);
margin-bottom: var(--space-2);
line-height: 1.3;
}
p {
margin-bottom: var(--space-3);
}
a {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 3px;
transition: color 0.2s ease;
&:hover {
color: var(--accent-hover);
}
}
ul,
ol {
padding-left: var(--space-4);
margin-bottom: var(--space-3);
li {
margin-bottom: var(--space-2);
}
}
blockquote {
border-left: 3px solid var(--accent);
padding-left: var(--space-4);
margin-inline: 0;
margin-block: var(--space-4);
color: var(--text-muted);
font-style: italic;
font-size: var(--font-size-lg);
}
code {
font-family: 'Courier New', Courier, monospace;
font-size: 0.9em;
background-color: var(--bg-muted);
padding: 3px 8px;
border-radius: 6px;
}
pre {
background-color: var(--bg-muted);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: var(--space-4);
overflow-x: auto;
margin-bottom: var(--space-4);
code {
background: none;
padding: 0;
font-size: 0.875em;
}
}
img {
max-width: 100%;
border-radius: var(--border-radius);
margin-block: var(--space-4);
box-shadow: 0 4px 20px oklch(0% 0 0 / 0.08);
}
hr {
border: none;
border-top: 1px solid var(--border-color);
margin-block: var(--space-4);
}
}
&__footer {
margin-top: calc(var(--space-4) * 2);
padding-top: var(--space-4);
border-top: 1px solid var(--border-color);
}
&__share {
display: flex;
align-items: center;
gap: var(--space-2);
}
&__share-label {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-muted);
}
&__share-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
background-color: var(--bg-muted);
color: var(--text-main);
transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
&:hover {
background-color: var(--accent);
color: var(--text-on-accent);
transform: scale(1.1);
}
}
&__not-found {
padding-top: calc(var(--space-4) * 2);
text-align: center;
h1 {
font-size: var(--font-size-xl);
margin-bottom: var(--space-2);
}
}
&__cover-skeleton {
width: 100%;
height: clamp(300px, 50vh, 500px);
background-color: var(--bg-muted);
margin-bottom: calc(var(--space-4) * 1.5);
}
&__skeleton .blog-detail__wrapper {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
}
.skeleton-line {
border-radius: 4px;
background: linear-gradient(90deg,
var(--bg-muted) 25%,
var(--border-color) 50%,
var(--bg-muted) 75%);
background-size: 800px 100%;
animation: skeleton-shimmer 1.4s ease-in-out infinite;
&--tag {
height: 1.2em;
width: 80px;
}
&--title {
height: 2em;
width: 75%;
margin-bottom: var(--space-1);
}
&--meta {
height: 0.9em;
width: 120px;
}
&--body {
height: 1em;
width: 100%;
}
&--short {
width: 60%;
}
}

View File

@@ -0,0 +1,101 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { DatePipe } from '@angular/common';
import { marked, Renderer } from 'marked';
import { createHighlighter, Highlighter } from 'shiki';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { BlogService } from '@core/services/blog.service';
import { SeoService } from '@core/services/seo.service';
import { OpenPanelService } from '@core/services/openpanel.service';
import { BlogPost } from '@core/models/blog-posts.model';
import { BlogNavComponent } from '../../components/blog-nav/blog-nav.component';
const SHIKI_LANGS = ['html', 'css', 'scss', 'javascript', 'typescript', 'sql', 'python'] as const;
const SHIKI_THEME = 'ayu-light';
@Component({
selector: 'app-blog-detail',
imports: [DatePipe, BlogNavComponent],
templateUrl: './blog-detail.component.html',
styleUrl: './blog-detail.component.scss',
})
export class BlogDetailComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly blogService = inject(BlogService);
private readonly seo = inject(SeoService);
private readonly op = inject(OpenPanelService);
private readonly sanitizer = inject(DomSanitizer);
post = signal<BlogPost | null>(null);
loading = signal(true);
notFound = signal(false);
parsedContent = signal<SafeHtml>('');
private highlighter: Highlighter | null = null;
private async getHighlighter(): Promise<Highlighter> {
if (!this.highlighter) {
this.highlighter = await createHighlighter({
themes: [SHIKI_THEME],
langs: [...SHIKI_LANGS],
});
}
return this.highlighter;
}
private async parseContent(content: string): Promise<SafeHtml> {
const highlighter = await this.getHighlighter();
const loadedLangs = highlighter.getLoadedLanguages();
const renderer = new Renderer();
renderer.code = ({ text, lang }) => {
const language = lang && loadedLangs.includes(lang as any) ? lang : 'text';
return highlighter.codeToHtml(text, { lang: language, theme: SHIKI_THEME });
};
marked.use({ renderer });
const html = await marked(content);
return this.sanitizer.bypassSecurityTrustHtml(html);
}
async ngOnInit(): Promise<void> {
const slug = this.route.snapshot.paramMap.get('slug') ?? '';
this.blogService.getPostBySlug(slug).subscribe({
next: async (post) => {
if (!post) {
this.notFound.set(true);
this.loading.set(false);
return;
}
this.post.set(post);
if (post.content) {
this.parsedContent.set(await this.parseContent(post.content));
}
this.loading.set(false);
this.seo.updateMetadata({
title: post.title,
description: post.summary,
image: post.cover_image
? this.blogService.getAssetUrl(post.cover_image, { width: 1200, quality: 85 })
: undefined,
type: 'article',
});
this.op.track('blog_post_view', { slug, title: post.title });
},
error: () => {
this.notFound.set(true);
this.loading.set(false);
},
});
}
getCoverUrl(post: BlogPost): string | null {
if (!post.cover_image) return null;
return this.blogService.getAssetUrl(post.cover_image, { width: 1200, quality: 85 });
}
}

View File

@@ -0,0 +1,87 @@
<app-blog-nav></app-blog-nav>
<main class="blog-list">
<div class="blog-list__wrapper">
<header class="blog-list__header">
<span class="blog-list__label">Wissen & Insights</span>
<h1>Blog</h1>
<p class="text-muted">Tipps, Hintergründe und Best Practices rund um Webdesign &amp; digitale Präsenz.</p>
</header>
@if (loading()) {
<div class="blog-list__grid">
@for (_ of [1, 2, 3]; track $index) {
<div class="blog-card blog-card--skeleton">
<div class="blog-card__image blog-card__image--skeleton"></div>
<div class="blog-card__body">
<div class="skeleton-line skeleton-line--title"></div>
<div class="skeleton-line skeleton-line--text"></div>
<div class="skeleton-line skeleton-line--text skeleton-line--short"></div>
</div>
</div>
}
</div>
} @else if (error()) {
<div class="blog-list__error">
<p>{{ error() }}</p>
</div>
} @else if (posts().length === 0) {
<div class="blog-list__empty">
<p>Noch keine Artikel veröffentlicht schau bald wieder vorbei.</p>
</div>
} @else {
<div class="blog-list__grid">
@for (post of posts(); track post.id) {
<a
class="blog-card"
[routerLink]="['/blog', post.slug]"
opTrack="blog_post_click"
[opTrackProps]="{ slug: post.slug, title: post.title }">
<div class="blog-card__image">
@if (getCoverUrl(post); as coverUrl) {
<img [src]="coverUrl" [alt]="post.title" loading="lazy" />
} @else {
<div class="blog-card__image-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M19 20H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h10l6 6v8a2 2 0 0 1-2 2Z"/>
<path d="m14 14-3 3-2-2-4 4"/>
</svg>
</div>
}
<div class="blog-card__read-time">
<span>5 Min. Lesezeit</span>
</div>
</div>
<div class="blog-card__body">
@if (post.tags.length > 0) {
<div class="blog-card__tags">
@for (tag of post.tags; track tag.tags_id.id) {
<span class="blog-card__tag">{{ tag.tags_id.name }}</span>
}
</div>
}
<h2 class="blog-card__title">{{ post.title }}</h2>
<p class="blog-card__summary">{{ post.summary }}</p>
<div class="blog-card__footer">
<time class="blog-card__date" [dateTime]="post.published_at">
{{ post.published_at | date:'d. MMM yyyy':'':'de' }}
</time>
<span class="blog-card__cta">
Weiterlesen
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</span>
</div>
</div>
</a>
}
</div>
}
</div>
</main>

View File

@@ -0,0 +1,236 @@
@use 'abstracts';
@keyframes skeleton-shimmer {
from { background-position: -400px 0; }
to { background-position: 400px 0; }
}
.blog-list {
min-height: calc(100vh - var(--nav-height));
padding-block: calc(var(--space-4) * 2);
&__wrapper {
@include abstracts.container-wrapper;
}
&__header {
text-align: center;
margin-bottom: calc(var(--space-4) * 1.5);
h1 {
font-size: var(--font-size-xl);
margin-bottom: var(--space-2);
}
p {
font-size: var(--font-size-lg);
}
}
&__label {
display: inline-block;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent);
margin-bottom: var(--space-2);
}
&__grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-3);
@include abstracts.breakpoint('md') {
grid-template-columns: repeat(2, 1fr);
}
@include abstracts.breakpoint('lg') {
grid-template-columns: repeat(3, 1fr);
}
}
&__error,
&__empty {
text-align: center;
padding: var(--space-4);
color: var(--text-muted);
font-size: var(--font-size-lg);
border: 1px dashed var(--border-color);
border-radius: var(--border-radius);
}
}
.blog-card {
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
overflow: hidden;
background-color: var(--bg-surface);
color: var(--text-main);
transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.25s ease,
border-color 0.25s ease;
&:hover {
transform: translateY(-6px);
border-color: var(--accent);
box-shadow: 0 16px 48px oklch(0% 0 0 / 0.1);
}
&__image {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
background-color: var(--bg-muted);
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.4s ease;
.blog-card:hover & {
transform: scale(1.05);
}
}
}
&__image-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--bg-muted) 0%, var(--border-color) 100%);
color: var(--text-muted);
opacity: 0.5;
}
&__read-time {
position: absolute;
bottom: var(--space-2);
right: var(--space-2);
background: oklch(0% 0 0 / 0.7);
backdrop-filter: blur(8px);
color: var(--color-white);
font-size: 0.7rem;
font-weight: 600;
padding: 4px 10px;
border-radius: 999px;
}
&__body {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3);
flex: 1;
}
&__tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
&__tag {
font-size: 0.7rem;
font-weight: 700;
padding: 3px 10px;
border-radius: 999px;
background: linear-gradient(135deg, oklch(from var(--accent) l c h / 0.12) 0%, oklch(from var(--accent) l c h / 0.08) 100%);
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.05em;
}
&__title {
font-size: var(--font-size-lg);
font-weight: 700;
line-height: 1.3;
transition: color 0.2s ease;
.blog-card:hover & {
color: var(--accent);
}
}
&__summary {
font-size: var(--font-size-base);
color: var(--text-muted);
line-height: 1.65;
flex: 1;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: var(--space-2);
border-top: 1px solid var(--border-color);
margin-top: auto;
}
&__date {
font-size: 0.8rem;
color: var(--text-muted);
}
&__cta {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: 0.8rem;
font-weight: 600;
color: var(--accent);
transition: gap 0.2s ease;
.blog-card:hover & {
gap: var(--space-2);
}
}
&--skeleton {
pointer-events: none;
.blog-card__image--skeleton {
background-color: var(--bg-muted);
}
}
}
.skeleton-line {
height: 1em;
border-radius: 4px;
background: linear-gradient(
90deg,
var(--bg-muted) 25%,
var(--border-color) 50%,
var(--bg-muted) 75%
);
background-size: 800px 100%;
animation: skeleton-shimmer 1.4s ease-in-out infinite;
&--title {
height: 1.4em;
width: 80%;
margin-bottom: var(--space-1);
}
&--text {
width: 100%;
}
&--short {
width: 50%;
}
}

View File

@@ -0,0 +1,51 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { RouterModule } from '@angular/router';
import { BlogService } from '@core/services/blog.service';
import { SeoService } from '@core/services/seo.service';
import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive';
import { BlogPost } from '@core/models/blog-posts.model';
import { BlogNavComponent } from '../../components/blog-nav/blog-nav.component';
@Component({
selector: 'app-blog-list',
imports: [RouterModule, DatePipe, OpenPanelTrackDirective, BlogNavComponent],
templateUrl: './blog-list.component.html',
styleUrl: './blog-list.component.scss',
})
export class BlogListComponent implements OnInit {
private readonly blogService = inject(BlogService);
private readonly seo = inject(SeoService);
posts = signal<BlogPost[]>([]);
loading = signal(true);
error = signal<string | null>(null);
ngOnInit(): void {
this.seo.updateMetadata({
title: 'Blog Webdesign-Tipps & Einblicke',
description: 'Artikel rund um Webdesign, Performance und digitale Präsenz für Handwerk und Vereine.',
type: 'website',
});
this.blogService.getPosts().subscribe({
next: (posts) => {
this.posts.set(posts);
this.loading.set(false);
},
error: () => {
this.error.set('Die Artikel konnten nicht geladen werden. Bitte versuche es später erneut.');
this.loading.set(false);
},
});
}
getCoverUrl(post: BlogPost): string | null {
if (!post.cover_image) return null;
return this.blogService.getAssetUrl(post.cover_image, { width: 640, quality: 80 });
}
formatDate(dateStr: string): string {
return dateStr;
}
}

View File

@@ -0,0 +1,145 @@
<section class="contact" id="contact">
<div class="contact__wrapper">
<div class="contact__header">
<h2>Projekt anfragen</h2>
<p class="text-muted">
Erzählen Sie uns von Ihrem Vorhaben kostenlos und unverbindlich.
Wir melden uns innerhalb von 24 Stunden.
</p>
</div>
<div class="contact__grid">
<!-- Info column -->
<div class="contact__info">
<div class="contact__info-item">
<div class="contact__info-icon">
<ng-icon name="cssPin"></ng-icon>
</div>
<div class="contact__info-text">
<p class="contact__info-label">Standort</p>
<address>
Hurler Webdesign<br />
Untermagerbein 30<br />
86751 Mönchsdeggingen, Bayern
</address>
</div>
</div>
<div class="contact__info-item">
<div class="contact__info-icon">
<ng-icon name="cssAlarm"></ng-icon>
</div>
<div class="contact__info-text">
<p class="contact__info-label">Reaktionszeit</p>
<p>Antwort innerhalb von 24 Stunden</p>
</div>
</div>
<div class="contact__info-item">
<div class="contact__info-icon">
<ng-icon name="cssLock"></ng-icon>
</div>
<div class="contact__info-text">
<p class="contact__info-label">Datenschutz</p>
<p>DSGVO-konform &amp; Server in Deutschland</p>
</div>
</div>
<ul class="contact__benefits">
<li>
<ng-icon name="cssCheck" aria-hidden="true"></ng-icon>
Kostenlose Erstberatung
</li>
<li>
<ng-icon name="cssCheck" aria-hidden="true"></ng-icon>
Transparente Festpreise
</li>
<li>
<ng-icon name="cssCheck" aria-hidden="true"></ng-icon>
Persönliche Betreuung
</li>
<li>
<ng-icon name="cssCheck" aria-hidden="true"></ng-icon>
Ohne Abo oder versteckte Kosten
</li>
</ul>
</div>
<!-- Form column -->
<div class="contact__form-wrapper">
@if (!submitted()) {
<form
[formGroup]="form"
(ngSubmit)="onSubmit()"
class="contact__form"
novalidate>
<div class="contact__field" [class.contact__field--error]="nameInvalid">
<label for="contact-name">Name</label>
<input
id="contact-name"
type="text"
formControlName="name"
placeholder="Max Mustermann"
autocomplete="name"
(focus)="onFirstFocus()" />
@if (nameInvalid) {
<span class="contact__error" role="alert">Bitte geben Sie Ihren Namen ein.</span>
}
</div>
<div class="contact__field" [class.contact__field--error]="emailInvalid">
<label for="contact-email">E-Mail</label>
<input
id="contact-email"
type="email"
formControlName="email"
placeholder="max@beispiel.de"
autocomplete="email"
(focus)="onFirstFocus()" />
@if (emailInvalid) {
<span class="contact__error" role="alert">Bitte geben Sie eine gültige E-Mail-Adresse ein.</span>
}
</div>
<div class="contact__field" [class.contact__field--error]="messageInvalid">
<label for="contact-message">Nachricht</label>
<textarea
id="contact-message"
formControlName="message"
rows="5"
placeholder="Erzählen Sie uns von Ihrem Projekt was brauchen Sie, wann soll es fertig sein?"
(focus)="onFirstFocus()"></textarea>
@if (messageInvalid) {
<span class="contact__error" role="alert">Bitte schreiben Sie mindestens 20 Zeichen.</span>
}
</div>
@if (sendError()) {
<p class="contact__send-error" role="alert">
Beim Senden ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.
</p>
}
<button type="submit" class="contact__submit" [disabled]="sending()">
{{ sending() ? 'Wird gesendet…' : 'Anfrage senden' }}
</button>
</form>
} @else {
<div class="contact__success">
<div class="contact__success-icon">
<ng-icon name="cssCheck"></ng-icon>
</div>
<h3>Vielen Dank für Ihre Anfrage!</h3>
<p>Wir melden uns innerhalb von 24 Stunden per E-Mail bei Ihnen.</p>
<button class="contact__reset" (click)="resetForm()">
Weitere Anfrage senden
</button>
</div>
}
</div>
</div>
</div>
</section>

View File

@@ -0,0 +1,255 @@
@use 'abstracts';
// ── Animations ────────────────────────────────────────────────────────────────
@keyframes success-pop {
from { opacity: 0; transform: scale(0.95) translateY(8px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
// ── Section ───────────────────────────────────────────────────────────────────
.contact {
padding-block: calc(var(--space-4) * 2);
background-color: var(--bg-muted);
&__wrapper {
@include abstracts.container-wrapper;
}
// ── Header ─────────────────────────────────────────────────────────────────
&__header {
text-align: center;
margin-bottom: calc(var(--space-4) * 1.5);
h2 {
font-size: var(--font-size-xl);
margin-bottom: var(--space-2);
}
p {
font-size: var(--font-size-lg);
max-width: 56ch;
margin-inline: auto;
}
}
// ── Two-column grid ─────────────────────────────────────────────────────────
&__grid {
display: grid;
grid-template-columns: 1fr;
gap: calc(var(--space-4) * 1.5);
@include abstracts.breakpoint('md') {
grid-template-columns: 1fr 1.4fr;
align-items: start;
}
}
// ── Info column ────────────────────────────────────────────────────────────
&__info {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
&__info-item {
display: flex;
gap: var(--space-3);
align-items: flex-start;
}
&__info-icon {
display: flex;
align-items: center;
justify-content: center;
width: abstracts.rem(40);
height: abstracts.rem(40);
border-radius: 10px;
background-color: oklch(from var(--accent) l c h / 0.12);
color: var(--accent);
font-size: abstracts.rem(18);
flex-shrink: 0;
}
&__info-text {
p, address {
font-size: var(--font-size-base);
color: var(--text-muted);
font-style: normal;
line-height: 1.6;
}
.contact__info-label {
font-weight: 700;
color: var(--text-main);
margin-bottom: var(--space-1);
}
}
&__benefits {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
padding-top: var(--space-2);
border-top: 1px solid var(--border-color);
li {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-size-base);
color: var(--text-muted);
ng-icon {
color: var(--accent);
font-size: abstracts.rem(16);
flex-shrink: 0;
}
}
}
// ── Form wrapper ───────────────────────────────────────────────────────────
&__form-wrapper {
background-color: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: var(--space-4);
}
&__form {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
// ── Field ──────────────────────────────────────────────────────────────────
&__field {
display: flex;
flex-direction: column;
gap: var(--space-1);
label {
font-size: var(--font-size-base);
font-weight: 600;
color: var(--text-main);
}
input,
textarea {
width: 100%;
box-sizing: border-box;
padding: 10px 14px;
border: 1.5px solid var(--border-color);
border-radius: calc(var(--border-radius) * 0.6);
background-color: var(--bg-surface);
color: var(--text-main);
font-size: var(--font-size-base);
font-family: inherit;
line-height: 1.5;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
resize: vertical;
&::placeholder {
color: var(--text-muted);
opacity: 0.6;
}
&:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px oklch(from var(--accent) l c h / 0.15);
}
}
&--error {
input,
textarea {
border-color: oklch(55% 0.2 25);
&:focus {
box-shadow: 0 0 0 3px oklch(55% 0.2 25 / 0.15);
}
}
}
}
&__error {
font-size: 0.8rem;
color: oklch(55% 0.2 25);
}
// ── Submit button ──────────────────────────────────────────────────────────
&__submit {
width: 100%;
padding: 12px 24px;
background-color: var(--accent);
color: var(--text-on-accent);
border: none;
border-radius: calc(var(--border-radius) * 0.6);
font-size: var(--font-size-base);
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.15s ease;
&:hover { background-color: var(--accent-hover); }
&:active { transform: translateY(1px); }
}
// ── Success state ──────────────────────────────────────────────────────────
&__success {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--space-4);
gap: var(--space-3);
animation: success-pop 0.4s cubic-bezier(0.22, 1, 0.36, 1);
h3 {
font-size: var(--font-size-lg);
font-weight: 700;
}
p {
color: var(--text-muted);
font-size: var(--font-size-base);
max-width: 36ch;
}
}
&__success-icon {
display: flex;
align-items: center;
justify-content: center;
width: abstracts.rem(64);
height: abstracts.rem(64);
border-radius: 50%;
background-color: oklch(from var(--accent) l c h / 0.12);
color: var(--accent);
font-size: abstracts.rem(28);
}
&__reset {
background: none;
border: 1.5px solid var(--border-color);
border-radius: calc(var(--border-radius) * 0.6);
padding: 8px 20px;
font-size: var(--font-size-base);
font-family: inherit;
color: var(--text-muted);
cursor: pointer;
transition: border-color 0.2s ease, color 0.2s ease;
&:hover { border-color: var(--accent); color: var(--accent); }
}
}

View File

@@ -0,0 +1,91 @@
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { cssCheck, cssPin, cssAlarm, cssLock } from '@ng-icons/css.gg';
import { OpenPanelService } from '@core/services/openpanel.service';
import { n8nEnvironment } from '../../../../../environments/n8n';
@Component({
selector: 'app-contact',
imports: [ReactiveFormsModule, NgIcon],
viewProviders: [provideIcons({ cssCheck, cssPin, cssAlarm, cssLock })],
templateUrl: './contact.component.html',
styleUrl: './contact.component.scss',
})
export class ContactComponent {
private readonly fb = inject(FormBuilder);
private readonly op = inject(OpenPanelService);
private readonly http = inject(HttpClient);
readonly submitted = signal(false);
readonly sending = signal(false);
readonly sendError = signal(false);
private hasStarted = false;
readonly form = this.fb.nonNullable.group({
name: ['', [Validators.required, Validators.minLength(2)]],
email: ['', [Validators.required, Validators.email]],
message: ['', [Validators.required, Validators.minLength(20)]],
});
onFirstFocus(): void {
if (!this.hasStarted) {
this.hasStarted = true;
this.op.track('contact_form_start');
}
}
onSubmit(): void {
console.log('submit');
if (this.form.invalid) {
this.form.markAllAsTouched();
this.op.track('contact_form_validation_error');
return;
}
this.op.track('contact_form_submit', {
message_length: this.form.value.message?.length ?? 0,
});
this.sending.set(true);
this.sendError.set(false);
console.log('posting to', n8nEnvironment.contactWebhookUrl, 'with payload', this.form.getRawValue());
this.http
.post(n8nEnvironment.contactWebhookUrl, this.form.getRawValue())
.subscribe({
next: () => {
this.submitted.set(true);
this.form.reset();
this.hasStarted = false;
this.sending.set(false);
},
error: () => {
this.sendError.set(true);
this.sending.set(false);
this.op.track('contact_form_send_error');
},
});
}
resetForm(): void {
this.submitted.set(false);
}
get nameInvalid(): boolean {
const c = this.form.controls.name;
return c.invalid && c.touched;
}
get emailInvalid(): boolean {
const c = this.form.controls.email;
return c.invalid && c.touched;
}
get messageInvalid(): boolean {
const c = this.form.controls.message;
return c.invalid && c.touched;
}
}

View File

@@ -1,13 +1,30 @@
<section class="features-section" id="features-section"> <section class="features-section" id="features-section">
<div class="features-section__wrapper"> <div class="features-section__wrapper">
<div class="features-section__grid centered"> <div class="features-section__header">
@for (feature of featuresList; track feature.id) { <span class="features-section__label">Unsere Vorteile</span>
<div class="features-section__card"> <h2>Warum Kunden uns wählen</h2>
<p class="text-muted">Kein Baukasten, keine Kompromisse nur echtes Handwerk für Ihre digitale Präsenz.</p>
</div>
<div class="features-section__grid">
@for (feature of featuresList; track feature.id; let i = $index) {
<div
#cardRef
class="features-section__card"
[style.--delay]="(i * 120) + 'ms'"
opTrack="feature_card_click"
[opTrackProps]="{ feature_id: feature.id, claim: feature.claim }">
<div class="features-section__icon-wrap">
<ng-icon [name]="feature.icon" class="features-section__icon"></ng-icon>
</div>
<h3 class="features-section__claim">{{ feature.claim }}</h3> <h3 class="features-section__claim">{{ feature.claim }}</h3>
<p class="features-section__description">{{ feature.description }}</p> <p class="features-section__description">{{ feature.description }}</p>
@if (feature.icon) {
<img [src]="feature.icon" [alt]="feature.iconDescription" /> <div class="features-section__benefit">
} <span class="features-section__check"></span>
{{ feature.benefit }}
</div>
</div> </div>
} }
</div> </div>

View File

@@ -1,55 +1,149 @@
@use 'abstracts'; @use 'abstracts';
@keyframes card-fade-up {
from {
opacity: 0;
transform: translateY(32px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.features-section { .features-section {
min-height: calc(100vh - var(--neg-nav-height)); min-height: 100vh;
margin-top: var(--neg-nav-height);
display: flex; display: flex;
align-items: center; align-items: center;
padding-block: calc(var(--space-4) * 2);
&__wrapper { &__wrapper {
@include abstracts.container-wrapper; @include abstracts.container-wrapper;
width: 100%;
}
&__header {
text-align: center;
margin-bottom: calc(var(--space-4) * 1.5);
h2 {
font-size: var(--font-size-xl);
margin-bottom: var(--space-2);
}
p {
font-size: var(--font-size-lg);
}
}
&__label {
display: inline-block;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent);
margin-bottom: var(--space-2);
} }
&__grid { &__grid {
width: 100%;
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: 1fr;
gap: var(--space-3); gap: var(--space-3);
@include abstracts.breakpoint('md') {
grid-template-columns: repeat(2, 1fr);
}
@include abstracts.breakpoint('lg') {
grid-template-columns: repeat(4, 1fr);
}
} }
&__card { &__card {
position: relative;
overflow: hidden;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--border-radius); border-radius: var(--border-radius);
height: abstracts.rem(300);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-inline: var(--space-3); padding: var(--space-4);
justify-content: center; gap: var(--space-3);
background-color: var(--bg-surface);
cursor: default;
opacity: 0;
&:nth-child(1) { &.is-visible {
grid-column: 1 / span 2; animation: card-fade-up 0.55s cubic-bezier(0.22, 1, 0.36, 1) var(--delay, 0ms) both;
grid-row: 1;
} }
&:nth-child(2) {
grid-column: 3; transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1),
grid-row: 1; box-shadow 0.25s ease,
border-color 0.25s ease;
&:hover {
transform: translateY(-4px);
border-color: var(--accent);
box-shadow: 0 12px 32px oklch(0% 0 0 / 0.08);
} }
&:nth-child(3) {
grid-column: 1;
grid-row: 2;
} }
&:nth-child(4) {
grid-column: 2 / span 2; &__icon-wrap {
grid-row: 2; display: flex;
align-items: center;
justify-content: center;
width: abstracts.rem(48);
height: abstracts.rem(48);
border-radius: 12px;
background: linear-gradient(135deg, oklch(from var(--accent) l c h / 0.15) 0%, oklch(from var(--accent) l c h / 0.08) 100%);
flex-shrink: 0;
transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
.features-section__card:hover & {
transform: scale(1.08);
} }
} }
&__icon {
font-size: abstracts.rem(24);
color: var(--accent);
}
&__claim { &__claim {
font-size: var(--font-size-xl); font-size: var(--font-size-lg);
font-weight: 700;
line-height: 1.3;
color: var(--text-main);
} }
&__description { &__description {
font-size: var(--font-size-lg); font-size: var(--font-size-base);
color: var(--text-muted);
line-height: 1.6;
flex: 1;
}
&__benefit {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: 0.85rem;
font-weight: 600;
color: var(--accent);
padding-top: var(--space-2);
border-top: 1px solid var(--border-color);
margin-top: auto;
}
&__check {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: oklch(from var(--accent) l c h / 0.15);
font-size: 0.7rem;
flex-shrink: 0;
} }
} }

View File

@@ -1,40 +1,86 @@
import { Component } from '@angular/core'; import {
AfterViewInit,
Component,
ElementRef,
PLATFORM_ID,
QueryList,
ViewChildren,
inject,
} from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { cssCode, cssLock, cssDatabase, cssBrowser } from '@ng-icons/css.gg';
import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive';
interface Features { interface Feature {
id: number, id: number;
claim: string, claim: string;
description: string, description: string;
icon?: string, benefit: string;
iconDescription?: string icon: string;
} }
@Component({ @Component({
selector: 'app-features-section', selector: 'app-features-section',
imports: [], imports: [NgIcon, OpenPanelTrackDirective],
viewProviders: [provideIcons({ cssCode, cssLock, cssDatabase, cssBrowser })],
templateUrl: './features-section.component.html', templateUrl: './features-section.component.html',
styleUrl: './features-section.component.scss', styleUrl: './features-section.component.scss',
}) })
export class FeaturesSectionComponent { export class FeaturesSectionComponent implements AfterViewInit {
featuresList: Features[] = [ @ViewChildren('cardRef') cardElements!: QueryList<ElementRef<HTMLElement>>;
private readonly platformId = inject(PLATFORM_ID);
featuresList: Feature[] = [
{ {
id: 1, id: 1,
claim: "Code statt Baukasten", claim: 'Blitzschnelle Ladezeiten',
description: "Handgefertigte Performance, die Google und Ihre Nutzer lieben werden." description:
'Handgefertigter Code statt träger WordPress-Templates. Ihre Seite lädt in unter einer Sekunde und das merken Google und Ihre Besucher.',
benefit: 'Besseres Google-Ranking & weniger Absprünge',
icon: 'cssCode',
}, },
{ {
id: 2, id: 2,
claim: "Sicher per Design", claim: 'Maximale Sicherheit',
description: "Maximale Rechtskonformität durch eRecht24 und hauseigene Server-Infrastruktur." description:
'Kein Plugin-Dschungel, keine veralteten CMS-Versionen. Maximale Rechtskonformität durch eRecht24-Integration und eine klar strukturierte Infrastruktur.',
benefit: 'Kein Risiko durch Sicherheitslücken',
icon: 'cssLock',
}, },
{ {
id: 3, id: 3,
claim: "Heimat für Ihre Daten", claim: 'Europäisches Hosting',
description: "Hosting und Services strikt nach europäischem Datenschutzstandard." description:
'Hosting und alle Services laufen ausschließlich auf europäischen Servern vollständig DSGVO-konform und ohne US-Cloudabhängigkeit.',
benefit: '100% DSGVO-konform & datenschutzrechtlich sicher',
icon: 'cssDatabase',
}, },
{ {
id: 4, id: 4,
claim: "Alles im Blick", claim: 'Einfaches Dashboard',
description: "Ein Portal für alles: Kommunikation, Verwaltung und Erfolgskontrolle" description:
'Ein Verwaltungsportal für alles: Inhalte pflegen, Anfragen verwalten und Ihren Webauftritt jederzeit selbst aktualisieren ohne Programmierkenntnisse.',
benefit: 'Zeitersparnis & Unabhängigkeit',
icon: 'cssBrowser',
}, },
] ];
ngAfterViewInit(): void {
if (!isPlatformBrowser(this.platformId)) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.1 }
);
this.cardElements.forEach((el) => observer.observe(el.nativeElement));
}
} }

View File

@@ -1,8 +1,44 @@
<footer class="footer"> <footer class="footer" id="contact">
<div class="footer__wrapper"> <div class="footer__wrapper">
Hurler Webdesign <br/> <div class="footer__grid">
Impressum <br/>
Über uns <div class="footer__col footer__col--brand">
<div class="footer__logo">
<span class="footer__logo-icon">H</span>
<span><span class="footer__logo-accent">Hurler</span> Webdesign</span>
</div>
<p class="footer__tagline">
Handgefertigte Webseiten für Unternehmen und Vereine im Raum Nördlingen.
</p>
<address class="footer__address">
Untermagerbein 30<br />
86751 Mönchsdeggingen<br />
Bayern
</address>
</div>
<div class="footer__col">
<h4 class="footer__col-title">Navigation</h4>
<ul class="footer__links">
<li><a href="#hero" opTrack="footer_nav_click" [opTrackProps]="{ target: 'hero' }">Start</a></li>
<li><a href="#features-section" opTrack="footer_nav_click" [opTrackProps]="{ target: 'features' }">Vorteile</a></li>
<li><a href="#projects" opTrack="footer_nav_click" [opTrackProps]="{ target: 'projects' }">Projekte</a></li>
<li><a href="#pricing" opTrack="footer_nav_click" [opTrackProps]="{ target: 'pricing' }">Preise</a></li>
</ul>
</div>
<div class="footer__col">
<h4 class="footer__col-title">Rechtliches</h4>
<ul class="footer__links">
<li><a routerLink="/impressum" opTrack="footer_legal_click" [opTrackProps]="{ target: 'impressum' }">Impressum</a></li>
<li><a routerLink="/datenschutz" opTrack="footer_legal_click" [opTrackProps]="{ target: 'datenschutz' }">Datenschutz</a></li>
</ul>
</div>
</div> </div>
<div class="footer__bottom">
<p>&copy; {{ currentYear }} Hurler Webdesign. Alle Rechte vorbehalten.</p>
</div>
</div>
</footer> </footer>

View File

@@ -1,11 +1,103 @@
@use 'abstracts'; @use 'abstracts';
.footer { .footer {
background-color: var(--accent); background-color: var(--text-main);
color: var(--bg-surface); color: var(--bg-surface);
padding: 20px 0; padding-top: calc(var(--space-4) * 2);
&__wrapper { &__wrapper {
@include abstracts.container-wrapper; @include abstracts.container-wrapper;
} }
&__grid {
display: grid;
grid-template-columns: 1fr;
gap: calc(var(--space-4) * 1.5);
padding-bottom: calc(var(--space-4) * 1.5);
@include abstracts.breakpoint('md') {
grid-template-columns: 2fr 1fr 1fr;
}
}
&__col-title {
font-size: 0.75rem;
font-weight: 700;
margin-bottom: var(--space-3);
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent);
}
&__logo {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-size-lg);
font-weight: 700;
margin-bottom: var(--space-3);
color: var(--bg-surface);
}
&__logo-icon {
display: flex;
align-items: center;
justify-content: center;
width: abstracts.rem(36);
height: abstracts.rem(36);
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
border-radius: 8px;
font-weight: 700;
color: var(--text-on-accent);
}
&__logo-accent {
color: var(--accent);
}
&__tagline {
font-size: var(--font-size-base);
line-height: 1.7;
margin-bottom: var(--space-3);
max-width: 34ch;
color: var(--bg-muted);
}
&__address {
font-size: var(--font-size-base);
font-style: normal;
line-height: 1.7;
color: var(--bg-muted);
}
&__links {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
a {
font-size: var(--font-size-base);
color: var(--bg-muted);
transition: color 0.2s ease;
&:hover {
color: var(--accent);
}
}
}
&__bottom {
border-top: 1px solid var(--border-color);
padding-block: var(--space-3);
text-align: center;
color: var(--text-muted);
p {
font-size: 0.85rem;
color: var(--text-muted);
}
}
} }

View File

@@ -1,11 +1,13 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive';
@Component({ @Component({
selector: 'app-footer', selector: 'app-footer',
imports: [], imports: [RouterModule, OpenPanelTrackDirective],
templateUrl: './footer.component.html', templateUrl: './footer.component.html',
styleUrl: './footer.component.scss', styleUrl: './footer.component.scss',
}) })
export class FooterComponent { export class FooterComponent {
readonly currentYear = new Date().getFullYear();
} }

View File

@@ -1,23 +1,46 @@
<section class="hero-section" id="hero"> <section class="hero-section" id="hero">
<div class="hero-section__video-container"> <div class="hero-section__video-container">
<video autoplay muted loop> <video autoplay muted loop playsinline>
<source src="/video/white_mit_black_stripes.webm" type="video/webm"> <source src="/video/white_mit_black_stripes.webm" type="video/webm">
</video> </video>
</div> </div>
<div class="hero-section__wrapper"> <div class="hero-section__wrapper">
<div class="hero-section__badge">
<span class="hero-section__badge-dot"></span>
Jetzt neue Webseite sichern bis zu 3 Monate kostenloses Hosting
</div>
<h1 class="hero-section__header"> <h1 class="hero-section__header">
Digitales Handwerk <br /> Webseiten, die<br />
statt Standard-Baukasten <span class="hero-section__header-accent">Kunden überzeugen</span>
</h1> </h1>
<p class="hero-section__claim"> <p class="hero-section__claim">
Wir programmieren Blitzschnelle, sichere und maßgeschneiderte Webseiten für kleine, Wir programmieren blitzschnelle, sichere und maßgeschneiderte Webseiten
mittelständische Unternehmen und Vereine. Ohne CMS-Ballast, dafür mit maximaler Performance. für kleine Unternehmen und Vereine ohne CMS-Ballast, dafür mit maximaler Performance.
</p> </p>
<div class="hero-section__links"> <div class="hero-section__links">
<app-button [item]="{ label: 'Über uns', type: 'anchor', target: '#about' }" variant="primary"></app-button> <app-button
<app-button (click)="onFeaturesClick()" [item]="{ label: 'Warum kein Wordpress', type: 'anchor', target: 'about'}" opTrack="hero_cta_features"
variant="primary"></app-button> [opTrackProps]="{ location: 'hero' }"
[item]="{ label: 'Vorteile entdecken', type: 'anchor', target: '#features-section' }"
variant="primary">
</app-button>
<app-button
opTrack="hero_cta_pricing"
[opTrackProps]="{ location: 'hero' }"
[item]="{ label: 'Preise ansehen', type: 'anchor', target: '#pricing' }"
variant="outline">
</app-button>
</div>
<div class="hero-section__social-proof">
<div class="hero-section__avatars">
<div class="hero-section__avatar">MK</div>
<div class="hero-section__avatar">AS</div>
<div class="hero-section__avatar">JW</div>
</div>
<div class="hero-section__proof-text">
<div class="hero-section__stars">★★★★★</div>
<span>Von 50+ Kunden empfohlen</span>
</div>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -1,7 +1,7 @@
@use "abstracts"; @use "abstracts";
.hero-section { .hero-section {
position: relative; // WICHTIG: Bezugspunkt für das Video position: relative;
min-height: calc(100vh + var(--nav-height)); min-height: calc(100vh + var(--nav-height));
margin-top: var(--neg-nav-height); margin-top: var(--neg-nav-height);
overflow: hidden; overflow: hidden;
@@ -21,6 +21,11 @@
&__wrapper { &__wrapper {
@include abstracts.container-wrapper; @include abstracts.container-wrapper;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
} }
&__video-container { &__video-container {
@@ -29,20 +34,52 @@
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
z-index: -2; // Hinter den Text legen z-index: -2;
} }
h1 { &__badge {
color: var(--text-main); // Dein Wunsch-Style display: inline-flex;
align-items: center;
gap: var(--space-2);
background: oklch(from var(--accent) l c h / 0.1);
border: 1px solid oklch(from var(--accent) l c h / 0.2);
padding: var(--space-1) var(--space-3);
border-radius: 999px;
font-size: 0.875rem;
font-weight: 500;
color: var(--accent);
animation: badge-pulse 2s ease-in-out infinite;
}
&__badge-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--accent);
animation: dot-pulse 1.5s ease-in-out infinite;
}
&__header {
color: var(--text-main);
font-size: var(--font-size-xxl); font-size: var(--font-size-xxl);
position: relative; // Stellt sicher, dass der Text über dem Video-Layer bleibt font-weight: 800;
margin-bottom: var(--space-4); line-height: 1.1;
position: relative;
max-width: 14ch;
}
&__header-accent {
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
} }
&__claim { &__claim {
color: var(--text-main); color: var(--text-main);
font-size: var(--font-size-xl); font-size: var(--font-size-lg);
margin-bottom: var(--space-4); max-width: 55ch;
line-height: 1.6;
} }
&__links { &__links {
@@ -50,18 +87,80 @@
flex-direction: row; flex-direction: row;
gap: var(--space-2); gap: var(--space-2);
justify-content: center; justify-content: center;
flex-wrap: wrap;
}
&__social-proof {
display: flex;
align-items: center;
gap: var(--space-3);
margin-top: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--border-color);
width: 100%;
max-width: 400px;
justify-content: center;
}
&__avatars {
display: flex;
margin-right: calc(var(--space-2) * -1);
}
&__avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
border: 2px solid var(--bg-surface);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
color: var(--text-on-accent);
margin-left: -10px;
&:first-child {
margin-left: 0;
}
}
&__proof-text {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
&__stars {
color: oklch(75% 0.18 45);
font-size: 0.875rem;
letter-spacing: 1px;
}
&__proof-text span {
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 500;
} }
video { video {
/* Das hier ist der entscheidende Teil */
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; // WICHTIG: Füllt den Container komplett aus, ohne zu verzerren object-fit: cover;
object-position: center; // Zentriert das Video, falls Ränder abgeschnitten werden object-position: center;
mask-image: linear-gradient(to bottom, mask-image: linear-gradient(to bottom, black 0%, black 70%, transparent 100%);
black 0%,
black 70%,
transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 70%, transparent 100%); -webkit-mask-image: linear-gradient(to bottom, black 0%, black 70%, transparent 100%);
} }
} }
@keyframes badge-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.85; }
}
@keyframes dot-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.2); }
}

View File

@@ -1,17 +1,11 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/button/button.component';
import { UmamiService } from '@core/services/umami.service'; import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive';
@Component({ @Component({
selector: 'app-hero', selector: 'app-hero',
imports: [ButtonComponent], imports: [ButtonComponent, OpenPanelTrackDirective],
templateUrl: './hero.component.html', templateUrl: './hero.component.html',
styleUrl: './hero.component.scss', styleUrl: './hero.component.scss',
}) })
export class HeroComponent { export class HeroComponent {}
constructor(private umami: UmamiService) {}
onFeaturesClick(): void {
this.umami.trackEvent('features-anchor-click')
}
}

View File

@@ -1,18 +1,66 @@
<div class="wrapper"> <div class="wrapper">
<section class="header"> <section class="header">
<div class="logo-container"> <a href="#hero" class="logo-container" (click)="closeMenu()">
<span class="logo-container__logo centered">H</span> <span class="logo-container__logo centered">
<p class="logo-container__company" (click)="onFeaturesClick('lustiges Zeug')"><span>Hurler</span> Webdesign</p> <svg width="244.1156" height="183.29309" viewBox="0 0 64.588919 48.496297" xmlns="http://www.w3.org/2000/svg"
</div> xmlns:svg="http://www.w3.org/2000/svg" aria-hidden="true">
<path
d="m 64.423989,1.7255188 q -0.05975,0.1211937 -0.896238,1.9390997 -0.836489,1.817906 -2.150973,4.6659588 -1.314483,2.8480527 -2.808214,6.0596867 -1.49373,3.211634 -2.867963,6.120284 1.135236,0.0606 1.971725,0 0.836489,-0.121194 1.254734,-0.424178 0.477994,-0.363581 0.77674,-0.363581 0.298746,0 0.298746,0.302984 0,0.424178 -0.657242,1.030147 -0.597492,0.545372 -1.792477,1.211937 -1.194984,0.605969 -2.927712,0.666566 -1.015737,2.060293 -2.389969,4.908346 -1.374233,2.787456 -2.628967,5.99909 -0.238997,0.605968 -0.477994,1.393728 -0.179247,0.787759 -0.179247,1.514921 0,0.848356 0.358495,1.454325 0.418245,0.605969 1.374232,0.605969 1.792477,0 3.584955,-1.211938 1.852226,-1.272534 3.644703,-3.211634 1.792477,-1.999696 3.465455,-4.24178 0.298747,-0.363581 0.537744,-0.363581 0.298746,0 0.298746,0.484774 0,0.363582 -0.238997,0.666566 -1.732728,2.302681 -3.584954,4.484168 -1.852227,2.12089 -3.883701,3.454022 -2.031474,1.333131 -4.361694,1.333131 -2.449718,0 -3.525204,-1.211938 -1.015737,-1.272534 -1.015737,-3.211634 0,-0.908953 0.179247,-1.878503 0.238997,-1.030146 0.537743,-2.060293 0.77674,-2.423875 1.971725,-4.847749 1.194985,-2.484472 2.031474,-4.181184 -1.015737,-0.121194 -2.808214,-0.424178 -1.792477,-0.302985 -3.8837,-0.545372 -2.091223,-0.242387 -4.182447,-0.181791 -1.493731,3.635812 -3.584954,7.514012 -2.091223,3.817602 -4.779938,7.332221 -2.628968,3.514618 -5.915176,6.302074 -3.226459,2.787456 -7.110159,4.302377 -3.8837,1.575519 -8.4246419,1.393728 -2.6289663,-0.121193 -4.839688,-1.454324 -2.2107217,-1.272535 -3.525205,-3.635812 -1.25473392,-2.363278 -1.25473392,-5.574912 0,-1.514922 0.29874618,-3.09044 0.29874617,-1.636116 0.95598774,-3.393425 1.3144833,-3.454021 3.8239511,-6.241477 2.5094679,-2.848053 5.6164278,-4.484168 3.10696,-1.696713 6.094422,-1.696713 2.628967,0 4.481193,1.75731 1.911975,1.757309 1.911975,5.090136 0,1.575519 -0.537743,3.514619 -1.075486,3.696408 -3.10696,5.696105 -2.031474,1.999697 -4.062948,2.848053 -1.493731,0.545372 -2.867963,0.545372 -1.075486,0 -1.852226,-0.363582 -0.716991,-0.363581 -0.8364898,-1.030146 v -0.181791 q 0,-0.605969 0.4779938,-0.605969 0.597492,0 0.657242,0.727163 0.05975,0.18179 0.418244,0.424178 0.418245,0.18179 1.135236,0.18179 0.477994,0 1.015737,-0.121193 0.597492,-0.121194 1.194984,-0.363581 1.553481,-0.666566 3.16671,-2.545069 1.672978,-1.878503 2.628966,-5.21133 0.597493,-1.999697 0.597493,-3.757006 0,-2.787456 -1.374233,-4.181184 -1.374232,-1.393728 -3.226458,-1.393728 -2.808215,0 -5.317682,1.757309 -2.449719,1.757309 -4.3019452,4.484168 -1.7924771,2.726859 -2.8679633,5.696106 -0.6572416,1.817906 -0.9559878,3.514618 -0.2987461,1.696712 -0.2987461,3.211634 0,3.999393 1.9717247,6.423268 1.9717247,2.423875 5.3774307,2.423875 2.987462,0 5.795676,-1.454325 2.808214,-1.393728 5.377431,-3.757006 2.569217,-2.302681 4.779939,-5.090137 2.270471,-2.848052 4.062949,-5.696105 1.852227,-2.90865 3.226459,-5.393121 1.374232,-2.484472 2.210722,-4.120587 -2.628967,0.424178 -4.540942,1.15134 -1.911977,0.666566 -3.047212,3.151037 -0.119499,0.302985 -0.537744,0.666566 -0.358495,0.302984 -0.657241,0.363581 -0.05975,0 -0.119499,0.0606 0,0 -0.05975,0 -0.418244,0 -0.418244,-0.484775 0,-0.424178 0.477993,-1.393728 1.792477,-3.029843 4.540943,-4.484168 2.748465,-1.514922 5.795676,-1.9391 1.015737,-1.9391 2.091223,-4.120587 1.075487,-2.181487 2.38997,-4.362974 1.194984,-2.120891 2.569217,-4.2417813 1.374232,-2.1814872 2.748465,-3.8176026 -0.597493,-0.060597 -1.194985,-0.060597 -0.597492,-0.060597 -1.254734,-0.060597 -3.166709,0 -6.333419,0.7877593 -3.10696,0.7877593 -5.43718,2.6056653 -2.270471,1.7573091 -2.987463,4.8477493 -0.05975,0.242388 -0.119499,0.545372 0,0.242388 0,0.545372 0,1.514922 0.89624,2.484472 0.955988,0.96955 2.270471,0.96955 1.194985,0 2.210722,-0.908953 1.075486,-0.908953 1.732728,-2.181488 0.657241,-1.272534 0.657241,-2.2420843 0,-0.7271624 0.418245,-0.7271624 0.358495,0 0.358495,0.6059687 0,2.060294 -0.955988,3.454022 -0.896238,1.333131 -2.27047,1.999697 -1.374233,0.666565 -2.867964,0.666565 -2.031474,0 -3.644704,-1.333131 -1.55348,-1.333131 -1.254734,-3.757006 0.358495,-2.848053 2.150972,-4.665959 1.852228,-1.817906 4.421445,-2.7874559 2.569217,-1.0301467 5.377431,-1.3937279 2.808214,-0.3635812 5.138434,-0.3635812 1.314483,0 2.38997,0.1211937 1.075486,0.060597 1.672978,0.1817906 0.179248,0.060597 0.179248,0.3029844 0,0.3029843 -0.298746,0.7877592 -0.238997,0.484775 -0.358496,0.6059687 -2.210721,3.6964089 -3.823951,7.5140114 -1.613229,3.817603 -3.345957,7.816997 1.732728,0.0606 3.823951,0.424178 2.150973,0.302984 4.062948,0.666565 1.911976,0.302984 2.987462,0.484775 1.971724,-3.635812 3.823951,-6.96864 1.852226,-3.393424 3.345957,-6.1202838 1.493731,-2.726859 2.389969,-4.3629744 0.83649,-1.5149217 1.55348,-2.18148716 0.716991,-0.7271624 1.075487,-0.78775927 h 0.238997 q 0.358495,0 0.477993,0.30298433 0.179248,0.24238747 0.179248,0.5453718 0,0.4847749 -0.179248,0.8483561 z"
class="logo_h" aria-label="H" />
</svg>
</span>
<p class="logo-container__company"><span>Hurler</span> Webdesign</p>
</a>
<div class="header__nav-section centered"> <div class="header__nav-section centered">
<div class="theme-toggle-container"> <div class="theme-toggle-container">
<app-toogle-theme></app-toogle-theme> <app-toogle-theme></app-toogle-theme>
</div> </div>
<app-nav-menu [items]="navigationService.landingNavigation()"></app-nav-menu> <app-nav-menu [items]="navigationService.landingNavigation()"></app-nav-menu>
<div class="burger-menu centered"> <div class="header__login-btn">
<ng-icon name="cssMenu" class="burger-menu__icon"></ng-icon> <app-button
[item]="loginItem"
variant="primary"
size="sm"
[disabled]="true">
</app-button>
</div> </div>
<button
class="burger-menu centered"
(click)="toggleMenu()"
[attr.aria-expanded]="isMenuOpen()"
aria-label="Navigation öffnen">
<ng-icon [name]="isMenuOpen() ? 'cssClose' : 'cssMenu'" class="burger-menu__icon"></ng-icon>
</button>
</div> </div>
</section> </section>
</div> </div>
<!-- Mobile Menu Overlay -->
@if (isMenuOpen()) {
<div class="mobile-overlay" (click)="closeMenu()" aria-hidden="true"></div>
<nav class="mobile-menu" aria-label="Mobile Navigation">
<ul class="mobile-menu__list">
@for (item of navigationService.landingNavigation(); track item.target) {
<li class="mobile-menu__item">
@if (isAnchor(item)) {
<a [href]="'#' + item.target" (click)="onMobileNavClick($event, item)">
{{ item.label }}
</a>
} @else {
<a [routerLink]="item.target" (click)="closeMenu()">
{{ item.label }}
</a>
}
</li>
}
</ul>
<div class="mobile-menu__cta">
<app-button
[item]="loginItem"
variant="primary"
[disabled]="true">
</app-button>
</div>
</nav>
}

View File

@@ -1,5 +1,19 @@
@use "abstracts"; @use "abstracts";
// ── Animations ────────────────────────────────────────────────────────────────
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slide-down {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
// ── Sticky wrapper ────────────────────────────────────────────────────────────
.wrapper { .wrapper {
height: var(--nav-height); height: var(--nav-height);
border-radius: 0 0 10px 10px; border-radius: 0 0 10px 10px;
@@ -11,37 +25,57 @@
z-index: var(--z-index-sticky); z-index: var(--z-index-sticky);
} }
// ── Header row ────────────────────────────────────────────────────────────────
.header { .header {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
min-height: abstracts.rem(60); min-height: abstracts.rem(60);
position: sticky; padding-inline: var(--space-3);
top: 0;
@include abstracts.breakpoint('md') {
padding-inline: var(--space-4);
}
&__nav-section { &__nav-section {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center;
gap: var(--space-2);
}
&__login-btn {
display: none;
@include abstracts.breakpoint('md') {
display: flex;
align-items: center;
}
} }
} }
// ── Logo ──────────────────────────────────────────────────────────────────────
.logo-container { .logo-container {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
padding-left: var(--space-4); text-decoration: none;
&__logo { &__logo {
stroke: var(--text-on-accent);
fill: var(--text-on-accent);
font-size: 1.5rem; font-size: 1.5rem;
font-weight: bold; font-weight: bold;
color: var(--text-on-accent);
border: 1px solid var(--accent);
width: abstracts.rem(30); width: abstracts.rem(30);
height: abstracts.rem(30); height: abstracts.rem(30);
display: flex; display: flex;
background-color: var(--accent); background-color: var(--accent);
border-radius: 5px; border-radius: 6px;
flex-shrink: 0;
padding: 4px;
} }
&__company { &__company {
@@ -55,30 +89,116 @@
} }
} }
// ── Theme toggle ──────────────────────────────────────────────────────────────
.theme-toggle-container { .theme-toggle-container {
width: abstracts.rem(24); width: abstracts.rem(24);
height: abstracts.rem(24); height: abstracts.rem(24);
margin: auto; display: flex;
margin-right: var(--space-4); align-items: center;
@include abstracts.breakpoint("md") {
margin: auto;
margin-right: var(--space-4);
}
} }
.burger-menu { // ── Burger button ─────────────────────────────────────────────────────────────
padding: 0 var(--space-4) 0 var(--space-4);
cursor: pointer;
@include abstracts.breakpoint("md") { .burger-menu {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-1);
background: none;
border: none;
cursor: pointer;
border-radius: 6px;
transition: background-color 0.2s ease;
&:hover {
background-color: oklch(from var(--accent) l c h / 0.1);
}
@include abstracts.breakpoint('md') {
display: none; display: none;
} }
&__icon { &__icon {
width: abstracts.rem(32); width: abstracts.rem(24);
height: abstracts.rem(32); height: abstracts.rem(24);
color: var(--text-main); color: var(--text-main);
} }
}
// ── Mobile overlay ────────────────────────────────────────────────────────────
.mobile-overlay {
position: fixed;
inset: 0;
background-color: oklch(0% 0 0 / 0.4);
z-index: calc(var(--z-index-sticky) - 1);
backdrop-filter: blur(4px);
animation: fade-in 0.2s ease;
@include abstracts.breakpoint('md') {
display: none;
}
}
// ── Mobile menu ───────────────────────────────────────────────────────────────
.mobile-menu {
position: fixed;
top: var(--nav-height);
left: 0;
right: 0;
background-color: var(--bg-surface);
border-bottom: 1px solid var(--border-color);
border-radius: 0 0 var(--border-radius) var(--border-radius);
padding: var(--space-3) var(--space-4) var(--space-4);
z-index: var(--z-index-sticky);
animation: slide-down 0.25s cubic-bezier(0.22, 1, 0.36, 1);
box-shadow: 0 8px 32px oklch(0% 0 0 / 0.12);
@include abstracts.breakpoint('md') {
display: none;
}
&__list {
list-style: none;
padding: 0;
margin: 0 0 var(--space-4);
}
&__item {
a {
display: block;
padding: var(--space-3) 0;
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-main);
text-decoration: none;
border-bottom: 1px solid var(--border-color);
transition: color 0.2s ease, padding-left 0.2s ease;
&:hover {
color: var(--accent);
padding-left: var(--space-2);
}
}
&:last-child a {
border-bottom: none;
}
}
&__cta {
display: flex;
justify-content: center;
app-button {
width: 100%;
::ng-deep .nav-btn {
width: 100%;
justify-content: center;
}
}
}
} }

View File

@@ -1,22 +1,53 @@
import { Component, inject } from '@angular/core'; import { Component, inject, signal, PLATFORM_ID } from '@angular/core';
import { NgIcon } from '@ng-icons/core'; import { isPlatformBrowser } from '@angular/common';
import { RouterLink } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { cssMenu, cssClose } from '@ng-icons/css.gg';
import { ToogleThemeComponent } from '@shared/utils/toogle-theme/toogle-theme.component'; import { ToogleThemeComponent } from '@shared/utils/toogle-theme/toogle-theme.component';
import { NavMenuComponent } from '@shared/ui/nav-menu/nav-menu.component'; import { NavMenuComponent } from '@shared/ui/nav-menu/nav-menu.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { NavigationService } from '@core/services/navigation.service'; import { NavigationService } from '@core/services/navigation.service';
import { OpenPanelService } from '@core/services/openpanel.service'; import { OpenPanelService } from '@core/services/openpanel.service';
import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive'; import { NavigationItem, isAnchor } from '@core/models/navigation.model';
@Component({ @Component({
selector: 'app-navigation', selector: 'app-navigation',
imports: [NgIcon, ToogleThemeComponent, NavMenuComponent, OpenPanelTrackDirective], imports: [NgIcon, ToogleThemeComponent, NavMenuComponent, ButtonComponent, RouterLink],
viewProviders: [provideIcons({ cssMenu, cssClose })],
templateUrl: './navigation.component.html', templateUrl: './navigation.component.html',
styleUrl: './navigation.component.scss', styleUrl: './navigation.component.scss',
}) })
export class NavigationComponent { export class NavigationComponent {
protected readonly navigationService = inject(NavigationService); protected readonly navigationService = inject(NavigationService);
private op = inject(OpenPanelService) private readonly op = inject(OpenPanelService);
private readonly platformId = inject(PLATFORM_ID);
onFeaturesClick(blindplan: string): void { readonly isMenuOpen = signal(false);
this.op.track('features_clicked', { blindplan }) protected readonly isAnchor = isAnchor;
readonly loginItem: NavigationItem = { label: 'Login', type: 'route', target: '/login' };
toggleMenu(): void {
const next = !this.isMenuOpen();
this.isMenuOpen.set(next);
if (isPlatformBrowser(this.platformId)) {
document.body.style.overflow = next ? 'hidden' : '';
}
this.op.track(next ? 'mobile_menu_open' : 'mobile_menu_close');
}
closeMenu(): void {
this.isMenuOpen.set(false);
if (isPlatformBrowser(this.platformId)) {
document.body.style.overflow = '';
}
}
onMobileNavClick(event: Event, item: NavigationItem): void {
this.closeMenu();
if (isAnchor(item)) {
event.preventDefault();
this.navigationService.navigate(item);
}
} }
} }

View File

@@ -1 +1,54 @@
<p>pricing works!</p> <section class="pricing" id="pricing">
<div class="pricing__wrapper">
<div class="pricing__header">
<span class="pricing__label">Transparent & Fair</span>
<h2>Preise & Pakete</h2>
<p class="text-muted">Kein Abo-Modell. Keine versteckten Kosten. Sie besitzen Ihre Webseite.</p>
</div>
<div class="pricing__grid">
@for (tier of tiers; track tier.id) {
<div class="pricing__card" [class.pricing__card--highlighted]="tier.highlighted">
@if (tier.highlighted) {
<span class="pricing__badge">Beliebteste Wahl</span>
}
<div class="pricing__card-header">
<h3 class="pricing__tier-name">{{ tier.name }}</h3>
<div class="pricing__price">{{ tier.price }}</div>
<p class="pricing__price-note">{{ tier.priceNote }}</p>
</div>
<p class="pricing__description">{{ tier.description }}</p>
<ul class="pricing__features">
@for (feature of tier.features; track $index) {
<li class="pricing__feature-item">
<span class="pricing__check" aria-hidden="true"></span>
{{ feature }}
</li>
}
</ul>
<div class="pricing__cta">
<app-button
[opTrack]="'pricing_cta_click'"
[opTrackProps]="{ tier: tier.id, cta: tier.cta }"
[item]="{ label: tier.cta, type: 'anchor', target: '#contact' }"
[variant]="tier.highlighted ? 'primary' : 'outline'">
</app-button>
</div>
</div>
}
</div>
<div class="pricing__trust">
<div class="pricing__trust-item">
<span class="pricing__trust-icon">🔒</span>
<span>30 Tage Geld-zurück-Garantie</span>
</div>
<div class="pricing__trust-item">
<span class="pricing__trust-icon">🏆</span>
<span>Persönliche Betreuung inklusive</span>
</div>
<div class="pricing__trust-item">
<span class="pricing__trust-icon"></span>
<span>Innerhalb von 4 Wochen fertig</span>
</div>
</div>
</div>
</section>

View File

@@ -0,0 +1,181 @@
@use 'abstracts';
.pricing {
min-height: 100vh;
display: flex;
align-items: center;
padding-block: var(--space-4);
background-color: var(--bg-muted);
&__wrapper {
@include abstracts.container-wrapper;
width: 100%;
}
&__header {
text-align: center;
margin-bottom: var(--space-4);
h2 {
font-size: var(--font-size-xl);
margin-bottom: var(--space-2);
}
p {
font-size: var(--font-size-lg);
}
}
&__label {
display: inline-block;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent);
margin-bottom: var(--space-2);
}
&__grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-3);
align-items: start;
@include abstracts.breakpoint('md') {
grid-template-columns: repeat(3, 1fr);
align-items: stretch;
}
}
&__card {
position: relative;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
background-color: var(--bg-surface);
transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.25s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px oklch(0% 0 0 / 0.1);
}
&--highlighted {
border-color: var(--accent);
box-shadow: 0 4px 24px oklch(45% 0.22 250 / 0.15);
background: linear-gradient(180deg, oklch(from var(--accent) l c h / 0.03) 0%, var(--bg-surface) 100%);
&:hover {
box-shadow: 0 12px 40px oklch(45% 0.22 250 / 0.2);
}
}
}
&__badge {
position: absolute;
top: -1px;
left: 50%;
transform: translateX(-50%) translateY(-50%);
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
color: var(--text-on-accent);
font-size: 0.75rem;
font-weight: 600;
padding: 4px 14px;
border-radius: 999px;
white-space: nowrap;
box-shadow: 0 2px 8px oklch(45% 0.22 250 / 0.3);
}
&__card-header {
text-align: center;
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--border-color);
}
&__tier-name {
font-size: var(--font-size-lg);
font-weight: 700;
margin-bottom: var(--space-2);
}
&__price {
font-size: var(--font-size-xl);
font-weight: 800;
color: var(--accent);
margin-bottom: var(--space-1);
}
&__price-note {
font-size: 0.8rem;
color: var(--text-muted);
}
&__description {
font-size: var(--font-size-base);
color: var(--text-muted);
line-height: 1.6;
text-align: center;
}
&__features {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
flex: 1;
}
&__feature-item {
display: flex;
align-items: flex-start;
gap: var(--space-2);
font-size: var(--font-size-base);
line-height: 1.5;
}
&__check {
color: var(--accent);
font-weight: 700;
flex-shrink: 0;
}
&__cta {
display: flex;
justify-content: center;
margin-top: auto;
app-button {
width: 100%;
}
}
&__trust {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--space-4);
margin-top: calc(var(--space-4) * 1.5);
padding-top: calc(var(--space-4) * 1.5);
border-top: 1px solid var(--border-color);
}
&__trust-item {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: 0.875rem;
font-weight: 500;
color: var(--text-muted);
}
&__trust-icon {
font-size: 1.25rem;
}
}

View File

@@ -1,11 +1,76 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive';
import { ButtonComponent } from '@shared/ui/button/button.component';
interface PricingTier {
id: string;
name: string;
price: string;
priceNote: string;
description: string;
features: string[];
cta: string;
highlighted: boolean;
}
@Component({ @Component({
selector: 'app-pricing', selector: 'app-pricing',
imports: [], imports: [OpenPanelTrackDirective, ButtonComponent],
templateUrl: './pricing.component.html', templateUrl: './pricing.component.html',
styleUrl: './pricing.component.scss', styleUrl: './pricing.component.scss',
}) })
export class PricingComponent { export class PricingComponent {
tiers: PricingTier[] = [
{
id: 'starter',
name: 'Starter',
price: '799 €',
priceNote: 'einmalig zzgl. MwSt.',
description: 'Ideal für Handwerker und Vereine mit klarem Fokus auf eine starke Online-Präsenz.',
features: [
'1-Pager / Landingpage',
'Individuelles Design',
'Suchmaschinenoptimierung (SEO)',
'Kontaktformular',
'Cookie-Banner & Datenschutz',
'12 Monate Hosting inklusive',
],
cta: 'Jetzt anfragen',
highlighted: false,
},
{
id: 'business',
name: 'Business',
price: '1.499 €',
priceNote: 'einmalig zzgl. MwSt.',
description: 'Für Unternehmen, die mehr wollen: mehrere Seiten, eigenes CMS-Portal und Analysen.',
features: [
'Mehrseiter (bis 5 Seiten)',
'Alles aus Starter',
'Verwaltungsportal (CMS)',
'Blog / Neuigkeiten',
'Performance-Analyse',
'Prioritäts-Support',
],
cta: 'Jetzt anfragen',
highlighted: true,
},
{
id: 'individual',
name: 'Individual',
price: 'Auf Anfrage',
priceNote: 'individuelles Angebot',
description: 'Shops, Web-Applikationen, API-Anbindungen wir setzen komplexe Projekte um.',
features: [
'Online-Shops',
'Web-Applikationen',
'API-Integration',
'Individuelle Funktionen',
'Langfristige Betreuung',
'Auf Ihre Bedürfnisse zugeschnitten',
],
cta: 'Kontakt aufnehmen',
highlighted: false,
},
];
} }

View File

@@ -1,19 +1,48 @@
<section class="projects" id="projects"> <section class="projects" id="projects">
<div class="projects__wrapper"> <div class="projects__wrapper">
<div class="projects__card-container centered"> <div class="projects__header">
@for(project of projects; track project.id) { <span class="projects__label">Erfolgsgeschichten</span>
<div class="projects__card"> <h2>Projekte, die überzeugen</h2>
<img [src]="project.image" /> <p class="text-muted">So helfen wir Unternehmen, online erfolgreich zu sein.</p>
<div class="projects__card__description"> </div>
<h3>{{ project.company }}</h3> <div class="projects__card-container">
<p>{{ project.shortDescription }}</p> @for (project of projects; track project.id; let i = $index) {
<a
[routerLink]="['/projekt', project.slug]"
class="projects__card"
[style.--delay]="(i * 100) + 'ms'"
opTrack="project_card_click"
[opTrackProps]="{ project_id: project.id, company: project.company, slug: project.slug }">
<div class="projects__card-image">
<img [src]="project.image" [alt]="project.company" loading="lazy" />
<div class="projects__card-overlay"></div>
</div>
<div class="projects__card-content">
<span class="projects__card-branch">{{ project.branch }}</span>
<h3 class="projects__card-title">{{ project.company }}</h3>
<p class="projects__card-description">{{ project.shortDescription }}</p>
<div class="projects__card-features"> <div class="projects__card-features">
@for(feature of project.features; track $index) { @for (feature of project.features; track $index) {
<p>{{ feature }}</p> <span class="projects__tag">{{ feature }}</span>
} }
</div> </div>
@if (project.result) {
<div class="projects__card-result">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline>
<polyline points="17 6 23 6 23 12"></polyline>
</svg>
<span>{{ project.result }}</span>
</div> </div>
}
<span class="projects__card-cta">
Projekt ansehen
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</span>
</div> </div>
</a>
} }
</div> </div>
</div> </div>

View File

@@ -1,45 +1,182 @@
@use 'abstracts'; @use 'abstracts';
@keyframes card-fade-up {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.projects { .projects {
display: flex; padding-block: calc(var(--space-4) * 2);
min-height: 100vh; background-color: var(--bg-muted);
margin-top: var(--neg-nav-height);
align-items: center;
&__wrapper { &__wrapper {
@include abstracts.container-wrapper; @include abstracts.container-wrapper;
width: 100%;
}
&__header {
text-align: center;
margin-bottom: calc(var(--space-4) * 1.5);
h2 {
font-size: var(--font-size-xl);
margin-bottom: var(--space-2);
}
p {
font-size: var(--font-size-lg);
}
}
&__label {
display: inline-block;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent);
margin-bottom: var(--space-2);
} }
&__card-container { &__card-container {
display: flex; display: grid;
grid-template-columns: 1fr;
gap: var(--space-3); gap: var(--space-3);
@include abstracts.breakpoint('md') {
grid-template-columns: repeat(3, 1fr);
}
} }
&__card { &__card {
position: relative; position: relative;
border-radius: var(--border-radius); border-radius: var(--border-radius);
overflow: hidden; overflow: hidden;
background-color: var(--bg-surface);
&__description { border: 1px solid var(--border-color);
position: absolute; cursor: pointer;
inset: 0; text-decoration: none;
color: inherit;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-end; transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1),
padding: var(--space-2); box-shadow 0.3s ease;
background: rgba(0, 0, 0, 0.7);
opacity: 0; animation: card-fade-up 0.55s cubic-bezier(0.22, 1, 0.36, 1) var(--delay, 0ms) both;
transition: opacity 0.3s ease-in-out;
&:hover {
transform: translateY(-6px);
box-shadow: 0 20px 50px oklch(0% 0 0 / 0.12);
}
} }
&:hover &__description { &__card-image {
opacity: 1; position: relative;
color: var(--color-white); aspect-ratio: 16 / 10;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.4s ease;
} }
.projects__card:hover & img {
transform: scale(1.05);
}
}
&__card-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to top,
oklch(0% 0 0 / 0.4) 0%,
transparent 50%
);
pointer-events: none;
}
&__card-content {
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
&__card-branch {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent);
}
&__card-title {
font-size: var(--font-size-lg);
font-weight: 700;
line-height: 1.3;
color: var(--text-main);
}
&__card-description {
font-size: var(--font-size-base);
color: var(--text-muted);
line-height: 1.6;
} }
&__card-features { &__card-features {
display: flex; display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-top: var(--space-1);
}
&__tag {
font-size: 0.7rem;
font-weight: 600;
padding: 4px 10px;
border-radius: 999px;
background: linear-gradient(135deg, oklch(from var(--accent) l c h / 0.12) 0%, oklch(from var(--accent) l c h / 0.08) 100%);
color: var(--accent);
}
&__card-result {
display: flex;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-2);
padding-top: var(--space-2);
border-top: 1px solid var(--border-color);
font-size: 0.875rem;
font-weight: 700;
color: var(--accent);
svg {
flex-shrink: 0;
}
}
&__card-cta {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: 0.875rem;
font-weight: 600;
color: var(--accent);
margin-top: var(--space-2);
transition: gap 0.2s ease;
.projects__card:hover & {
gap: var(--space-2); gap: var(--space-2);
} }
}
} }

View File

@@ -1,16 +1,21 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive';
interface Project { interface Project {
id: number, id: number;
image: string, slug: string;
company: string, image: string;
shortDescription: string, company: string;
features: string[] branch: string;
shortDescription: string;
features: string[];
result?: string;
} }
@Component({ @Component({
selector: 'app-projects', selector: 'app-projects',
imports: [], imports: [RouterLink, OpenPanelTrackDirective],
templateUrl: './projects.component.html', templateUrl: './projects.component.html',
styleUrl: './projects.component.scss', styleUrl: './projects.component.scss',
}) })
@@ -18,24 +23,33 @@ export class ProjectsComponent {
projects: Project[] = [ projects: Project[] = [
{ {
id: 1, id: 1,
company: "Backerei Müller", slug: 'metzgerei-schlachthof-qualitaet',
image: "/images/bakery.jpg", company: 'Metzgerei Schlachthof-Qualität',
shortDescription: "Landingpage mit wechselnden Angeboten", branch: 'Fleischerei & Metzgerei',
features: ["SEO", "Angebote", "Dark/Light"], image: '/images/projekte/metzgerei.jpg',
shortDescription: 'Premium-Webauftritt für traditionelle Metzgerei mit Fleischerei in der Region',
features: ['SEO-Optimierung', 'Responsive Design', 'DSGVO-konform'],
result: '+40% mehr Anfragen über Website',
}, },
{ {
id: 2, id: 2,
company: "Backerei Müller", slug: 'finanzberatung-vermoegenswert',
image: "/images/bakery.jpg", company: 'Finanzberatung Vermögenswert',
shortDescription: "Landingpage mit wechselnden Angeboten", branch: 'Finanzdienstleistung',
features: ["SEO", "Angebote", "Dark/Light"], image: '/images/projekte/finanzberatung.jpg',
shortDescription: 'Vertrauenswürdiger Online-Auftritt für unabhängige Finanzberatung',
features: ['Lead-Generierung', 'Terminbuchung', 'Premium-Design'],
result: '+60% neue Terminbuchungen',
}, },
{ {
id: 3, id: 3,
company: "Backerei Müller", slug: 'physiotherapie-beweglich',
image: "/images/bakery.jpg", company: 'Physiotherapie Beweglich',
shortDescription: "Landingpage mit wechselnden Angeboten", branch: 'Gesundheitswesen',
features: ["SEO", "Angebote", "Dark/Light"], image: '/images/projekte/physiotherapie.jpg',
} shortDescription: 'Moderne Praxis-Website für Physiotherapie und Rehabilitation',
] features: ['Online-Terminbuchung', 'Leistungen', 'Google-optimiert'],
result: 'Top 3 bei Google-Suche',
},
];
} }

View File

@@ -0,0 +1,19 @@
<section class="stats" id="stats">
<div class="stats__wrapper">
<div class="stats__header">
<h2>Zahlen, die für sich sprechen</h2>
<p class="text-muted">Transparent. Messbar. Vertrauenswürdig.</p>
</div>
<div class="stats__grid">
@for (stat of stats; track stat.id; let i = $index) {
<div class="stats__card" #statRef>
<div class="stats__value">
{{ displayedValues()[i] }}{{ stat.suffix }}
</div>
<div class="stats__label">{{ stat.label }}</div>
<div class="stats__description">{{ stat.description }}</div>
</div>
}
</div>
</div>
</section>

View File

@@ -0,0 +1,90 @@
@use 'abstracts';
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.stats {
padding-block: calc(var(--space-4) * 2);
background: linear-gradient(180deg, var(--bg-surface) 0%, var(--bg-muted) 100%);
&__wrapper {
@include abstracts.container-wrapper;
width: 100%;
}
&__header {
text-align: center;
margin-bottom: calc(var(--space-4) * 1.5);
h2 {
font-size: var(--font-size-xl);
margin-bottom: var(--space-2);
}
}
&__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-3);
@include abstracts.breakpoint('md') {
grid-template-columns: repeat(4, 1fr);
}
}
&__card {
text-align: center;
padding: var(--space-4) var(--space-3);
border-radius: var(--border-radius);
background-color: var(--bg-surface);
border: 1px solid var(--border-color);
opacity: 0;
&.is-visible {
animation: fade-in-up 0.6s cubic-bezier(0.22, 1, 0.36, 1) both;
}
&:nth-child(1).is-visible { animation-delay: 0ms; }
&:nth-child(2).is-visible { animation-delay: 100ms; }
&:nth-child(3).is-visible { animation-delay: 200ms; }
&:nth-child(4).is-visible { animation-delay: 300ms; }
&:hover {
border-color: var(--accent);
transform: translateY(-2px);
transition: transform 0.2s ease, border-color 0.2s ease;
}
}
&__value {
font-size: clamp(2.5rem, 5vw + 1rem, 4rem);
font-weight: 800;
line-height: 1;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: var(--space-2);
}
&__label {
font-size: var(--font-size-base);
font-weight: 700;
color: var(--text-main);
margin-bottom: var(--space-1);
}
&__description {
font-size: 0.8rem;
color: var(--text-muted);
line-height: 1.4;
}
}

View File

@@ -0,0 +1,95 @@
import { Component, AfterViewInit, ElementRef, ViewChildren, QueryList, inject, PLATFORM_ID, signal } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
interface Stat {
id: number;
value: number;
suffix: string;
label: string;
description: string;
}
@Component({
selector: 'app-stats',
templateUrl: './stats.component.html',
styleUrl: './stats.component.scss',
})
export class StatsComponent implements AfterViewInit {
@ViewChildren('statRef') statElements!: QueryList<ElementRef<HTMLElement>>;
private platformId = inject(PLATFORM_ID);
displayedValues = signal<number[]>([0, 0, 0, 0]);
stats: Stat[] = [
{
id: 1,
value: 50,
suffix: '+',
label: 'Projekte',
description: 'Webseiten erfolgreich umgesetzt',
},
{
id: 2,
value: 99,
suffix: '%',
label: 'Kundenzufriedenheit',
description: 'Würden uns weiterempfehlen',
},
{
id: 3,
value: 7,
suffix: '+',
label: 'Jahre Erfahrung',
description: 'Im Webdesign & Development',
},
{
id: 4,
value: 100,
suffix: '%',
label: 'DSGVO-konform',
description: 'European Hosting & Datenschutz',
},
];
ngAfterViewInit(): void {
if (!isPlatformBrowser(this.platformId)) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
this.animateValues();
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.3 }
);
this.statElements.forEach((el) => observer.observe(el.nativeElement));
}
private animateValues(): void {
const duration = 2000;
const steps = 60;
const stepDuration = duration / steps;
this.stats.forEach((stat, index) => {
let current = 0;
const increment = stat.value / steps;
const timer = setInterval(() => {
current += increment;
if (current >= stat.value) {
current = stat.value;
clearInterval(timer);
}
this.displayedValues.update((values) => {
const newValues = [...values];
newValues[index] = Math.floor(current);
return newValues;
});
}, stepDuration);
});
}
}

View File

@@ -0,0 +1,46 @@
<section class="testimonials" id="testimonials">
<div class="testimonials__wrapper">
<div class="testimonials__header">
<h2>Das sagen unsere Kunden</h2>
<p class="text-muted">Echte Ergebnisse für echte Unternehmen.</p>
</div>
<div class="testimonials__grid">
@for (testimonial of testimonials; track testimonial.id; let i = $index) {
<div
class="testimonials__card"
[style.--delay]="(i * 150) + 'ms'"
opTrack="testimonial_view"
[opTrackProps]="{ testimonial_id: testimonial.id, company: testimonial.company }">
<div class="testimonials__quote-icon">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
<path d="M10 8C6.686 8 4 10.686 4 14v10c0 1.1.9 2 2 2h2c1.1 0 2-.9 2-2v-6c0-1.1.9-2 2-2h4c1.1 0 2 .9 2 2v6c0 1.1.9 2 2 2h2c1.1 0 2-.9 2-2V14c0-3.314-2.686-6-6-6h-6z" fill="currentColor" opacity="0.15"/>
</svg>
</div>
<div class="testimonials__stars">
@for (star of [1,2,3,4,5]; track star) {
<span [class.filled]="star <= testimonial.rating"></span>
}
</div>
<blockquote class="testimonials__text">
"{{ testimonial.quote }}"
</blockquote>
<div class="testimonials__author">
<div
class="testimonials__avatar"
[style.background]="'linear-gradient(135deg, ' + testimonial.gradientFrom + ', ' + testimonial.gradientTo + ')'">
{{ testimonial.initials }}
</div>
<div class="testimonials__author-info">
<div class="testimonials__name">{{ testimonial.name }}</div>
<div class="testimonials__role">{{ testimonial.role }}, {{ testimonial.company }}</div>
</div>
</div>
</div>
}
</div>
</div>
</section>

View File

@@ -0,0 +1,128 @@
@use 'abstracts';
@keyframes card-fade-up {
from {
opacity: 0;
transform: translateY(32px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.testimonials {
padding-block: calc(var(--space-4) * 2);
background-color: var(--bg-muted);
&__wrapper {
@include abstracts.container-wrapper;
width: 100%;
}
&__header {
text-align: center;
margin-bottom: calc(var(--space-4) * 1.5);
h2 {
font-size: var(--font-size-xl);
margin-bottom: var(--space-2);
}
}
&__grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-3);
@include abstracts.breakpoint('md') {
grid-template-columns: repeat(3, 1fr);
}
}
&__card {
position: relative;
background-color: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.25s ease;
animation: card-fade-up 0.55s cubic-bezier(0.22, 1, 0.36, 1) var(--delay, 0ms) both;
&:hover {
transform: translateY(-4px);
box-shadow: 0 12px 32px oklch(0% 0 0 / 0.08);
}
}
&__quote-icon {
color: var(--accent);
opacity: 0.6;
position: absolute;
top: var(--space-3);
right: var(--space-3);
}
&__stars {
display: flex;
gap: 2px;
font-size: 1rem;
color: var(--border-color);
.filled {
color: oklch(75% 0.18 45);
}
}
&__text {
font-size: var(--font-size-base);
line-height: 1.7;
color: var(--text-main);
font-style: normal;
flex: 1;
margin: 0;
}
&__author {
display: flex;
align-items: center;
gap: var(--space-2);
padding-top: var(--space-2);
border-top: 1px solid var(--border-color);
}
&__avatar {
width: 44px;
height: 44px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
font-weight: 700;
color: var(--text-on-accent);
flex-shrink: 0;
}
&__author-info {
display: flex;
flex-direction: column;
gap: 2px;
}
&__name {
font-weight: 700;
font-size: var(--font-size-base);
color: var(--text-main);
}
&__role {
font-size: 0.8rem;
color: var(--text-muted);
}
}

View File

@@ -0,0 +1,61 @@
import { Component } from '@angular/core';
import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive';
interface Testimonial {
id: number;
name: string;
role: string;
company: string;
quote: string;
rating: number;
initials: string;
gradientFrom: string;
gradientTo: string;
}
@Component({
selector: 'app-testimonials',
imports: [OpenPanelTrackDirective],
templateUrl: './testimonials.component.html',
styleUrl: './testimonials.component.scss',
})
export class TestimonialsComponent {
testimonials: Testimonial[] = [
{
id: 1,
name: 'Markus Krause',
role: 'Geschäftsführer',
company: 'Krause Metallbau GmbH',
quote:
'Endlich eine Webseite, die nicht aussieht wie jede andere Handwerker-Homepage. Seit dem Relaunch haben wir 40% mehr Anfragen über die Website.',
rating: 5,
initials: 'MK',
gradientFrom: 'oklch(45% 0.22 250)',
gradientTo: 'oklch(55% 0.22 250)',
},
{
id: 2,
name: 'Anna Schlüter',
role: 'Vorstandsvorsitzende',
company: 'Turnverein Blau-Weiß 09',
quote:
'Der neue Online-Auftritt hat uns geholfen, jüngere Mitglieder anzusprechen. Das Verwaltungsportal spart uns enorm viel Zeit.',
rating: 5,
initials: 'AS',
gradientFrom: 'oklch(70% 0.15 250)',
gradientTo: 'oklch(65% 0.18 250)',
},
{
id: 3,
name: 'Jan Winkler',
role: 'Inhaber',
company: 'Winkler IT-Services',
quote:
'Professionell, zuverlässig und super Kommunikation. Die Seite lädt rasend schnell und unser Google-Ranking hat sich deutlich verbessert.',
rating: 5,
initials: 'JW',
gradientFrom: 'oklch(55% 0.2 250)',
gradientTo: 'oklch(60% 0.22 250)',
},
];
}

View File

@@ -1,5 +1,9 @@
<app-navigation></app-navigation> <app-navigation></app-navigation>
<app-hero></app-hero> <app-hero></app-hero>
<app-stats></app-stats>
<app-features-section></app-features-section> <app-features-section></app-features-section>
<app-projects></app-projects> <app-projects></app-projects>
<app-testimonials></app-testimonials>
<app-pricing></app-pricing>
<app-contact></app-contact>
<app-footer></app-footer> <app-footer></app-footer>

View File

@@ -1,9 +1,13 @@
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { NavigationComponent } from '../components/navigation/navigation.component'; import { NavigationComponent } from '../components/navigation/navigation.component';
import { HeroComponent } from '../components/hero/hero.component'; import { HeroComponent } from '../components/hero/hero.component';
import { StatsComponent } from '../components/stats/stats.component';
import { FeaturesSectionComponent } from '../components/features-section/features-section.component'; import { FeaturesSectionComponent } from '../components/features-section/features-section.component';
import { FooterComponent } from '../components/footer/footer.component'; import { TestimonialsComponent } from '../components/testimonials/testimonials.component';
import { ProjectsComponent } from '../components/projects/projects.component'; import { ProjectsComponent } from '../components/projects/projects.component';
import { PricingComponent } from '../components/pricing/pricing.component';
import { ContactComponent } from '../components/contact/contact.component';
import { FooterComponent } from '../components/footer/footer.component';
import { SeoService } from '@core/services/seo.service'; import { SeoService } from '@core/services/seo.service';
@Component({ @Component({
@@ -11,10 +15,14 @@ import { SeoService } from '@core/services/seo.service';
imports: [ imports: [
NavigationComponent, NavigationComponent,
HeroComponent, HeroComponent,
StatsComponent,
FeaturesSectionComponent, FeaturesSectionComponent,
TestimonialsComponent,
ProjectsComponent, ProjectsComponent,
PricingComponent,
ContactComponent,
FooterComponent, FooterComponent,
], ],
templateUrl: './landingpage.component.html', templateUrl: './landingpage.component.html',
styleUrl: './landingpage.component.scss', styleUrl: './landingpage.component.scss',
}) })

View File

@@ -0,0 +1,187 @@
<main class="project-detail">
@if (project(); as p) {
<section class="project-detail__hero">
<div class="project-detail__hero-image">
<img [src]="p.image" [alt]="p.company" />
<div class="project-detail__hero-overlay"></div>
</div>
<div class="project-detail__hero-content">
<a routerLink="/" class="project-detail__back">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Zurück
</a>
<span class="project-detail__branch">{{ p.branch }}</span>
<h1 class="project-detail__title">{{ p.company }}</h1>
<p class="project-detail__description">{{ p.description }}</p>
@if (p.result) {
<div class="project-detail__result">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline>
<polyline points="17 6 23 6 23 12"></polyline>
</svg>
<span>{{ p.result }}</span>
</div>
}
</div>
</section>
<section class="project-detail__features">
<div class="project-detail__wrapper">
<h2 class="project-detail__section-title">Leistungen im Projekt</h2>
<div class="project-detail__features-grid">
@for (feature of p.features; track feature.title; let i = $index) {
<div class="project-detail__feature" [style.--delay]="(i * 100) + 'ms'">
<div class="project-detail__feature-icon">
@switch (feature.icon) {
@case ('search') {
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
}
@case ('smartphone') {
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="5" y="2" width="14" height="20" rx="2" ry="2"></rect>
<line x1="12" y1="18" x2="12.01" y2="18"></line>
</svg>
}
@case ('shield') {
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
</svg>
}
@case ('target') {
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="6"></circle>
<circle cx="12" cy="12" r="2"></circle>
</svg>
}
@case ('calendar') {
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
}
@case ('award') {
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="8" r="7"></circle>
<polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"></polyline>
</svg>
}
@case ('calendar-check') {
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
<path d="m9 16 2 2 4-4"></path>
</svg>
}
@case ('list') {
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="8" y1="6" x2="21" y2="6"></line>
<line x1="8" y1="12" x2="21" y2="12"></line>
<line x1="8" y1="18" x2="21" y2="18"></line>
<line x1="3" y1="6" x2="3.01" y2="6"></line>
<line x1="3" y1="12" x2="3.01" y2="12"></line>
<line x1="3" y1="18" x2="3.01" y2="18"></line>
</svg>
}
@case ('google') {
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 8v8"></path>
<path d="M8 12h8"></path>
</svg>
}
}
</div>
<div class="project-detail__feature-content">
<h3>{{ feature.title }}</h3>
<p>{{ feature.description }}</p>
</div>
</div>
}
</div>
</div>
</section>
@if (p.testimonial) {
<section class="project-detail__testimonial">
<div class="project-detail__wrapper project-detail__wrapper--narrow">
<blockquote class="project-detail__quote">
<svg class="project-detail__quote-icon" width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 8c-3.314 0-6 2.686-6 6v10c0 1.1.9 2 2 2h2c1.1 0 2-.9 2-2v-6c0-1.1.9-2 2-2h4c1.1 0 2 .9 2 2v6c0 1.1.9 2 2 2h2c1.1 0 2-.9 2-2V14c0-3.314-2.686-6-6-6h-6z"/>
</svg>
<p>"{{ p.testimonial.quote }}"</p>
<footer>
<cite>
<strong>{{ p.testimonial.author }}</strong>
<span>{{ p.testimonial.role }}, {{ p.company }}</span>
</cite>
</footer>
</blockquote>
</div>
</section>
}
<section class="project-detail__cta">
<div class="project-detail__wrapper">
<h2>Erfolgreich online wie Ihr Projekt</h2>
<p>Lassen Sie uns gemeinsam Ihr nächstes Projekt realisieren.</p>
<a routerLink="/#contact" class="project-detail__cta-button">
Projekt anfragen
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</a>
</div>
</section>
@if (otherProjects().length > 0) {
<section class="project-detail__more">
<div class="project-detail__wrapper">
<h2 class="project-detail__section-title">Weitere Projekte</h2>
<div class="project-detail__more-grid">
@for (other of otherProjects(); track other.slug) {
<a
[routerLink]="['/projekt', other.slug]"
class="project-detail__more-card">
<div class="project-detail__more-image">
<img [src]="other.image" [alt]="other.company" loading="lazy" />
</div>
<div class="project-detail__more-content">
<span class="project-detail__more-branch">{{ other.branch }}</span>
<h3>{{ other.company }}</h3>
<span class="project-detail__more-cta">
Projekt ansehen
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</span>
</div>
</a>
}
</div>
</div>
</section>
}
} @else {
<div class="project-detail__not-found">
<div class="project-detail__wrapper">
<h1>Projekt nicht gefunden</h1>
<p>Das gesuchte Projekt existiert leider nicht.</p>
<a routerLink="/" class="project-detail__back-link">Zurück zur Startseite</a>
</div>
</div>
}
</main>

View File

@@ -0,0 +1,391 @@
@use 'abstracts';
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.project-detail {
min-height: calc(100vh - var(--nav-height));
&__wrapper {
@include abstracts.container-wrapper;
width: 100%;
&--narrow {
max-width: 740px;
}
}
// ── Hero ─────────────────────────────────────────────────────────────────────
&__hero {
position: relative;
}
&__hero-image {
position: relative;
width: 100%;
height: clamp(400px, 60vh, 600px);
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__hero-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to top,
oklch(from var(--bg-surface) l c h) 0%,
oklch(from var(--bg-surface) l c h / 0.8) 30%,
oklch(from var(--bg-surface) l c h / 0.4) 60%,
transparent 100%
);
}
&__hero-content {
position: relative;
margin-top: -200px;
padding-bottom: calc(var(--space-4) * 2);
@include abstracts.container-wrapper;
max-width: 800px;
animation: fade-in-up 0.6s ease-out both;
}
&__back {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: 0.875rem;
font-weight: 500;
color: var(--text-muted);
margin-bottom: var(--space-4);
transition: color 0.2s ease;
&:hover {
color: var(--accent);
}
}
&__branch {
display: inline-block;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent);
margin-bottom: var(--space-2);
}
&__title {
font-size: clamp(var(--font-size-xl), 5vw, 3rem);
font-weight: 800;
line-height: 1.1;
letter-spacing: -0.02em;
margin-bottom: var(--space-3);
}
&__description {
font-size: var(--font-size-lg);
color: var(--text-muted);
line-height: 1.7;
margin-bottom: var(--space-4);
}
&__result {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: linear-gradient(135deg, oklch(from var(--accent) l c h / 0.15) 0%, oklch(from var(--accent) l c h / 0.08) 100%);
border: 1px solid oklch(from var(--accent) l c h / 0.2);
border-radius: 999px;
font-size: 0.875rem;
font-weight: 700;
color: var(--accent);
svg {
flex-shrink: 0;
}
}
// ── Features ─────────────────────────────────────────────────────────────────
&__features {
padding-block: calc(var(--space-4) * 2);
background-color: var(--bg-muted);
}
&__section-title {
font-size: var(--font-size-xl);
font-weight: 700;
margin-bottom: calc(var(--space-4) * 1.5);
text-align: center;
}
&__features-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-3);
@include abstracts.breakpoint('md') {
grid-template-columns: repeat(3, 1fr);
}
}
&__feature {
background-color: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.25s ease;
animation: fade-in-up 0.55s cubic-bezier(0.22, 1, 0.36, 1) var(--delay, 0ms) both;
&:hover {
transform: translateY(-4px);
box-shadow: 0 12px 32px oklch(0% 0 0 / 0.08);
}
}
&__feature-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, oklch(from var(--accent) l c h / 0.15) 0%, oklch(from var(--accent) l c h / 0.08) 100%);
color: var(--accent);
flex-shrink: 0;
}
&__feature-content {
h3 {
font-size: var(--font-size-base);
font-weight: 700;
margin-bottom: var(--space-1);
}
p {
font-size: var(--font-size-base);
color: var(--text-muted);
line-height: 1.6;
}
}
// ── Testimonial ──────────────────────────────────────────────────────────────
&__testimonial {
padding-block: calc(var(--space-4) * 2);
background-color: var(--bg-surface);
}
&__quote {
position: relative;
text-align: center;
padding: var(--space-4);
}
&__quote-icon {
color: var(--accent);
opacity: 0.15;
margin-bottom: var(--space-3);
}
&__quote p {
font-size: var(--font-size-xl);
font-weight: 500;
line-height: 1.5;
color: var(--text-main);
margin-bottom: var(--space-4);
font-style: italic;
}
&__quote footer cite {
display: flex;
flex-direction: column;
gap: 4px;
font-style: normal;
strong {
font-weight: 700;
color: var(--text-main);
}
span {
font-size: 0.875rem;
color: var(--text-muted);
}
}
// ── CTA ─────────────────────────────────────────────────────────────────────
&__cta {
padding-block: calc(var(--space-4) * 2);
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
text-align: center;
h2 {
font-size: var(--font-size-xl);
font-weight: 700;
color: var(--text-on-accent);
margin-bottom: var(--space-2);
}
p {
font-size: var(--font-size-lg);
color: oklch(from var(--text-on-accent) l c h / 0.85);
margin-bottom: var(--space-4);
}
}
&__cta-button {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
background-color: var(--bg-surface);
color: var(--accent);
font-weight: 700;
font-size: var(--font-size-base);
border-radius: 999px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: scale(1.05);
box-shadow: 0 8px 24px oklch(0% 0 0 / 0.2);
}
}
// ── More Projects ────────────────────────────────────────────────────────────
&__more {
padding-block: calc(var(--space-4) * 2);
}
&__more-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-3);
@include abstracts.breakpoint('md') {
grid-template-columns: repeat(2, 1fr);
}
}
&__more-card {
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
overflow: hidden;
background-color: var(--bg-surface);
transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.25s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 12px 32px oklch(0% 0 0 / 0.08);
}
}
&__more-image {
aspect-ratio: 16 / 9;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.4s ease;
.project-detail__more-card:hover & {
transform: scale(1.05);
}
}
}
&__more-content {
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-1);
h3 {
font-size: var(--font-size-lg);
font-weight: 700;
}
}
&__more-branch {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent);
}
&__more-cta {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: 0.875rem;
font-weight: 600;
color: var(--accent);
margin-top: var(--space-2);
transition: gap 0.2s ease;
.project-detail__more-card:hover & {
gap: var(--space-2);
}
}
// ── Not Found ────────────────────────────────────────────────────────────────
&__not-found {
padding-block: calc(var(--space-4) * 3);
text-align: center;
h1 {
font-size: var(--font-size-xl);
margin-bottom: var(--space-2);
}
p {
color: var(--text-muted);
margin-bottom: var(--space-4);
}
}
&__back-link {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
background-color: var(--accent);
color: var(--text-on-accent);
font-weight: 600;
border-radius: 999px;
transition: background-color 0.2s ease;
&:hover {
background-color: var(--accent-hover);
}
}
}

View File

@@ -0,0 +1,172 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { SeoService } from '@core/services/seo.service';
interface ProjectFeature {
icon: string;
title: string;
description: string;
}
interface Project {
slug: string;
company: string;
branch: string;
image: string;
galleryImages: string[];
shortDescription: string;
description: string;
features: ProjectFeature[];
result?: string;
testimonial?: {
quote: string;
author: string;
role: string;
};
}
const PROJECTS: Project[] = [
{
slug: 'metzgerei-schlachthof-qualitaet',
company: 'Metzgerei Schlachthof-Qualität',
branch: 'Fleischerei & Metzgerei',
image: '/images/projekte/metzgerei.jpg',
galleryImages: [
'/images/projekte/metzgerei.jpg',
'/images/projekte/metzgerei-detail1.jpg',
'/images/projekte/metzgerei-detail2.jpg'
],
shortDescription: 'Premium-Webauftritt für traditionelle Metzgerei mit Fleischerei in der Region',
description: 'Eine traditionelle Metzgerei mit jahrzehntelanger Erfahrung wollte ihre handwerkliche Qualität auch online sichtbar machen. Wir haben einen Webauftritt geschaffen, der die Wertigkeit ihrer Produkte widerspiegelt modern, aber mit einem Hauch von Tradition.',
features: [
{
icon: 'search',
title: 'SEO-Optimierung',
description: 'Auffindbar bei Google für regionale Suchbegriffe wie "Metzgerei in der Region" und "Frisches Fleisch online".'
},
{
icon: 'smartphone',
title: 'Responsive Design',
description: 'Perfekte Darstellung auf allen Geräten vom Smartphone beim Einkauf bis zum Desktop.'
},
{
icon: 'shield',
title: 'DSGVO-konform',
description: 'Vollständig rechtssicher mit Cookie-Banner, Impressum und Datenschutzerklärung nach aktuellen Standards.'
}
],
result: '+40% mehr Anfragen über Website',
testimonial: {
quote: 'Endlich eine Webseite, die unsere handwerkliche Qualität widerspiegelt. Die Zusammenarbeit war professionell und unkompliziert.',
author: 'Thomas B.',
role: 'Inhaber'
}
},
{
slug: 'finanzberatung-vermoegenswert',
company: 'Finanzberatung Vermögenswert',
branch: 'Finanzdienstleistung',
image: '/images/projekte/finanzberatung.jpg',
galleryImages: [
'/images/projekte/finanzberatung.jpg',
'/images/projekte/finanzberatung-detail1.jpg',
'/images/projekte/finanzberatung-detail2.jpg'
],
shortDescription: 'Vertrauenswürdiger Online-Auftritt für unabhängige Finanzberatung',
description: 'Als unabhängige Finanzberatung ist Vertrauen das wichtigste Gut. Wir haben eine Website entwickelt, die Kompetenz und Seriosität vermittelt, ohne dabei steif oder unpersönlich zu wirken.',
features: [
{
icon: 'target',
title: 'Lead-Generierung',
description: 'Strategisch platzierte Call-to-Actions und ein optimiertes Kontaktformular für qualifizierte Anfragen.'
},
{
icon: 'calendar',
title: 'Terminbuchung',
description: 'Online-Terminvereinbarung direkt über die Website rund um die Uhr, ohne telefonische Hürden.'
},
{
icon: 'award',
title: 'Premium-Design',
description: 'Hochwertige Optik, die das Qualitätsversprechen der Beratung visuell unterstreicht.'
}
],
result: '+60% neue Terminbuchungen',
testimonial: {
quote: 'Unsere neuen Kunden sagen oft, dass sie uns wegen der professionellen Website kontaktiert haben. Das zeigt, wie wichtig ein guter erster Eindruck ist.',
author: 'Michael S.',
role: 'Geschäftsführer'
}
},
{
slug: 'physiotherapie-beweglich',
company: 'Physiotherapie Beweglich',
branch: 'Gesundheitswesen',
image: '/images/projekte/physiotherapie.jpg',
galleryImages: [
'/images/projekte/physiotherapie.jpg',
'/images/projekte/physiotherapie-detail1.jpg',
'/images/projekte/physiotherapie-detail2.jpg'
],
shortDescription: 'Moderne Praxis-Website für Physiotherapie und Rehabilitation',
description: 'Eine modern aufgestellte Physiotherapie-Praxis mit focus auf ganzheitliche Behandlungsmethoden. Die Website sollte Patienten dabei helfen, die richtige Behandlung zu finden und einfach einen Termin zu buchen.',
features: [
{
icon: 'calendar-check',
title: 'Online-Terminbuchung',
description: 'Intuitives Buchungssystem mit Kalenderansicht und automatischen Erinnerungen per E-Mail.'
},
{
icon: 'list',
title: 'Leistungsübersicht',
description: 'Übersichtliche Darstellung aller Behandlungsangebote mit detaillierten Beschreibungen.'
},
{
icon: 'google',
title: 'Google-optimiert',
description: 'Lokale SEO-Strategie für Top-Platzierungen bei Suchbegriffen wie "Physiotherapie" in der Umgebung.'
}
],
result: 'Top 3 bei Google-Suche',
testimonial: {
quote: 'Wir werden regelmäßig für unsere moderne Website gelobt. Viele Patienten buchen sogar direkt online das spart uns Zeit.',
author: 'Sarah M.',
role: 'Praxisinhaberin'
}
}
];
@Component({
selector: 'app-project-detail',
imports: [RouterLink],
templateUrl: './project-detail.component.html',
styleUrl: './project-detail.component.scss',
})
export class ProjectDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
private seo = inject(SeoService);
project = signal<Project | null>(null);
otherProjects = signal<Project[]>([]);
ngOnInit(): void {
const slug = this.route.snapshot.paramMap.get('slug');
const project = PROJECTS.find(p => p.slug === slug) || null;
this.project.set(project);
this.otherProjects.set(PROJECTS.filter(p => p.slug !== slug));
if (project) {
this.seo.updateMetadata({
title: `${project.company} | Hurler Webdesign`,
description: project.shortDescription,
socialsDescription: `Erfahren Sie, wie wir ${project.company} zu mehr Erfolg im Internet verholfen haben.`,
type: 'website'
});
}
}
getProjectBySlug(slug: string): Project | undefined {
return PROJECTS.find(p => p.slug === slug);
}
}

View File

@@ -1,16 +1,30 @@
<!-- Router Link (internal navigation) --> <!-- Router Link (internal navigation) -->
@if (isRouteType) { @if (isRouteType) {
@if (disabled) {
<button
[class]="hostClasses.join(' ')"
disabled
type="button"
aria-disabled="true"
>
@if (item.icon) {
<span class="nav-btn__icon" aria-hidden="true">{{ item.icon }}</span>
}
<span class="nav-btn__label">{{ item.label }}</span>
<span class="nav-btn__badge">Demnächst</span>
</button>
} @else {
<a <a
[routerLink]="item.target" [routerLink]="item.target"
routerLinkActive="nav-btn--active" routerLinkActive="nav-btn--active"
[class]="hostClasses.join(' ')" [class]="hostClasses.join(' ')"
[attr.aria-disabled]="disabled || null"
> >
@if (item.icon) { @if (item.icon) {
<span class="nav-btn__icon" aria-hidden="true">{{ item.icon }}</span> <span class="nav-btn__icon" aria-hidden="true">{{ item.icon }}</span>
} }
<span class="nav-btn__label">{{ item.label }}</span> <span class="nav-btn__label">{{ item.label }}</span>
</a> </a>
}
} }
<!-- Anchor (smooth scroll) --> <!-- Anchor (smooth scroll) -->

View File

@@ -1,33 +1,33 @@
// ─── Design Tokens ─────────────────────────────────────────────────────────── // ─── Design Tokens ───────────────────────────────────────────────────────────
:host { :host {
--btn-font: 'DM Mono', 'Courier New', monospace; --btn-font: inherit;
--btn-radius: 4px; --btn-radius: var(--border-radius, 8px);
--btn-transition: 160ms cubic-bezier(0.4, 0, 0.2, 1); --btn-transition: 160ms cubic-bezier(0.4, 0, 0.2, 1);
// Size tokens // Size tokens
--btn-sm-padding: 6px 14px; --btn-sm-padding: 6px 16px;
--btn-md-padding: 10px 22px; --btn-md-padding: 10px 24px;
--btn-lg-padding: 14px 32px; --btn-lg-padding: 14px 32px;
--btn-sm-font: 0.75rem; --btn-sm-font: 0.8rem;
--btn-md-font: 0.875rem; --btn-md-font: 0.9rem;
--btn-lg-font: 1rem; --btn-lg-font: 1rem;
// Color tokens — override at :root level to theme globally // Color tokens — use global theme CSS custom properties
--btn-primary-bg: #0f0f0f; --btn-primary-bg: var(--accent);
--btn-primary-color: #f5f5f5; --btn-primary-color: var(--text-on-accent);
--btn-primary-border: #0f0f0f; --btn-primary-border: var(--accent);
--btn-primary-hover-bg: #2a2a2a; --btn-primary-hover-bg: var(--accent-hover);
--btn-ghost-bg: transparent; --btn-ghost-bg: transparent;
--btn-ghost-color: #0f0f0f; --btn-ghost-color: var(--text-main);
--btn-ghost-border: transparent; --btn-ghost-border: transparent;
--btn-ghost-hover-bg: rgba(0, 0, 0, 0.06); --btn-ghost-hover-bg: oklch(from var(--accent) l c h / 0.1);
--btn-outline-bg: transparent; --btn-outline-bg: transparent;
--btn-outline-color: #0f0f0f; --btn-outline-color: var(--accent);
--btn-outline-border: #0f0f0f; --btn-outline-border: var(--accent);
--btn-outline-hover-bg: #0f0f0f; --btn-outline-hover-bg: var(--accent);
--btn-outline-hover-color: #f5f5f5; --btn-outline-hover-color: var(--text-on-accent);
display: inline-block; display: inline-block;
} }
@@ -136,4 +136,14 @@
opacity: 0.65; opacity: 0.65;
margin-left: -2px; margin-left: -2px;
} }
&__badge {
font-size: 0.65em;
padding: 2px 6px;
border-radius: 999px;
background-color: oklch(from currentColor l c h / 0.15);
letter-spacing: 0;
text-transform: none;
font-weight: 500;
}
} }

View File

@@ -4,7 +4,8 @@
<li class="navigation__list-item"> <li class="navigation__list-item">
<!-- Anchor-Link: Smooth Scroll --> <!-- Anchor-Link: Smooth Scroll -->
@if (isAnchor(item)) { @if (isAnchor(item)) {
<a [href]="'#' + item.target" (click)="onAnchorClick($event, item)" opTrack="links_nav_menu" [opTrackProps]="{location: `${item.target}`}"> <a [href]="'#' + item.target" (click)="onAnchorClick($event, item)" opTrack="links_nav_menu"
[opTrackProps]="{location: `${item.target}`}">
@if (item.icon) { @if (item.icon) {
<ng-icon [name]="item.icon"></ng-icon> <ng-icon [name]="item.icon"></ng-icon>
} }
@@ -14,7 +15,7 @@
<!-- Route-Link: Angular Router --> <!-- Route-Link: Angular Router -->
@else if (isRoute(item)) { @else if (isRoute(item)) {
<a [routerLink]="item.target"> <a [routerLink]="item.target" opTrack="links_nav_menu" [opTrackProps]="{location: `${item.target}`}">
@if (item.icon) { @if (item.icon) {
<ng-icon [name]="item.icon"></ng-icon> <ng-icon [name]="item.icon"></ng-icon>
} }

View File

@@ -0,0 +1,4 @@
export const environment = {
production: false,
directusUrl: 'https://backend.hurler-webdesign.de',
};

3
src/environments/n8n.ts Normal file
View File

@@ -0,0 +1,3 @@
export const n8nEnvironment = {
contactWebhookUrl: 'https://n8n.hurler-webdesign.de/webhook/fbcced3d-503d-4bca-9147-405fe809253e',
};

View File

@@ -1,9 +1,8 @@
// environment.ts // environment.ts
export const environment = { export const environment = {
production: false, production: false,
secret: "sec_4aa70c091e704023c6df",
openPanel: { openPanel: {
clientId: '727b9649-26ac-4083-96ea-92c3a60fe7a8', clientId: 'c0e6dcf4-3eca-4b0b-a631-a93aa5df1477',
apiUrl: 'https://analytics.hurler-webdesign.de/api', // oder self-hosted URL apiUrl: 'https://data.hurler-webdesign.de',
} }
}; };

View File

@@ -1,14 +1,16 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>HurlerWebdesignSaas</title> <title>HurlerWebdesignSaas</title>
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/x-icon" href="favicon.ico">
<script defer src="https://stats.hurler-webdesign.de/script.js" data-website-id="33763c9b-43a8-4e36-a1c1-e07d31ed1e5b"></script>
</head> </head>
<body> <body>
<app-root></app-root> <app-root></app-root>
</body> </body>
</html> </html>

1
tsconfig.tsbuildinfo Normal file
View File

@@ -0,0 +1 @@
{"fileNames":[],"fileInfos":[],"root":[],"options":{"experimentalDecorators":true,"importHelpers":true,"module":200,"noFallthroughCasesInSwitch":true,"noImplicitOverride":true,"noImplicitReturns":true,"noPropertyAccessFromIndexSignature":true,"skipLibCheck":true,"strict":true,"target":9},"version":"5.9.3"}