Compare commits

..

13 Commits

117 changed files with 7607 additions and 949 deletions

View File

@@ -0,0 +1,23 @@
# This is an example configuration file
# To learn more, see the full config.yaml reference: https://docs.continue.dev/reference
name: Example Config
version: 1.0.0
schema: v1
# Define which models can be used
# https://docs.continue.dev/customization/models
models:
- name: my gpt-5
provider: openai
model: gpt-5
apiKey: YOUR_OPENAI_API_KEY_HERE
- uses: ollama/qwen2.5-coder-7b
- uses: anthropic/claude-4-sonnet
with:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
# MCP Servers that Continue can access
# https://docs.continue.dev/customization/mcp-tools
mcpServers:
- uses: anthropic/memory-mcp

View File

@@ -0,0 +1,10 @@
name: New MCP server
version: 0.0.1
schema: v1
mcpServers:
- name: New MCP server
command: npx
args:
- -y
- <your-mcp-server>
env: {}

1
.nvmrc Normal file
View File

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

61
CLAUDE.md Normal file
View File

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

View File

@@ -11,7 +11,8 @@
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
"style": "scss",
"type": "component"
}
},
"root": "",
@@ -37,6 +38,9 @@
"outputMode": "server",
"ssr": {
"entry": "src/server.ts"
},
"stylePreprocessorOptions": {
"includePaths": ["src/styles"]
}
},
"configurations": {
@@ -49,8 +53,8 @@
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
"maximumWarning": "6kB",
"maximumError": "10kB"
}
],
"outputHashing": "all"

2351
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,26 +24,30 @@
"private": true,
"packageManager": "npm@11.8.0",
"dependencies": {
"@angular/common": "^21.0.0",
"@angular/compiler": "^21.0.0",
"@angular/core": "^21.0.0",
"@angular/forms": "^21.0.0",
"@angular/platform-browser": "^21.0.0",
"@angular/platform-server": "^21.0.0",
"@angular/router": "^21.0.0",
"@angular/common": "^21.2.5",
"@angular/compiler": "^21.2.5",
"@angular/core": "^21.2.5",
"@angular/forms": "^21.2.5",
"@angular/platform-browser": "^21.2.5",
"@angular/platform-server": "^21.2.5",
"@angular/router": "^21.2.5",
"@angular/ssr": "^21.0.3",
"@ng-icons/core": "^33.1.0",
"@ng-icons/css.gg": "^33.1.0",
"@openpanel/web": "^1.3.0",
"express": "^5.1.0",
"marked": "^17.0.5",
"rxjs": "~7.8.0",
"shiki": "^4.0.2",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^21.0.3",
"@angular/cli": "^21.1.4",
"@angular/compiler-cli": "^21.0.0",
"@angular/build": "^21.2.3",
"@angular/cli": "^21.2.3",
"@angular/compiler-cli": "^21.2.5",
"@types/express": "^5.0.1",
"@types/node": "^20.17.19",
"@types/marked": "^5.0.2",
"@types/node": "^20.19.37",
"jsdom": "^27.1.0",
"typescript": "~5.9.2",
"vitest": "^4.0.8"

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

BIN
public/images/bakery.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

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

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

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,15 +1,30 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { ApplicationConfig, provideBrowserGlobalErrorListeners, LOCALE_ID } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { provideNgIconsConfig } from '@ng-icons/core';
import { environment } from '../environments/openpanel';
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 { provideClientHydration, withEventReplay } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
{ provide: LOCALE_ID, useValue: 'de' },
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideRouter(routes),
provideHttpClient(withFetch()),
provideClientHydration(withEventReplay()),
provideNgIconsConfig({})
provideNgIconsConfig({}),
provideOpenPanel({
clientId: environment.openPanel.clientId,
apiUrl: environment.openPanel.apiUrl,
trackScreenViews: true,
debug: !environment.production,
})
]
};

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,58 @@
import { Directive, HostListener, Input, inject } from '@angular/core';
import { OpenPanelService, TrackProperties } from '@core/services/openpanel.service';
/**
* Directive for declarative event tracking directly in templates.
*
* @example
* <button opTrack="signup_clicked" [opTrackProps]="{ location: 'hero' }">
* Sign Up
* </button>
*
* @example
* <a routerLink="/pricing" opTrack="pricing_link_clicked">Pricing</a>
*/
@Directive({
selector: '[opTrack]',
standalone: true,
})
export class OpenPanelTrackDirective {
private readonly op = inject(OpenPanelService);
/** The event name to track on click. */
@Input({ required: true }) opTrack!: string;
/** Optional properties to send with the event. */
@Input() opTrackProps?: TrackProperties;
/** Which DOM event triggers tracking. Default: 'click' */
@Input() opTrackOn: 'click' | 'mouseenter' | 'focus' | 'blur' = 'click';
@HostListener('click')
onClick(): void {
if (this.opTrackOn === 'click') {
this.op.track(this.opTrack, this.opTrackProps);
}
}
@HostListener('mouseenter')
onMouseEnter(): void {
if (this.opTrackOn === 'mouseenter') {
this.op.track(this.opTrack, this.opTrackProps);
}
}
@HostListener('focus')
onFocus(): void {
if (this.opTrackOn === 'focus') {
this.op.track(this.opTrack, this.opTrackProps);
}
}
@HostListener('blur')
onBlur(): void {
if (this.opTrackOn === 'blur') {
this.op.track(this.opTrack, this.opTrackProps);
}
}
}

View File

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

View File

@@ -0,0 +1,12 @@
export type NavigationType = "anchor" | "route" | "external";
export interface NavigationItem {
readonly label: string;
readonly type: NavigationType;
readonly target: string;
readonly icon?: string;
readonly children?: NavigationItem[];
}
export const isAnchor = (item: NavigationItem): boolean => item.type === "anchor";
export const isRoute = (item: NavigationItem): boolean => item.type === "route";

View File

@@ -0,0 +1,35 @@
import { InjectionToken } from '@angular/core';
export interface OpenPanelConfig {
/** Your OpenPanel Client ID (required) */
clientId: string;
/** URL of your OpenPanel API or self-hosted instance.
* Defaults to https://api.openpanel.dev */
apiUrl?: string;
/** Automatically track Angular Router navigation events as screen views.
* Default: true */
trackScreenViews?: boolean;
/** Track clicks on outgoing links automatically.
* Default: false */
trackOutgoingLinks?: boolean;
/** Enable declarative tracking via data-track HTML attributes.
* Default: false */
trackAttributes?: boolean;
/** Global properties sent with every event (e.g. app_version, environment). */
globalProperties?: Record<string, string | number | boolean>;
/** Completely disable all tracking (e.g. in test environments).
* Default: false */
disabled?: boolean;
/** Enable verbose console logging for debugging.
* Default: false */
debug?: boolean;
}
export const OPENPANEL_CONFIG = new InjectionToken<OpenPanelConfig>('OPENPANEL_CONFIG');

View File

@@ -0,0 +1,8 @@
export interface Project {
id: string;
name: string;
client: string;
description: string;
socialDescription?: string;
image: string;
}

View File

@@ -0,0 +1,7 @@
export interface SeoData {
title: string;
description: string;
socialsDescription?: string;
image?: string;
type?: "website" | "article";
}

View File

@@ -0,0 +1,31 @@
import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
import { OpenPanelConfig, OPENPANEL_CONFIG } from '@core/models/openpanel.model';
import { OpenPanelService } from '@core/services/openpanel.service';
/**
* Provides the OpenPanel analytics service for your Angular application.
*
* @example
* // app.config.ts
* import { provideOpenPanel } from './openpanel/openpanel.provider';
*
* export const appConfig: ApplicationConfig = {
* providers: [
* provideRouter(routes),
* provideOpenPanel({
* clientId: 'your-client-id',
* trackScreenViews: true,
* globalProperties: {
* app_version: '1.0.0',
* environment: 'production',
* },
* }),
* ],
* };
*/
export function provideOpenPanel(config: OpenPanelConfig): EnvironmentProviders {
return makeEnvironmentProviders([
{ provide: OPENPANEL_CONFIG, useValue: config },
OpenPanelService,
]);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,75 @@
// core/services/navigation.service.ts
import { Injectable, signal, computed, inject } from '@angular/core';
import { Router } from '@angular/router';
import { NavigationItem, isAnchor, isRoute } from '../models/navigation.model';
@Injectable({ providedIn: 'root' })
export class NavigationService {
private readonly router = inject(Router);
// === STATE ===
private readonly _navigationItems = signal<NavigationItem[]>([
// Anchor-Links (Landingpage intern)
{ label: 'Home', type: 'anchor', target: 'hero' },
{ label: 'Features', type: 'anchor', target: 'features-section'},
{ label: 'Projekte', type: 'anchor', target: 'projects' },
{ label: 'Pricing', type: 'anchor', target: 'pricing' },
// Route-Links
{ label: 'Blog', type: 'route', target: '/blog' },
{ label: 'Login', type: 'route', target: '/login' },
{
label: 'Dashboard',
type: 'route',
target: '/dashboard',
icon: 'layout',
}
]);
// === PUBLIC SIGNALS ===
readonly navigationItems = this._navigationItems.asReadonly();
// Gefiltert nach Kontext (Landingpage vs. App)
readonly landingNavigation = computed(() =>
this._navigationItems().filter(item =>
isAnchor(item) || item.target === '/blog'
)
);
readonly appNavigation = computed(() =>
this._navigationItems().filter(item => isRoute(item))
);
// === METHODS ===
navigate(item: NavigationItem): void {
switch (item.type) {
case 'anchor':
this.scrollToAnchor(item.target);
break;
case 'route':
this.router.navigate([item.target]);
break;
case 'external':
window.open(item.target, '_blank');
break;
}
}
private scrollToAnchor(anchorId: string): void {
const element = document.getElementById(anchorId);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}
// Dynamische Updates (z.B. nach Login)
addItem(item: NavigationItem): void {
this._navigationItems.update(items => [...items, item]);
}
removeItem(target: string): void {
this._navigationItems.update(items =>
items.filter(i => i.target !== target)
);
}
}

View File

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

View File

@@ -0,0 +1,150 @@
import { Injectable, OnDestroy, inject } from '@angular/core';
import { Router, NavigationEnd } from '@angular/router';
import { isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID } from '@angular/core';
import { filter, Subscription, skip } from 'rxjs';
import { OpenPanel } from '@openpanel/web';
import type { IdentifyPayload } from '@openpanel/web';
import { OpenPanelConfig, OPENPANEL_CONFIG } from '@core/models/openpanel.model';
export type TrackProperties = Record<string, string | number | boolean | null | undefined>;
@Injectable({
providedIn: 'root',
})
export class OpenPanelService implements OnDestroy {
private readonly config = inject(OPENPANEL_CONFIG);
private readonly platformId = inject(PLATFORM_ID)
private readonly router = inject(Router);
private op?: OpenPanel;
private routerSubscription?: Subscription;
constructor() {
if(isPlatformBrowser(this.platformId)) {
this.initialize();
}
}
// ─── Initialization ────────────────────────────────────────────────────────
private initialize(): void {
this.op = new OpenPanel({
clientId: this.config.clientId,
apiUrl: this.config.apiUrl,
trackScreenViews: false, // We handle this manually via Router
trackOutgoingLinks: this.config.trackOutgoingLinks ?? false,
trackAttributes: this.config.trackAttributes ?? false,
disabled: this.config.disabled ?? false,
});
if (this.config.globalProperties) {
this.op.setGlobalProperties(this.config.globalProperties);
}
if (this.config.trackScreenViews !== false) {
this.setupRouteTracking();
}
}
private setupRouteTracking(): void {
this.routerSubscription?.unsubscribe();
this.routerSubscription = this.router.events.pipe(
filter((event) => event instanceof NavigationEnd),
).subscribe(() => {
const route = this.getActiveRoute();
const trackName = route.snapshot.data['trackName'] ?? this.router.url;
this.trackScreenView(trackName);
});
}
private getActiveRoute() {
let route = this.router.routerState.root;
while (route.firstChild) route = route.firstChild;
return route;
}
// ─── Public API ────────────────────────────────────────────────────────────
/**
* Tracks a custom event with optional properties.
* @example opService.track('button_clicked', { button_name: 'signup' });
*/
track(eventName: string, properties?: TrackProperties): void {
if (!this.op) return;
if (this.config.debug) {
console.debug('[OpenPanel] track:', eventName, properties);
}
this.op.track(eventName, properties);
}
/**
* Identifies the current user. Call this after login.
* @example opService.identify({ profileId: 'user-123', email: 'user@example.com' });
*/
identify(payload: IdentifyPayload): void {
if (!this.op) return;
if (this.config.debug) {
console.debug('[OpenPanel] identify:', payload.profileId);
}
this.op.identify(payload);
}
/**
* Clears the current user identity. Call this on logout.
*/
clearUser(): void {
if (!this.op) return;
if (this.config.debug) {
console.debug('[OpenPanel] clearUser');
}
this.op.clear();
}
/**
* Sets properties that will be sent with every subsequent event.
*/
setGlobalProperties(properties: TrackProperties): void {
if (!this.op) return;
this.op.setGlobalProperties(properties);
}
/**
* Increments a numeric property on the user profile.
* @example opService.increment('login_count');
*/
increment(property: string): void {
if (!this.op) return;
this.op.increment(property);
}
/**
* Decrements a numeric property on the user profile.
* @example opService.decrement('credits');
*/
decrement(property: string): void {
if (!this.op) return;
this.op.decrement(property);
}
/**
* Manually tracks a screen/page view.
*/
trackScreenView(path?: string): void {
if (!this.op) return;
const currentPath = path ?? this.router.url;
if (this.config.debug) {
console.debug('[OpenPanel] screenView:', currentPath);
}
this.op.track('screen_view', { path: currentPath });
}
// ─── Cleanup ───────────────────────────────────────────────────────────────
ngOnDestroy(): void {
this.routerSubscription?.unsubscribe();
}
}

View File

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

View File

@@ -0,0 +1,136 @@
import { Injectable, inject } from "@angular/core";
import { Title, Meta } from "@angular/platform-browser";
import { DOCUMENT } from "@angular/common";
import { RendererFactory2 } from "@angular/core";
import { SeoData } from "@core/models/seo.model";
@Injectable({ providedIn: "root" })
export class SeoService {
private titleService = inject(Title);
private metaService = inject(Meta);
private document = inject(DOCUMENT);
private rendererFactory = inject(RendererFactory2);
private renderer =
this.rendererFactory.createRenderer(null, null);
// Constants for metadata
private readonly BRAND = "Hurler Webdesign";
private readonly FALLBACK_IMAGE =
'https://hurler-webdesign.de/assets/og-default.jpg';
private readonly DEFAULT_TYPE = "website";
/**
* Updates SEO metadata with the provided data.
*
* @param data - The SeoData object containing title, description, type, and image.
* @param canonicalPath - Optional parameter for the canonical URL path.
*/
updateMetadata(data: SeoData, canonicalPath?: string) {
const fullTitle = `${data.title} | ${this.BRAND}`;
const url = `https://hurler-webdesign.de${canonicalPath || ""}`;
// Update title and meta description
this.titleService.setTitle(fullTitle);
this.metaService.updateTag({ name: "description", content: data.description });
// Open Graph metadata
this.metaService.updateTag({
property: "og:title",
content: fullTitle,
});
this.metaService.updateTag({
property: "og:description",
content: data.description,
});
this.metaService.updateTag({
property: "og:type",
content: data.type || this.DEFAULT_TYPE,
});
this.metaService.updateTag({
property: "og:image",
content: data.image || this.FALLBACK_IMAGE,
});
this.metaService.updateTag({ property: "og:url", content: url });
// Twitter card metadata
this.metaService.updateTag({
name: "twitter:card",
content: "summary_large_image",
});
this.metaService.updateTag({
name: "twitter:title",
content: fullTitle,
});
this.metaService.updateTag({
name: "twitter:description",
content: data.socialsDescription || data.description,
});
this.metaService.updateTag({ name: "twitter:url", content: url });
this.metaService.updateTag({
name: "twitter:image",
content: data.image || this.FALLBACK_IMAGE,
});
// Update canonical URL if provided
if (canonicalPath) {
this.updateCanonicalUrl(url);
}
// Set local business schema.org metadata
this.setLocalBusinessSchema();
}
/**
* Updates the canonical URL for the given page.
*
* @param url - The new canonical URL.
*/
private updateCanonicalUrl(url: string) {
let link: HTMLLinkElement =
this.document.querySelector("link[rel='canonical']") ||
this.renderer.createElement("link");
this.renderer.setAttribute(link, "rel", "canonical");
this.renderer.setAttribute(link, "href", url);
if (!this.document.head.contains(link)) {
this.renderer.appendChild(this.document.head, link);
}
}
/**
* Sets the local business schema.org JSON-LD script in the head of the document.
*/
private setLocalBusinessSchema() {
const oldScript = this.document.getElementById('schema-org-data');
if (oldScript) this.renderer.removeChild(this.document.head, oldScript);
const schema = {
"@context": "https://schema.org",
"@type": "WebDesignService",
"name": this.BRAND,
"url": "https://hurler-webdesign.de",
"logo": "https://hurler-webdesign.de/assets/logo.png",
"image": "https://hurler-webdesign.de/assets/office.jpg",
"description": "Spezialist für schnelle Webseiten ohne WordPress für Handwerk & Vereine. Wir bieten maßgeschneidertes Webdesign für eine schnelle und sichere Online-Präsenz.",
"address": {
"@type": "PostalAddress",
"streetAddress": "Untermagerbein 30",
"addressLocality": "Mönchsdeggingen",
"postalCode": "86751",
"addressCountry": "DE"
},
"geo": {
"@type": "GeoCoordinates",
"latitude": 48.7506,
"longitude": 10.5773
},
"areaServed": ["Nördlingen", "Donauwörth", "Augsburg", "Bayern"],
"telephone": "+49 171 8084830",
"priceRange": "€€"
};
const script = this.renderer.createElement('script');
script.type = 'application/ld+json';
script.id = 'schema-org-data';
script.text = JSON.stringify(schema);
this.renderer.appendChild(this.document.head, script);
}
}

View File

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

View File

@@ -0,0 +1,55 @@
// core/services/theme.service.ts
import { Injectable, signal, effect, computed, Inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID } from '@angular/core';
export type Theme = 'light' | 'dark' | 'system';
@Injectable({ providedIn: 'root' })
export class ThemeService {
// Privater Signal-State
private readonly _theme = signal<Theme>('system');
// Öffentlicher Readonly-Signal
readonly theme = this._theme.asReadonly();
// Abgeleiteter Wert: Tatsächlich aktives Theme (aufgelöst)
readonly effectiveTheme = computed(() => {
const current = this._theme();
if (current !== 'system') return current;
if (isPlatformBrowser(this.platformId)) {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
return 'light';
});
constructor(@Inject(PLATFORM_ID) private platformId: object) {
// Effekt: Reagiert auf Änderungen (nur im Browser)
effect(() => {
if (isPlatformBrowser(this.platformId)) {
const theme = this.effectiveTheme();
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', this._theme());
}
});
// Initialisierung
this.initializeTheme();
}
setTheme(theme: Theme): void {
this._theme.set(theme);
}
private initializeTheme(): void {
if (isPlatformBrowser(this.platformId)) {
const saved = localStorage.getItem('theme') as Theme | null;
if (saved) {
this._theme.set(saved);
}
}
}
}

View File

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

View File

@@ -0,0 +1,42 @@
import { Injectable } from '@angular/core';
export interface UmamiEventData {
[key: string]: string | number | boolean;
}
// Typdefinition für das globale umami-Objekt
declare global {
interface Window {
umami?: {
track: (eventName?: string, data?: UmamiEventData) => void;
};
}
}
@Injectable({
providedIn: 'root',
})
export class UmamiService {
private get isAvailable(): boolean {
return typeof window !== 'undefined' && typeof window.umami !== 'undefined';
}
/**
* Trackt einen Pageview manuell nötig bei SPAs wie Angular,
* da kein echter Seitenaufruf stattfindet.
*/
trackPageview(): void {
if (!this.isAvailable) return;
window.umami!.track();
}
/**
* Trackt ein benutzerdefiniertes Event.
* @param eventName Name des Events, z.B. 'button-click'
* @param data Optionale Zusatzdaten, z.B. { label: 'Hero CTA' }
*/
trackEvent(eventName: string, data?: UmamiEventData): void {
if (!this.isAvailable) return;
window.umami!.track(eventName, data);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
<p>dashboard works!</p>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Dashboard } from './dashboard';
describe('Dashboard', () => {
let component: Dashboard;
let fixture: ComponentFixture<Dashboard>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Dashboard]
})
.compileComponents();
fixture = TestBed.createComponent(Dashboard);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-dashboard',
imports: [],
templateUrl: './dashboard.html',
styleUrl: './dashboard.scss',
})
export class Dashboard {
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,149 @@
@use 'abstracts';
@keyframes card-fade-up {
from {
opacity: 0;
transform: translateY(32px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.features-section {
min-height: 100vh;
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;
align-items: center;
justify-content: center;
width: abstracts.rem(48);
height: abstracts.rem(48);
border-radius: 12px;
background: linear-gradient(135deg, oklch(from var(--accent) l c h / 0.15) 0%, oklch(from var(--accent) l c h / 0.08) 100%);
flex-shrink: 0;
transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
.features-section__card:hover & {
transform: scale(1.08);
}
}
&__icon {
font-size: abstracts.rem(24);
color: var(--accent);
}
&__claim {
font-size: var(--font-size-lg);
font-weight: 700;
line-height: 1.3;
color: var(--text-main);
}
&__description {
font-size: var(--font-size-base);
color: var(--text-muted);
line-height: 1.6;
flex: 1;
}
&__benefit {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: 0.85rem;
font-weight: 600;
color: var(--accent);
padding-top: var(--space-2);
border-top: 1px solid var(--border-color);
margin-top: auto;
}
&__check {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: oklch(from var(--accent) l c h / 0.15);
font-size: 0.7rem;
flex-shrink: 0;
}
}

View File

@@ -1,11 +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 Feature {
id: number;
claim: string;
description: string;
benefit: string;
icon: string;
}
@Component({
selector: 'app-features-section',
imports: [],
imports: [NgIcon, OpenPanelTrackDirective],
viewProviders: [provideIcons({ cssCode, cssLock, cssDatabase, cssBrowser })],
templateUrl: './features-section.component.html',
styleUrl: './features-section.component.scss',
})
export class FeaturesSectionComponent {
export class FeaturesSectionComponent implements AfterViewInit {
@ViewChildren('cardRef') cardElements!: QueryList<ElementRef<HTMLElement>>;
private readonly platformId = inject(PLATFORM_ID);
featuresList: Feature[] = [
{
id: 1,
claim: 'Blitzschnelle Ladezeiten',
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,
claim: 'Maximale Sicherheit',
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,
claim: 'Europäisches Hosting',
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,
claim: 'Einfaches Dashboard',
description:
'Ein Verwaltungsportal für alles: Inhalte pflegen, Anfragen verwalten und Ihren Webauftritt jederzeit selbst aktualisieren ohne Programmierkenntnisse.',
benefit: 'Zeitersparnis & Unabhängigkeit',
icon: 'cssBrowser',
},
];
ngAfterViewInit(): void {
if (!isPlatformBrowser(this.platformId)) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.1 }
);
this.cardElements.forEach((el) => observer.observe(el.nativeElement));
}
}

View File

@@ -1 +1,44 @@
<p>footer works!</p>
<footer class="footer" id="contact">
<div class="footer__wrapper">
<div class="footer__grid">
<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 class="footer__bottom">
<p>&copy; {{ currentYear }} Hurler Webdesign. Alle Rechte vorbehalten.</p>
</div>
</div>
</footer>

View File

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

View File

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

View File

@@ -1 +1,46 @@
<section>hero works!</section>
<section class="hero-section" id="hero">
<div class="hero-section__video-container">
<video autoplay muted loop playsinline>
<source src="/video/white_mit_black_stripes.webm" type="video/webm">
</video>
</div>
<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">
Webseiten, die<br />
<span class="hero-section__header-accent">Kunden überzeugen</span>
</h1>
<p class="hero-section__claim">
Wir programmieren blitzschnelle, sichere und maßgeschneiderte Webseiten
für kleine Unternehmen und Vereine ohne CMS-Ballast, dafür mit maximaler Performance.
</p>
<div class="hero-section__links">
<app-button
opTrack="hero_cta_features"
[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>
</section>

View File

@@ -0,0 +1,166 @@
@use "abstracts";
.hero-section {
position: relative;
min-height: calc(100vh + var(--nav-height));
margin-top: var(--neg-nav-height);
overflow: hidden;
display: flex;
align-items: center;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--hero-video-overlay);
z-index: -1;
}
&__wrapper {
@include abstracts.container-wrapper;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
}
&__video-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -2;
}
&__badge {
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-weight: 800;
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 {
color: var(--text-main);
font-size: var(--font-size-lg);
max-width: 55ch;
line-height: 1.6;
}
&__links {
display: flex;
flex-direction: row;
gap: var(--space-2);
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 {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
mask-image: linear-gradient(to bottom, black 0%, black 70%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 70%, transparent 100%);
}
}
@keyframes badge-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.85; }
}
@keyframes dot-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.2); }
}

View File

@@ -1,11 +1,11 @@
import { Component } from '@angular/core';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive';
@Component({
selector: 'app-hero',
imports: [],
imports: [ButtonComponent, OpenPanelTrackDirective],
templateUrl: './hero.component.html',
styleUrl: './hero.component.scss',
})
export class HeroComponent {
}
export class HeroComponent {}

View File

@@ -1,15 +1,66 @@
<div class="logo-container">
<span class="logo-container__logo centered">H</span>
<p class="logo-container__company"><span>Hurler</span> Webdesign</p>
<div class="wrapper">
<section class="header">
<a href="#hero" class="logo-container" (click)="closeMenu()">
<span class="logo-container__logo centered">
<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" 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="theme-toggle-container">
<app-toogle-theme></app-toogle-theme>
</div>
<app-nav-menu [items]="navigationService.landingNavigation()"></app-nav-menu>
<div class="header__login-btn">
<app-button
[item]="loginItem"
variant="primary"
size="sm"
[disabled]="true">
</app-button>
</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>
</section>
</div>
<nav class="navigation">
<ul class="navigation__list">
<li class="navigation__list-item"><a href="#">Home</a></li>
<li class="navigation__list-item"><a href="#">Features</a></li>
<li class="navigation__list-item"><a href="#">Contact</a></li>
</ul>
</nav>
<div class="burger-menu centered">
<ng-icon name="cssMenu" class="burger-menu__icon"></ng-icon>
</div>
<!-- Mobile Menu Overlay -->
@if (isMenuOpen()) {
<div class="mobile-overlay" (click)="closeMenu()" aria-hidden="true"></div>
<nav class="mobile-menu" aria-label="Mobile Navigation">
<ul class="mobile-menu__list">
@for (item of navigationService.landingNavigation(); track item.target) {
<li class="mobile-menu__item">
@if (isAnchor(item)) {
<a [href]="'#' + item.target" (click)="onMobileNavClick($event, item)">
{{ item.label }}
</a>
} @else {
<a [routerLink]="item.target" (click)="closeMenu()">
{{ item.label }}
</a>
}
</li>
}
</ul>
<div class="mobile-menu__cta">
<app-button
[item]="loginItem"
variant="primary"
[disabled]="true">
</app-button>
</div>
</nav>
}

View File

@@ -1,77 +1,204 @@
@use "../../../../../styles/abstracts";
@use "abstracts";
.navigation {
display: none;
// ── Animations ────────────────────────────────────────────────────────────────
@include abstracts.breakpoint("md") {
display: block;
padding-right: var(--space-4);
}
&__list {
display: flex;
gap: var(--space-4);
}
&__list-item {
list-style: none;
font-weight: 500;
font-size: var(--font-size-base);
color: var(--text-main);
transition: color 0.3s ease;
a {
color: var(--accent);
&:hover {
color: var(--accent-hover);
}
}
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.logo-container {
@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 {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
min-height: abstracts.rem(60);
padding-inline: var(--space-3);
@include abstracts.breakpoint('md') {
padding-inline: var(--space-4);
}
&__nav-section {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--space-2);
padding-left: var(--space-4);
}
&__logo {
font-size: 1.5rem;
font-weight: bold;
color: var(--text-main);
border: 1px solid var(--accent);
width: abstracts.rem(30);
height: abstracts.rem(30);
display: flex;
background-color: var(--accent);
border-radius: 5px;
color: white;
}
&__company {
font-size: var(--font-size-base);
font-weight: 700;
color: var(--text-main);
span {
color: var(--accent);
}
&__login-btn {
display: none;
@include abstracts.breakpoint('md') {
display: flex;
align-items: center;
}
}
}
// ── 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 {
width: abstracts.rem(24);
height: abstracts.rem(24);
display: flex;
align-items: center;
}
// ── Burger button ─────────────────────────────────────────────────────────────
.burger-menu {
padding-right: var(--space-4);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-1);
background: none;
border: none;
cursor: pointer;
border-radius: 6px;
transition: background-color 0.2s ease;
@include abstracts.breakpoint("md") {
display: none;
&:hover {
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 {
width: abstracts.rem(32);
height: abstracts.rem(32);
color: var(--text-main);
&:last-child a {
border-bottom: none;
}
}
}
&__cta {
display: flex;
justify-content: center;
app-button {
width: 100%;
::ng-deep .nav-btn {
width: 100%;
justify-content: center;
}
}
}
}

View File

@@ -1,12 +1,53 @@
import { Component } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { Component, inject, signal, PLATFORM_ID } from '@angular/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 { 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 { OpenPanelService } from '@core/services/openpanel.service';
import { NavigationItem, isAnchor } from '@core/models/navigation.model';
@Component({
selector: 'app-navigation',
imports: [NgIcon],
imports: [NgIcon, ToogleThemeComponent, NavMenuComponent, ButtonComponent, RouterLink],
viewProviders: [provideIcons({ cssMenu, cssClose })],
templateUrl: './navigation.component.html',
styleUrl: './navigation.component.scss',
})
export class NavigationComponent {
protected readonly navigationService = inject(NavigationService);
private readonly op = inject(OpenPanelService);
private readonly platformId = inject(PLATFORM_ID);
readonly isMenuOpen = signal(false);
protected readonly isAnchor = isAnchor;
readonly loginItem: NavigationItem = { label: 'Login', type: 'route', target: '/login' };
toggleMenu(): void {
const next = !this.isMenuOpen();
this.isMenuOpen.set(next);
if (isPlatformBrowser(this.platformId)) {
document.body.style.overflow = next ? 'hidden' : '';
}
this.op.track(next ? 'mobile_menu_open' : 'mobile_menu_close');
}
closeMenu(): void {
this.isMenuOpen.set(false);
if (isPlatformBrowser(this.platformId)) {
document.body.style.overflow = '';
}
}
onMobileNavClick(event: Event, item: NavigationItem): void {
this.closeMenu();
if (isAnchor(item)) {
event.preventDefault();
this.navigationService.navigate(item);
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PricingComponent } from './pricing.component';
describe('PricingComponent', () => {
let component: PricingComponent;
let fixture: ComponentFixture<PricingComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PricingComponent]
})
.compileComponents();
fixture = TestBed.createComponent(PricingComponent);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

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

View File

@@ -0,0 +1,49 @@
<section class="projects" id="projects">
<div class="projects__wrapper">
<div class="projects__header">
<span class="projects__label">Erfolgsgeschichten</span>
<h2>Projekte, die überzeugen</h2>
<p class="text-muted">So helfen wir Unternehmen, online erfolgreich zu sein.</p>
</div>
<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>

View File

@@ -0,0 +1,182 @@
@use 'abstracts';
@keyframes card-fade-up {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.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;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-2);
padding-top: var(--space-2);
border-top: 1px solid var(--border-color);
font-size: 0.875rem;
font-weight: 700;
color: var(--accent);
svg {
flex-shrink: 0;
}
}
&__card-cta {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: 0.875rem;
font-weight: 600;
color: var(--accent);
margin-top: var(--space-2);
transition: gap 0.2s ease;
.projects__card:hover & {
gap: var(--space-2);
}
}
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProjectsComponent } from './projects.component';
describe('ProjectsComponent', () => {
let component: ProjectsComponent;
let fixture: ComponentFixture<ProjectsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProjectsComponent]
})
.compileComponents();
fixture = TestBed.createComponent(ProjectsComponent);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,55 @@
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive';
interface Project {
id: number;
slug: string;
image: string;
company: string;
branch: string;
shortDescription: string;
features: string[];
result?: string;
}
@Component({
selector: 'app-projects',
imports: [RouterLink, OpenPanelTrackDirective],
templateUrl: './projects.component.html',
styleUrl: './projects.component.scss',
})
export class ProjectsComponent {
projects: Project[] = [
{
id: 1,
slug: 'metzgerei-schlachthof-qualitaet',
company: 'Metzgerei Schlachthof-Qualität',
branch: 'Fleischerei & Metzgerei',
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,
slug: 'finanzberatung-vermoegenswert',
company: 'Finanzberatung Vermögenswert',
branch: 'Finanzdienstleistung',
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,
slug: 'physiotherapie-beweglich',
company: 'Physiotherapie Beweglich',
branch: 'Gesundheitswesen',
image: '/images/projekte/physiotherapie.jpg',
shortDescription: 'Moderne Praxis-Website für Physiotherapie und Rehabilitation',
features: ['Online-Terminbuchung', 'Leistungen', 'Google-optimiert'],
result: 'Top 3 bei Google-Suche',
},
];
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
// features/landing/public-api.ts (oder index.ts)
// Nur was von außen importiert werden soll
export { LandingpageComponent } from './pages/landingpage.component';

View File

@@ -1,6 +1,9 @@
<div class="landing-grid">
<app-navigation class="navigation__component"></app-navigation>
<app-hero class="hero__component"></app-hero>
<app-features-section class="features-section__component"></app-features-section>
<app-footer class="footer__component"></app-footer>
</div>
<app-navigation></app-navigation>
<app-hero></app-hero>
<app-stats></app-stats>
<app-features-section></app-features-section>
<app-projects></app-projects>
<app-testimonials></app-testimonials>
<app-pricing></app-pricing>
<app-contact></app-contact>
<app-footer></app-footer>

View File

@@ -1,20 +1,40 @@
import { Component } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import { NavigationComponent } from '../components/navigation/navigation.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 { TestimonialsComponent } from '../components/testimonials/testimonials.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';
@Component({
selector: 'app-landingpage',
imports: [
NavigationComponent,
HeroComponent,
StatsComponent,
FeaturesSectionComponent,
FooterComponent
TestimonialsComponent,
ProjectsComponent,
PricingComponent,
ContactComponent,
FooterComponent,
],
templateUrl: './landingpage.component.html',
styleUrl: './landingpage.component.scss',
})
export class LandingpageComponent {
export class LandingpageComponent implements OnInit {
private seo = inject(SeoService);
ngOnInit(): void {
this.seo.updateMetadata({
title: 'Schnelle Webseiten für Handwerk & Vereine',
description: 'Spezialist für schnelle Webseiten ohne WordPress für Handwerk & Vereine. Wir bieten maßgeschneidertes Webdesign für eine schnelle und sichere Online-Präsenz.',
socialsDescription: 'Webdesign ohne WordPress | Hurler Webdesign Nördlingen',
type: 'website'
});
}
}

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
<p>button works!</p>

View File

@@ -1,11 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-button',
imports: [],
templateUrl: './button.component.html',
styleUrl: './button.component.scss',
})
export class ButtonComponent {
}

View File

@@ -0,0 +1,118 @@
# NavButtonComponent
Eine Angular Standalone-Komponente die ein `NavigationItem` rendert und je nach `NavigationType` automatisch den passenden Link-Typ erzeugt.
## Voraussetzungen
- Angular 17+
- `RouterModule` im Projekt eingebunden
## Dateien
```
button/
├── button.component.ts
├── button.component.html
└── button.component.scss
```
## Verwendung
Die Komponente ist standalone und wird direkt in den `imports` einer anderen Komponente oder eines Moduls eingebunden:
```typescript
import { NavButtonComponent } from './nav-button/nav-button.component';
@Component({
standalone: true,
imports: [NavButtonComponent],
// ...
})
export class AppComponent {}
```
## Inputs
| Input | Typ | Pflicht | Default | Beschreibung |
|-----------|--------------------------------------|---------|-------------|-------------------------------------|
| `item` | `NavigationItem` | ✅ | — | Das NavigationItem-Objekt |
| `size` | `'sm' \| 'md' \| 'lg'` | ❌ | `'md'` | Größe des Buttons |
| `variant` | `'primary' \| 'ghost' \| 'outline'` | ❌ | `'primary'` | Visueller Stil |
| `disabled`| `boolean` | ❌ | `false` | Deaktiviert den Button |
## NavigationItem
```typescript
export type NavigationType = "anchor" | "route" | "external";
export interface NavigationItem {
readonly label: string; // Anzeigetext
readonly type: NavigationType;
readonly target: string; // Pfad, Anchor (#id) oder URL
readonly icon?: string; // Optionales Icon (z. B. Emoji oder Icon-String)
readonly children?: NavigationItem[];
}
```
## Verhalten je NavigationType
| Typ | Verhalten |
|------------|----------------------------------------------------------------|
| `route` | Rendert `<a [routerLink]>` mit `routerLinkActive` |
| `anchor` | Rendert `<a href>` mit `scrollIntoView({ behavior: 'smooth'})` |
| `external` | Rendert `<a target="_blank" rel="noopener noreferrer">` mit `↗`|
## Beispiele
```html
<!-- Interner Routerlink -->
<app-nav-button
[item]="{ label: 'Home', type: 'route', target: '/home' }"
/>
<!-- Anchor Scroll -->
<app-nav-button
[item]="{ label: 'Über uns', type: 'anchor', target: '#about' }"
variant="outline"
/>
<!-- Externer Link -->
<app-nav-button
[item]="{ label: 'GitHub', type: 'external', target: 'https://github.com', icon: '🐙' }"
variant="ghost"
size="sm"
/>
<!-- Deaktiviert -->
<app-nav-button
[item]="{ label: 'Gesperrt', type: 'route', target: '/admin' }"
[disabled]="true"
/>
```
## Theming
Alle Farben und Abstände sind über CSS Custom Properties steuerbar. Überschreibe die Tokens global in deinem `styles.scss`:
```scss
:root {
--btn-primary-bg: #1a1a2e;
--btn-primary-color: #ffffff;
--btn-primary-hover-bg: #16213e;
--btn-outline-border: #1a1a2e;
--btn-outline-color: #1a1a2e;
--btn-outline-hover-bg: #1a1a2e;
--btn-outline-hover-color: #ffffff;
--btn-radius: 8px;
--btn-font: 'Your Font', sans-serif;
}
```
## Accessibility
- Aktiver Routerlink erhält die Klasse `nav-btn--active`
- Deaktivierte Links erhalten `aria-disabled="true"`
- Externe Links sind mit `aria-label="Opens in new tab"` gekennzeichnet
- Focus-Styles über `:focus-visible` vorhanden

View File

@@ -0,0 +1,60 @@
<!-- Router Link (internal navigation) -->
@if (isRouteType) {
@if (disabled) {
<button
[class]="hostClasses.join(' ')"
disabled
type="button"
aria-disabled="true"
>
@if (item.icon) {
<span class="nav-btn__icon" aria-hidden="true">{{ item.icon }}</span>
}
<span class="nav-btn__label">{{ item.label }}</span>
<span class="nav-btn__badge">Demnächst</span>
</button>
} @else {
<a
[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) -->
@if (isAnchorType) {
<a
[href]="item.target"
(click)="scrollToAnchor($event)"
[class]="hostClasses.join(' ')"
[attr.aria-disabled]="disabled || null"
>
@if (item.icon) {
<span class="nav-btn__icon" aria-hidden="true">{{ item.icon }}</span>
}
<span class="nav-btn__label">{{ item.label }}</span>
</a>
}
<!-- External Link -->
@if (isExternalType) {
<a
[href]="item.target"
target="_blank"
rel="noopener noreferrer"
[class]="hostClasses.join(' ')"
[attr.aria-disabled]="disabled || null"
>
@if (item.icon) {
<span class="nav-btn__icon" aria-hidden="true">{{ item.icon }}</span>
}
<span class="nav-btn__label">{{ item.label }}</span>
<span class="nav-btn__external-icon" aria-label="Opens in new tab"></span>
</a>
}

View File

@@ -0,0 +1,149 @@
// ─── Design Tokens ───────────────────────────────────────────────────────────
:host {
--btn-font: inherit;
--btn-radius: var(--border-radius, 8px);
--btn-transition: 160ms cubic-bezier(0.4, 0, 0.2, 1);
// Size tokens
--btn-sm-padding: 6px 16px;
--btn-md-padding: 10px 24px;
--btn-lg-padding: 14px 32px;
--btn-sm-font: 0.8rem;
--btn-md-font: 0.9rem;
--btn-lg-font: 1rem;
// Color tokens — use global theme CSS custom properties
--btn-primary-bg: var(--accent);
--btn-primary-color: var(--text-on-accent);
--btn-primary-border: var(--accent);
--btn-primary-hover-bg: var(--accent-hover);
--btn-ghost-bg: transparent;
--btn-ghost-color: var(--text-main);
--btn-ghost-border: transparent;
--btn-ghost-hover-bg: oklch(from var(--accent) l c h / 0.1);
--btn-outline-bg: transparent;
--btn-outline-color: var(--accent);
--btn-outline-border: var(--accent);
--btn-outline-hover-bg: var(--accent);
--btn-outline-hover-color: var(--text-on-accent);
display: inline-block;
}
// ─── Base ─────────────────────────────────────────────────────────────────────
.nav-btn {
display: inline-flex;
align-items: center;
gap: 8px;
font-family: var(--btn-font);
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
text-decoration: none;
border: 1.5px solid;
border-radius: var(--btn-radius);
cursor: pointer;
white-space: nowrap;
outline-offset: 3px;
transition:
background-color var(--btn-transition),
color var(--btn-transition),
border-color var(--btn-transition),
box-shadow var(--btn-transition),
transform var(--btn-transition);
&:focus-visible {
outline: 2px solid currentColor;
}
&:active:not(.nav-btn--disabled) {
transform: translateY(1px);
}
// ── Sizes ──────────────────────────────────────────────────────────────────
&--sm {
padding: var(--btn-sm-padding);
font-size: var(--btn-sm-font);
}
&--md {
padding: var(--btn-md-padding);
font-size: var(--btn-md-font);
}
&--lg {
padding: var(--btn-lg-padding);
font-size: var(--btn-lg-font);
}
// ── Variants ───────────────────────────────────────────────────────────────
&--primary {
background-color: var(--btn-primary-bg);
color: var(--btn-primary-color);
border-color: var(--btn-primary-border);
&:hover:not(.nav-btn--disabled) {
background-color: var(--btn-primary-hover-bg);
}
}
&--ghost {
background-color: var(--btn-ghost-bg);
color: var(--btn-ghost-color);
border-color: var(--btn-ghost-border);
&:hover:not(.nav-btn--disabled) {
background-color: var(--btn-ghost-hover-bg);
}
}
&--outline {
background-color: var(--btn-outline-bg);
color: var(--btn-outline-color);
border-color: var(--btn-outline-border);
&:hover:not(.nav-btn--disabled) {
background-color: var(--btn-outline-hover-bg);
color: var(--btn-outline-hover-color);
}
}
// ── States ─────────────────────────────────────────────────────────────────
&--active {
box-shadow: inset 0 0 0 1.5px currentColor;
}
&--disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
// ── Parts ──────────────────────────────────────────────────────────────────
&__icon {
font-size: 1.1em;
line-height: 1;
}
&__label {
line-height: 1;
}
&__external-icon {
font-size: 0.85em;
opacity: 0.65;
margin-left: -2px;
}
&__badge {
font-size: 0.65em;
padding: 2px 6px;
border-radius: 999px;
background-color: oklch(from currentColor l c h / 0.15);
letter-spacing: 0;
text-transform: none;
font-weight: 500;
}
}

View File

@@ -0,0 +1,55 @@
import {
Component,
Input,
OnInit,
ChangeDetectionStrategy,
} from '@angular/core';
import { RouterModule } from '@angular/router';
import { NavigationItem, isAnchor, isRoute } from '@core/models/navigation.model';
@Component({
selector: 'app-button',
standalone: true,
imports: [RouterModule],
templateUrl: './button.component.html',
styleUrls: ['./button.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ButtonComponent implements OnInit {
@Input({ required: true }) item!: NavigationItem;
/** Optional size variant */
@Input() size: 'sm' | 'md' | 'lg' = 'md';
/** Optional visual variant */
@Input() variant: 'primary' | 'ghost' | 'outline' = 'primary';
/** Whether the button is disabled */
@Input() disabled = false;
isAnchorType = false;
isRouteType = false;
isExternalType = false;
ngOnInit(): void {
this.isAnchorType = isAnchor(this.item);
this.isRouteType = isRoute(this.item);
this.isExternalType = this.item.type === 'external';
}
/** Scroll to anchor target */
scrollToAnchor(event: Event): void {
event.preventDefault();
const target = document.querySelector(this.item.target);
target?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
get hostClasses(): string[] {
return [
'nav-btn',
`nav-btn--${this.variant}`,
`nav-btn--${this.size}`,
this.disabled ? 'nav-btn--disabled' : '',
].filter(Boolean);
}
}

View File

@@ -0,0 +1,28 @@
<nav class="navigation">
<ul class="navigation__list">
@for (item of items(); track item.target) {
<li class="navigation__list-item">
<!-- Anchor-Link: Smooth Scroll -->
@if (isAnchor(item)) {
<a [href]="'#' + item.target" (click)="onAnchorClick($event, item)" opTrack="links_nav_menu"
[opTrackProps]="{location: `${item.target}`}">
@if (item.icon) {
<ng-icon [name]="item.icon"></ng-icon>
}
{{ item.label }}
</a>
}
<!-- Route-Link: Angular Router -->
@else if (isRoute(item)) {
<a [routerLink]="item.target" opTrack="links_nav_menu" [opTrackProps]="{location: `${item.target}`}">
@if (item.icon) {
<ng-icon [name]="item.icon"></ng-icon>
}
{{ item.label }}
</a>
}
</li>
}
</ul>
</nav>

View File

@@ -0,0 +1,36 @@
@use "abstracts";
.navigation {
display: none;
@include abstracts.breakpoint("md") {
display: block;
padding-right: var(--space-4);
}
&__list {
display: flex;
gap: var(--space-4);
}
&__list-item {
list-style: none;
font-weight: 500;
font-size: var(--font-size-base);
color: var(--text-main);
transition: color 0.3s ease;
display: flex;
align-items: center;
a {
color: var(--accent);
display: inline-flex;
align-items: center;
gap: var(--space-1);
&:hover {
color: var(--accent-hover);
}
}
}
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NavMenuComponent } from './nav-menu.component';
describe('NavMenuComponent', () => {
let component: NavMenuComponent;
let fixture: ComponentFixture<NavMenuComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NavMenuComponent]
})
.compileComponents();
fixture = TestBed.createComponent(NavMenuComponent);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,25 @@
import { Component, inject, input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { NgIcon } from '@ng-icons/core';
import { NavigationService } from '@core/services/navigation.service';
import {NavigationItem, isAnchor, isRoute} from '@core/models/navigation.model';
import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive';
@Component({
selector: 'app-nav-menu',
imports: [RouterLink, NgIcon, OpenPanelTrackDirective],
templateUrl: './nav-menu.component.html',
styleUrl: './nav-menu.component.scss',
})
export class NavMenuComponent {
protected readonly navigationService = inject(NavigationService);
items = input<NavigationItem[]>([]);
protected readonly isAnchor = isAnchor;
protected readonly isRoute = isRoute;
onAnchorClick(event: Event, item: NavigationItem): void {
event.preventDefault();
this.navigationService.navigate(item);
}
}

View File

@@ -0,0 +1,3 @@
<ng-icon name="cssSun" (click)="toggleTheme()" class="theme-icon-sun"></ng-icon>
<ng-icon name="cssMoon" (click)="toggleTheme()" class="theme-icon-moon"></ng-icon>

View File

@@ -0,0 +1,16 @@
.theme-icon-moon,
.theme-icon-sun {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
color: var(--accent);
}
:host-context([data-theme='dark']) .theme-icon-sun {
display: none;
}
:host-context([data-theme='light']) .theme-icon-moon {
display: none;
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ToogleThemeComponent } from './toogle-theme.component';
describe('ToogleThemeComponent', () => {
let component: ToogleThemeComponent;
let fixture: ComponentFixture<ToogleThemeComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ToogleThemeComponent]
})
.compileComponents();
fixture = TestBed.createComponent(ToogleThemeComponent);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

Some files were not shown because too many files have changed in this diff Show More