Compare commits
3 Commits
5138005397
...
landingpag
| Author | SHA1 | Date | |
|---|---|---|---|
| 012636ec35 | |||
| 11e2553549 | |||
| cd694d0776 |
61
CLAUDE.md
Normal file
61
CLAUDE.md
Normal 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`
|
||||||
@@ -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
780
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
13
public/images/text1.svg
Normal 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 |
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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' }
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/app/core/models/blog-posts.model.ts
Normal file
24
src/app/core/models/blog-posts.model.ts
Normal 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;
|
||||||
|
}
|
||||||
16
src/app/core/services/blog.service.spec.ts
Normal file
16
src/app/core/services/blog.service.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
52
src/app/core/services/blog.service.ts
Normal file
52
src/app/core/services/blog.service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,15 +14,15 @@ export class NavigationService {
|
|||||||
{ label: 'Features', type: 'anchor', target: 'features-section'},
|
{ label: 'Features', type: 'anchor', target: 'features-section'},
|
||||||
{ 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
|
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -30,9 +30,9 @@ export class NavigationService {
|
|||||||
readonly navigationItems = this._navigationItems.asReadonly();
|
readonly navigationItems = this._navigationItems.asReadonly();
|
||||||
|
|
||||||
// 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'
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -48,19 +48,21 @@ 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),
|
).subscribe(() => {
|
||||||
// Ersten initialNavigation-Event überspringen – SSR hat ihn schon getriggert
|
const route = this.getActiveRoute();
|
||||||
skip(1),
|
const trackName = route.snapshot.data['trackName'] ?? this.router.url;
|
||||||
)
|
this.trackScreenView(trackName);
|
||||||
.subscribe((event) => {
|
});
|
||||||
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;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
2
src/app/features/blog/index.ts
Normal file
2
src/app/features/blog/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { BlogListComponent } from './pages/blog-list/blog-list.component';
|
||||||
|
export { BlogDetailComponent } from './pages/blog-detail/blog-detail.component';
|
||||||
@@ -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>
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/app/features/blog/pages/blog-detail/blog-detail.component.ts
Normal file
101
src/app/features/blog/pages/blog-detail/blog-detail.component.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 & 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>
|
||||||
236
src/app/features/blog/pages/blog-list/blog-list.component.scss
Normal file
236
src/app/features/blog/pages/blog-list/blog-list.component.scss
Normal 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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/app/features/blog/pages/blog-list/blog-list.component.ts
Normal file
51
src/app/features/blog/pages/blog-list/blog-list.component.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 & 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>
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,32 @@
|
|||||||
<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>
|
||||||
<h3 class="features-section__claim">{{ feature.claim }}</h3>
|
<p class="text-muted">Kein Baukasten, keine Kompromisse – nur echtes Handwerk für Ihre digitale Präsenz.</p>
|
||||||
<p class="features-section__description">{{ feature.description }}</p>
|
|
||||||
@if (feature.icon) {
|
|
||||||
<img [src]="feature.icon" [alt]="feature.iconDescription" />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<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>
|
||||||
|
<p class="features-section__description">{{ feature.description }}</p>
|
||||||
|
|
||||||
|
<div class="features-section__benefit">
|
||||||
|
<span class="features-section__check">✓</span>
|
||||||
|
{{ feature.benefit }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
align-items: center;
|
||||||
|
padding-block: calc(var(--space-4) * 2);
|
||||||
|
|
||||||
|
&__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 {
|
||||||
|
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(4, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: var(--space-4);
|
||||||
|
gap: var(--space-3);
|
||||||
|
background-color: var(--bg-surface);
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
&.is-visible {
|
||||||
|
animation: card-fade-up 0.55s cubic-bezier(0.22, 1, 0.36, 1) var(--delay, 0ms) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(-4px);
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 12px 32px oklch(0% 0 0 / 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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);
|
||||||
|
|
||||||
&__wrapper {
|
.features-section__card:hover & {
|
||||||
@include abstracts.container-wrapper;
|
transform: scale(1.08);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__grid {
|
&__icon {
|
||||||
width: 100%;
|
font-size: abstracts.rem(24);
|
||||||
display: grid;
|
color: var(--accent);
|
||||||
grid-template-columns: repeat(3, 1fr);
|
}
|
||||||
gap: var(--space-3);
|
|
||||||
|
|
||||||
}
|
&__claim {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
&__card {
|
font-weight: 700;
|
||||||
border: 1px solid var(--border-color);
|
line-height: 1.3;
|
||||||
border-radius: var(--border-radius);
|
color: var(--text-main);
|
||||||
height: abstracts.rem(300);
|
}
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding-inline: var(--space-3);
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
&:nth-child(1) {
|
&__description {
|
||||||
grid-column: 1 / span 2;
|
font-size: var(--font-size-base);
|
||||||
grid-row: 1;
|
color: var(--text-muted);
|
||||||
}
|
line-height: 1.6;
|
||||||
&:nth-child(2) {
|
flex: 1;
|
||||||
grid-column: 3;
|
}
|
||||||
grid-row: 1;
|
|
||||||
}
|
|
||||||
&:nth-child(3) {
|
|
||||||
grid-column: 1;
|
|
||||||
grid-row: 2;
|
|
||||||
}
|
|
||||||
&:nth-child(4) {
|
|
||||||
grid-column: 2 / span 2;
|
|
||||||
grid-row: 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__claim {
|
&__benefit {
|
||||||
font-size: var(--font-size-xl);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
&__description {
|
&__check {
|
||||||
font-size: var(--font-size-lg);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</footer>
|
|
||||||
|
<div class="footer__bottom">
|
||||||
|
<p>© {{ currentYear }} Hurler Webdesign. Alle Rechte vorbehalten.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,84 +1,204 @@
|
|||||||
@use "abstracts";
|
@use "abstracts";
|
||||||
|
|
||||||
.wrapper {
|
// ── Animations ────────────────────────────────────────────────────────────────
|
||||||
height: var(--nav-height);
|
|
||||||
border-radius: 0 0 10px 10px;
|
@keyframes fade-in {
|
||||||
background-color: var(--nav-bg);
|
from { opacity: 0; }
|
||||||
backdrop-filter: var(--nav-backdrop);
|
to { opacity: 1; }
|
||||||
box-shadow: var(--nav-shadow);
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: var(--z-index-sticky);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes slide-down {
|
||||||
|
from { opacity: 0; transform: translateY(-8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sticky wrapper ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
height: var(--nav-height);
|
||||||
|
border-radius: 0 0 10px 10px;
|
||||||
|
background-color: var(--nav-bg);
|
||||||
|
backdrop-filter: var(--nav-backdrop);
|
||||||
|
box-shadow: var(--nav-shadow);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
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;
|
|
||||||
|
|
||||||
&__nav-section {
|
@include abstracts.breakpoint('md') {
|
||||||
display: flex;
|
padding-inline: var(--space-4);
|
||||||
flex-direction: row;
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-container {
|
&__nav-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
padding-left: var(--space-4);
|
}
|
||||||
|
|
||||||
&__logo {
|
&__login-btn {
|
||||||
font-size: 1.5rem;
|
display: none;
|
||||||
font-weight: bold;
|
|
||||||
color: var(--text-on-accent);
|
@include abstracts.breakpoint('md') {
|
||||||
border: 1px solid var(--accent);
|
display: flex;
|
||||||
width: abstracts.rem(30);
|
align-items: center;
|
||||||
height: abstracts.rem(30);
|
|
||||||
display: flex;
|
|
||||||
background-color: var(--accent);
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__company {
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-main);
|
|
||||||
|
|
||||||
span {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Logo ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&__logo {
|
||||||
|
stroke: var(--text-on-accent);
|
||||||
|
fill: var(--text-on-accent);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
width: abstracts.rem(30);
|
||||||
|
height: abstracts.rem(30);
|
||||||
|
display: flex;
|
||||||
|
background-color: var(--accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__company {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 button ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
.burger-menu {
|
.burger-menu {
|
||||||
padding: 0 var(--space-4) 0 var(--space-4);
|
display: flex;
|
||||||
cursor: pointer;
|
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;
|
||||||
|
|
||||||
@include abstracts.breakpoint("md") {
|
&:hover {
|
||||||
display: none;
|
background-color: oklch(from var(--accent) l c h / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include abstracts.breakpoint('md') {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
width: abstracts.rem(24);
|
||||||
|
height: abstracts.rem(24);
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__icon {
|
&:last-child a {
|
||||||
width: abstracts.rem(32);
|
border-bottom: none;
|
||||||
height: abstracts.rem(32);
|
|
||||||
color: var(--text-main);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
&__cta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
app-button {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
::ng-deep .nav-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,49 @@
|
|||||||
<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">
|
|
||||||
<h3>{{ project.company }}</h3>
|
|
||||||
<p>{{ project.shortDescription }}</p>
|
|
||||||
<div class="projects__card-features">
|
|
||||||
@for(feature of project.features; track $index) {
|
|
||||||
<p>{{ feature }}</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div class="projects__card-container">
|
||||||
|
@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">
|
||||||
|
@for (feature of project.features; track $index) {
|
||||||
|
<span class="projects__tag">{{ feature }}</span>
|
||||||
|
}
|
||||||
|
</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>
|
||||||
|
}
|
||||||
|
<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>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-3);
|
||||||
|
|
||||||
|
@include abstracts.breakpoint('md') {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card {
|
||||||
|
position: relative;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
box-shadow 0.3s ease;
|
||||||
|
|
||||||
|
animation: card-fade-up 0.55s cubic-bezier(0.22, 1, 0.36, 1) var(--delay, 0ms) both;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-6px);
|
||||||
|
box-shadow: 0 20px 50px oklch(0% 0 0 / 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card-image {
|
||||||
|
position: relative;
|
||||||
|
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 {
|
||||||
|
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;
|
display: flex;
|
||||||
min-height: 100vh;
|
|
||||||
margin-top: var(--neg-nav-height);
|
|
||||||
align-items: center;
|
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);
|
||||||
|
|
||||||
&__wrapper {
|
svg {
|
||||||
@include abstracts.container-wrapper;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__card-container {
|
&__card-cta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-3);
|
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);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
&__card {
|
}
|
||||||
position: relative;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&__description {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-end;
|
|
||||||
padding: var(--space-2);
|
|
||||||
background: rgba(0, 0, 0, 0.7);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover &__description {
|
|
||||||
opacity: 1;
|
|
||||||
color: var(--color-white);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__card-features {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/app/features/landing/components/stats/stats.component.ts
Normal file
95
src/app/features/landing/components/stats/stats.component.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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-footer></app-footer>
|
<app-testimonials></app-testimonials>
|
||||||
|
<app-pricing></app-pricing>
|
||||||
|
<app-contact></app-contact>
|
||||||
|
<app-footer></app-footer>
|
||||||
|
|||||||
@@ -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',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,30 @@
|
|||||||
<!-- Router Link (internal navigation) -->
|
<!-- Router Link (internal navigation) -->
|
||||||
@if (isRouteType) {
|
@if (isRouteType) {
|
||||||
<a
|
@if (disabled) {
|
||||||
[routerLink]="item.target"
|
<button
|
||||||
routerLinkActive="nav-btn--active"
|
[class]="hostClasses.join(' ')"
|
||||||
[class]="hostClasses.join(' ')"
|
disabled
|
||||||
[attr.aria-disabled]="disabled || null"
|
type="button"
|
||||||
>
|
aria-disabled="true"
|
||||||
@if (item.icon) {
|
>
|
||||||
<span class="nav-btn__icon" aria-hidden="true">{{ item.icon }}</span>
|
@if (item.icon) {
|
||||||
}
|
<span class="nav-btn__icon" aria-hidden="true">{{ item.icon }}</span>
|
||||||
<span class="nav-btn__label">{{ item.label }}</span>
|
}
|
||||||
</a>
|
<span class="nav-btn__label">{{ item.label }}</span>
|
||||||
|
<span class="nav-btn__badge">Demnächst</span>
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<a
|
||||||
|
[routerLink]="item.target"
|
||||||
|
routerLinkActive="nav-btn--active"
|
||||||
|
[class]="hostClasses.join(' ')"
|
||||||
|
>
|
||||||
|
@if (item.icon) {
|
||||||
|
<span class="nav-btn__icon" aria-hidden="true">{{ item.icon }}</span>
|
||||||
|
}
|
||||||
|
<span class="nav-btn__label">{{ item.label }}</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Anchor (smooth scroll) -->
|
<!-- Anchor (smooth scroll) -->
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,27 +1,28 @@
|
|||||||
<nav class="navigation">
|
<nav class="navigation">
|
||||||
<ul class="navigation__list">
|
<ul class="navigation__list">
|
||||||
@for (item of items(); track item.target) {
|
@for (item of items(); track item.target) {
|
||||||
<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"
|
||||||
@if (item.icon) {
|
[opTrackProps]="{location: `${item.target}`}">
|
||||||
<ng-icon [name]="item.icon"></ng-icon>
|
@if (item.icon) {
|
||||||
}
|
<ng-icon [name]="item.icon"></ng-icon>
|
||||||
{{ item.label }}
|
|
||||||
</a>
|
|
||||||
}
|
}
|
||||||
|
{{ item.label }}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- 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>
|
||||||
}
|
|
||||||
{{ item.label }}
|
|
||||||
</a>
|
|
||||||
}
|
}
|
||||||
</li>
|
{{ item.label }}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
4
src/environments/environment.directus.ts
Normal file
4
src/environments/environment.directus.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: false,
|
||||||
|
directusUrl: 'https://backend.hurler-webdesign.de',
|
||||||
|
};
|
||||||
3
src/environments/n8n.ts
Normal file
3
src/environments/n8n.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const n8nEnvironment = {
|
||||||
|
contactWebhookUrl: 'https://n8n.hurler-webdesign.de/webhook/fbcced3d-503d-4bca-9147-405fe809253e',
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -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
1
tsconfig.tsbuildinfo
Normal 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"}
|
||||||
Reference in New Issue
Block a user