openpanel integration und entwurf blog

This commit is contained in:
2026-04-03 17:05:16 +02:00
parent 5138005397
commit cd694d0776
45 changed files with 2558 additions and 310 deletions

1
.nvmrc Normal file
View File

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

780
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -34,9 +34,11 @@
"@angular/ssr": "^21.0.3",
"@ng-icons/core": "^33.1.0",
"@ng-icons/css.gg": "^33.1.0",
"@openpanel/web": "^1.2.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": {
@@ -44,6 +46,7 @@
"@angular/cli": "^21.2.3",
"@angular/compiler-cli": "^21.2.5",
"@types/express": "^5.0.1",
"@types/marked": "^5.0.2",
"@types/node": "^20.19.37",
"jsdom": "^27.1.0",
"typescript": "~5.9.2",

View File

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

View File

@@ -2,7 +2,18 @@ import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: "",
loadComponent: () => import('@features/landing/').then(m => m.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' }
}
];

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
// 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);
return this.http
.get<DirectusResponse<BlogPost[]>>(`${this.baseUrl}/items/blog_posts`, { params })
.pipe(map(res => res.data));
}
getPostBySlug(slug: string): Observable<BlogPost | null> {
const params = new HttpParams()
.set('filter', JSON.stringify({ slug: { _eq: slug }, status: { _eq: 'published' } }))
.set('fields', `${this.defaultFields},content`);
return this.http
.get<DirectusResponse<BlogPost[]>>(`${this.baseUrl}/items/blog_posts`, { params })
.pipe(map(res => res.data[0] ?? null));
}
getAssetUrl(assetId: string, params?: { width?: number; height?: number; quality?: number }): string {
const url = new URL(`${this.baseUrl}/assets/${assetId}`);
if (params?.width) url.searchParams.set('width', String(params.width));
if (params?.height) url.searchParams.set('height', String(params.height));
if (params?.quality) url.searchParams.set('quality', String(params.quality));
return url.toString();
}
}

View File

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

View File

@@ -48,19 +48,21 @@ export class OpenPanelService implements OnDestroy {
}
private setupRouteTracking(): void {
// Nur einmalig subscriben, vorherige Sub zerstören
this.routerSubscription?.unsubscribe();
this.routerSubscription = this.router.events
.pipe(
filter((event) => event instanceof NavigationEnd),
// Ersten initialNavigation-Event überspringen SSR hat ihn schon getriggert
skip(1),
)
.subscribe((event) => {
const navEvent = event as NavigationEnd;
this.trackScreenView(navEvent.urlAfterRedirects);
});
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 ────────────────────────────────────────────────────────────
@@ -121,7 +123,7 @@ export class OpenPanelService implements OnDestroy {
/**
* Decrements a numeric property on the user profile.
* @example opService.decrement('credits', 5);
* @example opService.decrement('credits');
*/
decrement(property: string): void {
if (!this.op) return;

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,57 @@
<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>
}
<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>
<time class="blog-detail__date" [dateTime]="post.published_at">
{{ post.published_at | date:'d. MMMM yyyy':'':'de' }}
</time>
<p class="blog-detail__summary">{{ post.summary }}</p>
</header>
<hr class="blog-detail__divider" />
<article class="blog-detail__content" [innerHTML]="parsedContent()"></article>
</div>
}
</main>

View File

@@ -0,0 +1,243 @@
@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 ──────────────────────────────────────────────────────────────
&__cover {
display: flex;
width: 100%;
height: abstracts.em(500);
overflow: hidden;
margin-bottom: calc(var(--space-4) * 1.5);
img {
margin: auto;
width: auto;
height: 100%;
object-fit: cover;
display: block;
}
}
// ── Header ─────────────────────────────────────────────────────────────
&__header {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-4);
&:not(:first-child) {
margin-top: calc(var(--space-4) * 1.5);
}
}
&__tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
&__tag {
font-size: 0.75rem;
font-weight: 600;
padding: 3px 10px;
border-radius: 999px;
background-color: oklch(from var(--accent) l c h / 0.12);
color: var(--accent);
}
&__title {
font-size: var(--font-size-xl);
font-weight: 700;
line-height: 1.2;
}
&__date {
font-size: 0.85rem;
color: var(--text-muted);
}
&__summary {
font-size: var(--font-size-lg);
color: var(--text-muted);
line-height: 1.6;
font-weight: 400;
}
&__divider {
border: none;
border-top: 1px solid var(--border-color);
margin-block: var(--space-4);
}
// ── Article content (from Directus HTML) ──────────────────────────────
&__content {
font-size: var(--font-size-base);
line-height: 1.75;
color: var(--text-main);
h2 {
font-size: var(--font-size-lg);
font-weight: 700;
margin-top: var(--space-4);
margin-bottom: var(--space-2);
}
h3 {
font-size: var(--font-size-base);
font-weight: 700;
margin-top: var(--space-3);
margin-bottom: var(--space-2);
}
p {
margin-bottom: var(--space-3);
}
a {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 3px;
&:hover {
color: var(--accent-hover);
}
}
ul,
ol {
padding-left: var(--space-4);
margin-bottom: var(--space-3);
li {
margin-bottom: var(--space-1);
}
}
blockquote {
border-left: 3px solid var(--accent);
padding-left: var(--space-3);
margin-inline: 0;
margin-block: var(--space-3);
color: var(--text-muted);
font-style: italic;
}
code {
font-family: 'Courier New', Courier, monospace;
font-size: 0.9em;
background-color: var(--bg-muted);
padding: 2px 6px;
border-radius: 4px;
}
pre {
background-color: var(--bg-muted);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: var(--space-3);
overflow-x: auto;
margin-bottom: var(--space-3);
code {
background: none;
padding: 0;
font-size: 0.875em;
}
}
img {
max-width: 100%;
border-radius: var(--border-radius);
margin-block: var(--space-3);
}
hr {
border: none;
border-top: 1px solid var(--border-color);
margin-block: var(--space-4);
}
}
// ── Not Found ──────────────────────────────────────────────────────────
&__not-found {
padding-top: calc(var(--space-4) * 2);
text-align: center;
h1 {
font-size: var(--font-size-xl);
margin-bottom: var(--space-2);
}
}
// ── Skeleton ───────────────────────────────────────────────────────────
&__cover-skeleton {
width: 100%;
height: 380px;
background-color: var(--bg-muted);
margin-bottom: calc(var(--space-4) * 1.5);
}
&__skeleton .blog-detail__wrapper {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
}
.skeleton-line {
border-radius: 4px;
background: linear-gradient(90deg,
var(--bg-muted) 25%,
var(--border-color) 50%,
var(--bg-muted) 75%);
background-size: 800px 100%;
animation: skeleton-shimmer 1.4s ease-in-out infinite;
&--tag {
height: 1.2em;
width: 80px;
}
&--title {
height: 2em;
width: 75%;
margin-bottom: var(--space-1);
}
&--meta {
height: 0.9em;
width: 120px;
}
&--body {
height: 1em;
width: 100%;
}
&--short {
width: 60%;
}
}

View File

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

View File

@@ -0,0 +1,70 @@
<app-blog-nav></app-blog-nav>
<main class="blog-list">
<div class="blog-list__wrapper">
<header class="blog-list__header">
<h1>Blog</h1>
<p class="text-muted">Einblicke, Tipps und Hintergründe rund um Webdesign &amp; digitale Präsenz.</p>
</header>
@if (loading()) {
<div class="blog-list__grid">
@for (_ of [1, 2, 3]; track $index) {
<div class="blog-card blog-card--skeleton">
<div class="blog-card__image blog-card__image--skeleton"></div>
<div class="blog-card__body">
<div class="skeleton-line skeleton-line--title"></div>
<div class="skeleton-line skeleton-line--text"></div>
<div class="skeleton-line skeleton-line--text skeleton-line--short"></div>
</div>
</div>
}
</div>
} @else if (error()) {
<div class="blog-list__error">
<p>{{ error() }}</p>
</div>
} @else if (posts().length === 0) {
<div class="blog-list__empty">
<p>Noch keine Artikel veröffentlicht schau bald wieder vorbei.</p>
</div>
} @else {
<div class="blog-list__grid">
@for (post of posts(); track post.id) {
<a
class="blog-card"
[routerLink]="['/blog', post.slug]"
opTrack="blog_post_click"
[opTrackProps]="{ slug: post.slug, title: post.title }">
<div class="blog-card__image">
@if (getCoverUrl(post); as coverUrl) {
<img [src]="coverUrl" [alt]="post.title" loading="lazy" />
} @else {
<div class="blog-card__image-placeholder"></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>
<time class="blog-card__date" [dateTime]="post.published_at">
{{ post.published_at | date:'d. MMMM yyyy':'':'de' }}
</time>
</div>
</a>
}
</div>
}
</div>
</main>

View File

@@ -0,0 +1,183 @@
@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);
}
}
&__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 ────────────────────────────────────────────────────────────────
.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.22s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.22s ease,
border-color 0.22s ease;
&:hover {
transform: translateY(-4px);
border-color: var(--accent);
box-shadow: 0 8px 24px oklch(0% 0 0 / 0.07);
}
&__image {
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.04);
}
}
}
&__image-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, var(--bg-muted) 0%, var(--border-color) 100%);
}
&__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.75rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
background-color: oklch(from var(--accent) l c h / 0.12);
color: var(--accent);
}
&__title {
font-size: var(--font-size-lg);
font-weight: 700;
line-height: 1.25;
}
&__summary {
font-size: var(--font-size-base);
color: var(--text-muted);
line-height: 1.6;
flex: 1;
// Clamp to 3 lines
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
&__date {
font-size: 0.85rem;
color: var(--text-muted);
margin-top: auto;
}
// ── Skeleton ────────────────────────────────────────────────────────────
&--skeleton {
pointer-events: none;
.blog-card__image--skeleton {
background-color: var(--bg-muted);
}
}
}
.skeleton-line {
height: 1em;
border-radius: 4px;
background: linear-gradient(
90deg,
var(--bg-muted) 25%,
var(--border-color) 50%,
var(--bg-muted) 75%
);
background-size: 800px 100%;
animation: skeleton-shimmer 1.4s ease-in-out infinite;
&--title {
height: 1.4em;
width: 80%;
margin-bottom: var(--space-1);
}
&--text {
width: 100%;
}
&--short {
width: 50%;
}
}

View File

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

View File

@@ -1,15 +1,30 @@
<section class="features-section" id="features-section">
<div class="features-section__wrapper">
<div class="features-section__grid centered">
@for (feature of featuresList; track feature.id) {
<div class="features-section__card">
<h3 class="features-section__claim">{{ feature.claim }}</h3>
<p class="features-section__description">{{ feature.description }}</p>
@if (feature.icon) {
<img [src]="feature.icon" [alt]="feature.iconDescription" />
}
</div>
}
</div>
<div class="features-section__wrapper">
<div class="features-section__header">
<h2>Warum Hurler Webdesign?</h2>
<p class="text-muted">Handwerk statt Baukasten das sind die Unterschiede, die zählen.</p>
</div>
</section>
<div class="features-section__grid">
@for (feature of featuresList; track feature.id; let i = $index) {
<div
#cardRef
class="features-section__card"
[style.--delay]="(i * 120) + 'ms'"
opTrack="feature_card_click"
[opTrackProps]="{ feature_id: feature.id, claim: feature.claim }">
<span class="features-section__card-number">0{{ feature.id }}</span>
<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__bar"></div>
</div>
}
</div>
</div>
</section>

View File

@@ -1,55 +1,175 @@
@use 'abstracts';
@keyframes card-fade-up {
from {
opacity: 0;
transform: translateY(36px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.features-section {
min-height: calc(100vh - var(--neg-nav-height));
margin-top: var(--neg-nav-height);
min-height: 100vh;
display: flex;
align-items: center;
padding-block: calc(var(--space-4) * 2);
&__wrapper {
@include abstracts.container-wrapper;
width: 100%;
}
// ── 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);
}
}
// ── Grid ──────────────────────────────────────────────────────────────────
&__grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-3);
@include abstracts.breakpoint('md') {
grid-template-columns: repeat(3, 1fr);
.features-section__card {
&:nth-child(1) { grid-column: 1 / span 2; grid-row: 1; }
&:nth-child(2) { grid-column: 3; grid-row: 1; }
&:nth-child(3) { grid-column: 1; grid-row: 2; }
&:nth-child(4) { grid-column: 2 / span 2; grid-row: 2; }
}
}
}
// ── Card ──────────────────────────────────────────────────────────────────
&__card {
position: relative;
overflow: hidden;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
min-height: abstracts.rem(220);
display: flex;
flex-direction: column;
padding: var(--space-4) var(--space-3) var(--space-3);
gap: var(--space-2);
background-color: var(--bg-surface);
cursor: default;
// Scroll-entrance: start hidden
opacity: 0;
&.is-visible {
animation: card-fade-up 0.55s cubic-bezier(0.22, 1, 0.36, 1) var(--delay, 0ms) both;
}
// Hover: lift + deepen shadow
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(-5px);
border-color: var(--accent);
box-shadow:
0 8px 24px oklch(0% 0 0 / 0.07),
0 2px 6px oklch(0% 0 0 / 0.04);
}
@include abstracts.breakpoint('md') {
min-height: abstracts.rem(280);
}
}
// ── Ghost number (decorative) ─────────────────────────────────────────────
&__card-number {
position: absolute;
top: -0.1em;
right: var(--space-3);
font-size: 5rem;
font-weight: 900;
line-height: 1;
color: var(--accent);
opacity: 0.06;
pointer-events: none;
user-select: none;
transition: opacity 0.25s ease;
.features-section__card:hover & {
opacity: 0.1;
}
}
// ── Icon ─────────────────────────────────────────────────────────────────
&__icon-wrap {
display: flex;
align-items: center;
justify-content: center;
width: abstracts.rem(44);
height: abstracts.rem(44);
border-radius: 10px;
background-color: oklch(from var(--accent) l c h / 0.12);
flex-shrink: 0;
transition: background-color 0.25s ease, transform 0.25s cubic-bezier(0.22, 1, 0.36, 1);
&__wrapper {
@include abstracts.container-wrapper;
.features-section__card:hover & {
background-color: oklch(from var(--accent) l c h / 0.2);
transform: scale(1.1) rotate(-4deg);
}
}
&__grid {
width: 100%;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-3);
&__icon {
font-size: abstracts.rem(22);
color: var(--accent);
}
// ── Text ──────────────────────────────────────────────────────────────────
&__claim {
font-size: var(--font-size-lg);
font-weight: 700;
line-height: 1.2;
}
&__description {
font-size: var(--font-size-base);
color: var(--text-muted);
line-height: 1.65;
flex: 1;
}
// ── Accent bar (grows on hover) ───────────────────────────────────────────
&__bar {
position: absolute;
bottom: 0;
left: 0;
height: 3px;
width: 0;
background: linear-gradient(90deg, var(--accent), var(--accent-hover));
border-radius: 0 3px 0 var(--border-radius);
transition: width 0.4s cubic-bezier(0.22, 1, 0.36, 1);
.features-section__card:hover & {
width: 100%;
}
&__card {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
height: abstracts.rem(300);
display: flex;
flex-direction: column;
padding-inline: var(--space-3);
justify-content: center;
&:nth-child(1) {
grid-column: 1 / span 2;
grid-row: 1;
}
&:nth-child(2) {
grid-column: 3;
grid-row: 1;
}
&:nth-child(3) {
grid-column: 1;
grid-row: 2;
}
&:nth-child(4) {
grid-column: 2 / span 2;
grid-row: 2;
}
}
&__claim {
font-size: var(--font-size-xl);
}
&__description {
font-size: var(--font-size-lg);
}
}
}
}

View File

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

View File

@@ -1,8 +1,44 @@
<footer class="footer">
<div class="footer__wrapper">
Hurler Webdesign <br/>
Impressum <br/>
Über uns
<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>
</footer>
<div class="footer__bottom">
<p>&copy; {{ currentYear }} Hurler Webdesign. Alle Rechte vorbehalten.</p>
</div>
</div>
</footer>

View File

@@ -1,11 +1,102 @@
@use 'abstracts';
.footer {
background-color: var(--accent);
color: var(--bg-surface);
padding: 20px 0;
background-color: var(--accent);
color: var(--text-on-accent);
padding-top: var(--space-4);
&__wrapper {
@include abstracts.container-wrapper;
&__wrapper {
@include abstracts.container-wrapper;
}
&__grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-4);
padding-bottom: var(--space-4);
@include abstracts.breakpoint('md') {
grid-template-columns: 2fr 1fr 1fr;
}
}
}
&__col-title {
font-size: var(--font-size-base);
font-weight: 700;
margin-bottom: var(--space-3);
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 0.05em;
}
&__logo {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-size-lg);
font-weight: 700;
margin-bottom: var(--space-3);
}
&__logo-icon {
display: flex;
align-items: center;
justify-content: center;
width: abstracts.rem(32);
height: abstracts.rem(32);
background-color: oklch(100% 0 0 / 0.2);
border: 1px solid oklch(100% 0 0 / 0.3);
border-radius: 5px;
font-weight: 700;
}
&__logo-accent {
// "Hurler" in slightly brighter shade for contrast on accent bg
opacity: 0.85;
}
&__tagline {
font-size: var(--font-size-base);
line-height: 1.6;
opacity: 0.8;
margin-bottom: var(--space-3);
max-width: 34ch;
}
&__address {
font-size: var(--font-size-base);
font-style: normal;
line-height: 1.7;
opacity: 0.7;
}
&__links {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
a {
font-size: var(--font-size-base);
opacity: 0.8;
transition: opacity 0.15s ease;
&:hover {
opacity: 1;
}
}
}
&__bottom {
border-top: 1px solid oklch(100% 0 0 / 0.15);
padding-block: var(--space-3);
text-align: center;
p {
font-size: 0.85rem;
opacity: 0.6;
}
}
}

View File

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

View File

@@ -1,23 +1,31 @@
<section class="hero-section" id="hero">
<div class="hero-section__video-container">
<video autoplay muted loop>
<video autoplay muted loop playsinline>
<source src="/video/white_mit_black_stripes.webm" type="video/webm">
</video>
</div>
<div class="hero-section__wrapper">
<h1 class="hero-section__header">
Digitales Handwerk <br />
Digitales Handwerk<br />
statt Standard-Baukasten
</h1>
<p class="hero-section__claim">
Wir programmieren Blitzschnelle, sichere und maßgeschneiderte Webseiten für kleine,
mittelständische Unternehmen und Vereine. Ohne CMS-Ballast, dafür mit maximaler Performance.
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 [item]="{ label: 'Über uns', type: 'anchor', target: '#about' }" variant="primary"></app-button>
<app-button (click)="onFeaturesClick()" [item]="{ label: 'Warum kein Wordpress', type: 'anchor', target: 'about'}"
variant="primary"></app-button>
<app-button
opTrack="hero_cta_features"
[opTrackProps]="{ location: 'hero' }"
[item]="{ label: 'Unsere Vorteile', type: 'anchor', target: '#features-section' }"
variant="primary">
</app-button>
<app-button
opTrack="hero_cta_pricing"
[opTrackProps]="{ location: 'hero' }"
[item]="{ label: 'Preise & Pakete', type: 'anchor', target: '#pricing' }"
variant="outline">
</app-button>
</div>
</div>
</section>
</section>

View File

@@ -1,7 +1,7 @@
@use "abstracts";
.hero-section {
position: relative; // WICHTIG: Bezugspunkt für das Video
position: relative;
min-height: calc(100vh + var(--nav-height));
margin-top: var(--neg-nav-height);
overflow: hidden;
@@ -21,6 +21,10 @@
&__wrapper {
@include abstracts.container-wrapper;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
&__video-container {
@@ -29,19 +33,20 @@
left: 0;
width: 100%;
height: 100%;
z-index: -2; // Hinter den Text legen
z-index: -2;
}
h1 {
color: var(--text-main); // Dein Wunsch-Style
&__header {
color: var(--text-main);
font-size: var(--font-size-xxl);
position: relative; // Stellt sicher, dass der Text über dem Video-Layer bleibt
position: relative;
margin-bottom: var(--space-4);
}
&__claim {
color: var(--text-main);
font-size: var(--font-size-xl);
font-size: var(--font-size-lg);
max-width: 60ch;
margin-bottom: var(--space-4);
}
@@ -50,18 +55,15 @@
flex-direction: row;
gap: var(--space-2);
justify-content: center;
flex-wrap: wrap;
}
video {
/* Das hier ist der entscheidende Teil */
width: 100%;
height: 100%;
object-fit: cover; // WICHTIG: Füllt den Container komplett aus, ohne zu verzerren
object-position: center; // Zentriert das Video, falls Ränder abgeschnitten werden
mask-image: linear-gradient(to bottom,
black 0%,
black 70%,
transparent 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%);
}
}
}

View File

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

View File

@@ -2,7 +2,7 @@
<section class="header">
<div class="logo-container">
<span class="logo-container__logo centered">H</span>
<p class="logo-container__company" (click)="onFeaturesClick('lustiges Zeug')"><span>Hurler</span> Webdesign</p>
<p class="logo-container__company"><span>Hurler</span> Webdesign</p>
</div>
<div class="header__nav-section centered">
<div class="theme-toggle-container">
@@ -14,5 +14,4 @@
</div>
</div>
</section>
</div>
</div>

View File

@@ -29,6 +29,7 @@
.logo-container {
display: flex;
align-items: center;
justify-items: center;
gap: var(--space-2);
padding-left: var(--space-4);
@@ -48,6 +49,7 @@
font-size: var(--font-size-base);
font-weight: 700;
color: var(--text-main);
margin: auto;
span {
color: var(--accent);

View File

@@ -3,20 +3,13 @@ import { NgIcon } from '@ng-icons/core';
import { ToogleThemeComponent } from '@shared/utils/toogle-theme/toogle-theme.component';
import { NavMenuComponent } from '@shared/ui/nav-menu/nav-menu.component';
import { NavigationService } from '@core/services/navigation.service';
import { OpenPanelService } from '@core/services/openpanel.service';
import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive';
@Component({
selector: 'app-navigation',
imports: [NgIcon, ToogleThemeComponent, NavMenuComponent, OpenPanelTrackDirective],
imports: [NgIcon, ToogleThemeComponent, NavMenuComponent],
templateUrl: './navigation.component.html',
styleUrl: './navigation.component.scss',
})
export class NavigationComponent {
protected readonly navigationService = inject(NavigationService);
private op = inject(OpenPanelService)
onFeaturesClick(blindplan: string): void {
this.op.track('features_clicked', { blindplan })
}
}

View File

@@ -1 +1,39 @@
<p>pricing works!</p>
<section class="pricing" id="pricing">
<div class="pricing__wrapper">
<div class="pricing__header">
<h2>Preise & Pakete</h2>
<p class="text-muted">Transparente Preise kein Abo, kein Versteckspiel.</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>
</section>

View File

@@ -0,0 +1,142 @@
@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);
}
}
&__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) var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-3);
background-color: var(--bg-surface);
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: 0 8px 32px oklch(0% 0 0 / 0.08);
}
&--highlighted {
border-color: var(--accent);
box-shadow: 0 4px 24px oklch(45% 0.22 250 / 0.15);
&:hover {
box-shadow: 0 8px 32px oklch(45% 0.22 250 / 0.2);
}
}
}
&__badge {
position: absolute;
top: -1px;
left: 50%;
transform: translateX(-50%) translateY(-50%);
background-color: var(--accent);
color: var(--text-on-accent);
font-size: 0.75rem;
font-weight: 600;
padding: 4px 12px;
border-radius: 999px;
white-space: nowrap;
}
&__card-header {
text-align: center;
}
&__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: 700;
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%;
}
}
}

View File

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

View File

@@ -1,20 +1,27 @@
<section class="projects" id="projects">
<div class="projects__wrapper">
<div class="projects__card-container centered">
@for(project of projects; track project.id) {
<div class="projects__card">
<img [src]="project.image" />
<div class="projects__card__description">
<h3>{{ project.company }}</h3>
<p>{{ project.shortDescription }}</p>
<div class="projects__card-features">
@for(feature of project.features; track $index) {
<p>{{ feature }}</p>
}
</div>
</div>
</div>
}
</div>
<div class="projects__wrapper">
<div class="projects__header">
<h2>Unsere Projekte</h2>
<p class="text-muted">Echte Webseiten für echte Unternehmen sehen Sie selbst.</p>
</div>
</section>
<div class="projects__card-container">
@for (project of projects; track project.id) {
<div
class="projects__card"
opTrack="project_card_click"
[opTrackProps]="{ project_id: project.id, company: project.company }">
<img [src]="project.image" [alt]="project.company" />
<div class="projects__card__overlay">
<h3>{{ project.company }}</h3>
<p>{{ project.shortDescription }}</p>
<div class="projects__card-features">
@for (feature of project.features; track $index) {
<span class="projects__tag">{{ feature }}</span>
}
</div>
</div>
</div>
}
</div>
</div>
</section>

View File

@@ -1,45 +1,99 @@
@use 'abstracts';
.projects {
min-height: 100vh;
display: flex;
align-items: center;
padding-block: var(--space-4);
&__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);
}
}
&__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;
aspect-ratio: 4 / 3;
background-color: var(--bg-muted);
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.4s ease;
}
&:hover img {
transform: scale(1.04);
}
&__overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: var(--space-3);
background: linear-gradient(to top, oklch(0% 0 0 / 0.8) 0%, transparent 60%);
opacity: 0;
transition: opacity 0.3s ease-in-out;
color: var(--color-white);
h3 {
font-size: var(--font-size-lg);
margin-bottom: var(--space-1);
}
p {
font-size: var(--font-size-base);
margin-bottom: var(--space-2);
opacity: 0.85;
}
}
&:hover &__overlay {
opacity: 1;
}
}
&__card-features {
display: flex;
min-height: 100vh;
margin-top: var(--neg-nav-height);
align-items: center;
flex-wrap: wrap;
gap: var(--space-1);
}
&__wrapper {
@include abstracts.container-wrapper;
}
&__card-container {
display: flex;
gap: var(--space-3);
}
&__card {
position: relative;
border-radius: var(--border-radius);
overflow: hidden;
&__description {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: var(--space-2);
background: rgba(0, 0, 0, 0.7);
opacity: 0;
transition: opacity 0.3s ease-in-out;
}
&:hover &__description {
opacity: 1;
color: var(--color-white);
}
}
&__card-features {
display: flex;
gap: var(--space-2);
}
}
&__tag {
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid oklch(100% 0 0 / 0.4);
color: var(--color-white);
}
}

View File

@@ -1,16 +1,17 @@
import { Component } from '@angular/core';
import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive';
interface Project {
id: number,
image: string,
company: string,
shortDescription: string,
features: string[]
id: number;
image: string;
company: string;
shortDescription: string;
features: string[];
}
@Component({
selector: 'app-projects',
imports: [],
imports: [OpenPanelTrackDirective],
templateUrl: './projects.component.html',
styleUrl: './projects.component.scss',
})
@@ -18,24 +19,24 @@ export class ProjectsComponent {
projects: Project[] = [
{
id: 1,
company: "Backerei Müller",
image: "/images/bakery.jpg",
shortDescription: "Landingpage mit wechselnden Angeboten",
features: ["SEO", "Angebote", "Dark/Light"],
company: 'Schreiner Müller GmbH',
image: '/images/schreiner-mueller.jpg',
shortDescription: 'Handwerkswebsite mit Leistungsübersicht und Kontaktformular',
features: ['SEO', 'Kontaktformular', 'Dark/Light'],
},
{
id: 2,
company: "Backerei Müller",
image: "/images/bakery.jpg",
shortDescription: "Landingpage mit wechselnden Angeboten",
features: ["SEO", "Angebote", "Dark/Light"],
company: 'Schützenverein Nördlingen e.V.',
image: '/images/schuetzenverein.jpg',
shortDescription: 'Vereinswebsite mit Terminen und Veranstaltungskalender',
features: ['SEO', 'Termine', 'Mitglieder'],
},
{
id: 3,
company: "Backerei Müller",
image: "/images/bakery.jpg",
shortDescription: "Landingpage mit wechselnden Angeboten",
features: ["SEO", "Angebote", "Dark/Light"],
}
]
company: 'Bäckerei Huber',
image: '/images/baeckerei-huber.jpg',
shortDescription: 'Landingpage mit täglich wechselnden Tagesangeboten',
features: ['SEO', 'Angebote', 'Responsive'],
},
];
}

View File

@@ -2,4 +2,5 @@
<app-hero></app-hero>
<app-features-section></app-features-section>
<app-projects></app-projects>
<app-footer></app-footer>
<app-pricing></app-pricing>
<app-footer></app-footer>

View File

@@ -4,6 +4,7 @@ import { HeroComponent } from '../components/hero/hero.component';
import { FeaturesSectionComponent } from '../components/features-section/features-section.component';
import { FooterComponent } from '../components/footer/footer.component';
import { ProjectsComponent } from '../components/projects/projects.component';
import { PricingComponent } from '../components/pricing/pricing.component';
import { SeoService } from '@core/services/seo.service';
@Component({
@@ -13,8 +14,9 @@ import { SeoService } from '@core/services/seo.service';
HeroComponent,
FeaturesSectionComponent,
ProjectsComponent,
PricingComponent,
FooterComponent,
],
],
templateUrl: './landingpage.component.html',
styleUrl: './landingpage.component.scss',
})

View File

@@ -1,27 +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>
<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">
@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>
}
</li>
{{ item.label }}
</a>
}
</li>
}
</ul>
</nav>
</nav>

View File

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

View File

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

View File

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