Compare commits

..

10 Commits

Author SHA1 Message Date
5138005397 Umzug Gitea 2026-03-28 05:56:35 +01:00
ff70a0b4a5 angular update 2026-03-22 14:42:09 +01:00
11a266879b openpanel service hinzugefügt 2026-03-12 07:51:36 +01:00
5cbe4525d1 gitea umzug 2026-03-12 06:16:36 +01:00
1294f0f07c major update angular 2026-03-11 20:58:58 +01:00
79c71fcf45 herosection erstellt 2026-03-04 22:01:49 +01:00
ce8cb07834 gitea umzug 2026-03-01 08:24:26 +01:00
5622fc211f working with herosection 2026-02-26 17:04:06 +01:00
8cbefe7078 Korrektur bzw. Ergänzung SEO 2026-02-23 21:19:33 +01:00
2fdeb41c25 Landingpage Grid-layout erstellt, Navbar implementiert, SEO vorbereitet 2026-02-23 20:52:45 +01:00
84 changed files with 2886 additions and 903 deletions

View File

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

View File

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

View File

@@ -11,7 +11,8 @@
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
"style": "scss",
"type": "component"
}
},
"root": "",
@@ -37,6 +38,9 @@
"outputMode": "server",
"ssr": {
"entry": "src/server.ts"
},
"stylePreprocessorOptions": {
"includePaths": ["src/styles"]
}
},
"configurations": {

1629
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,26 +24,27 @@
"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.2.0",
"express": "^5.1.0",
"rxjs": "~7.8.0",
"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/node": "^20.19.37",
"jsdom": "^27.1.0",
"typescript": "~5.9.2",
"vitest": "^4.0.8"

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

BIN
public/images/bakery.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,6 +1,8 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideNgIconsConfig } from '@ng-icons/core';
import { environment } from '../environments/openpanel';
import { provideOpenPanel } from '@core/provider/openpanel.provider';
import { routes } from './app.routes';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
@@ -10,6 +12,12 @@ export const appConfig: ApplicationConfig = {
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideClientHydration(withEventReplay()),
provideNgIconsConfig({})
provideNgIconsConfig({}),
provideOpenPanel({
clientId: environment.openPanel.clientId,
apiUrl: environment.openPanel.apiUrl,
trackScreenViews: true,
debug: !environment.production,
})
]
};

View File

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

View File

@@ -1,9 +1,8 @@
import { Routes } from '@angular/router';
import { LandingpageComponent } from './features/landing/pages/landingpage.component';
export const routes: Routes = [
{
path: "",
component: LandingpageComponent
loadComponent: () => import('@features/landing/').then(m => m.LandingpageComponent)
}
];

View File

@@ -1,7 +1,9 @@
import { Component, signal } 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 {cssMenu} from "@ng-icons/css.gg"
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();
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,148 @@
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 {
// 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);
});
}
// ─── 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', 5);
*/
decrement(property: string): void {
if (!this.op) return;
this.op.decrement(property);
}
/**
* Manually tracks a screen/page view.
*/
trackScreenView(path?: string): void {
if (!this.op) return;
const currentPath = path ?? this.router.url;
if (this.config.debug) {
console.debug('[OpenPanel] screenView:', currentPath);
}
this.op.track('screen_view', { path: currentPath });
}
// ─── Cleanup ───────────────────────────────────────────────────────────────
ngOnDestroy(): void {
this.routerSubscription?.unsubscribe();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1,15 @@
<p>features-section works!</p>
<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>
</section>

View File

@@ -0,0 +1,55 @@
@use 'abstracts';
.features-section {
min-height: calc(100vh - var(--neg-nav-height));
margin-top: var(--neg-nav-height);
display: flex;
align-items: center;
&__wrapper {
@include abstracts.container-wrapper;
}
&__grid {
width: 100%;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-3);
}
&__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,5 +1,13 @@
import { Component } from '@angular/core';
interface Features {
id: number,
claim: string,
description: string,
icon?: string,
iconDescription?: string
}
@Component({
selector: 'app-features-section',
imports: [],
@@ -7,5 +15,26 @@ import { Component } from '@angular/core';
styleUrl: './features-section.component.scss',
})
export class FeaturesSectionComponent {
featuresList: Features[] = [
{
id: 1,
claim: "Code statt Baukasten",
description: "Handgefertigte Performance, die Google und Ihre Nutzer lieben werden."
},
{
id: 2,
claim: "Sicher per Design",
description: "Maximale Rechtskonformität durch eRecht24 und hauseigene Server-Infrastruktur."
},
{
id: 3,
claim: "Heimat für Ihre Daten",
description: "Hosting und Services strikt nach europäischem Datenschutzstandard."
},
{
id: 4,
claim: "Alles im Blick",
description: "Ein Portal für alles: Kommunikation, Verwaltung und Erfolgskontrolle"
},
]
}

View File

@@ -1 +1,8 @@
<p>footer works!</p>
<footer class="footer">
<div class="footer__wrapper">
Hurler Webdesign <br/>
Impressum <br/>
Über uns
</div>
</footer>

View File

@@ -0,0 +1,11 @@
@use 'abstracts';
.footer {
background-color: var(--accent);
color: var(--bg-surface);
padding: 20px 0;
&__wrapper {
@include abstracts.container-wrapper;
}
}

View File

@@ -1 +1,23 @@
<section>hero works!</section>
<section class="hero-section" id="hero">
<div class="hero-section__video-container">
<video autoplay muted loop>
<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 />
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.
</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>
</div>
</div>
</section>

View File

@@ -0,0 +1,67 @@
@use "abstracts";
.hero-section {
position: relative; // WICHTIG: Bezugspunkt für das Video
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;
}
&__video-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -2; // Hinter den Text legen
}
h1 {
color: var(--text-main); // Dein Wunsch-Style
font-size: var(--font-size-xxl);
position: relative; // Stellt sicher, dass der Text über dem Video-Layer bleibt
margin-bottom: var(--space-4);
}
&__claim {
color: var(--text-main);
font-size: var(--font-size-xl);
margin-bottom: var(--space-4);
}
&__links {
display: flex;
flex-direction: row;
gap: var(--space-2);
justify-content: center;
}
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%);
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 70%, transparent 100%);
}
}

View File

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

View File

@@ -1,15 +1,18 @@
<div class="wrapper">
<section class="header">
<div class="logo-container">
<span class="logo-container__logo centered">H</span>
<p class="logo-container__company"><span>Hurler</span> Webdesign</p>
<p class="logo-container__company" (click)="onFeaturesClick('lustiges Zeug')"><span>Hurler</span> Webdesign</p>
</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="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="burger-menu centered">
<ng-icon name="cssMenu" class="burger-menu__icon"></ng-icon>
</div>
</div>
</section>
</div>

View File

@@ -1,32 +1,28 @@
@use "../../../../../styles/abstracts";
@use "abstracts";
.navigation {
display: none;
@include abstracts.breakpoint("md") {
display: block;
padding-right: var(--space-4);
.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);
}
&__list {
.header {
max-width: 1200px;
margin: 0 auto;
display: flex;
gap: var(--space-4);
}
justify-content: space-between;
min-height: abstracts.rem(60);
position: sticky;
top: 0;
&__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);
}
}
&__nav-section {
display: flex;
flex-direction: row;
}
}
@@ -39,14 +35,13 @@
&__logo {
font-size: 1.5rem;
font-weight: bold;
color: var(--text-main);
color: var(--text-on-accent);
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 {
@@ -60,8 +55,20 @@
}
}
.theme-toggle-container {
width: abstracts.rem(24);
height: abstracts.rem(24);
margin: auto;
margin-right: var(--space-4);
@include abstracts.breakpoint("md") {
margin: auto;
margin-right: var(--space-4);
}
}
.burger-menu {
padding-right: var(--space-4);
padding: 0 var(--space-4) 0 var(--space-4);
cursor: pointer;
@include abstracts.breakpoint("md") {

View File

@@ -1,12 +1,22 @@
import { Component } from '@angular/core';
import { Component, inject } from '@angular/core';
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],
imports: [NgIcon, ToogleThemeComponent, NavMenuComponent, OpenPanelTrackDirective],
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

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

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
<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>
</section>

View File

@@ -0,0 +1,45 @@
@use 'abstracts';
.projects {
display: flex;
min-height: 100vh;
margin-top: var(--neg-nav-height);
align-items: center;
&__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);
}
}

View File

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

View File

@@ -0,0 +1,41 @@
import { Component } from '@angular/core';
interface Project {
id: number,
image: string,
company: string,
shortDescription: string,
features: string[]
}
@Component({
selector: 'app-projects',
imports: [],
templateUrl: './projects.component.html',
styleUrl: './projects.component.scss',
})
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"],
},
{
id: 2,
company: "Backerei Müller",
image: "/images/bakery.jpg",
shortDescription: "Landingpage mit wechselnden Angeboten",
features: ["SEO", "Angebote", "Dark/Light"],
},
{
id: 3,
company: "Backerei Müller",
image: "/images/bakery.jpg",
shortDescription: "Landingpage mit wechselnden Angeboten",
features: ["SEO", "Angebote", "Dark/Light"],
}
]
}

View File

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

View File

@@ -1,6 +1,5 @@
<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-features-section></app-features-section>
<app-projects></app-projects>
<app-footer></app-footer>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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;
}
}

View File

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

View File

@@ -0,0 +1,27 @@
<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">
@if (item.icon) {
<ng-icon [name]="item.icon"></ng-icon>
}
{{ item.label }}
</a>
}
</li>
}
</ul>
</nav>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
import { Component, inject } from '@angular/core';
import { ThemeService } from '@core/services/theme.service';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { cssMoon, cssSun } from '@ng-icons/css.gg';
@Component({
selector: 'app-toogle-theme',
imports: [NgIcon],
templateUrl: './toogle-theme.component.html',
styleUrl: './toogle-theme.component.scss',
viewProviders: [provideIcons({cssMoon, cssSun})]
})
export class ToogleThemeComponent {
themeService = inject(ThemeService);
toggleTheme() {
const current = this.themeService.theme();
const next = current === 'light' ? 'dark' : 'light';
this.themeService.setTheme(next);
}
}

View File

@@ -0,0 +1,11 @@
import { Project } from "@core/models/project.model";
export const MOCK_PROJECTS: Project[] = [
{
id: "schreiner-mueller",
name: "Schreiner Müller",
client: "Schreiner Müller GmbH",
description: "Ein modernes Webdesign für die Schreiner Müller GmbH, das die Handwerkskunst und Qualität ihrer Dienstleistungen hervorhebt. Die Website bietet eine benutzerfreundliche Navigation, ansprechende Bildergalerien und detaillierte Informationen zu ihren maßgeschneiderten Schreinerarbeiten.",
image: "assets/projects/schreiner-mueller.jpg"
}
]

View File

@@ -0,0 +1,9 @@
// 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
}
};

View File

@@ -6,6 +6,7 @@
<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>

View File

@@ -1,3 +1,2 @@
@use "styles/abstracts";
@use "styles/base";
@use "styles/layout";
@use "abstracts";
@use "base";

View File

@@ -13,3 +13,9 @@ $breakpoints: (
@content;
}
}
@mixin container-wrapper {
max-width: 1200px;
margin: 0 auto;
padding-inline: 20px;
}

View File

@@ -1,12 +1,18 @@
@use "tokens";
html {
font-size: 100%;
box-sizing: border-box;
scroll-padding-top: 60px;
scroll-behavior: smooth;
}
*,
*::before,
*::after {
box-sizing: inherit;
padding: 0;
margin: 0;
}
body {

View File

@@ -1,3 +1,3 @@
@forward "tokens";
@forward "boilerplate";
@forward "typography";
@forward "tokens";

View File

@@ -1,7 +1,22 @@
@use "abstracts";
@use 'sass:math';
// ==============================
// Tier 1: Primitives
// ==============================
:root {
// Sie haben diese bereits als OKLCH
--color-white: oklch(100% 0 0);
--color-black: oklch(0% 0 0);
}
// ==============================
// Tier 2: Semantic
// ==============================
:root {
// Farben
--brand-vue: 250;
--bg-surface: oklch(88% 0.01 250);
--bg-surface: oklch(100% 0.01 250);
--bg-muted: oklch(99% 0.02 250);
--text-main: oklch(20% 0.01 250);
--text-muted: oklch(45% 0.02 250);
@@ -10,8 +25,10 @@
--accent-hover: oklch(55% 0.22 250);
--button-text: oklch(100% 0.01 250);
--border: oklch(90% 0.02 250);
--border-color: oklch(31.836% 0.00775 250);
--border-radius: 10px;
--shadow-color: oklch(0% 0 250);
--z-index-sticky: 100;
// Skalierung (modulare Skala)
--space-unit: 0.25rem;
@@ -24,6 +41,7 @@
--font-size-base: clamp(1rem, 0.5vw + 0.875rem, 1.125rem);
--font-size-lg: clamp(1.25rem, 1vw + 1rem, 1.5rem);
--font-size-xl: clamp(1.5rem, 2vw + 1rem, 2.5rem);
--font-size-xxl: clamp(2.5rem, 3vw + 1.5rem, 4.5rem)
}
[data-theme="dark"] {
@@ -37,4 +55,35 @@
--border: oklch(25% 0.02 250);
--shadow-color: 0 0% 100%;
}
// ==============================
// Tier 3: Component-Specific
// ==============================
:root {
--text-on-accent: var(--color-white);
// Navigation
--nav-shadow: 1px 2px 10px oklch(80% 0 250 / 0.1);
--nav-backdrop: blur(10px);
--nav-bg: oklch(100% 0.00011 271.152 / 0.05);
--nav-height: 3.75rem;
--neg-nav-height: -3.75rem;
// Hero
--hero-video-overlay: oklch(100% 0.01 250 / 0.9);
}
[data-theme="dark"] {
--text-on-accent: var(--color-white);
// Navigation
--nav-shadow: 1px 2px 10px oklch(0% 0 250 / 0.5);
--nav-bg: oklch(20% 0.02 250 / 0.8);
// Hero
--hero-video-overlay: oklch(15% 0.02 250 / 0.9);
}

View File

@@ -0,0 +1,82 @@
@use 'sass:math';
@use 'abstracts' as *;
// ==============================
// Base Typography
// ==============================
:root {
// Schriftarten (Beispiel: System-Stack)
--font-family-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', sans-serif;
--font-family-heading: var(--font-family-base); // oder eine spezifische Schrift wie 'Inter', 'Helvetica Neue'
// Schriftgrößen (fluid, basierend auf _tokens.scss)
--font-size-sm: clamp(0.875rem, 0.5vw + 0.75rem, 1rem);
--font-size-base: var(--font-size-base); // aus _tokens.scss
--font-size-lg: var(--font-size-lg); // aus _tokens.scss
--font-size-xl: var(--font-size-xl); // aus _tokens.scss
// Schriftgewichte
--font-weight-light: 300;
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-bold: 700;
// Zeilenhöhen
--line-height-base: 1.5;
--line-height-heading: 1.2;
// Buchstabenabstand
--letter-spacing-base: 0;
--letter-spacing-heading: -0.02em;
}
// ==============================
// Typografie für Elemente
// ==============================
body {
font-family: var(--font-family-base);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
color: var(--text-main);
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-family-heading);
font-weight: var(--font-weight-bold);
line-height: var(--line-height-heading);
margin: 0 0 var(--space-2);
color: var(--text-main);
}
h1 { font-size: var(--font-size-xxl); }
h2 { font-size: var(--font-size-xl); }
h3 { font-size: var(--font-size-lg); }
h4 { font-size: var(--font-size-base); }
p {
margin: 0 0 var(--space-3);
}
a {
color: var(--accent);
text-decoration: none;
transition: color 0.2s ease;
&:hover {
color: var(--accent-hover);
}
}
// ==============================
// Utility-Klassen für Typografie
// ==============================
.text-muted {
color: var(--text-muted);
}
.text-center {
text-align: center;
}
.font-light { font-weight: var(--font-weight-light); }
.font-medium { font-weight: var(--font-weight-medium); }

View File

@@ -1,41 +1,51 @@
@use "../abstracts";
@use "abstracts";
.landing-grid {
display: grid;
grid-template-columns: 1fr minmax(auto, 1200px) 1fr;
grid-template-areas:
"navigation navigation navigation"
". hero ."
". features-section ."
"footer footer footer";
.layout-wrapper {
max-width: 1200px;
margin: auto;
padding-inline: 20px;
}
.navigation__component {
grid-area: navigation;
display: flex;
justify-content: space-between;
border-radius: 0 0 10px 10px;
backdrop-filter: blur(10px);
box-shadow: 1px 2px 10px rgba(197, 197, 197, 0.824);
position: sticky;
top: 0;
}
// .landing-grid {
// display: grid;
// grid-template-columns: 1fr minmax(auto, 1200px) 1fr;
// grid-template-areas:
// "navigation navigation navigation"
// "hero hero hero"
// ". features-section ."
// "footer footer footer";
// }
.hero__component {
grid-area: hero;
height: 80vh;
padding: var(--space-4);
// .grid-area-navigation {
// grid-area: navigation;
@include abstracts.breakpoint("md") {
padding: 0;
}
}
// }
.features-section__component {
grid-area: features-section;
height: 80vh;
}
// .grid-area-hero {
// grid-area: hero;
// margin-top: -60px;
// padding-top: 60px;
// height: 100vh;
// display: flex;
// justify-content: center;
// background:
// linear-gradient(rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 1)),
// linear-gradient(0.25turn, oklch(56.347% 0.24089 260.834 / 0.8), oklch(55.088% 0.23462 260.772 / 0.8), oklch(56.347% 0.24089 260.834 / 0.8)),
// url("/images/backgroundpattern.webp") center / cover no-repeat;
// }
.footer__component {
grid-area: footer;
}
// [data-theme="dark"] .grid-area-hero {
// background:
// linear-gradient(rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0.2)),
// linear-gradient(0.25turn, oklch(20.648% 0.14311 250 / 0.5), oklch(27.99% 0.10701 258.719 / 0.5), oklch(20.648% 0.14311 250 / 0.5)),
// url("/images/backgroundpattern.webp") center / cover no-repeat;
// }
// .grid-area-features {
// grid-area: features-section;
// height: 80vh;
// }
// .grid-area-footer {
// grid-area: footer;
// }

View File

@@ -1,2 +0,0 @@
@forward "grid";
@forward "header";

View File

@@ -13,7 +13,12 @@
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
"module": "preserve",
"paths": {
"@core/*": ["./src/app/core/*"],
"@features/*": ["./src/app/features/*"],
"@shared/*": ["./src/app/shared/*"]
}
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,