diff --git a/public/video/black mit white stripes.webm b/public/video/black mit white stripes.webm new file mode 100644 index 0000000..940e25b Binary files /dev/null and b/public/video/black mit white stripes.webm differ diff --git a/public/video/dark mit blue stripes.webm b/public/video/dark mit blue stripes.webm new file mode 100644 index 0000000..67a0722 Binary files /dev/null and b/public/video/dark mit blue stripes.webm differ diff --git a/public/video/türkis motherboard.webm b/public/video/türkis motherboard.webm new file mode 100644 index 0000000..df66095 Binary files /dev/null and b/public/video/türkis motherboard.webm differ diff --git a/public/video/white_mit_black_stripes.webm b/public/video/white_mit_black_stripes.webm new file mode 100644 index 0000000..8e5ab38 Binary files /dev/null and b/public/video/white_mit_black_stripes.webm differ diff --git a/src/app/app.ts b/src/app/app.ts index 4674f1e..a36c593 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,7 +1,9 @@ -import { Component, signal, inject } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; +import { Component, signal, OnInit } 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'; @Component({ selector: 'app-root', @@ -10,6 +12,19 @@ import {cssMenu} from "@ng-icons/css.gg"; styleUrl: './app.scss', viewProviders: [provideIcons({cssMenu})] }) -export class App { +export class App implements OnInit { protected readonly title = signal('hurler-webdesign-saas'); + + constructor( + private router: Router, + private umami: UmamiService + ) {} + + ngOnInit(): void { + this.router.events.pipe( + filter(event => event instanceof NavigationEnd) + ).subscribe(() => { + this.umami.trackPageview(); + }); + } } diff --git a/src/app/core/services/seo.service.ts b/src/app/core/services/seo.service.ts index 4844f24..3cf07f0 100644 --- a/src/app/core/services/seo.service.ts +++ b/src/app/core/services/seo.service.ts @@ -13,19 +13,27 @@ export class SeoService { 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 + // Open Graph metadata this.metaService.updateTag({ property: "og:title", content: fullTitle, @@ -44,7 +52,7 @@ export class SeoService { }); this.metaService.updateTag({ property: "og:url", content: url }); - // Twitter + // Twitter card metadata this.metaService.updateTag({ name: "twitter:card", content: "summary_large_image", @@ -63,13 +71,20 @@ export class SeoService { 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']") || @@ -81,7 +96,9 @@ export class SeoService { } } - + /** + * 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); diff --git a/src/app/core/services/umami.service.spec.ts b/src/app/core/services/umami.service.spec.ts new file mode 100644 index 0000000..d87eff0 --- /dev/null +++ b/src/app/core/services/umami.service.spec.ts @@ -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(); + }); +}); diff --git a/src/app/core/services/umami.service.ts b/src/app/core/services/umami.service.ts new file mode 100644 index 0000000..35fe5c2 --- /dev/null +++ b/src/app/core/services/umami.service.ts @@ -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); + } +} \ No newline at end of file diff --git a/src/app/features/landing/components/features-section/features-section.component.scss b/src/app/features/landing/components/features-section/features-section.component.scss index e69de29..9a60954 100644 --- a/src/app/features/landing/components/features-section/features-section.component.scss +++ b/src/app/features/landing/components/features-section/features-section.component.scss @@ -0,0 +1,3 @@ +p { + height: 100vh; +} \ No newline at end of file diff --git a/src/app/features/landing/components/footer/footer.component.html b/src/app/features/landing/components/footer/footer.component.html index 28c0d7d..11f87ee 100644 --- a/src/app/features/landing/components/footer/footer.component.html +++ b/src/app/features/landing/components/footer/footer.component.html @@ -1 +1,3 @@ -
footer works!
+ \ No newline at end of file diff --git a/src/app/features/landing/components/footer/footer.component.scss b/src/app/features/landing/components/footer/footer.component.scss index e69de29..42ec025 100644 --- a/src/app/features/landing/components/footer/footer.component.scss +++ b/src/app/features/landing/components/footer/footer.component.scss @@ -0,0 +1,3 @@ +footer { + height: 500px; +} \ No newline at end of file diff --git a/src/app/features/landing/components/hero/hero.component.html b/src/app/features/landing/components/hero/hero.component.html index e100fee..5b25b61 100644 --- a/src/app/features/landing/components/hero/hero.component.html +++ b/src/app/features/landing/components/hero/hero.component.html @@ -1,16 +1,23 @@- 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, + mittelständische Unternehmen und Vereine. Ohne CMS-Ballast, dafür mit maximaler Performance. +
+Hurler Webdesign
-Hurler Webdesign
button works!
+ +@if (isRouteType) { + + @if (item.icon) { + + } + + +} + + +@if (isAnchorType) { + + @if (item.icon) { + + } + + +} + + +@if (isExternalType) { + + @if (item.icon) { + + } + + + +} \ No newline at end of file diff --git a/src/app/shared/ui/button/button.component.scss b/src/app/shared/ui/button/button.component.scss index e69de29..b97efe4 100644 --- a/src/app/shared/ui/button/button.component.scss +++ b/src/app/shared/ui/button/button.component.scss @@ -0,0 +1,139 @@ +// ─── Design Tokens ─────────────────────────────────────────────────────────── +:host { + --btn-font: 'DM Mono', 'Courier New', monospace; + --btn-radius: 4px; + --btn-transition: 160ms cubic-bezier(0.4, 0, 0.2, 1); + + // Size tokens + --btn-sm-padding: 6px 14px; + --btn-md-padding: 10px 22px; + --btn-lg-padding: 14px 32px; + --btn-sm-font: 0.75rem; + --btn-md-font: 0.875rem; + --btn-lg-font: 1rem; + + // Color tokens — override at :root level to theme globally + --btn-primary-bg: #0f0f0f; + --btn-primary-color: #f5f5f5; + --btn-primary-border: #0f0f0f; + --btn-primary-hover-bg: #2a2a2a; + + --btn-ghost-bg: transparent; + --btn-ghost-color: #0f0f0f; + --btn-ghost-border: transparent; + --btn-ghost-hover-bg: rgba(0, 0, 0, 0.06); + + --btn-outline-bg: transparent; + --btn-outline-color: #0f0f0f; + --btn-outline-border: #0f0f0f; + --btn-outline-hover-bg: #0f0f0f; + --btn-outline-hover-color: #f5f5f5; + + 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; + } +} \ No newline at end of file diff --git a/src/app/shared/ui/button/button.component.ts b/src/app/shared/ui/button/button.component.ts index 1dc1ae8..d9b4a09 100644 --- a/src/app/shared/ui/button/button.component.ts +++ b/src/app/shared/ui/button/button.component.ts @@ -1,11 +1,55 @@ -import { Component } from '@angular/core'; +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', - imports: [], + standalone: true, + imports: [RouterModule], templateUrl: './button.component.html', - styleUrl: './button.component.scss', + styleUrls: ['./button.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class ButtonComponent { +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); + } +} \ No newline at end of file diff --git a/src/index.html b/src/index.html index e3a46b8..5f752bc 100644 --- a/src/index.html +++ b/src/index.html @@ -6,6 +6,7 @@