Compare commits
13 Commits
b6ca9be225
...
landingpag
| Author | SHA1 | Date | |
|---|---|---|---|
| 012636ec35 | |||
| 11e2553549 | |||
| cd694d0776 | |||
| 5138005397 | |||
| ff70a0b4a5 | |||
| 11a266879b | |||
| 5cbe4525d1 | |||
| 1294f0f07c | |||
| 79c71fcf45 | |||
| ce8cb07834 | |||
| 5622fc211f | |||
| 8cbefe7078 | |||
| 2fdeb41c25 |
23
.continue/agents/new-config.yaml
Normal file
23
.continue/agents/new-config.yaml
Normal 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
|
||||
10
.continue/mcpServers/new-mcp-server.yaml
Normal file
10
.continue/mcpServers/new-mcp-server.yaml
Normal 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: {}
|
||||
61
CLAUDE.md
Normal file
61
CLAUDE.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm start # Dev server at http://localhost:4200
|
||||
npm run build # Production build (SSR)
|
||||
npm test # Run unit tests with Vitest
|
||||
ng generate component features/my-feature/components/my-comp # Scaffold component (SCSS, type=component)
|
||||
node dist/hurler-webdesign-saas/server/server.mjs # Run SSR server after build
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Angular 21 app with SSR (`@angular/ssr`) and Vitest for testing. The backend is a **Directus CMS** at `https://backend.hurler-webdesign.de` — all blog content is fetched via `BlogService` using Angular's `HttpClient`.
|
||||
|
||||
### Folder structure
|
||||
|
||||
```
|
||||
src/app/
|
||||
core/ # Services, models, directives — singleton, app-wide
|
||||
features/ # Route-level modules (landing, blog, dashboard)
|
||||
landing/ # Landingpage with Navigation, Hero, Features, Pricing, Projects, Footer
|
||||
blog/ # BlogList + BlogDetail pages, fetched from Directus
|
||||
shared/ # Reusable UI components (Button, NavMenu, ToggleTheme)
|
||||
src/environments/ # Config constants (Directus URL, OpenPanel credentials)
|
||||
src/styles/ # Global SCSS: abstracts (functions, mixins), base (tokens, typography), layout
|
||||
```
|
||||
|
||||
### Path aliases (tsconfig)
|
||||
|
||||
| Alias | Resolves to |
|
||||
|---|---|
|
||||
| `@core/*` | `src/app/core/*` |
|
||||
| `@features/*` | `src/app/features/*` |
|
||||
| `@shared/*` | `src/app/shared/*` |
|
||||
|
||||
Routes use lazy-loaded components via `loadComponent`.
|
||||
|
||||
### Styling conventions
|
||||
|
||||
- **SCSS** everywhere; `src/styles/abstracts/` is globally included via `stylePreprocessorOptions.includePaths`
|
||||
- Use `@use 'abstracts'` to access mixins/functions
|
||||
- Design tokens live in `src/styles/base/_tokens.scss` as CSS custom properties (OKLCH color space, fluid typography with `clamp()`)
|
||||
- Dark mode via `[data-theme="dark"]` attribute on `<html>`, managed by `ThemeService` (uses Angular Signals + `localStorage`)
|
||||
- Breakpoints: `sm` (400px), `md` (700px), `lg` (1200px) — use `@include breakpoint('md')` mixin
|
||||
- Max content width: 1200px — use `@mixin container-wrapper`
|
||||
|
||||
### Analytics
|
||||
|
||||
OpenPanel is configured in `src/environments/openpanel.ts` and provided app-wide via `provideOpenPanel()` in `app.config.ts`. The `OpenpanelDirective` (`@core/directives`) enables declarative event tracking.
|
||||
|
||||
### Angular patterns used
|
||||
|
||||
- Standalone components throughout (no NgModules)
|
||||
- `inject()` function instead of constructor injection
|
||||
- Angular Signals for reactive state (`ThemeService`, etc.)
|
||||
- `provideHttpClient(withFetch())` + `provideClientHydration(withEventReplay())` for SSR hydration
|
||||
- German locale (`de`) registered and set as `LOCALE_ID`
|
||||
10
angular.json
10
angular.json
@@ -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
2351
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -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"
|
||||
|
||||
BIN
public/images/backgroundpattern.webp
Normal file
BIN
public/images/backgroundpattern.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
BIN
public/images/bakery.jpg
Normal file
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
13
public/images/text1.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
width="244.1156"
|
||||
height="183.29309"
|
||||
viewBox="0 0 64.588919 48.496297"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<g>
|
||||
<path
|
||||
d="m 64.423989,1.7255188 q -0.05975,0.1211937 -0.896238,1.9390997 -0.836489,1.817906 -2.150973,4.6659588 -1.314483,2.8480527 -2.808214,6.0596867 -1.49373,3.211634 -2.867963,6.120284 1.135236,0.0606 1.971725,0 0.836489,-0.121194 1.254734,-0.424178 0.477994,-0.363581 0.77674,-0.363581 0.298746,0 0.298746,0.302984 0,0.424178 -0.657242,1.030147 -0.597492,0.545372 -1.792477,1.211937 -1.194984,0.605969 -2.927712,0.666566 -1.015737,2.060293 -2.389969,4.908346 -1.374233,2.787456 -2.628967,5.99909 -0.238997,0.605968 -0.477994,1.393728 -0.179247,0.787759 -0.179247,1.514921 0,0.848356 0.358495,1.454325 0.418245,0.605969 1.374232,0.605969 1.792477,0 3.584955,-1.211938 1.852226,-1.272534 3.644703,-3.211634 1.792477,-1.999696 3.465455,-4.24178 0.298747,-0.363581 0.537744,-0.363581 0.298746,0 0.298746,0.484774 0,0.363582 -0.238997,0.666566 -1.732728,2.302681 -3.584954,4.484168 -1.852227,2.12089 -3.883701,3.454022 -2.031474,1.333131 -4.361694,1.333131 -2.449718,0 -3.525204,-1.211938 -1.015737,-1.272534 -1.015737,-3.211634 0,-0.908953 0.179247,-1.878503 0.238997,-1.030146 0.537743,-2.060293 0.77674,-2.423875 1.971725,-4.847749 1.194985,-2.484472 2.031474,-4.181184 -1.015737,-0.121194 -2.808214,-0.424178 -1.792477,-0.302985 -3.8837,-0.545372 -2.091223,-0.242387 -4.182447,-0.181791 -1.493731,3.635812 -3.584954,7.514012 -2.091223,3.817602 -4.779938,7.332221 -2.628968,3.514618 -5.915176,6.302074 -3.226459,2.787456 -7.110159,4.302377 -3.8837,1.575519 -8.4246419,1.393728 -2.6289663,-0.121193 -4.839688,-1.454324 -2.2107217,-1.272535 -3.525205,-3.635812 -1.25473392,-2.363278 -1.25473392,-5.574912 0,-1.514922 0.29874618,-3.09044 0.29874617,-1.636116 0.95598774,-3.393425 1.3144833,-3.454021 3.8239511,-6.241477 2.5094679,-2.848053 5.6164278,-4.484168 3.10696,-1.696713 6.094422,-1.696713 2.628967,0 4.481193,1.75731 1.911975,1.757309 1.911975,5.090136 0,1.575519 -0.537743,3.514619 -1.075486,3.696408 -3.10696,5.696105 -2.031474,1.999697 -4.062948,2.848053 -1.493731,0.545372 -2.867963,0.545372 -1.075486,0 -1.852226,-0.363582 -0.716991,-0.363581 -0.8364898,-1.030146 v -0.181791 q 0,-0.605969 0.4779938,-0.605969 0.597492,0 0.657242,0.727163 0.05975,0.18179 0.418244,0.424178 0.418245,0.18179 1.135236,0.18179 0.477994,0 1.015737,-0.121193 0.597492,-0.121194 1.194984,-0.363581 1.553481,-0.666566 3.16671,-2.545069 1.672978,-1.878503 2.628966,-5.21133 0.597493,-1.999697 0.597493,-3.757006 0,-2.787456 -1.374233,-4.181184 -1.374232,-1.393728 -3.226458,-1.393728 -2.808215,0 -5.317682,1.757309 -2.449719,1.757309 -4.3019452,4.484168 -1.7924771,2.726859 -2.8679633,5.696106 -0.6572416,1.817906 -0.9559878,3.514618 -0.2987461,1.696712 -0.2987461,3.211634 0,3.999393 1.9717247,6.423268 1.9717247,2.423875 5.3774307,2.423875 2.987462,0 5.795676,-1.454325 2.808214,-1.393728 5.377431,-3.757006 2.569217,-2.302681 4.779939,-5.090137 2.270471,-2.848052 4.062949,-5.696105 1.852227,-2.90865 3.226459,-5.393121 1.374232,-2.484472 2.210722,-4.120587 -2.628967,0.424178 -4.540942,1.15134 -1.911977,0.666566 -3.047212,3.151037 -0.119499,0.302985 -0.537744,0.666566 -0.358495,0.302984 -0.657241,0.363581 -0.05975,0 -0.119499,0.0606 0,0 -0.05975,0 -0.418244,0 -0.418244,-0.484775 0,-0.424178 0.477993,-1.393728 1.792477,-3.029843 4.540943,-4.484168 2.748465,-1.514922 5.795676,-1.9391 1.015737,-1.9391 2.091223,-4.120587 1.075487,-2.181487 2.38997,-4.362974 1.194984,-2.120891 2.569217,-4.2417813 1.374232,-2.1814872 2.748465,-3.8176026 -0.597493,-0.060597 -1.194985,-0.060597 -0.597492,-0.060597 -1.254734,-0.060597 -3.166709,0 -6.333419,0.7877593 -3.10696,0.7877593 -5.43718,2.6056653 -2.270471,1.7573091 -2.987463,4.8477493 -0.05975,0.242388 -0.119499,0.545372 0,0.242388 0,0.545372 0,1.514922 0.89624,2.484472 0.955988,0.96955 2.270471,0.96955 1.194985,0 2.210722,-0.908953 1.075486,-0.908953 1.732728,-2.181488 0.657241,-1.272534 0.657241,-2.2420843 0,-0.7271624 0.418245,-0.7271624 0.358495,0 0.358495,0.6059687 0,2.060294 -0.955988,3.454022 -0.896238,1.333131 -2.27047,1.999697 -1.374233,0.666565 -2.867964,0.666565 -2.031474,0 -3.644704,-1.333131 -1.55348,-1.333131 -1.254734,-3.757006 0.358495,-2.848053 2.150972,-4.665959 1.852228,-1.817906 4.421445,-2.7874559 2.569217,-1.0301467 5.377431,-1.3937279 2.808214,-0.3635812 5.138434,-0.3635812 1.314483,0 2.38997,0.1211937 1.075486,0.060597 1.672978,0.1817906 0.179248,0.060597 0.179248,0.3029844 0,0.3029843 -0.298746,0.7877592 -0.238997,0.484775 -0.358496,0.6059687 -2.210721,3.6964089 -3.823951,7.5140114 -1.613229,3.817603 -3.345957,7.816997 1.732728,0.0606 3.823951,0.424178 2.150973,0.302984 4.062948,0.666565 1.911976,0.302984 2.987462,0.484775 1.971724,-3.635812 3.823951,-6.96864 1.852226,-3.393424 3.345957,-6.1202838 1.493731,-2.726859 2.389969,-4.3629744 0.83649,-1.5149217 1.55348,-2.18148716 0.716991,-0.7271624 1.075487,-0.78775927 h 0.238997 q 0.358495,0 0.477993,0.30298433 0.179248,0.24238747 0.179248,0.5453718 0,0.4847749 -0.179248,0.8483561 z"
|
||||
class="logo_h"
|
||||
aria-label="H" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/video/black mit white stripes.webm
Normal file
BIN
public/video/black mit white stripes.webm
Normal file
Binary file not shown.
BIN
public/video/dark mit blue stripes.webm
Normal file
BIN
public/video/dark mit blue stripes.webm
Normal file
Binary file not shown.
BIN
public/video/türkis motherboard.webm
Normal file
BIN
public/video/türkis motherboard.webm
Normal file
Binary file not shown.
BIN
public/video/white_mit_black_stripes.webm
Normal file
BIN
public/video/white_mit_black_stripes.webm
Normal file
Binary file not shown.
@@ -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),
|
||||
provideHttpClient(withFetch()),
|
||||
provideClientHydration(withEventReplay()),
|
||||
provideNgIconsConfig({})
|
||||
provideNgIconsConfig({}),
|
||||
provideOpenPanel({
|
||||
clientId: environment.openPanel.clientId,
|
||||
apiUrl: environment.openPanel.apiUrl,
|
||||
trackScreenViews: true,
|
||||
debug: !environment.production,
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
58
src/app/core/directives/openpanel.directive.ts
Normal file
58
src/app/core/directives/openpanel.directive.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/app/core/models/blog-posts.model.ts
Normal file
24
src/app/core/models/blog-posts.model.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// src/app/blog/models/blog-post.model.ts
|
||||
|
||||
export interface Tag {
|
||||
tags_id: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BlogPost {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
summary: string;
|
||||
content: string;
|
||||
cover_image: string | null;
|
||||
published_at: string;
|
||||
status: 'Entwurf' | 'Veröffentlicht' | 'Archiviert';
|
||||
tags: Tag[];
|
||||
}
|
||||
|
||||
export interface DirectusResponse<T> {
|
||||
data: T;
|
||||
}
|
||||
12
src/app/core/models/navigation.model.ts
Normal file
12
src/app/core/models/navigation.model.ts
Normal 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";
|
||||
35
src/app/core/models/openpanel.model.ts
Normal file
35
src/app/core/models/openpanel.model.ts
Normal 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');
|
||||
8
src/app/core/models/project.model.ts
Normal file
8
src/app/core/models/project.model.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
client: string;
|
||||
description: string;
|
||||
socialDescription?: string;
|
||||
image: string;
|
||||
}
|
||||
7
src/app/core/models/seo.model.ts
Normal file
7
src/app/core/models/seo.model.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface SeoData {
|
||||
title: string;
|
||||
description: string;
|
||||
socialsDescription?: string;
|
||||
image?: string;
|
||||
type?: "website" | "article";
|
||||
}
|
||||
31
src/app/core/provider/openpanel.provider.ts
Normal file
31
src/app/core/provider/openpanel.provider.ts
Normal 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,
|
||||
]);
|
||||
}
|
||||
16
src/app/core/services/blog.service.spec.ts
Normal file
16
src/app/core/services/blog.service.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { BlogService } from './blog.service';
|
||||
|
||||
describe('BlogService', () => {
|
||||
let service: BlogService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(BlogService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
52
src/app/core/services/blog.service.ts
Normal file
52
src/app/core/services/blog.service.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// src/app/blog/services/blog.service.ts
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, map } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment.directus';
|
||||
import { BlogPost, DirectusResponse } from '@core/models/blog-posts.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BlogService {
|
||||
private http = inject(HttpClient);
|
||||
private baseUrl = environment.directusUrl;
|
||||
|
||||
private readonly defaultFields = [
|
||||
'id',
|
||||
'title',
|
||||
'slug',
|
||||
'summary',
|
||||
'cover_image',
|
||||
'published_at',
|
||||
'tags.tags_id.id',
|
||||
'tags.tags_id.name'
|
||||
].join(',');
|
||||
|
||||
getPosts(): Observable<BlogPost[]> {
|
||||
const params = new HttpParams()
|
||||
.set('sort', '-published_at')
|
||||
.set('fields', this.defaultFields)
|
||||
.set('filter', JSON.stringify({ status: { _eq: 'published' } }));
|
||||
|
||||
return this.http
|
||||
.get<DirectusResponse<BlogPost[]>>(`${this.baseUrl}/items/blog_posts`, { params })
|
||||
.pipe(map(res => res.data));
|
||||
}
|
||||
|
||||
getPostBySlug(slug: string): Observable<BlogPost | null> {
|
||||
const params = new HttpParams()
|
||||
.set('filter', JSON.stringify({ slug: { _eq: slug }, status: { _eq: 'published' } }))
|
||||
.set('fields', `${this.defaultFields},content`);
|
||||
|
||||
return this.http
|
||||
.get<DirectusResponse<BlogPost[]>>(`${this.baseUrl}/items/blog_posts`, { params })
|
||||
.pipe(map(res => res.data[0] ?? null));
|
||||
}
|
||||
|
||||
getAssetUrl(assetId: string, params?: { width?: number; height?: number; quality?: number }): string {
|
||||
const url = new URL(`${this.baseUrl}/assets/${assetId}`);
|
||||
if (params?.width) url.searchParams.set('width', String(params.width));
|
||||
if (params?.height) url.searchParams.set('height', String(params.height));
|
||||
if (params?.quality) url.searchParams.set('quality', String(params.quality));
|
||||
return url.toString();
|
||||
}
|
||||
}
|
||||
16
src/app/core/services/navigation.service.spec.ts
Normal file
16
src/app/core/services/navigation.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
75
src/app/core/services/navigation.service.ts
Normal file
75
src/app/core/services/navigation.service.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
16
src/app/core/services/openpanel.service.spec.ts
Normal file
16
src/app/core/services/openpanel.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
150
src/app/core/services/openpanel.service.ts
Normal file
150
src/app/core/services/openpanel.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
16
src/app/core/services/seo.service.spec.ts
Normal file
16
src/app/core/services/seo.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
136
src/app/core/services/seo.service.ts
Normal file
136
src/app/core/services/seo.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
16
src/app/core/services/theme.service.spec.ts
Normal file
16
src/app/core/services/theme.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
55
src/app/core/services/theme.service.ts
Normal file
55
src/app/core/services/theme.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/app/core/services/umami.service.spec.ts
Normal file
16
src/app/core/services/umami.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
42
src/app/core/services/umami.service.ts
Normal file
42
src/app/core/services/umami.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<div class="blog-nav">
|
||||
<div class="blog-nav__inner">
|
||||
<a routerLink="/" class="blog-nav__logo">
|
||||
<span class="blog-nav__logo-icon">H</span>
|
||||
<span><span class="blog-nav__logo-accent">Hurler</span> Webdesign</span>
|
||||
</a>
|
||||
|
||||
<div class="blog-nav__actions">
|
||||
@if (showBack) {
|
||||
<a routerLink="/blog" class="blog-nav__back">
|
||||
← Alle Artikel
|
||||
</a>
|
||||
} @else {
|
||||
<a routerLink="/" class="blog-nav__back">
|
||||
← Zurück zur Startseite
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,57 @@
|
||||
@use 'abstracts';
|
||||
|
||||
.blog-nav {
|
||||
height: var(--nav-height);
|
||||
background-color: var(--nav-bg);
|
||||
backdrop-filter: var(--nav-backdrop);
|
||||
box-shadow: var(--nav-shadow);
|
||||
border-radius: 0 0 10px 10px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-index-sticky);
|
||||
|
||||
&__inner {
|
||||
@include abstracts.container-wrapper;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
&__logo-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: abstracts.rem(30);
|
||||
height: abstracts.rem(30);
|
||||
background-color: var(--accent);
|
||||
color: var(--text-on-accent);
|
||||
border-radius: 5px;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__logo-accent {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
&__back {
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-muted);
|
||||
transition: color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-blog-nav',
|
||||
imports: [RouterModule],
|
||||
templateUrl: './blog-nav.component.html',
|
||||
styleUrl: './blog-nav.component.scss',
|
||||
})
|
||||
export class BlogNavComponent {
|
||||
@Input() showBack = false;
|
||||
}
|
||||
2
src/app/features/blog/index.ts
Normal file
2
src/app/features/blog/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { BlogListComponent } from './pages/blog-list/blog-list.component';
|
||||
export { BlogDetailComponent } from './pages/blog-detail/blog-detail.component';
|
||||
@@ -0,0 +1,87 @@
|
||||
<app-blog-nav [showBack]="true"></app-blog-nav>
|
||||
|
||||
<main class="blog-detail">
|
||||
|
||||
@if (loading()) {
|
||||
<div class="blog-detail__skeleton">
|
||||
<div class="blog-detail__cover-skeleton"></div>
|
||||
<div class="blog-detail__wrapper">
|
||||
<div class="skeleton-line skeleton-line--tag"></div>
|
||||
<div class="skeleton-line skeleton-line--title"></div>
|
||||
<div class="skeleton-line skeleton-line--meta"></div>
|
||||
<div class="skeleton-line skeleton-line--body"></div>
|
||||
<div class="skeleton-line skeleton-line--body"></div>
|
||||
<div class="skeleton-line skeleton-line--body skeleton-line--short"></div>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (notFound()) {
|
||||
<div class="blog-detail__wrapper blog-detail__not-found">
|
||||
<h1>Artikel nicht gefunden</h1>
|
||||
<p class="text-muted">Dieser Artikel existiert nicht oder wurde entfernt.</p>
|
||||
</div>
|
||||
} @else if (post(); as post) {
|
||||
|
||||
@if (getCoverUrl(post); as coverUrl) {
|
||||
<div class="blog-detail__cover">
|
||||
<img [src]="coverUrl" [alt]="post.title" />
|
||||
<div class="blog-detail__cover-overlay"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="blog-detail__wrapper">
|
||||
|
||||
<header class="blog-detail__header">
|
||||
@if (post.tags.length > 0) {
|
||||
<div class="blog-detail__tags">
|
||||
@for (tag of post.tags; track tag.tags_id.id) {
|
||||
<span class="blog-detail__tag">{{ tag.tags_id.name }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<h1 class="blog-detail__title">{{ post.title }}</h1>
|
||||
|
||||
<div class="blog-detail__meta">
|
||||
<time class="blog-detail__date" [dateTime]="post.published_at">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>
|
||||
{{ post.published_at | date:'d. MMMM yyyy':'':'de' }}
|
||||
</time>
|
||||
<span class="blog-detail__read-time">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<polyline points="12 6 12 12 16 14"></polyline>
|
||||
</svg>
|
||||
5 Min. Lesezeit
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="blog-detail__summary">{{ post.summary }}</p>
|
||||
</header>
|
||||
|
||||
<article class="blog-detail__content" [innerHTML]="parsedContent()"></article>
|
||||
|
||||
<footer class="blog-detail__footer">
|
||||
<div class="blog-detail__share">
|
||||
<span class="blog-detail__share-label">Artikel teilen:</span>
|
||||
<a href="#" class="blog-detail__share-btn" aria-label="Auf Twitter teilen">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="#" class="blog-detail__share-btn" aria-label="Auf LinkedIn teilen">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
</main>
|
||||
@@ -0,0 +1,294 @@
|
||||
@use 'abstracts';
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
from {
|
||||
background-position: -400px 0;
|
||||
}
|
||||
|
||||
to {
|
||||
background-position: 400px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.blog-detail {
|
||||
min-height: calc(100vh - var(--nav-height));
|
||||
padding-bottom: calc(var(--space-4) * 3);
|
||||
|
||||
&__wrapper {
|
||||
@include abstracts.container-wrapper;
|
||||
max-width: 740px;
|
||||
}
|
||||
|
||||
&__cover {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: clamp(300px, 50vh, 500px);
|
||||
overflow: hidden;
|
||||
margin-bottom: calc(var(--space-4) * 1.5);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&__cover-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 50%;
|
||||
background: linear-gradient(to top, var(--bg-surface) 0%, transparent 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: calc(var(--space-4) * 1.5);
|
||||
}
|
||||
|
||||
&__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
&__tag {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, oklch(from var(--accent) l c h / 0.12) 0%, oklch(from var(--accent) l c h / 0.08) 100%);
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: clamp(var(--font-size-xl), 4vw, 3rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__date,
|
||||
&__read-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
|
||||
svg {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
&__summary {
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.65;
|
||||
font-weight: 400;
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
&__content {
|
||||
font-size: var(--font-size-base);
|
||||
line-height: 1.8;
|
||||
color: var(--text-main);
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 700;
|
||||
margin-top: calc(var(--space-4) * 1.5);
|
||||
margin-bottom: var(--space-3);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 700;
|
||||
margin-top: var(--space-4);
|
||||
margin-bottom: var(--space-2);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding-left: var(--space-4);
|
||||
margin-bottom: var(--space-3);
|
||||
|
||||
li {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 3px solid var(--accent);
|
||||
padding-left: var(--space-4);
|
||||
margin-inline: 0;
|
||||
margin-block: var(--space-4);
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 0.9em;
|
||||
background-color: var(--bg-muted);
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: var(--bg-muted);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--space-4);
|
||||
overflow-x: auto;
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
border-radius: var(--border-radius);
|
||||
margin-block: var(--space-4);
|
||||
box-shadow: 0 4px 20px oklch(0% 0 0 / 0.08);
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-block: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
margin-top: calc(var(--space-4) * 2);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
&__share {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
&__share-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
&__share-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--bg-muted);
|
||||
color: var(--text-main);
|
||||
transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--accent);
|
||||
color: var(--text-on-accent);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
&__not-found {
|
||||
padding-top: calc(var(--space-4) * 2);
|
||||
text-align: center;
|
||||
|
||||
h1 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
&__cover-skeleton {
|
||||
width: 100%;
|
||||
height: clamp(300px, 50vh, 500px);
|
||||
background-color: var(--bg-muted);
|
||||
margin-bottom: calc(var(--space-4) * 1.5);
|
||||
}
|
||||
|
||||
&__skeleton .blog-detail__wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg,
|
||||
var(--bg-muted) 25%,
|
||||
var(--border-color) 50%,
|
||||
var(--bg-muted) 75%);
|
||||
background-size: 800px 100%;
|
||||
animation: skeleton-shimmer 1.4s ease-in-out infinite;
|
||||
|
||||
&--tag {
|
||||
height: 1.2em;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
&--title {
|
||||
height: 2em;
|
||||
width: 75%;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
&--meta {
|
||||
height: 0.9em;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
&--body {
|
||||
height: 1em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&--short {
|
||||
width: 60%;
|
||||
}
|
||||
}
|
||||
101
src/app/features/blog/pages/blog-detail/blog-detail.component.ts
Normal file
101
src/app/features/blog/pages/blog-detail/blog-detail.component.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { marked, Renderer } from 'marked';
|
||||
import { createHighlighter, Highlighter } from 'shiki';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { BlogService } from '@core/services/blog.service';
|
||||
import { SeoService } from '@core/services/seo.service';
|
||||
import { OpenPanelService } from '@core/services/openpanel.service';
|
||||
import { BlogPost } from '@core/models/blog-posts.model';
|
||||
import { BlogNavComponent } from '../../components/blog-nav/blog-nav.component';
|
||||
|
||||
const SHIKI_LANGS = ['html', 'css', 'scss', 'javascript', 'typescript', 'sql', 'python'] as const;
|
||||
const SHIKI_THEME = 'ayu-light';
|
||||
|
||||
@Component({
|
||||
selector: 'app-blog-detail',
|
||||
imports: [DatePipe, BlogNavComponent],
|
||||
templateUrl: './blog-detail.component.html',
|
||||
styleUrl: './blog-detail.component.scss',
|
||||
})
|
||||
export class BlogDetailComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly blogService = inject(BlogService);
|
||||
private readonly seo = inject(SeoService);
|
||||
private readonly op = inject(OpenPanelService);
|
||||
private readonly sanitizer = inject(DomSanitizer);
|
||||
|
||||
post = signal<BlogPost | null>(null);
|
||||
loading = signal(true);
|
||||
notFound = signal(false);
|
||||
parsedContent = signal<SafeHtml>('');
|
||||
|
||||
private highlighter: Highlighter | null = null;
|
||||
|
||||
private async getHighlighter(): Promise<Highlighter> {
|
||||
if (!this.highlighter) {
|
||||
this.highlighter = await createHighlighter({
|
||||
themes: [SHIKI_THEME],
|
||||
langs: [...SHIKI_LANGS],
|
||||
});
|
||||
}
|
||||
return this.highlighter;
|
||||
}
|
||||
|
||||
private async parseContent(content: string): Promise<SafeHtml> {
|
||||
const highlighter = await this.getHighlighter();
|
||||
const loadedLangs = highlighter.getLoadedLanguages();
|
||||
|
||||
const renderer = new Renderer();
|
||||
renderer.code = ({ text, lang }) => {
|
||||
const language = lang && loadedLangs.includes(lang as any) ? lang : 'text';
|
||||
return highlighter.codeToHtml(text, { lang: language, theme: SHIKI_THEME });
|
||||
};
|
||||
|
||||
marked.use({ renderer });
|
||||
const html = await marked(content);
|
||||
return this.sanitizer.bypassSecurityTrustHtml(html);
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const slug = this.route.snapshot.paramMap.get('slug') ?? '';
|
||||
|
||||
this.blogService.getPostBySlug(slug).subscribe({
|
||||
next: async (post) => {
|
||||
if (!post) {
|
||||
this.notFound.set(true);
|
||||
this.loading.set(false);
|
||||
return;
|
||||
}
|
||||
this.post.set(post);
|
||||
|
||||
if (post.content) {
|
||||
this.parsedContent.set(await this.parseContent(post.content));
|
||||
}
|
||||
|
||||
this.loading.set(false);
|
||||
|
||||
this.seo.updateMetadata({
|
||||
title: post.title,
|
||||
description: post.summary,
|
||||
image: post.cover_image
|
||||
? this.blogService.getAssetUrl(post.cover_image, { width: 1200, quality: 85 })
|
||||
: undefined,
|
||||
type: 'article',
|
||||
});
|
||||
|
||||
this.op.track('blog_post_view', { slug, title: post.title });
|
||||
},
|
||||
error: () => {
|
||||
this.notFound.set(true);
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getCoverUrl(post: BlogPost): string | null {
|
||||
if (!post.cover_image) return null;
|
||||
return this.blogService.getAssetUrl(post.cover_image, { width: 1200, quality: 85 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<app-blog-nav></app-blog-nav>
|
||||
|
||||
<main class="blog-list">
|
||||
<div class="blog-list__wrapper">
|
||||
|
||||
<header class="blog-list__header">
|
||||
<span class="blog-list__label">Wissen & Insights</span>
|
||||
<h1>Blog</h1>
|
||||
<p class="text-muted">Tipps, Hintergründe und Best Practices rund um Webdesign & digitale Präsenz.</p>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="blog-list__grid">
|
||||
@for (_ of [1, 2, 3]; track $index) {
|
||||
<div class="blog-card blog-card--skeleton">
|
||||
<div class="blog-card__image blog-card__image--skeleton"></div>
|
||||
<div class="blog-card__body">
|
||||
<div class="skeleton-line skeleton-line--title"></div>
|
||||
<div class="skeleton-line skeleton-line--text"></div>
|
||||
<div class="skeleton-line skeleton-line--text skeleton-line--short"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<div class="blog-list__error">
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
} @else if (posts().length === 0) {
|
||||
<div class="blog-list__empty">
|
||||
<p>Noch keine Artikel veröffentlicht – schau bald wieder vorbei.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="blog-list__grid">
|
||||
@for (post of posts(); track post.id) {
|
||||
<a
|
||||
class="blog-card"
|
||||
[routerLink]="['/blog', post.slug]"
|
||||
opTrack="blog_post_click"
|
||||
[opTrackProps]="{ slug: post.slug, title: post.title }">
|
||||
|
||||
<div class="blog-card__image">
|
||||
@if (getCoverUrl(post); as coverUrl) {
|
||||
<img [src]="coverUrl" [alt]="post.title" loading="lazy" />
|
||||
} @else {
|
||||
<div class="blog-card__image-placeholder">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M19 20H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h10l6 6v8a2 2 0 0 1-2 2Z"/>
|
||||
<path d="m14 14-3 3-2-2-4 4"/>
|
||||
</svg>
|
||||
</div>
|
||||
}
|
||||
<div class="blog-card__read-time">
|
||||
<span>5 Min. Lesezeit</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="blog-card__body">
|
||||
@if (post.tags.length > 0) {
|
||||
<div class="blog-card__tags">
|
||||
@for (tag of post.tags; track tag.tags_id.id) {
|
||||
<span class="blog-card__tag">{{ tag.tags_id.name }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<h2 class="blog-card__title">{{ post.title }}</h2>
|
||||
<p class="blog-card__summary">{{ post.summary }}</p>
|
||||
<div class="blog-card__footer">
|
||||
<time class="blog-card__date" [dateTime]="post.published_at">
|
||||
{{ post.published_at | date:'d. MMM yyyy':'':'de' }}
|
||||
</time>
|
||||
<span class="blog-card__cta">
|
||||
Weiterlesen
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
</main>
|
||||
236
src/app/features/blog/pages/blog-list/blog-list.component.scss
Normal file
236
src/app/features/blog/pages/blog-list/blog-list.component.scss
Normal file
@@ -0,0 +1,236 @@
|
||||
@use 'abstracts';
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
from { background-position: -400px 0; }
|
||||
to { background-position: 400px 0; }
|
||||
}
|
||||
|
||||
.blog-list {
|
||||
min-height: calc(100vh - var(--nav-height));
|
||||
padding-block: calc(var(--space-4) * 2);
|
||||
|
||||
&__wrapper {
|
||||
@include abstracts.container-wrapper;
|
||||
}
|
||||
|
||||
&__header {
|
||||
text-align: center;
|
||||
margin-bottom: calc(var(--space-4) * 1.5);
|
||||
|
||||
h1 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--accent);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-3);
|
||||
|
||||
@include abstracts.breakpoint('md') {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@include abstracts.breakpoint('lg') {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
&__error,
|
||||
&__empty {
|
||||
text-align: center;
|
||||
padding: var(--space-4);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-lg);
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
}
|
||||
|
||||
.blog-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-surface);
|
||||
color: var(--text-main);
|
||||
transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 0.25s ease,
|
||||
border-color 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-6px);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 16px 48px oklch(0% 0 0 / 0.1);
|
||||
}
|
||||
|
||||
&__image {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-muted);
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transition: transform 0.4s ease;
|
||||
|
||||
.blog-card:hover & {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__image-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--bg-muted) 0%, var(--border-color) 100%);
|
||||
color: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&__read-time {
|
||||
position: absolute;
|
||||
bottom: var(--space-2);
|
||||
right: var(--space-2);
|
||||
background: oklch(0% 0 0 / 0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
color: var(--color-white);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
&__tag {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, oklch(from var(--accent) l c h / 0.12) 0%, oklch(from var(--accent) l c h / 0.08) 100%);
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
.blog-card:hover & {
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
&__summary {
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.65;
|
||||
flex: 1;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
&__cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
transition: gap 0.2s ease;
|
||||
|
||||
.blog-card:hover & {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
&--skeleton {
|
||||
pointer-events: none;
|
||||
|
||||
.blog-card__image--skeleton {
|
||||
background-color: var(--bg-muted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 1em;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-muted) 25%,
|
||||
var(--border-color) 50%,
|
||||
var(--bg-muted) 75%
|
||||
);
|
||||
background-size: 800px 100%;
|
||||
animation: skeleton-shimmer 1.4s ease-in-out infinite;
|
||||
|
||||
&--title {
|
||||
height: 1.4em;
|
||||
width: 80%;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
&--text {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&--short {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
51
src/app/features/blog/pages/blog-list/blog-list.component.ts
Normal file
51
src/app/features/blog/pages/blog-list/blog-list.component.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { BlogService } from '@core/services/blog.service';
|
||||
import { SeoService } from '@core/services/seo.service';
|
||||
import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive';
|
||||
import { BlogPost } from '@core/models/blog-posts.model';
|
||||
import { BlogNavComponent } from '../../components/blog-nav/blog-nav.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-blog-list',
|
||||
imports: [RouterModule, DatePipe, OpenPanelTrackDirective, BlogNavComponent],
|
||||
templateUrl: './blog-list.component.html',
|
||||
styleUrl: './blog-list.component.scss',
|
||||
})
|
||||
export class BlogListComponent implements OnInit {
|
||||
private readonly blogService = inject(BlogService);
|
||||
private readonly seo = inject(SeoService);
|
||||
|
||||
posts = signal<BlogPost[]>([]);
|
||||
loading = signal(true);
|
||||
error = signal<string | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.seo.updateMetadata({
|
||||
title: 'Blog – Webdesign-Tipps & Einblicke',
|
||||
description: 'Artikel rund um Webdesign, Performance und digitale Präsenz für Handwerk und Vereine.',
|
||||
type: 'website',
|
||||
});
|
||||
|
||||
this.blogService.getPosts().subscribe({
|
||||
next: (posts) => {
|
||||
this.posts.set(posts);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.error.set('Die Artikel konnten nicht geladen werden. Bitte versuche es später erneut.');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getCoverUrl(post: BlogPost): string | null {
|
||||
if (!post.cover_image) return null;
|
||||
return this.blogService.getAssetUrl(post.cover_image, { width: 640, quality: 80 });
|
||||
}
|
||||
|
||||
formatDate(dateStr: string): string {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
1
src/app/features/dashboard/dashboard.html
Normal file
1
src/app/features/dashboard/dashboard.html
Normal file
@@ -0,0 +1 @@
|
||||
<p>dashboard works!</p>
|
||||
23
src/app/features/dashboard/dashboard.spec.ts
Normal file
23
src/app/features/dashboard/dashboard.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
11
src/app/features/dashboard/dashboard.ts
Normal file
11
src/app/features/dashboard/dashboard.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
imports: [],
|
||||
templateUrl: './dashboard.html',
|
||||
styleUrl: './dashboard.scss',
|
||||
})
|
||||
export class Dashboard {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<section class="contact" id="contact">
|
||||
<div class="contact__wrapper">
|
||||
<div class="contact__header">
|
||||
<h2>Projekt anfragen</h2>
|
||||
<p class="text-muted">
|
||||
Erzählen Sie uns von Ihrem Vorhaben – kostenlos und unverbindlich.
|
||||
Wir melden uns innerhalb von 24 Stunden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="contact__grid">
|
||||
|
||||
<!-- Info column -->
|
||||
<div class="contact__info">
|
||||
<div class="contact__info-item">
|
||||
<div class="contact__info-icon">
|
||||
<ng-icon name="cssPin"></ng-icon>
|
||||
</div>
|
||||
<div class="contact__info-text">
|
||||
<p class="contact__info-label">Standort</p>
|
||||
<address>
|
||||
Hurler Webdesign<br />
|
||||
Untermagerbein 30<br />
|
||||
86751 Mönchsdeggingen, Bayern
|
||||
</address>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contact__info-item">
|
||||
<div class="contact__info-icon">
|
||||
<ng-icon name="cssAlarm"></ng-icon>
|
||||
</div>
|
||||
<div class="contact__info-text">
|
||||
<p class="contact__info-label">Reaktionszeit</p>
|
||||
<p>Antwort innerhalb von 24 Stunden</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contact__info-item">
|
||||
<div class="contact__info-icon">
|
||||
<ng-icon name="cssLock"></ng-icon>
|
||||
</div>
|
||||
<div class="contact__info-text">
|
||||
<p class="contact__info-label">Datenschutz</p>
|
||||
<p>DSGVO-konform & Server in Deutschland</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="contact__benefits">
|
||||
<li>
|
||||
<ng-icon name="cssCheck" aria-hidden="true"></ng-icon>
|
||||
Kostenlose Erstberatung
|
||||
</li>
|
||||
<li>
|
||||
<ng-icon name="cssCheck" aria-hidden="true"></ng-icon>
|
||||
Transparente Festpreise
|
||||
</li>
|
||||
<li>
|
||||
<ng-icon name="cssCheck" aria-hidden="true"></ng-icon>
|
||||
Persönliche Betreuung
|
||||
</li>
|
||||
<li>
|
||||
<ng-icon name="cssCheck" aria-hidden="true"></ng-icon>
|
||||
Ohne Abo oder versteckte Kosten
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Form column -->
|
||||
<div class="contact__form-wrapper">
|
||||
@if (!submitted()) {
|
||||
<form
|
||||
[formGroup]="form"
|
||||
(ngSubmit)="onSubmit()"
|
||||
class="contact__form"
|
||||
novalidate>
|
||||
|
||||
<div class="contact__field" [class.contact__field--error]="nameInvalid">
|
||||
<label for="contact-name">Name</label>
|
||||
<input
|
||||
id="contact-name"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Max Mustermann"
|
||||
autocomplete="name"
|
||||
(focus)="onFirstFocus()" />
|
||||
@if (nameInvalid) {
|
||||
<span class="contact__error" role="alert">Bitte geben Sie Ihren Namen ein.</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="contact__field" [class.contact__field--error]="emailInvalid">
|
||||
<label for="contact-email">E-Mail</label>
|
||||
<input
|
||||
id="contact-email"
|
||||
type="email"
|
||||
formControlName="email"
|
||||
placeholder="max@beispiel.de"
|
||||
autocomplete="email"
|
||||
(focus)="onFirstFocus()" />
|
||||
@if (emailInvalid) {
|
||||
<span class="contact__error" role="alert">Bitte geben Sie eine gültige E-Mail-Adresse ein.</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="contact__field" [class.contact__field--error]="messageInvalid">
|
||||
<label for="contact-message">Nachricht</label>
|
||||
<textarea
|
||||
id="contact-message"
|
||||
formControlName="message"
|
||||
rows="5"
|
||||
placeholder="Erzählen Sie uns von Ihrem Projekt – was brauchen Sie, wann soll es fertig sein?"
|
||||
(focus)="onFirstFocus()"></textarea>
|
||||
@if (messageInvalid) {
|
||||
<span class="contact__error" role="alert">Bitte schreiben Sie mindestens 20 Zeichen.</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (sendError()) {
|
||||
<p class="contact__send-error" role="alert">
|
||||
Beim Senden ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.
|
||||
</p>
|
||||
}
|
||||
|
||||
<button type="submit" class="contact__submit" [disabled]="sending()">
|
||||
{{ sending() ? 'Wird gesendet…' : 'Anfrage senden' }}
|
||||
</button>
|
||||
</form>
|
||||
} @else {
|
||||
<div class="contact__success">
|
||||
<div class="contact__success-icon">
|
||||
<ng-icon name="cssCheck"></ng-icon>
|
||||
</div>
|
||||
<h3>Vielen Dank für Ihre Anfrage!</h3>
|
||||
<p>Wir melden uns innerhalb von 24 Stunden per E-Mail bei Ihnen.</p>
|
||||
<button class="contact__reset" (click)="resetForm()">
|
||||
Weitere Anfrage senden
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,255 @@
|
||||
@use 'abstracts';
|
||||
|
||||
// ── Animations ────────────────────────────────────────────────────────────────
|
||||
|
||||
@keyframes success-pop {
|
||||
from { opacity: 0; transform: scale(0.95) translateY(8px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
// ── Section ───────────────────────────────────────────────────────────────────
|
||||
|
||||
.contact {
|
||||
padding-block: calc(var(--space-4) * 2);
|
||||
background-color: var(--bg-muted);
|
||||
|
||||
&__wrapper {
|
||||
@include abstracts.container-wrapper;
|
||||
}
|
||||
|
||||
// ── Header ─────────────────────────────────────────────────────────────────
|
||||
|
||||
&__header {
|
||||
text-align: center;
|
||||
margin-bottom: calc(var(--space-4) * 1.5);
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-lg);
|
||||
max-width: 56ch;
|
||||
margin-inline: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Two-column grid ─────────────────────────────────────────────────────────
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: calc(var(--space-4) * 1.5);
|
||||
|
||||
@include abstracts.breakpoint('md') {
|
||||
grid-template-columns: 1fr 1.4fr;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Info column ────────────────────────────────────────────────────────────
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
&__info-item {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&__info-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: abstracts.rem(40);
|
||||
height: abstracts.rem(40);
|
||||
border-radius: 10px;
|
||||
background-color: oklch(from var(--accent) l c h / 0.12);
|
||||
color: var(--accent);
|
||||
font-size: abstracts.rem(18);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__info-text {
|
||||
p, address {
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-muted);
|
||||
font-style: normal;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.contact__info-label {
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
}
|
||||
|
||||
&__benefits {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--border-color);
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-muted);
|
||||
|
||||
ng-icon {
|
||||
color: var(--accent);
|
||||
font-size: abstracts.rem(16);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Form wrapper ───────────────────────────────────────────────────────────
|
||||
|
||||
&__form-wrapper {
|
||||
background-color: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
&__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
// ── Field ──────────────────────────────────────────────────────────────────
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
|
||||
label {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 14px;
|
||||
border: 1.5px solid var(--border-color);
|
||||
border-radius: calc(var(--border-radius) * 0.6);
|
||||
background-color: var(--bg-surface);
|
||||
color: var(--text-main);
|
||||
font-size: var(--font-size-base);
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
resize: vertical;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px oklch(from var(--accent) l c h / 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
&--error {
|
||||
input,
|
||||
textarea {
|
||||
border-color: oklch(55% 0.2 25);
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 3px oklch(55% 0.2 25 / 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
font-size: 0.8rem;
|
||||
color: oklch(55% 0.2 25);
|
||||
}
|
||||
|
||||
// ── Submit button ──────────────────────────────────────────────────────────
|
||||
|
||||
&__submit {
|
||||
width: 100%;
|
||||
padding: 12px 24px;
|
||||
background-color: var(--accent);
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: calc(var(--border-radius) * 0.6);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, transform 0.15s ease;
|
||||
&:hover { background-color: var(--accent-hover); }
|
||||
&:active { transform: translateY(1px); }
|
||||
}
|
||||
|
||||
// ── Success state ──────────────────────────────────────────────────────────
|
||||
|
||||
&__success {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: var(--space-4);
|
||||
gap: var(--space-3);
|
||||
animation: success-pop 0.4s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
|
||||
h3 {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-base);
|
||||
max-width: 36ch;
|
||||
}
|
||||
}
|
||||
|
||||
&__success-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: abstracts.rem(64);
|
||||
height: abstracts.rem(64);
|
||||
border-radius: 50%;
|
||||
background-color: oklch(from var(--accent) l c h / 0.12);
|
||||
color: var(--accent);
|
||||
font-size: abstracts.rem(28);
|
||||
}
|
||||
|
||||
&__reset {
|
||||
background: none;
|
||||
border: 1.5px solid var(--border-color);
|
||||
border-radius: calc(var(--border-radius) * 0.6);
|
||||
padding: 8px 20px;
|
||||
font-size: var(--font-size-base);
|
||||
font-family: inherit;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, color 0.2s ease;
|
||||
&:hover { border-color: var(--accent); color: var(--accent); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { cssCheck, cssPin, cssAlarm, cssLock } from '@ng-icons/css.gg';
|
||||
import { OpenPanelService } from '@core/services/openpanel.service';
|
||||
import { n8nEnvironment } from '../../../../../environments/n8n';
|
||||
|
||||
@Component({
|
||||
selector: 'app-contact',
|
||||
imports: [ReactiveFormsModule, NgIcon],
|
||||
viewProviders: [provideIcons({ cssCheck, cssPin, cssAlarm, cssLock })],
|
||||
templateUrl: './contact.component.html',
|
||||
styleUrl: './contact.component.scss',
|
||||
})
|
||||
export class ContactComponent {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly op = inject(OpenPanelService);
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
readonly submitted = signal(false);
|
||||
readonly sending = signal(false);
|
||||
readonly sendError = signal(false);
|
||||
private hasStarted = false;
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
name: ['', [Validators.required, Validators.minLength(2)]],
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
message: ['', [Validators.required, Validators.minLength(20)]],
|
||||
});
|
||||
|
||||
onFirstFocus(): void {
|
||||
if (!this.hasStarted) {
|
||||
this.hasStarted = true;
|
||||
this.op.track('contact_form_start');
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
console.log('submit');
|
||||
if (this.form.invalid) {
|
||||
this.form.markAllAsTouched();
|
||||
this.op.track('contact_form_validation_error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.op.track('contact_form_submit', {
|
||||
message_length: this.form.value.message?.length ?? 0,
|
||||
});
|
||||
|
||||
this.sending.set(true);
|
||||
this.sendError.set(false);
|
||||
|
||||
console.log('posting to', n8nEnvironment.contactWebhookUrl, 'with payload', this.form.getRawValue());
|
||||
|
||||
this.http
|
||||
.post(n8nEnvironment.contactWebhookUrl, this.form.getRawValue())
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.submitted.set(true);
|
||||
this.form.reset();
|
||||
this.hasStarted = false;
|
||||
this.sending.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.sendError.set(true);
|
||||
this.sending.set(false);
|
||||
this.op.track('contact_form_send_error');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
resetForm(): void {
|
||||
this.submitted.set(false);
|
||||
}
|
||||
|
||||
get nameInvalid(): boolean {
|
||||
const c = this.form.controls.name;
|
||||
return c.invalid && c.touched;
|
||||
}
|
||||
|
||||
get emailInvalid(): boolean {
|
||||
const c = this.form.controls.email;
|
||||
return c.invalid && c.touched;
|
||||
}
|
||||
|
||||
get messageInvalid(): boolean {
|
||||
const c = this.form.controls.message;
|
||||
return c.invalid && c.touched;
|
||||
}
|
||||
}
|
||||
@@ -1 +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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>© {{ currentYear }} Hurler Webdesign. Alle Rechte vorbehalten.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: abstracts.rem(32);
|
||||
height: abstracts.rem(32);
|
||||
color: var(--text-main);
|
||||
}
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
&: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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<section class="stats" id="stats">
|
||||
<div class="stats__wrapper">
|
||||
<div class="stats__header">
|
||||
<h2>Zahlen, die für sich sprechen</h2>
|
||||
<p class="text-muted">Transparent. Messbar. Vertrauenswürdig.</p>
|
||||
</div>
|
||||
<div class="stats__grid">
|
||||
@for (stat of stats; track stat.id; let i = $index) {
|
||||
<div class="stats__card" #statRef>
|
||||
<div class="stats__value">
|
||||
{{ displayedValues()[i] }}{{ stat.suffix }}
|
||||
</div>
|
||||
<div class="stats__label">{{ stat.label }}</div>
|
||||
<div class="stats__description">{{ stat.description }}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,90 @@
|
||||
@use 'abstracts';
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
padding-block: calc(var(--space-4) * 2);
|
||||
background: linear-gradient(180deg, var(--bg-surface) 0%, var(--bg-muted) 100%);
|
||||
|
||||
&__wrapper {
|
||||
@include abstracts.container-wrapper;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__header {
|
||||
text-align: center;
|
||||
margin-bottom: calc(var(--space-4) * 1.5);
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-3);
|
||||
|
||||
@include abstracts.breakpoint('md') {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
&__card {
|
||||
text-align: center;
|
||||
padding: var(--space-4) var(--space-3);
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
opacity: 0;
|
||||
|
||||
&.is-visible {
|
||||
animation: fade-in-up 0.6s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
&:nth-child(1).is-visible { animation-delay: 0ms; }
|
||||
&:nth-child(2).is-visible { animation-delay: 100ms; }
|
||||
&:nth-child(3).is-visible { animation-delay: 200ms; }
|
||||
&:nth-child(4).is-visible { animation-delay: 300ms; }
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: clamp(2.5rem, 5vw + 1rem, 4rem);
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
95
src/app/features/landing/components/stats/stats.component.ts
Normal file
95
src/app/features/landing/components/stats/stats.component.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Component, AfterViewInit, ElementRef, ViewChildren, QueryList, inject, PLATFORM_ID, signal } from '@angular/core';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
|
||||
interface Stat {
|
||||
id: number;
|
||||
value: number;
|
||||
suffix: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-stats',
|
||||
templateUrl: './stats.component.html',
|
||||
styleUrl: './stats.component.scss',
|
||||
})
|
||||
export class StatsComponent implements AfterViewInit {
|
||||
@ViewChildren('statRef') statElements!: QueryList<ElementRef<HTMLElement>>;
|
||||
private platformId = inject(PLATFORM_ID);
|
||||
|
||||
displayedValues = signal<number[]>([0, 0, 0, 0]);
|
||||
|
||||
stats: Stat[] = [
|
||||
{
|
||||
id: 1,
|
||||
value: 50,
|
||||
suffix: '+',
|
||||
label: 'Projekte',
|
||||
description: 'Webseiten erfolgreich umgesetzt',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
value: 99,
|
||||
suffix: '%',
|
||||
label: 'Kundenzufriedenheit',
|
||||
description: 'Würden uns weiterempfehlen',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
value: 7,
|
||||
suffix: '+',
|
||||
label: 'Jahre Erfahrung',
|
||||
description: 'Im Webdesign & Development',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
value: 100,
|
||||
suffix: '%',
|
||||
label: 'DSGVO-konform',
|
||||
description: 'European Hosting & Datenschutz',
|
||||
},
|
||||
];
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
if (!isPlatformBrowser(this.platformId)) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('is-visible');
|
||||
this.animateValues();
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.3 }
|
||||
);
|
||||
|
||||
this.statElements.forEach((el) => observer.observe(el.nativeElement));
|
||||
}
|
||||
|
||||
private animateValues(): void {
|
||||
const duration = 2000;
|
||||
const steps = 60;
|
||||
const stepDuration = duration / steps;
|
||||
|
||||
this.stats.forEach((stat, index) => {
|
||||
let current = 0;
|
||||
const increment = stat.value / steps;
|
||||
const timer = setInterval(() => {
|
||||
current += increment;
|
||||
if (current >= stat.value) {
|
||||
current = stat.value;
|
||||
clearInterval(timer);
|
||||
}
|
||||
this.displayedValues.update((values) => {
|
||||
const newValues = [...values];
|
||||
newValues[index] = Math.floor(current);
|
||||
return newValues;
|
||||
});
|
||||
}, stepDuration);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<section class="testimonials" id="testimonials">
|
||||
<div class="testimonials__wrapper">
|
||||
<div class="testimonials__header">
|
||||
<h2>Das sagen unsere Kunden</h2>
|
||||
<p class="text-muted">Echte Ergebnisse für echte Unternehmen.</p>
|
||||
</div>
|
||||
<div class="testimonials__grid">
|
||||
@for (testimonial of testimonials; track testimonial.id; let i = $index) {
|
||||
<div
|
||||
class="testimonials__card"
|
||||
[style.--delay]="(i * 150) + 'ms'"
|
||||
opTrack="testimonial_view"
|
||||
[opTrackProps]="{ testimonial_id: testimonial.id, company: testimonial.company }">
|
||||
|
||||
<div class="testimonials__quote-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||
<path d="M10 8C6.686 8 4 10.686 4 14v10c0 1.1.9 2 2 2h2c1.1 0 2-.9 2-2v-6c0-1.1.9-2 2-2h4c1.1 0 2 .9 2 2v6c0 1.1.9 2 2 2h2c1.1 0 2-.9 2-2V14c0-3.314-2.686-6-6-6h-6z" fill="currentColor" opacity="0.15"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="testimonials__stars">
|
||||
@for (star of [1,2,3,4,5]; track star) {
|
||||
<span [class.filled]="star <= testimonial.rating">★</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<blockquote class="testimonials__text">
|
||||
"{{ testimonial.quote }}"
|
||||
</blockquote>
|
||||
|
||||
<div class="testimonials__author">
|
||||
<div
|
||||
class="testimonials__avatar"
|
||||
[style.background]="'linear-gradient(135deg, ' + testimonial.gradientFrom + ', ' + testimonial.gradientTo + ')'">
|
||||
{{ testimonial.initials }}
|
||||
</div>
|
||||
<div class="testimonials__author-info">
|
||||
<div class="testimonials__name">{{ testimonial.name }}</div>
|
||||
<div class="testimonials__role">{{ testimonial.role }}, {{ testimonial.company }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,128 @@
|
||||
@use 'abstracts';
|
||||
|
||||
@keyframes card-fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(32px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.testimonials {
|
||||
padding-block: calc(var(--space-4) * 2);
|
||||
background-color: var(--bg-muted);
|
||||
|
||||
&__wrapper {
|
||||
@include abstracts.container-wrapper;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__header {
|
||||
text-align: center;
|
||||
margin-bottom: calc(var(--space-4) * 1.5);
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-3);
|
||||
|
||||
@include abstracts.breakpoint('md') {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
&__card {
|
||||
position: relative;
|
||||
background-color: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 0.25s ease;
|
||||
|
||||
animation: card-fade-up 0.55s cubic-bezier(0.22, 1, 0.36, 1) var(--delay, 0ms) both;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 32px oklch(0% 0 0 / 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
&__quote-icon {
|
||||
color: var(--accent);
|
||||
opacity: 0.6;
|
||||
position: absolute;
|
||||
top: var(--space-3);
|
||||
right: var(--space-3);
|
||||
}
|
||||
|
||||
&__stars {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
font-size: 1rem;
|
||||
color: var(--border-color);
|
||||
|
||||
.filled {
|
||||
color: oklch(75% 0.18 45);
|
||||
}
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: var(--font-size-base);
|
||||
line-height: 1.7;
|
||||
color: var(--text-main);
|
||||
font-style: normal;
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-on-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__author-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-weight: 700;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
&__role {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive';
|
||||
|
||||
interface Testimonial {
|
||||
id: number;
|
||||
name: string;
|
||||
role: string;
|
||||
company: string;
|
||||
quote: string;
|
||||
rating: number;
|
||||
initials: string;
|
||||
gradientFrom: string;
|
||||
gradientTo: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-testimonials',
|
||||
imports: [OpenPanelTrackDirective],
|
||||
templateUrl: './testimonials.component.html',
|
||||
styleUrl: './testimonials.component.scss',
|
||||
})
|
||||
export class TestimonialsComponent {
|
||||
testimonials: Testimonial[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Markus Krause',
|
||||
role: 'Geschäftsführer',
|
||||
company: 'Krause Metallbau GmbH',
|
||||
quote:
|
||||
'Endlich eine Webseite, die nicht aussieht wie jede andere Handwerker-Homepage. Seit dem Relaunch haben wir 40% mehr Anfragen über die Website.',
|
||||
rating: 5,
|
||||
initials: 'MK',
|
||||
gradientFrom: 'oklch(45% 0.22 250)',
|
||||
gradientTo: 'oklch(55% 0.22 250)',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Anna Schlüter',
|
||||
role: 'Vorstandsvorsitzende',
|
||||
company: 'Turnverein Blau-Weiß 09',
|
||||
quote:
|
||||
'Der neue Online-Auftritt hat uns geholfen, jüngere Mitglieder anzusprechen. Das Verwaltungsportal spart uns enorm viel Zeit.',
|
||||
rating: 5,
|
||||
initials: 'AS',
|
||||
gradientFrom: 'oklch(70% 0.15 250)',
|
||||
gradientTo: 'oklch(65% 0.18 250)',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Jan Winkler',
|
||||
role: 'Inhaber',
|
||||
company: 'Winkler IT-Services',
|
||||
quote:
|
||||
'Professionell, zuverlässig und super Kommunikation. Die Seite lädt rasend schnell und unser Google-Ranking hat sich deutlich verbessert.',
|
||||
rating: 5,
|
||||
initials: 'JW',
|
||||
gradientFrom: 'oklch(55% 0.2 250)',
|
||||
gradientTo: 'oklch(60% 0.22 250)',
|
||||
},
|
||||
];
|
||||
}
|
||||
3
src/app/features/landing/index.ts
Normal file
3
src/app/features/landing/index.ts
Normal 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';
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<main class="project-detail">
|
||||
|
||||
@if (project(); as p) {
|
||||
|
||||
<section class="project-detail__hero">
|
||||
<div class="project-detail__hero-image">
|
||||
<img [src]="p.image" [alt]="p.company" />
|
||||
<div class="project-detail__hero-overlay"></div>
|
||||
</div>
|
||||
<div class="project-detail__hero-content">
|
||||
<a routerLink="/" class="project-detail__back">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Zurück
|
||||
</a>
|
||||
<span class="project-detail__branch">{{ p.branch }}</span>
|
||||
<h1 class="project-detail__title">{{ p.company }}</h1>
|
||||
<p class="project-detail__description">{{ p.description }}</p>
|
||||
@if (p.result) {
|
||||
<div class="project-detail__result">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline>
|
||||
<polyline points="17 6 23 6 23 12"></polyline>
|
||||
</svg>
|
||||
<span>{{ p.result }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="project-detail__features">
|
||||
<div class="project-detail__wrapper">
|
||||
<h2 class="project-detail__section-title">Leistungen im Projekt</h2>
|
||||
<div class="project-detail__features-grid">
|
||||
@for (feature of p.features; track feature.title; let i = $index) {
|
||||
<div class="project-detail__feature" [style.--delay]="(i * 100) + 'ms'">
|
||||
<div class="project-detail__feature-icon">
|
||||
@switch (feature.icon) {
|
||||
@case ('search') {
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.35-4.35"></path>
|
||||
</svg>
|
||||
}
|
||||
@case ('smartphone') {
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="5" y="2" width="14" height="20" rx="2" ry="2"></rect>
|
||||
<line x1="12" y1="18" x2="12.01" y2="18"></line>
|
||||
</svg>
|
||||
}
|
||||
@case ('shield') {
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
|
||||
</svg>
|
||||
}
|
||||
@case ('target') {
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<circle cx="12" cy="12" r="6"></circle>
|
||||
<circle cx="12" cy="12" r="2"></circle>
|
||||
</svg>
|
||||
}
|
||||
@case ('calendar') {
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>
|
||||
}
|
||||
@case ('award') {
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="8" r="7"></circle>
|
||||
<polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"></polyline>
|
||||
</svg>
|
||||
}
|
||||
@case ('calendar-check') {
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
<path d="m9 16 2 2 4-4"></path>
|
||||
</svg>
|
||||
}
|
||||
@case ('list') {
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="8" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="8" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="8" y1="18" x2="21" y2="18"></line>
|
||||
<line x1="3" y1="6" x2="3.01" y2="6"></line>
|
||||
<line x1="3" y1="12" x2="3.01" y2="12"></line>
|
||||
<line x1="3" y1="18" x2="3.01" y2="18"></line>
|
||||
</svg>
|
||||
}
|
||||
@case ('google') {
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M12 8v8"></path>
|
||||
<path d="M8 12h8"></path>
|
||||
</svg>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="project-detail__feature-content">
|
||||
<h3>{{ feature.title }}</h3>
|
||||
<p>{{ feature.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (p.testimonial) {
|
||||
<section class="project-detail__testimonial">
|
||||
<div class="project-detail__wrapper project-detail__wrapper--narrow">
|
||||
<blockquote class="project-detail__quote">
|
||||
<svg class="project-detail__quote-icon" width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10 8c-3.314 0-6 2.686-6 6v10c0 1.1.9 2 2 2h2c1.1 0 2-.9 2-2v-6c0-1.1.9-2 2-2h4c1.1 0 2 .9 2 2v6c0 1.1.9 2 2 2h2c1.1 0 2-.9 2-2V14c0-3.314-2.686-6-6-6h-6z"/>
|
||||
</svg>
|
||||
<p>"{{ p.testimonial.quote }}"</p>
|
||||
<footer>
|
||||
<cite>
|
||||
<strong>{{ p.testimonial.author }}</strong>
|
||||
<span>{{ p.testimonial.role }}, {{ p.company }}</span>
|
||||
</cite>
|
||||
</footer>
|
||||
</blockquote>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="project-detail__cta">
|
||||
<div class="project-detail__wrapper">
|
||||
<h2>Erfolgreich online – wie Ihr Projekt</h2>
|
||||
<p>Lassen Sie uns gemeinsam Ihr nächstes Projekt realisieren.</p>
|
||||
<a routerLink="/#contact" class="project-detail__cta-button">
|
||||
Projekt anfragen
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (otherProjects().length > 0) {
|
||||
<section class="project-detail__more">
|
||||
<div class="project-detail__wrapper">
|
||||
<h2 class="project-detail__section-title">Weitere Projekte</h2>
|
||||
<div class="project-detail__more-grid">
|
||||
@for (other of otherProjects(); track other.slug) {
|
||||
<a
|
||||
[routerLink]="['/projekt', other.slug]"
|
||||
class="project-detail__more-card">
|
||||
<div class="project-detail__more-image">
|
||||
<img [src]="other.image" [alt]="other.company" loading="lazy" />
|
||||
</div>
|
||||
<div class="project-detail__more-content">
|
||||
<span class="project-detail__more-branch">{{ other.branch }}</span>
|
||||
<h3>{{ other.company }}</h3>
|
||||
<span class="project-detail__more-cta">
|
||||
Projekt ansehen
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
} @else {
|
||||
<div class="project-detail__not-found">
|
||||
<div class="project-detail__wrapper">
|
||||
<h1>Projekt nicht gefunden</h1>
|
||||
<p>Das gesuchte Projekt existiert leider nicht.</p>
|
||||
<a routerLink="/" class="project-detail__back-link">Zurück zur Startseite</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
</main>
|
||||
@@ -0,0 +1,391 @@
|
||||
@use 'abstracts';
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.project-detail {
|
||||
min-height: calc(100vh - var(--nav-height));
|
||||
|
||||
&__wrapper {
|
||||
@include abstracts.container-wrapper;
|
||||
width: 100%;
|
||||
|
||||
&--narrow {
|
||||
max-width: 740px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hero ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
&__hero {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__hero-image {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: clamp(400px, 60vh, 600px);
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
&__hero-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
oklch(from var(--bg-surface) l c h) 0%,
|
||||
oklch(from var(--bg-surface) l c h / 0.8) 30%,
|
||||
oklch(from var(--bg-surface) l c h / 0.4) 60%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
&__hero-content {
|
||||
position: relative;
|
||||
margin-top: -200px;
|
||||
padding-bottom: calc(var(--space-4) * 2);
|
||||
@include abstracts.container-wrapper;
|
||||
max-width: 800px;
|
||||
animation: fade-in-up 0.6s ease-out both;
|
||||
}
|
||||
|
||||
&__back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-4);
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
&__branch {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--accent);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: clamp(var(--font-size-xl), 5vw, 3rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.7;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
&__result {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: linear-gradient(135deg, oklch(from var(--accent) l c h / 0.15) 0%, oklch(from var(--accent) l c h / 0.08) 100%);
|
||||
border: 1px solid oklch(from var(--accent) l c h / 0.2);
|
||||
border-radius: 999px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Features ─────────────────────────────────────────────────────────────────
|
||||
|
||||
&__features {
|
||||
padding-block: calc(var(--space-4) * 2);
|
||||
background-color: var(--bg-muted);
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 700;
|
||||
margin-bottom: calc(var(--space-4) * 1.5);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-3);
|
||||
|
||||
@include abstracts.breakpoint('md') {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
&__feature {
|
||||
background-color: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 0.25s ease;
|
||||
|
||||
animation: fade-in-up 0.55s cubic-bezier(0.22, 1, 0.36, 1) var(--delay, 0ms) both;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 32px oklch(0% 0 0 / 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
&__feature-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, oklch(from var(--accent) l c h / 0.15) 0%, oklch(from var(--accent) l c h / 0.08) 100%);
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__feature-content {
|
||||
h3 {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 700;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Testimonial ──────────────────────────────────────────────────────────────
|
||||
|
||||
&__testimonial {
|
||||
padding-block: calc(var(--space-4) * 2);
|
||||
background-color: var(--bg-surface);
|
||||
}
|
||||
|
||||
&__quote {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
&__quote-icon {
|
||||
color: var(--accent);
|
||||
opacity: 0.15;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
&__quote p {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
color: var(--text-main);
|
||||
margin-bottom: var(--space-4);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
&__quote footer cite {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-style: normal;
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
// ── CTA ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
&__cta {
|
||||
padding-block: calc(var(--space-4) * 2);
|
||||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
|
||||
text-align: center;
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-on-accent);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-lg);
|
||||
color: oklch(from var(--text-on-accent) l c h / 0.85);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
&__cta-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background-color: var(--bg-surface);
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
font-size: var(--font-size-base);
|
||||
border-radius: 999px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 8px 24px oklch(0% 0 0 / 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// ── More Projects ────────────────────────────────────────────────────────────
|
||||
|
||||
&__more {
|
||||
padding-block: calc(var(--space-4) * 2);
|
||||
}
|
||||
|
||||
&__more-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-3);
|
||||
|
||||
@include abstracts.breakpoint('md') {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
&__more-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-surface);
|
||||
transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 32px oklch(0% 0 0 / 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
&__more-image {
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.4s ease;
|
||||
|
||||
.project-detail__more-card:hover & {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__more-content {
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
|
||||
h3 {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
&__more-branch {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
&__more-cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
margin-top: var(--space-2);
|
||||
transition: gap 0.2s ease;
|
||||
|
||||
.project-detail__more-card:hover & {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Not Found ────────────────────────────────────────────────────────────────
|
||||
|
||||
&__not-found {
|
||||
padding-block: calc(var(--space-4) * 3);
|
||||
text-align: center;
|
||||
|
||||
h1 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
&__back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background-color: var(--accent);
|
||||
color: var(--text-on-accent);
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { SeoService } from '@core/services/seo.service';
|
||||
|
||||
interface ProjectFeature {
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
slug: string;
|
||||
company: string;
|
||||
branch: string;
|
||||
image: string;
|
||||
galleryImages: string[];
|
||||
shortDescription: string;
|
||||
description: string;
|
||||
features: ProjectFeature[];
|
||||
result?: string;
|
||||
testimonial?: {
|
||||
quote: string;
|
||||
author: string;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
|
||||
const PROJECTS: Project[] = [
|
||||
{
|
||||
slug: 'metzgerei-schlachthof-qualitaet',
|
||||
company: 'Metzgerei Schlachthof-Qualität',
|
||||
branch: 'Fleischerei & Metzgerei',
|
||||
image: '/images/projekte/metzgerei.jpg',
|
||||
galleryImages: [
|
||||
'/images/projekte/metzgerei.jpg',
|
||||
'/images/projekte/metzgerei-detail1.jpg',
|
||||
'/images/projekte/metzgerei-detail2.jpg'
|
||||
],
|
||||
shortDescription: 'Premium-Webauftritt für traditionelle Metzgerei mit Fleischerei in der Region',
|
||||
description: 'Eine traditionelle Metzgerei mit jahrzehntelanger Erfahrung wollte ihre handwerkliche Qualität auch online sichtbar machen. Wir haben einen Webauftritt geschaffen, der die Wertigkeit ihrer Produkte widerspiegelt – modern, aber mit einem Hauch von Tradition.',
|
||||
features: [
|
||||
{
|
||||
icon: 'search',
|
||||
title: 'SEO-Optimierung',
|
||||
description: 'Auffindbar bei Google für regionale Suchbegriffe wie "Metzgerei in der Region" und "Frisches Fleisch online".'
|
||||
},
|
||||
{
|
||||
icon: 'smartphone',
|
||||
title: 'Responsive Design',
|
||||
description: 'Perfekte Darstellung auf allen Geräten – vom Smartphone beim Einkauf bis zum Desktop.'
|
||||
},
|
||||
{
|
||||
icon: 'shield',
|
||||
title: 'DSGVO-konform',
|
||||
description: 'Vollständig rechtssicher mit Cookie-Banner, Impressum und Datenschutzerklärung nach aktuellen Standards.'
|
||||
}
|
||||
],
|
||||
result: '+40% mehr Anfragen über Website',
|
||||
testimonial: {
|
||||
quote: 'Endlich eine Webseite, die unsere handwerkliche Qualität widerspiegelt. Die Zusammenarbeit war professionell und unkompliziert.',
|
||||
author: 'Thomas B.',
|
||||
role: 'Inhaber'
|
||||
}
|
||||
},
|
||||
{
|
||||
slug: 'finanzberatung-vermoegenswert',
|
||||
company: 'Finanzberatung Vermögenswert',
|
||||
branch: 'Finanzdienstleistung',
|
||||
image: '/images/projekte/finanzberatung.jpg',
|
||||
galleryImages: [
|
||||
'/images/projekte/finanzberatung.jpg',
|
||||
'/images/projekte/finanzberatung-detail1.jpg',
|
||||
'/images/projekte/finanzberatung-detail2.jpg'
|
||||
],
|
||||
shortDescription: 'Vertrauenswürdiger Online-Auftritt für unabhängige Finanzberatung',
|
||||
description: 'Als unabhängige Finanzberatung ist Vertrauen das wichtigste Gut. Wir haben eine Website entwickelt, die Kompetenz und Seriosität vermittelt, ohne dabei steif oder unpersönlich zu wirken.',
|
||||
features: [
|
||||
{
|
||||
icon: 'target',
|
||||
title: 'Lead-Generierung',
|
||||
description: 'Strategisch platzierte Call-to-Actions und ein optimiertes Kontaktformular für qualifizierte Anfragen.'
|
||||
},
|
||||
{
|
||||
icon: 'calendar',
|
||||
title: 'Terminbuchung',
|
||||
description: 'Online-Terminvereinbarung direkt über die Website – rund um die Uhr, ohne telefonische Hürden.'
|
||||
},
|
||||
{
|
||||
icon: 'award',
|
||||
title: 'Premium-Design',
|
||||
description: 'Hochwertige Optik, die das Qualitätsversprechen der Beratung visuell unterstreicht.'
|
||||
}
|
||||
],
|
||||
result: '+60% neue Terminbuchungen',
|
||||
testimonial: {
|
||||
quote: 'Unsere neuen Kunden sagen oft, dass sie uns wegen der professionellen Website kontaktiert haben. Das zeigt, wie wichtig ein guter erster Eindruck ist.',
|
||||
author: 'Michael S.',
|
||||
role: 'Geschäftsführer'
|
||||
}
|
||||
},
|
||||
{
|
||||
slug: 'physiotherapie-beweglich',
|
||||
company: 'Physiotherapie Beweglich',
|
||||
branch: 'Gesundheitswesen',
|
||||
image: '/images/projekte/physiotherapie.jpg',
|
||||
galleryImages: [
|
||||
'/images/projekte/physiotherapie.jpg',
|
||||
'/images/projekte/physiotherapie-detail1.jpg',
|
||||
'/images/projekte/physiotherapie-detail2.jpg'
|
||||
],
|
||||
shortDescription: 'Moderne Praxis-Website für Physiotherapie und Rehabilitation',
|
||||
description: 'Eine modern aufgestellte Physiotherapie-Praxis mit focus auf ganzheitliche Behandlungsmethoden. Die Website sollte Patienten dabei helfen, die richtige Behandlung zu finden und einfach einen Termin zu buchen.',
|
||||
features: [
|
||||
{
|
||||
icon: 'calendar-check',
|
||||
title: 'Online-Terminbuchung',
|
||||
description: 'Intuitives Buchungssystem mit Kalenderansicht und automatischen Erinnerungen per E-Mail.'
|
||||
},
|
||||
{
|
||||
icon: 'list',
|
||||
title: 'Leistungsübersicht',
|
||||
description: 'Übersichtliche Darstellung aller Behandlungsangebote mit detaillierten Beschreibungen.'
|
||||
},
|
||||
{
|
||||
icon: 'google',
|
||||
title: 'Google-optimiert',
|
||||
description: 'Lokale SEO-Strategie für Top-Platzierungen bei Suchbegriffen wie "Physiotherapie" in der Umgebung.'
|
||||
}
|
||||
],
|
||||
result: 'Top 3 bei Google-Suche',
|
||||
testimonial: {
|
||||
quote: 'Wir werden regelmäßig für unsere moderne Website gelobt. Viele Patienten buchen sogar direkt online – das spart uns Zeit.',
|
||||
author: 'Sarah M.',
|
||||
role: 'Praxisinhaberin'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-project-detail',
|
||||
imports: [RouterLink],
|
||||
templateUrl: './project-detail.component.html',
|
||||
styleUrl: './project-detail.component.scss',
|
||||
})
|
||||
export class ProjectDetailComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private seo = inject(SeoService);
|
||||
|
||||
project = signal<Project | null>(null);
|
||||
otherProjects = signal<Project[]>([]);
|
||||
|
||||
ngOnInit(): void {
|
||||
const slug = this.route.snapshot.paramMap.get('slug');
|
||||
const project = PROJECTS.find(p => p.slug === slug) || null;
|
||||
|
||||
this.project.set(project);
|
||||
this.otherProjects.set(PROJECTS.filter(p => p.slug !== slug));
|
||||
|
||||
if (project) {
|
||||
this.seo.updateMetadata({
|
||||
title: `${project.company} | Hurler Webdesign`,
|
||||
description: project.shortDescription,
|
||||
socialsDescription: `Erfahren Sie, wie wir ${project.company} zu mehr Erfolg im Internet verholfen haben.`,
|
||||
type: 'website'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getProjectBySlug(slug: string): Project | undefined {
|
||||
return PROJECTS.find(p => p.slug === slug);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<p>button works!</p>
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
118
src/app/shared/ui/button/README_BUTTON.md
Normal file
118
src/app/shared/ui/button/README_BUTTON.md
Normal 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
|
||||
60
src/app/shared/ui/button/button.component.html
Normal file
60
src/app/shared/ui/button/button.component.html
Normal 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>
|
||||
}
|
||||
149
src/app/shared/ui/button/button.component.scss
Normal file
149
src/app/shared/ui/button/button.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
55
src/app/shared/ui/button/button.component.ts
Normal file
55
src/app/shared/ui/button/button.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
28
src/app/shared/ui/nav-menu/nav-menu.component.html
Normal file
28
src/app/shared/ui/nav-menu/nav-menu.component.html
Normal 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>
|
||||
36
src/app/shared/ui/nav-menu/nav-menu.component.scss
Normal file
36
src/app/shared/ui/nav-menu/nav-menu.component.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/app/shared/ui/nav-menu/nav-menu.component.spec.ts
Normal file
23
src/app/shared/ui/nav-menu/nav-menu.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
25
src/app/shared/ui/nav-menu/nav-menu.component.ts
Normal file
25
src/app/shared/ui/nav-menu/nav-menu.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user