Compare commits
10 Commits
b6ca9be225
...
5138005397
| Author | SHA1 | Date | |
|---|---|---|---|
| 5138005397 | |||
| ff70a0b4a5 | |||
| 11a266879b | |||
| 5cbe4525d1 | |||
| 1294f0f07c | |||
| 79c71fcf45 | |||
| ce8cb07834 | |||
| 5622fc211f | |||
| 8cbefe7078 | |||
| 2fdeb41c25 |
23
.continue/agents/new-config.yaml
Normal file
23
.continue/agents/new-config.yaml
Normal file
@@ -0,0 +1,23 @@
|
||||
# This is an example configuration file
|
||||
# To learn more, see the full config.yaml reference: https://docs.continue.dev/reference
|
||||
|
||||
name: Example Config
|
||||
version: 1.0.0
|
||||
schema: v1
|
||||
|
||||
# Define which models can be used
|
||||
# https://docs.continue.dev/customization/models
|
||||
models:
|
||||
- name: my gpt-5
|
||||
provider: openai
|
||||
model: gpt-5
|
||||
apiKey: YOUR_OPENAI_API_KEY_HERE
|
||||
- uses: ollama/qwen2.5-coder-7b
|
||||
- uses: anthropic/claude-4-sonnet
|
||||
with:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# MCP Servers that Continue can access
|
||||
# https://docs.continue.dev/customization/mcp-tools
|
||||
mcpServers:
|
||||
- uses: anthropic/memory-mcp
|
||||
10
.continue/mcpServers/new-mcp-server.yaml
Normal file
10
.continue/mcpServers/new-mcp-server.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
name: New MCP server
|
||||
version: 0.0.1
|
||||
schema: v1
|
||||
mcpServers:
|
||||
- name: New MCP server
|
||||
command: npx
|
||||
args:
|
||||
- -y
|
||||
- <your-mcp-server>
|
||||
env: {}
|
||||
@@ -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
1629
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@@ -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"
|
||||
|
||||
BIN
public/images/backgroundpattern.webp
Normal file
BIN
public/images/backgroundpattern.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
BIN
public/images/bakery.jpg
Normal file
BIN
public/images/bakery.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/video/black mit white stripes.webm
Normal file
BIN
public/video/black mit white stripes.webm
Normal file
Binary file not shown.
BIN
public/video/dark mit blue stripes.webm
Normal file
BIN
public/video/dark mit blue stripes.webm
Normal file
Binary file not shown.
BIN
public/video/türkis motherboard.webm
Normal file
BIN
public/video/türkis motherboard.webm
Normal file
Binary file not shown.
BIN
public/video/white_mit_black_stripes.webm
Normal file
BIN
public/video/white_mit_black_stripes.webm
Normal file
Binary file not shown.
@@ -1,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,
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { RenderMode, ServerRoute } from '@angular/ssr';
|
||||
|
||||
export const serverRoutes: ServerRoute[] = [
|
||||
{
|
||||
path: '',
|
||||
renderMode: RenderMode.Prerender
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
renderMode: RenderMode.Prerender
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
58
src/app/core/directives/openpanel.directive.ts
Normal file
58
src/app/core/directives/openpanel.directive.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Directive, HostListener, Input, inject } from '@angular/core';
|
||||
import { OpenPanelService, TrackProperties } from '@core/services/openpanel.service';
|
||||
|
||||
/**
|
||||
* Directive for declarative event tracking directly in templates.
|
||||
*
|
||||
* @example
|
||||
* <button opTrack="signup_clicked" [opTrackProps]="{ location: 'hero' }">
|
||||
* Sign Up
|
||||
* </button>
|
||||
*
|
||||
* @example
|
||||
* <a routerLink="/pricing" opTrack="pricing_link_clicked">Pricing</a>
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[opTrack]',
|
||||
standalone: true,
|
||||
})
|
||||
export class OpenPanelTrackDirective {
|
||||
private readonly op = inject(OpenPanelService);
|
||||
|
||||
/** The event name to track on click. */
|
||||
@Input({ required: true }) opTrack!: string;
|
||||
|
||||
/** Optional properties to send with the event. */
|
||||
@Input() opTrackProps?: TrackProperties;
|
||||
|
||||
/** Which DOM event triggers tracking. Default: 'click' */
|
||||
@Input() opTrackOn: 'click' | 'mouseenter' | 'focus' | 'blur' = 'click';
|
||||
|
||||
@HostListener('click')
|
||||
onClick(): void {
|
||||
if (this.opTrackOn === 'click') {
|
||||
this.op.track(this.opTrack, this.opTrackProps);
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('mouseenter')
|
||||
onMouseEnter(): void {
|
||||
if (this.opTrackOn === 'mouseenter') {
|
||||
this.op.track(this.opTrack, this.opTrackProps);
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('focus')
|
||||
onFocus(): void {
|
||||
if (this.opTrackOn === 'focus') {
|
||||
this.op.track(this.opTrack, this.opTrackProps);
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('blur')
|
||||
onBlur(): void {
|
||||
if (this.opTrackOn === 'blur') {
|
||||
this.op.track(this.opTrack, this.opTrackProps);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/app/core/models/navigation.model.ts
Normal file
12
src/app/core/models/navigation.model.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export type NavigationType = "anchor" | "route" | "external";
|
||||
|
||||
export interface NavigationItem {
|
||||
readonly label: string;
|
||||
readonly type: NavigationType;
|
||||
readonly target: string;
|
||||
readonly icon?: string;
|
||||
readonly children?: NavigationItem[];
|
||||
}
|
||||
|
||||
export const isAnchor = (item: NavigationItem): boolean => item.type === "anchor";
|
||||
export const isRoute = (item: NavigationItem): boolean => item.type === "route";
|
||||
35
src/app/core/models/openpanel.model.ts
Normal file
35
src/app/core/models/openpanel.model.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
export interface OpenPanelConfig {
|
||||
/** Your OpenPanel Client ID (required) */
|
||||
clientId: string;
|
||||
|
||||
/** URL of your OpenPanel API or self-hosted instance.
|
||||
* Defaults to https://api.openpanel.dev */
|
||||
apiUrl?: string;
|
||||
|
||||
/** Automatically track Angular Router navigation events as screen views.
|
||||
* Default: true */
|
||||
trackScreenViews?: boolean;
|
||||
|
||||
/** Track clicks on outgoing links automatically.
|
||||
* Default: false */
|
||||
trackOutgoingLinks?: boolean;
|
||||
|
||||
/** Enable declarative tracking via data-track HTML attributes.
|
||||
* Default: false */
|
||||
trackAttributes?: boolean;
|
||||
|
||||
/** Global properties sent with every event (e.g. app_version, environment). */
|
||||
globalProperties?: Record<string, string | number | boolean>;
|
||||
|
||||
/** Completely disable all tracking (e.g. in test environments).
|
||||
* Default: false */
|
||||
disabled?: boolean;
|
||||
|
||||
/** Enable verbose console logging for debugging.
|
||||
* Default: false */
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
export const OPENPANEL_CONFIG = new InjectionToken<OpenPanelConfig>('OPENPANEL_CONFIG');
|
||||
8
src/app/core/models/project.model.ts
Normal file
8
src/app/core/models/project.model.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
client: string;
|
||||
description: string;
|
||||
socialDescription?: string;
|
||||
image: string;
|
||||
}
|
||||
7
src/app/core/models/seo.model.ts
Normal file
7
src/app/core/models/seo.model.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface SeoData {
|
||||
title: string;
|
||||
description: string;
|
||||
socialsDescription?: string;
|
||||
image?: string;
|
||||
type?: "website" | "article";
|
||||
}
|
||||
31
src/app/core/provider/openpanel.provider.ts
Normal file
31
src/app/core/provider/openpanel.provider.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
|
||||
import { OpenPanelConfig, OPENPANEL_CONFIG } from '@core/models/openpanel.model';
|
||||
import { OpenPanelService } from '@core/services/openpanel.service';
|
||||
|
||||
/**
|
||||
* Provides the OpenPanel analytics service for your Angular application.
|
||||
*
|
||||
* @example
|
||||
* // app.config.ts
|
||||
* import { provideOpenPanel } from './openpanel/openpanel.provider';
|
||||
*
|
||||
* export const appConfig: ApplicationConfig = {
|
||||
* providers: [
|
||||
* provideRouter(routes),
|
||||
* provideOpenPanel({
|
||||
* clientId: 'your-client-id',
|
||||
* trackScreenViews: true,
|
||||
* globalProperties: {
|
||||
* app_version: '1.0.0',
|
||||
* environment: 'production',
|
||||
* },
|
||||
* }),
|
||||
* ],
|
||||
* };
|
||||
*/
|
||||
export function provideOpenPanel(config: OpenPanelConfig): EnvironmentProviders {
|
||||
return makeEnvironmentProviders([
|
||||
{ provide: OPENPANEL_CONFIG, useValue: config },
|
||||
OpenPanelService,
|
||||
]);
|
||||
}
|
||||
16
src/app/core/services/navigation.service.spec.ts
Normal file
16
src/app/core/services/navigation.service.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { NavigationService } from './navigation.service';
|
||||
|
||||
describe('NavigationService', () => {
|
||||
let service: NavigationService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(NavigationService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
75
src/app/core/services/navigation.service.ts
Normal file
75
src/app/core/services/navigation.service.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// core/services/navigation.service.ts
|
||||
import { Injectable, signal, computed, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { NavigationItem, isAnchor, isRoute } from '../models/navigation.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class NavigationService {
|
||||
private readonly router = inject(Router);
|
||||
|
||||
// === STATE ===
|
||||
private readonly _navigationItems = signal<NavigationItem[]>([
|
||||
// Anchor-Links (Landingpage intern)
|
||||
{ label: 'Home', type: 'anchor', target: 'hero' },
|
||||
{ label: 'Features', type: 'anchor', target: 'features-section'},
|
||||
{ label: 'Projekte', type: 'anchor', target: 'projects' },
|
||||
{ label: 'Pricing', type: 'anchor', target: 'pricing' },
|
||||
|
||||
// Route-Links ( 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
16
src/app/core/services/openpanel.service.spec.ts
Normal file
16
src/app/core/services/openpanel.service.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { OpenPanelService } from './openpanel.service';
|
||||
|
||||
describe('OpenpanelService', () => {
|
||||
let service: OpenPanelService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(OpenPanelService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
148
src/app/core/services/openpanel.service.ts
Normal file
148
src/app/core/services/openpanel.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
16
src/app/core/services/seo.service.spec.ts
Normal file
16
src/app/core/services/seo.service.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { SeoService } from './seo.service';
|
||||
|
||||
describe('SeoService', () => {
|
||||
let service: SeoService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(SeoService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
136
src/app/core/services/seo.service.ts
Normal file
136
src/app/core/services/seo.service.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Injectable, inject } from "@angular/core";
|
||||
import { Title, Meta } from "@angular/platform-browser";
|
||||
import { DOCUMENT } from "@angular/common";
|
||||
import { RendererFactory2 } from "@angular/core";
|
||||
import { SeoData } from "@core/models/seo.model";
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class SeoService {
|
||||
private titleService = inject(Title);
|
||||
private metaService = inject(Meta);
|
||||
private document = inject(DOCUMENT);
|
||||
private rendererFactory = inject(RendererFactory2);
|
||||
private renderer =
|
||||
this.rendererFactory.createRenderer(null, null);
|
||||
|
||||
// Constants for metadata
|
||||
private readonly BRAND = "Hurler Webdesign";
|
||||
private readonly FALLBACK_IMAGE =
|
||||
'https://hurler-webdesign.de/assets/og-default.jpg';
|
||||
private readonly DEFAULT_TYPE = "website";
|
||||
|
||||
/**
|
||||
* Updates SEO metadata with the provided data.
|
||||
*
|
||||
* @param data - The SeoData object containing title, description, type, and image.
|
||||
* @param canonicalPath - Optional parameter for the canonical URL path.
|
||||
*/
|
||||
updateMetadata(data: SeoData, canonicalPath?: string) {
|
||||
const fullTitle = `${data.title} | ${this.BRAND}`;
|
||||
const url = `https://hurler-webdesign.de${canonicalPath || ""}`;
|
||||
|
||||
// Update title and meta description
|
||||
this.titleService.setTitle(fullTitle);
|
||||
this.metaService.updateTag({ name: "description", content: data.description });
|
||||
|
||||
// Open Graph metadata
|
||||
this.metaService.updateTag({
|
||||
property: "og:title",
|
||||
content: fullTitle,
|
||||
});
|
||||
this.metaService.updateTag({
|
||||
property: "og:description",
|
||||
content: data.description,
|
||||
});
|
||||
this.metaService.updateTag({
|
||||
property: "og:type",
|
||||
content: data.type || this.DEFAULT_TYPE,
|
||||
});
|
||||
this.metaService.updateTag({
|
||||
property: "og:image",
|
||||
content: data.image || this.FALLBACK_IMAGE,
|
||||
});
|
||||
this.metaService.updateTag({ property: "og:url", content: url });
|
||||
|
||||
// Twitter card metadata
|
||||
this.metaService.updateTag({
|
||||
name: "twitter:card",
|
||||
content: "summary_large_image",
|
||||
});
|
||||
this.metaService.updateTag({
|
||||
name: "twitter:title",
|
||||
content: fullTitle,
|
||||
});
|
||||
this.metaService.updateTag({
|
||||
name: "twitter:description",
|
||||
content: data.socialsDescription || data.description,
|
||||
});
|
||||
this.metaService.updateTag({ name: "twitter:url", content: url });
|
||||
this.metaService.updateTag({
|
||||
name: "twitter:image",
|
||||
content: data.image || this.FALLBACK_IMAGE,
|
||||
});
|
||||
|
||||
// Update canonical URL if provided
|
||||
if (canonicalPath) {
|
||||
this.updateCanonicalUrl(url);
|
||||
}
|
||||
|
||||
// Set local business schema.org metadata
|
||||
this.setLocalBusinessSchema();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the canonical URL for the given page.
|
||||
*
|
||||
* @param url - The new canonical URL.
|
||||
*/
|
||||
private updateCanonicalUrl(url: string) {
|
||||
let link: HTMLLinkElement =
|
||||
this.document.querySelector("link[rel='canonical']") ||
|
||||
this.renderer.createElement("link");
|
||||
this.renderer.setAttribute(link, "rel", "canonical");
|
||||
this.renderer.setAttribute(link, "href", url);
|
||||
if (!this.document.head.contains(link)) {
|
||||
this.renderer.appendChild(this.document.head, link);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the local business schema.org JSON-LD script in the head of the document.
|
||||
*/
|
||||
private setLocalBusinessSchema() {
|
||||
const oldScript = this.document.getElementById('schema-org-data');
|
||||
if (oldScript) this.renderer.removeChild(this.document.head, oldScript);
|
||||
|
||||
const schema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebDesignService",
|
||||
"name": this.BRAND,
|
||||
"url": "https://hurler-webdesign.de",
|
||||
"logo": "https://hurler-webdesign.de/assets/logo.png",
|
||||
"image": "https://hurler-webdesign.de/assets/office.jpg",
|
||||
"description": "Spezialist für schnelle Webseiten ohne WordPress für Handwerk & Vereine. Wir bieten maßgeschneidertes Webdesign für eine schnelle und sichere Online-Präsenz.",
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"streetAddress": "Untermagerbein 30",
|
||||
"addressLocality": "Mönchsdeggingen",
|
||||
"postalCode": "86751",
|
||||
"addressCountry": "DE"
|
||||
},
|
||||
"geo": {
|
||||
"@type": "GeoCoordinates",
|
||||
"latitude": 48.7506,
|
||||
"longitude": 10.5773
|
||||
},
|
||||
"areaServed": ["Nördlingen", "Donauwörth", "Augsburg", "Bayern"],
|
||||
"telephone": "+49 171 8084830",
|
||||
"priceRange": "€€"
|
||||
};
|
||||
const script = this.renderer.createElement('script');
|
||||
script.type = 'application/ld+json';
|
||||
script.id = 'schema-org-data';
|
||||
script.text = JSON.stringify(schema);
|
||||
this.renderer.appendChild(this.document.head, script);
|
||||
}
|
||||
}
|
||||
16
src/app/core/services/theme.service.spec.ts
Normal file
16
src/app/core/services/theme.service.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ThemeService } from './theme.service';
|
||||
|
||||
describe('Theme', () => {
|
||||
let service: ThemeService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(ThemeService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
55
src/app/core/services/theme.service.ts
Normal file
55
src/app/core/services/theme.service.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// core/services/theme.service.ts
|
||||
import { Injectable, signal, effect, computed, Inject } from '@angular/core';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { PLATFORM_ID } from '@angular/core';
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ThemeService {
|
||||
// Privater Signal-State
|
||||
private readonly _theme = signal<Theme>('system');
|
||||
|
||||
// Öffentlicher Readonly-Signal
|
||||
readonly theme = this._theme.asReadonly();
|
||||
|
||||
// Abgeleiteter Wert: Tatsächlich aktives Theme (aufgelöst)
|
||||
readonly effectiveTheme = computed(() => {
|
||||
const current = this._theme();
|
||||
if (current !== 'system') return current;
|
||||
|
||||
if (isPlatformBrowser(this.platformId)) {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
}
|
||||
return 'light';
|
||||
});
|
||||
|
||||
constructor(@Inject(PLATFORM_ID) private platformId: object) {
|
||||
// Effekt: Reagiert auf Änderungen (nur im Browser)
|
||||
effect(() => {
|
||||
if (isPlatformBrowser(this.platformId)) {
|
||||
const theme = this.effectiveTheme();
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', this._theme());
|
||||
}
|
||||
});
|
||||
|
||||
// Initialisierung
|
||||
this.initializeTheme();
|
||||
}
|
||||
|
||||
setTheme(theme: Theme): void {
|
||||
this._theme.set(theme);
|
||||
}
|
||||
|
||||
private initializeTheme(): void {
|
||||
if (isPlatformBrowser(this.platformId)) {
|
||||
const saved = localStorage.getItem('theme') as Theme | null;
|
||||
if (saved) {
|
||||
this._theme.set(saved);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/app/core/services/umami.service.spec.ts
Normal file
16
src/app/core/services/umami.service.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { UmamiService } from './umami.service';
|
||||
|
||||
describe('UmamiService', () => {
|
||||
let service: UmamiService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(UmamiService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
42
src/app/core/services/umami.service.ts
Normal file
42
src/app/core/services/umami.service.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
export interface UmamiEventData {
|
||||
[key: string]: string | number | boolean;
|
||||
}
|
||||
|
||||
// Typdefinition für das globale umami-Objekt
|
||||
declare global {
|
||||
interface Window {
|
||||
umami?: {
|
||||
track: (eventName?: string, data?: UmamiEventData) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class UmamiService {
|
||||
private get isAvailable(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.umami !== 'undefined';
|
||||
}
|
||||
|
||||
/**
|
||||
* Trackt einen Pageview manuell – nötig bei SPAs wie Angular,
|
||||
* da kein echter Seitenaufruf stattfindet.
|
||||
*/
|
||||
trackPageview(): void {
|
||||
if (!this.isAvailable) return;
|
||||
window.umami!.track();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trackt ein benutzerdefiniertes Event.
|
||||
* @param eventName Name des Events, z.B. 'button-click'
|
||||
* @param data Optionale Zusatzdaten, z.B. { label: 'Hero CTA' }
|
||||
*/
|
||||
trackEvent(eventName: string, data?: UmamiEventData): void {
|
||||
if (!this.isAvailable) return;
|
||||
window.umami!.track(eventName, data);
|
||||
}
|
||||
}
|
||||
1
src/app/features/dashboard/dashboard.html
Normal file
1
src/app/features/dashboard/dashboard.html
Normal file
@@ -0,0 +1 @@
|
||||
<p>dashboard works!</p>
|
||||
23
src/app/features/dashboard/dashboard.spec.ts
Normal file
23
src/app/features/dashboard/dashboard.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { Dashboard } from './dashboard';
|
||||
|
||||
describe('Dashboard', () => {
|
||||
let component: Dashboard;
|
||||
let fixture: ComponentFixture<Dashboard>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Dashboard]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(Dashboard);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
11
src/app/features/dashboard/dashboard.ts
Normal file
11
src/app/features/dashboard/dashboard.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
imports: [],
|
||||
templateUrl: './dashboard.html',
|
||||
styleUrl: './dashboard.scss',
|
||||
})
|
||||
export class Dashboard {
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
<p>footer works!</p>
|
||||
<footer class="footer">
|
||||
<div class="footer__wrapper">
|
||||
Hurler Webdesign <br/>
|
||||
Impressum <br/>
|
||||
Über uns
|
||||
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,11 @@
|
||||
@use 'abstracts';
|
||||
|
||||
.footer {
|
||||
background-color: var(--accent);
|
||||
color: var(--bg-surface);
|
||||
padding: 20px 0;
|
||||
|
||||
&__wrapper {
|
||||
@include abstracts.container-wrapper;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
<div class="logo-container">
|
||||
<span class="logo-container__logo centered">H</span>
|
||||
<p class="logo-container__company"><span>Hurler</span> Webdesign</p>
|
||||
</div>
|
||||
<div class="wrapper">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<nav class="navigation">
|
||||
<ul class="navigation__list">
|
||||
<li class="navigation__list-item"><a href="#">Home</a></li>
|
||||
<li class="navigation__list-item"><a href="#">Features</a></li>
|
||||
<li class="navigation__list-item"><a href="#">Contact</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="burger-menu centered">
|
||||
<ng-icon name="cssMenu" class="burger-menu__icon"></ng-icon>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,32 +1,28 @@
|
||||
@use "../../../../../styles/abstracts";
|
||||
@use "abstracts";
|
||||
|
||||
.navigation {
|
||||
display: none;
|
||||
.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);
|
||||
}
|
||||
|
||||
@include abstracts.breakpoint("md") {
|
||||
display: block;
|
||||
padding-right: var(--space-4);
|
||||
}
|
||||
.header {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
min-height: abstracts.rem(60);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
||||
&__list {
|
||||
&__nav-section {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
&__list-item {
|
||||
list-style: none;
|
||||
font-weight: 500;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-main);
|
||||
transition: color 0.3s ease;
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
|
||||
&:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
}
|
||||
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") {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<p>pricing works!</p>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { PricingComponent } from './pricing.component';
|
||||
|
||||
describe('PricingComponent', () => {
|
||||
let component: PricingComponent;
|
||||
let fixture: ComponentFixture<PricingComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PricingComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PricingComponent);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-pricing',
|
||||
imports: [],
|
||||
templateUrl: './pricing.component.html',
|
||||
styleUrl: './pricing.component.scss',
|
||||
})
|
||||
export class PricingComponent {
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ProjectsComponent } from './projects.component';
|
||||
|
||||
describe('ProjectsComponent', () => {
|
||||
let component: ProjectsComponent;
|
||||
let fixture: ComponentFixture<ProjectsComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ProjectsComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ProjectsComponent);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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"],
|
||||
}
|
||||
]
|
||||
}
|
||||
3
src/app/features/landing/index.ts
Normal file
3
src/app/features/landing/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// features/landing/public-api.ts (oder index.ts)
|
||||
// Nur was von außen importiert werden soll
|
||||
export { LandingpageComponent } from './pages/landingpage.component';
|
||||
@@ -1,6 +1,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>
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<p>button works!</p>
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-button',
|
||||
imports: [],
|
||||
templateUrl: './button.component.html',
|
||||
styleUrl: './button.component.scss',
|
||||
})
|
||||
export class ButtonComponent {
|
||||
|
||||
}
|
||||
118
src/app/shared/ui/button/README_BUTTON.md
Normal file
118
src/app/shared/ui/button/README_BUTTON.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# NavButtonComponent
|
||||
|
||||
Eine Angular Standalone-Komponente die ein `NavigationItem` rendert und je nach `NavigationType` automatisch den passenden Link-Typ erzeugt.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Angular 17+
|
||||
- `RouterModule` im Projekt eingebunden
|
||||
|
||||
## Dateien
|
||||
|
||||
```
|
||||
button/
|
||||
├── button.component.ts
|
||||
├── button.component.html
|
||||
└── button.component.scss
|
||||
```
|
||||
|
||||
## Verwendung
|
||||
|
||||
Die Komponente ist standalone und wird direkt in den `imports` einer anderen Komponente oder eines Moduls eingebunden:
|
||||
|
||||
```typescript
|
||||
import { NavButtonComponent } from './nav-button/nav-button.component';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [NavButtonComponent],
|
||||
// ...
|
||||
})
|
||||
export class AppComponent {}
|
||||
```
|
||||
|
||||
## Inputs
|
||||
|
||||
| Input | Typ | Pflicht | Default | Beschreibung |
|
||||
|-----------|--------------------------------------|---------|-------------|-------------------------------------|
|
||||
| `item` | `NavigationItem` | ✅ | — | Das NavigationItem-Objekt |
|
||||
| `size` | `'sm' \| 'md' \| 'lg'` | ❌ | `'md'` | Größe des Buttons |
|
||||
| `variant` | `'primary' \| 'ghost' \| 'outline'` | ❌ | `'primary'` | Visueller Stil |
|
||||
| `disabled`| `boolean` | ❌ | `false` | Deaktiviert den Button |
|
||||
|
||||
## NavigationItem
|
||||
|
||||
```typescript
|
||||
export type NavigationType = "anchor" | "route" | "external";
|
||||
|
||||
export interface NavigationItem {
|
||||
readonly label: string; // Anzeigetext
|
||||
readonly type: NavigationType;
|
||||
readonly target: string; // Pfad, Anchor (#id) oder URL
|
||||
readonly icon?: string; // Optionales Icon (z. B. Emoji oder Icon-String)
|
||||
readonly children?: NavigationItem[];
|
||||
}
|
||||
```
|
||||
|
||||
## Verhalten je NavigationType
|
||||
|
||||
| Typ | Verhalten |
|
||||
|------------|----------------------------------------------------------------|
|
||||
| `route` | Rendert `<a [routerLink]>` mit `routerLinkActive` |
|
||||
| `anchor` | Rendert `<a href>` mit `scrollIntoView({ behavior: 'smooth'})` |
|
||||
| `external` | Rendert `<a target="_blank" rel="noopener noreferrer">` mit `↗`|
|
||||
|
||||
## Beispiele
|
||||
|
||||
```html
|
||||
<!-- Interner Routerlink -->
|
||||
<app-nav-button
|
||||
[item]="{ label: 'Home', type: 'route', target: '/home' }"
|
||||
/>
|
||||
|
||||
<!-- Anchor Scroll -->
|
||||
<app-nav-button
|
||||
[item]="{ label: 'Über uns', type: 'anchor', target: '#about' }"
|
||||
variant="outline"
|
||||
/>
|
||||
|
||||
<!-- Externer Link -->
|
||||
<app-nav-button
|
||||
[item]="{ label: 'GitHub', type: 'external', target: 'https://github.com', icon: '🐙' }"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<!-- Deaktiviert -->
|
||||
<app-nav-button
|
||||
[item]="{ label: 'Gesperrt', type: 'route', target: '/admin' }"
|
||||
[disabled]="true"
|
||||
/>
|
||||
```
|
||||
|
||||
## Theming
|
||||
|
||||
Alle Farben und Abstände sind über CSS Custom Properties steuerbar. Überschreibe die Tokens global in deinem `styles.scss`:
|
||||
|
||||
```scss
|
||||
:root {
|
||||
--btn-primary-bg: #1a1a2e;
|
||||
--btn-primary-color: #ffffff;
|
||||
--btn-primary-hover-bg: #16213e;
|
||||
|
||||
--btn-outline-border: #1a1a2e;
|
||||
--btn-outline-color: #1a1a2e;
|
||||
--btn-outline-hover-bg: #1a1a2e;
|
||||
--btn-outline-hover-color: #ffffff;
|
||||
|
||||
--btn-radius: 8px;
|
||||
--btn-font: 'Your Font', sans-serif;
|
||||
}
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Aktiver Routerlink erhält die Klasse `nav-btn--active`
|
||||
- Deaktivierte Links erhalten `aria-disabled="true"`
|
||||
- Externe Links sind mit `aria-label="Opens in new tab"` gekennzeichnet
|
||||
- Focus-Styles über `:focus-visible` vorhanden
|
||||
46
src/app/shared/ui/button/button.component.html
Normal file
46
src/app/shared/ui/button/button.component.html
Normal 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>
|
||||
}
|
||||
139
src/app/shared/ui/button/button.component.scss
Normal file
139
src/app/shared/ui/button/button.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
55
src/app/shared/ui/button/button.component.ts
Normal file
55
src/app/shared/ui/button/button.component.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Component,
|
||||
Input,
|
||||
OnInit,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { NavigationItem, isAnchor, isRoute } from '@core/models/navigation.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-button',
|
||||
standalone: true,
|
||||
imports: [RouterModule],
|
||||
templateUrl: './button.component.html',
|
||||
styleUrls: ['./button.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ButtonComponent implements OnInit {
|
||||
@Input({ required: true }) item!: NavigationItem;
|
||||
|
||||
/** Optional size variant */
|
||||
@Input() size: 'sm' | 'md' | 'lg' = 'md';
|
||||
|
||||
/** Optional visual variant */
|
||||
@Input() variant: 'primary' | 'ghost' | 'outline' = 'primary';
|
||||
|
||||
/** Whether the button is disabled */
|
||||
@Input() disabled = false;
|
||||
|
||||
isAnchorType = false;
|
||||
isRouteType = false;
|
||||
isExternalType = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.isAnchorType = isAnchor(this.item);
|
||||
this.isRouteType = isRoute(this.item);
|
||||
this.isExternalType = this.item.type === 'external';
|
||||
}
|
||||
|
||||
/** Scroll to anchor target */
|
||||
scrollToAnchor(event: Event): void {
|
||||
event.preventDefault();
|
||||
const target = document.querySelector(this.item.target);
|
||||
target?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
get hostClasses(): string[] {
|
||||
return [
|
||||
'nav-btn',
|
||||
`nav-btn--${this.variant}`,
|
||||
`nav-btn--${this.size}`,
|
||||
this.disabled ? 'nav-btn--disabled' : '',
|
||||
].filter(Boolean);
|
||||
}
|
||||
}
|
||||
27
src/app/shared/ui/nav-menu/nav-menu.component.html
Normal file
27
src/app/shared/ui/nav-menu/nav-menu.component.html
Normal 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>
|
||||
36
src/app/shared/ui/nav-menu/nav-menu.component.scss
Normal file
36
src/app/shared/ui/nav-menu/nav-menu.component.scss
Normal file
@@ -0,0 +1,36 @@
|
||||
@use "abstracts";
|
||||
|
||||
.navigation {
|
||||
display: none;
|
||||
|
||||
@include abstracts.breakpoint("md") {
|
||||
display: block;
|
||||
padding-right: var(--space-4);
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
&__list-item {
|
||||
list-style: none;
|
||||
font-weight: 500;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-main);
|
||||
transition: color 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
|
||||
&:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/app/shared/ui/nav-menu/nav-menu.component.spec.ts
Normal file
23
src/app/shared/ui/nav-menu/nav-menu.component.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { NavMenuComponent } from './nav-menu.component';
|
||||
|
||||
describe('NavMenuComponent', () => {
|
||||
let component: NavMenuComponent;
|
||||
let fixture: ComponentFixture<NavMenuComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NavMenuComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(NavMenuComponent);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
25
src/app/shared/ui/nav-menu/nav-menu.component.ts
Normal file
25
src/app/shared/ui/nav-menu/nav-menu.component.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Component, inject, input } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { NavigationService } from '@core/services/navigation.service';
|
||||
import {NavigationItem, isAnchor, isRoute} from '@core/models/navigation.model';
|
||||
import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive';
|
||||
|
||||
@Component({
|
||||
selector: 'app-nav-menu',
|
||||
imports: [RouterLink, NgIcon, OpenPanelTrackDirective],
|
||||
templateUrl: './nav-menu.component.html',
|
||||
styleUrl: './nav-menu.component.scss',
|
||||
})
|
||||
export class NavMenuComponent {
|
||||
protected readonly navigationService = inject(NavigationService);
|
||||
items = input<NavigationItem[]>([]);
|
||||
|
||||
protected readonly isAnchor = isAnchor;
|
||||
protected readonly isRoute = isRoute;
|
||||
|
||||
onAnchorClick(event: Event, item: NavigationItem): void {
|
||||
event.preventDefault();
|
||||
this.navigationService.navigate(item);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<ng-icon name="cssSun" (click)="toggleTheme()" class="theme-icon-sun"></ng-icon>
|
||||
|
||||
<ng-icon name="cssMoon" (click)="toggleTheme()" class="theme-icon-moon"></ng-icon>
|
||||
@@ -0,0 +1,16 @@
|
||||
.theme-icon-moon,
|
||||
.theme-icon-sun {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
:host-context([data-theme='dark']) .theme-icon-sun {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:host-context([data-theme='light']) .theme-icon-moon {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ToogleThemeComponent } from './toogle-theme.component';
|
||||
|
||||
describe('ToogleThemeComponent', () => {
|
||||
let component: ToogleThemeComponent;
|
||||
let fixture: ComponentFixture<ToogleThemeComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ToogleThemeComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ToogleThemeComponent);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
20
src/app/shared/utils/toogle-theme/toogle-theme.component.ts
Normal file
20
src/app/shared/utils/toogle-theme/toogle-theme.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
src/environments/mock-projects.ts
Normal file
11
src/environments/mock-projects.ts
Normal 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"
|
||||
}
|
||||
]
|
||||
9
src/environments/openpanel.ts
Normal file
9
src/environments/openpanel.ts
Normal 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
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
@use "styles/abstracts";
|
||||
@use "styles/base";
|
||||
@use "styles/layout";
|
||||
@use "abstracts";
|
||||
@use "base";
|
||||
@@ -9,7 +9,13 @@ $breakpoints: (
|
||||
);
|
||||
|
||||
@mixin breakpoint($size) {
|
||||
@media (width >=map.get($breakpoints, $size)) {
|
||||
@media (width >= map.get($breakpoints, $size)) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin container-wrapper {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding-inline: 20px;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
@forward "tokens";
|
||||
@forward "boilerplate";
|
||||
@forward "typography";
|
||||
@forward "tokens";
|
||||
@forward "typography";
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
// }
|
||||
@@ -1,2 +0,0 @@
|
||||
@forward "grid";
|
||||
@forward "header";
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user