Add landing pages library with comprehensive components and demos
🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
63
projects/ui-landing-pages/README.md
Normal file
63
projects/ui-landing-pages/README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# UiLandingPages
|
||||
|
||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.2.0.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||
|
||||
```bash
|
||||
ng generate component component-name
|
||||
```
|
||||
|
||||
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||
|
||||
```bash
|
||||
ng generate --help
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To build the library, run:
|
||||
|
||||
```bash
|
||||
ng build ui-landing-pages
|
||||
```
|
||||
|
||||
This command will compile your project, and the build artifacts will be placed in the `dist/` directory.
|
||||
|
||||
### Publishing the Library
|
||||
|
||||
Once the project is built, you can publish your library by following these steps:
|
||||
|
||||
1. Navigate to the `dist` directory:
|
||||
```bash
|
||||
cd dist/ui-landing-pages
|
||||
```
|
||||
|
||||
2. Run the `npm publish` command to publish your library to the npm registry:
|
||||
```bash
|
||||
npm publish
|
||||
```
|
||||
|
||||
## Running unit tests
|
||||
|
||||
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
|
||||
|
||||
```bash
|
||||
ng test
|
||||
```
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
For end-to-end (e2e) testing, run:
|
||||
|
||||
```bash
|
||||
ng e2e
|
||||
```
|
||||
|
||||
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||
7
projects/ui-landing-pages/ng-package.json
Normal file
7
projects/ui-landing-pages/ng-package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
|
||||
"dest": "../../dist/ui-landing-pages",
|
||||
"lib": {
|
||||
"entryFile": "src/public-api.ts"
|
||||
}
|
||||
}
|
||||
12
projects/ui-landing-pages/package.json
Normal file
12
projects/ui-landing-pages/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "ui-landing-pages",
|
||||
"version": "0.0.1",
|
||||
"peerDependencies": {
|
||||
"@angular/common": "^19.2.0",
|
||||
"@angular/core": "^19.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"sideEffects": false
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
@use '../../../../../ui-design-system/src/styles/semantic/index' as *;
|
||||
|
||||
.ui-lp-faq {
|
||||
padding: $semantic-spacing-layout-section-lg 0;
|
||||
|
||||
&__header {
|
||||
text-align: center;
|
||||
margin-bottom: $semantic-spacing-layout-section-md;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: map-get($semantic-typography-heading-h2, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h2, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h2, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h2, line-height);
|
||||
color: $semantic-color-text-primary;
|
||||
margin-bottom: $semantic-spacing-content-heading;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-family: map-get($semantic-typography-body-large, font-family);
|
||||
font-size: map-get($semantic-typography-body-large, font-size);
|
||||
font-weight: map-get($semantic-typography-body-large, font-weight);
|
||||
line-height: map-get($semantic-typography-body-large, line-height);
|
||||
color: $semantic-color-text-secondary;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
&__search {
|
||||
max-width: 500px;
|
||||
margin: 0 auto $semantic-spacing-layout-section-sm auto;
|
||||
}
|
||||
|
||||
&__items {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
&__item {
|
||||
border-bottom: 1px solid $semantic-color-border-subtle;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__question {
|
||||
width: 100%;
|
||||
padding: $semantic-spacing-component-lg $semantic-spacing-component-md;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||
|
||||
&:hover {
|
||||
background: $semantic-color-surface-elevated;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $semantic-color-focus-ring;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
&__question-text {
|
||||
font-family: map-get($semantic-typography-heading-h4, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h4, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h4, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h4, line-height);
|
||||
color: $semantic-color-text-primary;
|
||||
margin-right: $semantic-spacing-component-md;
|
||||
}
|
||||
|
||||
&__toggle-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: $semantic-color-text-secondary;
|
||||
transition: transform $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
&__item--open &__toggle-icon svg {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
&__answer {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height $semantic-motion-duration-normal $semantic-motion-easing-ease;
|
||||
}
|
||||
|
||||
&__item--open &__answer {
|
||||
max-height: 500px; // Adjust based on content needs
|
||||
}
|
||||
|
||||
&__answer-content {
|
||||
padding: 0 $semantic-spacing-component-md $semantic-spacing-component-lg $semantic-spacing-component-md;
|
||||
|
||||
p {
|
||||
font-family: map-get($semantic-typography-body-medium, font-family);
|
||||
font-size: map-get($semantic-typography-body-medium, font-size);
|
||||
font-weight: map-get($semantic-typography-body-medium, font-weight);
|
||||
line-height: map-get($semantic-typography-body-medium, line-height);
|
||||
color: $semantic-color-text-secondary;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__empty {
|
||||
text-align: center;
|
||||
padding: $semantic-spacing-layout-section-sm 0;
|
||||
|
||||
p {
|
||||
font-family: map-get($semantic-typography-body-medium, font-family);
|
||||
font-size: map-get($semantic-typography-body-medium, font-size);
|
||||
font-weight: map-get($semantic-typography-body-medium, font-weight);
|
||||
line-height: map-get($semantic-typography-body-medium, line-height);
|
||||
color: $semantic-color-text-secondary;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Theme Variants
|
||||
&--bordered {
|
||||
.ui-lp-faq__item {
|
||||
border: 1px solid $semantic-color-border-subtle;
|
||||
border-radius: $semantic-border-card-radius;
|
||||
margin-bottom: $semantic-spacing-component-md;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-lp-faq__question:hover {
|
||||
background: $semantic-color-surface-hover;
|
||||
}
|
||||
}
|
||||
|
||||
&--minimal {
|
||||
.ui-lp-faq__question {
|
||||
padding: $semantic-spacing-component-md 0;
|
||||
|
||||
&:hover {
|
||||
background: none;
|
||||
color: $semantic-color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-lp-faq__answer-content {
|
||||
padding: 0 0 $semantic-spacing-component-md 0;
|
||||
}
|
||||
|
||||
.ui-lp-faq__item {
|
||||
border-bottom: 1px solid $semantic-color-border-subtle;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive Design
|
||||
@media (max-width: $semantic-breakpoint-md - 1) {
|
||||
&__title {
|
||||
font-family: map-get($semantic-typography-heading-h3, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h3, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h3, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h3, line-height);
|
||||
}
|
||||
|
||||
&__question {
|
||||
padding: $semantic-spacing-component-md $semantic-spacing-component-sm;
|
||||
}
|
||||
|
||||
&__question-text {
|
||||
font-family: map-get($semantic-typography-heading-h5, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h5, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h5, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h5, line-height);
|
||||
}
|
||||
|
||||
&__answer-content {
|
||||
padding: 0 $semantic-spacing-component-sm $semantic-spacing-component-md $semantic-spacing-component-sm;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation, signal, computed, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ContainerComponent } from 'ui-essentials';
|
||||
import { SearchBarComponent } from 'ui-essentials';
|
||||
import { FAQConfig, FAQItem } from '../../interfaces/content.interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-lp-faq',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ContainerComponent, SearchBarComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<section class="ui-lp-faq" [class]="getComponentClasses()">
|
||||
<ui-container [size]="'lg'" [padding]="'md'">
|
||||
@if (config().title || config().subtitle) {
|
||||
<div class="ui-lp-faq__header">
|
||||
@if (config().title) {
|
||||
<h2 class="ui-lp-faq__title">{{ config().title }}</h2>
|
||||
}
|
||||
|
||||
@if (config().subtitle) {
|
||||
<p class="ui-lp-faq__subtitle">{{ config().subtitle }}</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (config().searchEnabled) {
|
||||
<div class="ui-lp-faq__search">
|
||||
<ui-search-bar
|
||||
[placeholder]="'Search FAQ...'"
|
||||
[(ngModel)]="searchQuery"
|
||||
[size]="'lg'"
|
||||
[clearable]="true">
|
||||
</ui-search-bar>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (filteredItems().length > 0) {
|
||||
<div class="ui-lp-faq__items">
|
||||
@for (item of filteredItems(); track item.id) {
|
||||
<div
|
||||
class="ui-lp-faq__item"
|
||||
[class.ui-lp-faq__item--open]="item.isOpen">
|
||||
<button
|
||||
class="ui-lp-faq__question"
|
||||
(click)="toggleItem(item)"
|
||||
[attr.aria-expanded]="item.isOpen"
|
||||
[attr.aria-controls]="'faq-answer-' + item.id">
|
||||
<span class="ui-lp-faq__question-text">{{ item.question }}</span>
|
||||
<span class="ui-lp-faq__toggle-icon" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 4l4 4H4l4-4z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="ui-lp-faq__answer"
|
||||
[id]="'faq-answer-' + item.id"
|
||||
[attr.aria-labelledby]="'faq-question-' + item.id">
|
||||
<div class="ui-lp-faq__answer-content">
|
||||
<p>{{ item.answer }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="ui-lp-faq__empty">
|
||||
<p>No FAQs found matching your search.</p>
|
||||
</div>
|
||||
}
|
||||
</ui-container>
|
||||
</section>
|
||||
`,
|
||||
styleUrl: './faq-section.component.scss'
|
||||
})
|
||||
export class FAQSectionComponent {
|
||||
config = signal<FAQConfig>({
|
||||
title: 'Frequently Asked Questions',
|
||||
items: [],
|
||||
searchEnabled: true,
|
||||
expandMultiple: false,
|
||||
theme: 'default'
|
||||
});
|
||||
|
||||
searchQuery = signal<string>('');
|
||||
|
||||
@Input() set configuration(value: FAQConfig) {
|
||||
// Initialize isOpen state for items if not provided
|
||||
const itemsWithState = value.items.map(item => ({
|
||||
...item,
|
||||
isOpen: item.isOpen ?? false
|
||||
}));
|
||||
|
||||
this.config.set({
|
||||
...value,
|
||||
items: itemsWithState
|
||||
});
|
||||
}
|
||||
|
||||
filteredItems = computed(() => {
|
||||
const query = this.searchQuery().toLowerCase().trim();
|
||||
if (!query) return this.config().items;
|
||||
|
||||
return this.config().items.filter(item =>
|
||||
item.question.toLowerCase().includes(query) ||
|
||||
item.answer.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
getComponentClasses(): string {
|
||||
const classes = ['ui-lp-faq'];
|
||||
|
||||
if (this.config().theme) {
|
||||
classes.push(`ui-lp-faq--${this.config().theme}`);
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
toggleItem(item: FAQItem): void {
|
||||
const currentConfig = this.config();
|
||||
const items = [...currentConfig.items];
|
||||
|
||||
if (!currentConfig.expandMultiple) {
|
||||
// Close all other items if expandMultiple is false
|
||||
items.forEach(i => {
|
||||
if (i.id !== item.id) {
|
||||
i.isOpen = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle the clicked item
|
||||
const targetItem = items.find(i => i.id === item.id);
|
||||
if (targetItem) {
|
||||
targetItem.isOpen = !targetItem.isOpen;
|
||||
}
|
||||
|
||||
this.config.set({
|
||||
...currentConfig,
|
||||
items
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './faq-section.component';
|
||||
export * from './team-grid.component';
|
||||
export * from './timeline-section.component';
|
||||
@@ -0,0 +1,219 @@
|
||||
@use '../../../../../ui-design-system/src/styles/semantic/index' as *;
|
||||
|
||||
.ui-lp-team-grid {
|
||||
padding: $semantic-spacing-layout-section-lg 0;
|
||||
|
||||
&__header {
|
||||
text-align: center;
|
||||
margin-bottom: $semantic-spacing-layout-section-md;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: map-get($semantic-typography-heading-h2, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h2, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h2, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h2, line-height);
|
||||
color: $semantic-color-text-primary;
|
||||
margin-bottom: $semantic-spacing-content-heading;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-family: map-get($semantic-typography-body-large, font-family);
|
||||
font-size: map-get($semantic-typography-body-large, font-size);
|
||||
font-weight: map-get($semantic-typography-body-large, font-weight);
|
||||
line-height: map-get($semantic-typography-body-large, line-height);
|
||||
color: $semantic-color-text-secondary;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
gap: $semantic-spacing-grid-gap-lg;
|
||||
|
||||
&--cols-2 {
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
&--cols-3 {
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
&--cols-4 {
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
&__member {
|
||||
background: $semantic-color-surface-elevated;
|
||||
border-radius: $semantic-border-card-radius;
|
||||
padding: $semantic-spacing-component-xl;
|
||||
text-align: center;
|
||||
box-shadow: $semantic-shadow-card-rest;
|
||||
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: $semantic-shadow-card-hover;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto $semantic-spacing-component-lg auto;
|
||||
overflow: hidden;
|
||||
border-radius: 50%;
|
||||
border: 3px solid $semantic-color-surface-primary;
|
||||
box-shadow: $semantic-shadow-card-rest;
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $semantic-spacing-component-sm;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-family: map-get($semantic-typography-heading-h4, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h4, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h4, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h4, line-height);
|
||||
color: $semantic-color-text-primary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__role {
|
||||
font-family: map-get($semantic-typography-body-medium, font-family);
|
||||
font-size: map-get($semantic-typography-body-medium, font-size);
|
||||
font-weight: 600;
|
||||
line-height: map-get($semantic-typography-body-medium, line-height);
|
||||
color: $semantic-color-primary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__bio {
|
||||
font-family: map-get($semantic-typography-body-small, font-family);
|
||||
font-size: map-get($semantic-typography-body-small, font-size);
|
||||
font-weight: map-get($semantic-typography-body-small, font-weight);
|
||||
line-height: map-get($semantic-typography-body-small, line-height);
|
||||
color: $semantic-color-text-secondary;
|
||||
margin: $semantic-spacing-component-sm 0 0 0;
|
||||
}
|
||||
|
||||
&__social {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: $semantic-spacing-component-sm;
|
||||
margin-top: $semantic-spacing-component-md;
|
||||
}
|
||||
|
||||
&__social-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: $semantic-color-surface-primary;
|
||||
color: $semantic-color-text-secondary;
|
||||
text-decoration: none;
|
||||
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||
border: 1px solid $semantic-color-border-subtle;
|
||||
|
||||
&:hover {
|
||||
background: $semantic-color-primary;
|
||||
color: $semantic-color-on-primary;
|
||||
border-color: $semantic-color-primary;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $semantic-color-focus-ring;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&__empty {
|
||||
text-align: center;
|
||||
padding: $semantic-spacing-layout-section-sm 0;
|
||||
|
||||
p {
|
||||
font-family: map-get($semantic-typography-body-medium, font-family);
|
||||
font-size: map-get($semantic-typography-body-medium, font-size);
|
||||
font-weight: map-get($semantic-typography-body-medium, font-weight);
|
||||
line-height: map-get($semantic-typography-body-medium, line-height);
|
||||
color: $semantic-color-text-secondary;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive Design
|
||||
@media (max-width: $semantic-breakpoint-lg - 1) {
|
||||
&__grid {
|
||||
&--cols-4 {
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $semantic-breakpoint-md - 1) {
|
||||
&__title {
|
||||
font-family: map-get($semantic-typography-heading-h3, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h3, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h3, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h3, line-height);
|
||||
}
|
||||
|
||||
&__grid {
|
||||
gap: $semantic-spacing-grid-gap-md;
|
||||
|
||||
&--cols-2,
|
||||
&--cols-3,
|
||||
&--cols-4 {
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
&__member {
|
||||
padding: $semantic-spacing-component-lg;
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin-bottom: $semantic-spacing-component-md;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $semantic-breakpoint-sm - 1) {
|
||||
&__grid {
|
||||
&--cols-2,
|
||||
&--cols-3,
|
||||
&--cols-4 {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-family: map-get($semantic-typography-heading-h5, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h5, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h5, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h5, line-height);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ContainerComponent } from 'ui-essentials';
|
||||
import { TeamConfig, TeamMember } from '../../interfaces/content.interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-lp-team-grid',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ContainerComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<section class="ui-lp-team-grid">
|
||||
<ui-container [size]="'xl'" [padding]="'md'">
|
||||
@if (config().title || config().subtitle) {
|
||||
<div class="ui-lp-team-grid__header">
|
||||
@if (config().title) {
|
||||
<h2 class="ui-lp-team-grid__title">{{ config().title }}</h2>
|
||||
}
|
||||
|
||||
@if (config().subtitle) {
|
||||
<p class="ui-lp-team-grid__subtitle">{{ config().subtitle }}</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (config().members.length > 0) {
|
||||
<div
|
||||
class="ui-lp-team-grid__grid"
|
||||
[class.ui-lp-team-grid__grid--cols-2]="config().columns === 2"
|
||||
[class.ui-lp-team-grid__grid--cols-3]="config().columns === 3"
|
||||
[class.ui-lp-team-grid__grid--cols-4]="config().columns === 4">
|
||||
|
||||
@for (member of config().members; track member.id) {
|
||||
<div class="ui-lp-team-grid__member">
|
||||
@if (member.image) {
|
||||
<div class="ui-lp-team-grid__avatar">
|
||||
<img
|
||||
[src]="member.image"
|
||||
[alt]="member.name + ' - ' + member.role"
|
||||
class="ui-lp-team-grid__image">
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="ui-lp-team-grid__info">
|
||||
<h3 class="ui-lp-team-grid__name">{{ member.name }}</h3>
|
||||
<p class="ui-lp-team-grid__role">{{ member.role }}</p>
|
||||
|
||||
@if (config().showBio && member.bio) {
|
||||
<p class="ui-lp-team-grid__bio">{{ member.bio }}</p>
|
||||
}
|
||||
|
||||
@if (config().showSocial && member.social) {
|
||||
<div class="ui-lp-team-grid__social">
|
||||
@if (member.social.linkedin) {
|
||||
<a
|
||||
[href]="member.social.linkedin"
|
||||
class="ui-lp-team-grid__social-link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
[attr.aria-label]="member.name + ' LinkedIn profile'">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||
</svg>
|
||||
</a>
|
||||
}
|
||||
|
||||
@if (member.social.twitter) {
|
||||
<a
|
||||
[href]="member.social.twitter"
|
||||
class="ui-lp-team-grid__social-link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
[attr.aria-label]="member.name + ' Twitter profile'">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/>
|
||||
</svg>
|
||||
</a>
|
||||
}
|
||||
|
||||
@if (member.social.github) {
|
||||
<a
|
||||
[href]="member.social.github"
|
||||
class="ui-lp-team-grid__social-link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
[attr.aria-label]="member.name + ' GitHub profile'">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
</a>
|
||||
}
|
||||
|
||||
@if (member.social.email) {
|
||||
<a
|
||||
[href]="'mailto:' + member.social.email"
|
||||
class="ui-lp-team-grid__social-link"
|
||||
[attr.aria-label]="'Email ' + member.name">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M24 5.457v13.909c0 .904-.732 1.636-1.636 1.636h-3.819V11.73L12 16.64l-6.545-4.91v9.273H1.636A1.636 1.636 0 0 1 0 19.366V5.457c0-.904.732-1.636 1.636-1.636h.832L12 10.77l9.532-6.95h.832c.904 0 1.636.732 1.636 1.637z"/>
|
||||
</svg>
|
||||
</a>
|
||||
}
|
||||
|
||||
@if (member.social.website) {
|
||||
<a
|
||||
[href]="member.social.website"
|
||||
class="ui-lp-team-grid__social-link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
[attr.aria-label]="member.name + ' website'">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.94-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
|
||||
</svg>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="ui-lp-team-grid__empty">
|
||||
<p>No team members to display.</p>
|
||||
</div>
|
||||
}
|
||||
</ui-container>
|
||||
</section>
|
||||
`,
|
||||
styleUrl: './team-grid.component.scss'
|
||||
})
|
||||
export class TeamGridComponent {
|
||||
config = signal<TeamConfig>({
|
||||
title: 'Meet Our Team',
|
||||
members: [],
|
||||
columns: 3,
|
||||
showSocial: true,
|
||||
showBio: true
|
||||
});
|
||||
|
||||
@Input() set configuration(value: TeamConfig) {
|
||||
this.config.set(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
@use '../../../../../ui-design-system/src/styles/semantic/index' as *;
|
||||
|
||||
.ui-lp-timeline {
|
||||
padding: $semantic-spacing-layout-section-lg 0;
|
||||
|
||||
&__header {
|
||||
text-align: center;
|
||||
margin-bottom: $semantic-spacing-layout-section-md;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: map-get($semantic-typography-heading-h2, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h2, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h2, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h2, line-height);
|
||||
color: $semantic-color-text-primary;
|
||||
margin-bottom: $semantic-spacing-content-heading;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-family: map-get($semantic-typography-body-large, font-family);
|
||||
font-size: map-get($semantic-typography-body-large, font-size);
|
||||
font-weight: map-get($semantic-typography-body-large, font-weight);
|
||||
line-height: map-get($semantic-typography-body-large, line-height);
|
||||
color: $semantic-color-text-secondary;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
&__container {
|
||||
position: relative;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
// Vertical Timeline (Default)
|
||||
&--vertical {
|
||||
.ui-lp-timeline__line {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: $semantic-color-border-subtle;
|
||||
}
|
||||
|
||||
.ui-lp-timeline__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $semantic-spacing-layout-section-sm;
|
||||
}
|
||||
|
||||
.ui-lp-timeline__item {
|
||||
position: relative;
|
||||
padding-left: 60px;
|
||||
}
|
||||
|
||||
.ui-lp-timeline__marker {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: $semantic-spacing-component-sm;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: $semantic-color-surface-primary;
|
||||
border: 2px solid $semantic-color-border-subtle;
|
||||
z-index: $semantic-z-index-tooltip;
|
||||
}
|
||||
|
||||
.ui-lp-timeline__content {
|
||||
background: $semantic-color-surface-elevated;
|
||||
border-radius: $semantic-border-card-radius;
|
||||
padding: $semantic-spacing-component-lg;
|
||||
box-shadow: $semantic-shadow-card-rest;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -10px;
|
||||
top: 20px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 10px solid transparent;
|
||||
border-right-color: $semantic-color-surface-elevated;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Horizontal Timeline
|
||||
&--horizontal {
|
||||
.ui-lp-timeline__line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 20px;
|
||||
height: 2px;
|
||||
background: $semantic-color-border-subtle;
|
||||
}
|
||||
|
||||
.ui-lp-timeline__items {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: $semantic-spacing-component-xl;
|
||||
}
|
||||
|
||||
.ui-lp-timeline__item {
|
||||
position: relative;
|
||||
padding-top: 60px;
|
||||
}
|
||||
|
||||
.ui-lp-timeline__marker {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
transform: translateX(-50%);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: $semantic-color-surface-primary;
|
||||
border: 2px solid $semantic-color-border-subtle;
|
||||
z-index: $semantic-z-index-tooltip;
|
||||
}
|
||||
|
||||
.ui-lp-timeline__content {
|
||||
background: $semantic-color-surface-elevated;
|
||||
border-radius: $semantic-border-card-radius;
|
||||
padding: $semantic-spacing-component-lg;
|
||||
box-shadow: $semantic-shadow-card-rest;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: -10px;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 10px solid transparent;
|
||||
border-bottom-color: $semantic-color-surface-elevated;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Status Variants
|
||||
&__item {
|
||||
&--completed {
|
||||
.ui-lp-timeline__marker {
|
||||
background: $semantic-color-success;
|
||||
border-color: $semantic-color-success;
|
||||
color: $semantic-color-on-primary;
|
||||
}
|
||||
|
||||
.ui-lp-timeline__item-title {
|
||||
color: $semantic-color-text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
&--current {
|
||||
.ui-lp-timeline__marker {
|
||||
background: $semantic-color-primary;
|
||||
border-color: $semantic-color-primary;
|
||||
color: $semantic-color-on-primary;
|
||||
box-shadow: 0 0 0 4px rgba($semantic-color-primary, 0.2);
|
||||
}
|
||||
|
||||
.ui-lp-timeline__content {
|
||||
border: 2px solid $semantic-color-primary;
|
||||
}
|
||||
|
||||
.ui-lp-timeline__item-title {
|
||||
color: $semantic-color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
&--upcoming {
|
||||
.ui-lp-timeline__marker {
|
||||
background: $semantic-color-surface-primary;
|
||||
border-color: $semantic-color-border-subtle;
|
||||
color: $semantic-color-text-secondary;
|
||||
}
|
||||
|
||||
.ui-lp-timeline__item-title {
|
||||
color: $semantic-color-text-secondary;
|
||||
}
|
||||
|
||||
.ui-lp-timeline__description {
|
||||
opacity: $semantic-opacity-subtle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-family: map-get($semantic-typography-body-small, font-family);
|
||||
font-size: map-get($semantic-typography-body-small, font-size);
|
||||
font-weight: 600;
|
||||
line-height: map-get($semantic-typography-body-small, line-height);
|
||||
color: $semantic-color-primary;
|
||||
margin-bottom: $semantic-spacing-component-sm;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
&__item-title {
|
||||
font-family: map-get($semantic-typography-heading-h4, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h4, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h4, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h4, line-height);
|
||||
color: $semantic-color-text-primary;
|
||||
margin: 0 0 $semantic-spacing-component-sm 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $semantic-spacing-component-sm;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
font-family: map-get($semantic-typography-body-small, font-family);
|
||||
font-size: map-get($semantic-typography-body-small, font-size);
|
||||
font-weight: 600;
|
||||
line-height: map-get($semantic-typography-body-small, line-height);
|
||||
background: $semantic-color-primary;
|
||||
color: $semantic-color-on-primary;
|
||||
padding: $semantic-spacing-component-xs $semantic-spacing-component-sm;
|
||||
border-radius: 9999px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-family: map-get($semantic-typography-body-medium, font-family);
|
||||
font-size: map-get($semantic-typography-body-medium, font-size);
|
||||
font-weight: map-get($semantic-typography-body-medium, font-weight);
|
||||
line-height: map-get($semantic-typography-body-medium, line-height);
|
||||
color: $semantic-color-text-secondary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
text-align: center;
|
||||
padding: $semantic-spacing-layout-section-sm 0;
|
||||
|
||||
p {
|
||||
font-family: map-get($semantic-typography-body-medium, font-family);
|
||||
font-size: map-get($semantic-typography-body-medium, font-size);
|
||||
font-weight: map-get($semantic-typography-body-medium, font-weight);
|
||||
line-height: map-get($semantic-typography-body-medium, line-height);
|
||||
color: $semantic-color-text-secondary;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Theme Variants
|
||||
&--minimal {
|
||||
.ui-lp-timeline__content {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
border: 1px solid $semantic-color-border-subtle;
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-lp-timeline__marker {
|
||||
background: $semantic-color-surface-primary;
|
||||
box-shadow: $semantic-shadow-card-rest;
|
||||
}
|
||||
}
|
||||
|
||||
&--connected {
|
||||
.ui-lp-timeline__line {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
$semantic-color-primary,
|
||||
$semantic-color-secondary
|
||||
);
|
||||
}
|
||||
|
||||
&.ui-lp-timeline--horizontal .ui-lp-timeline__line {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
$semantic-color-primary,
|
||||
$semantic-color-secondary
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive Design
|
||||
@media (max-width: $semantic-breakpoint-md - 1) {
|
||||
&__title {
|
||||
font-family: map-get($semantic-typography-heading-h3, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h3, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h3, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h3, line-height);
|
||||
}
|
||||
|
||||
&--horizontal {
|
||||
.ui-lp-timeline__items {
|
||||
grid-template-columns: 1fr;
|
||||
gap: $semantic-spacing-component-lg;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding: $semantic-spacing-component-md;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $semantic-breakpoint-sm - 1) {
|
||||
&--vertical {
|
||||
.ui-lp-timeline__line {
|
||||
left: 15px;
|
||||
}
|
||||
|
||||
.ui-lp-timeline__item {
|
||||
padding-left: 50px;
|
||||
}
|
||||
|
||||
.ui-lp-timeline__marker {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&--horizontal {
|
||||
.ui-lp-timeline__marker {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.ui-lp-timeline__item {
|
||||
padding-top: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
&__item-title {
|
||||
font-family: map-get($semantic-typography-heading-h5, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h5, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h5, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h5, line-height);
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: $semantic-spacing-component-xs;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ContainerComponent } from 'ui-essentials';
|
||||
import { TimelineConfig, TimelineItem } from '../../interfaces/content.interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-lp-timeline',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ContainerComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<section
|
||||
class="ui-lp-timeline"
|
||||
[class]="getComponentClasses()">
|
||||
|
||||
<ui-container [size]="'lg'" [padding]="'md'">
|
||||
@if (config().title || config().subtitle) {
|
||||
<div class="ui-lp-timeline__header">
|
||||
@if (config().title) {
|
||||
<h2 class="ui-lp-timeline__title">{{ config().title }}</h2>
|
||||
}
|
||||
|
||||
@if (config().subtitle) {
|
||||
<p class="ui-lp-timeline__subtitle">{{ config().subtitle }}</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (config().items.length > 0) {
|
||||
<div class="ui-lp-timeline__container">
|
||||
<div class="ui-lp-timeline__line" aria-hidden="true"></div>
|
||||
|
||||
<div class="ui-lp-timeline__items">
|
||||
@for (item of config().items; track item.id; let i = $index) {
|
||||
<div
|
||||
class="ui-lp-timeline__item"
|
||||
[class]="getItemClasses(item)">
|
||||
|
||||
<div class="ui-lp-timeline__marker" aria-hidden="true">
|
||||
@if (item.icon) {
|
||||
<div class="ui-lp-timeline__icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
@switch (item.icon) {
|
||||
@case ('check') {
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
}
|
||||
@case ('star') {
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01z"/>
|
||||
}
|
||||
@case ('rocket') {
|
||||
<path d="M9.19 6.35c-2.04 2.29-3.44 5.58-3.44 5.58s2.12-1.29 4.69-1.29c-.18-.9-.34-1.8-.34-2.72 0-.66.06-1.3.15-1.93-.36.12-.72.24-1.06.36z"/>
|
||||
}
|
||||
@default {
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
}
|
||||
}
|
||||
</svg>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="ui-lp-timeline__dot"></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="ui-lp-timeline__content">
|
||||
@if (config().showDates && item.date) {
|
||||
<div class="ui-lp-timeline__date">{{ item.date }}</div>
|
||||
}
|
||||
|
||||
<div class="ui-lp-timeline__info">
|
||||
<h3 class="ui-lp-timeline__item-title">
|
||||
{{ item.title }}
|
||||
@if (item.badge) {
|
||||
<span class="ui-lp-timeline__badge">{{ item.badge }}</span>
|
||||
}
|
||||
</h3>
|
||||
|
||||
<p class="ui-lp-timeline__description">{{ item.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="ui-lp-timeline__empty">
|
||||
<p>No timeline items to display.</p>
|
||||
</div>
|
||||
}
|
||||
</ui-container>
|
||||
</section>
|
||||
`,
|
||||
styleUrl: './timeline-section.component.scss'
|
||||
})
|
||||
export class TimelineSectionComponent {
|
||||
config = signal<TimelineConfig>({
|
||||
title: 'Our Journey',
|
||||
items: [],
|
||||
orientation: 'vertical',
|
||||
theme: 'default',
|
||||
showDates: true
|
||||
});
|
||||
|
||||
@Input() set configuration(value: TimelineConfig) {
|
||||
this.config.set(value);
|
||||
}
|
||||
|
||||
getComponentClasses(): string {
|
||||
const classes = ['ui-lp-timeline'];
|
||||
|
||||
if (this.config().orientation) {
|
||||
classes.push(`ui-lp-timeline--${this.config().orientation}`);
|
||||
}
|
||||
if (this.config().theme) {
|
||||
classes.push(`ui-lp-timeline--${this.config().theme}`);
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
getItemClasses(item: TimelineItem): string {
|
||||
const classes = ['ui-lp-timeline__item'];
|
||||
|
||||
if (item.status) {
|
||||
classes.push(`ui-lp-timeline__item--${item.status}`);
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
@use '../../../../../ui-design-system/src/styles/semantic/index' as *;
|
||||
|
||||
.contact-form {
|
||||
background: #ffffff;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
|
||||
&__header {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #1f2937;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: 1.25rem;
|
||||
color: #6b7280;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__success {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
&__form {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
|
||||
&--single-column {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
&--two-column {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
&--inline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
align-items: flex-end;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__submit {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
&__button {
|
||||
min-width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
.success-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
background: #dcfce7;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
&__icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #10b981;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__text {
|
||||
color: #166534;
|
||||
font-weight: 500;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.form-field {
|
||||
&--full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
&--checkbox {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
&__label {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
&__required {
|
||||
color: #dc2626;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&__input,
|
||||
&__textarea,
|
||||
&__select {
|
||||
padding: 1rem;
|
||||
border: 2px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
color: #1f2937;
|
||||
background: #ffffff;
|
||||
transition: all 0.2s;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
&--error {
|
||||
border-color: #dc2626;
|
||||
|
||||
&:focus {
|
||||
border-color: #dc2626;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__select {
|
||||
cursor: pointer;
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6,9 12,15 18,9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 1rem center;
|
||||
background-size: 16px;
|
||||
padding-right: 3rem;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
&__error {
|
||||
font-size: 0.875rem;
|
||||
color: #dc2626;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
&::before {
|
||||
content: '⚠';
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
&__label {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&__input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&:checked + .checkbox-group__checkmark {
|
||||
background-color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
|
||||
&::after {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__checkmark {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
background: #ffffff;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
margin-top: 2px;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 6px;
|
||||
height: 10px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: translate(-50%, -60%) rotate(45deg) scale(0);
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 1rem;
|
||||
color: #1f2937;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__required {
|
||||
color: #dc2626;
|
||||
font-weight: bold;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
&__error {
|
||||
font-size: 0.875rem;
|
||||
color: #dc2626;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: 32px;
|
||||
|
||||
&::before {
|
||||
content: '⚠';
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, AbstractControl } from '@angular/forms';
|
||||
import { ButtonComponent, FlexComponent, ContainerComponent } from 'ui-essentials';
|
||||
import { ContactFormConfig, FormField } from '../../interfaces/conversion.interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-contact-form',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
ButtonComponent,
|
||||
FlexComponent,
|
||||
ContainerComponent
|
||||
],
|
||||
template: `
|
||||
<div class="contact-form">
|
||||
<ui-container>
|
||||
|
||||
<!-- Header -->
|
||||
<div *ngIf="config.title || config.description" class="contact-form__header">
|
||||
<h2 *ngIf="config.title" class="contact-form__title">{{ config.title }}</h2>
|
||||
<p *ngIf="config.description" class="contact-form__description">{{ config.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div *ngIf="showSuccess" class="contact-form__success">
|
||||
<div class="success-message">
|
||||
<span class="success-message__icon">✓</span>
|
||||
<span class="success-message__text">{{ config.successMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form
|
||||
*ngIf="!showSuccess"
|
||||
[formGroup]="contactForm"
|
||||
(ngSubmit)="onSubmit()"
|
||||
class="contact-form__form"
|
||||
[class.contact-form__form--single-column]="config.layout === 'single-column'"
|
||||
[class.contact-form__form--two-column]="config.layout === 'two-column'"
|
||||
[class.contact-form__form--inline]="config.layout === 'inline'">
|
||||
|
||||
<!-- Dynamic Fields -->
|
||||
<div
|
||||
*ngFor="let field of config.fields; trackBy: trackByField"
|
||||
class="form-field"
|
||||
[class.form-field--full-width]="field.type === 'textarea' || config.layout === 'single-column'"
|
||||
[class.form-field--checkbox]="field.type === 'checkbox'">
|
||||
|
||||
<!-- Text Input -->
|
||||
<div *ngIf="field.type === 'text' || field.type === 'email' || field.type === 'tel'" class="input-group">
|
||||
<label [for]="field.name" class="input-group__label">
|
||||
{{ field.label }}
|
||||
<span *ngIf="field.required" class="input-group__required">*</span>
|
||||
</label>
|
||||
<input
|
||||
[id]="field.name"
|
||||
[type]="field.type"
|
||||
[formControlName]="field.name"
|
||||
[placeholder]="field.placeholder || ''"
|
||||
class="input-group__input"
|
||||
[class.input-group__input--error]="isFieldInvalid(field.name)"
|
||||
[attr.aria-describedby]="field.name + '-error'"
|
||||
[attr.aria-invalid]="isFieldInvalid(field.name)">
|
||||
|
||||
<div
|
||||
*ngIf="isFieldInvalid(field.name)"
|
||||
[id]="field.name + '-error'"
|
||||
class="input-group__error"
|
||||
role="alert">
|
||||
{{ getFieldError(field.name) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Textarea -->
|
||||
<div *ngIf="field.type === 'textarea'" class="input-group">
|
||||
<label [for]="field.name" class="input-group__label">
|
||||
{{ field.label }}
|
||||
<span *ngIf="field.required" class="input-group__required">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
[id]="field.name"
|
||||
[formControlName]="field.name"
|
||||
[placeholder]="field.placeholder || ''"
|
||||
[rows]="field.rows || 4"
|
||||
class="input-group__textarea"
|
||||
[class.input-group__textarea--error]="isFieldInvalid(field.name)"
|
||||
[attr.aria-describedby]="field.name + '-error'"
|
||||
[attr.aria-invalid]="isFieldInvalid(field.name)">
|
||||
</textarea>
|
||||
|
||||
<div
|
||||
*ngIf="isFieldInvalid(field.name)"
|
||||
[id]="field.name + '-error'"
|
||||
class="input-group__error"
|
||||
role="alert">
|
||||
{{ getFieldError(field.name) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Select -->
|
||||
<div *ngIf="field.type === 'select'" class="input-group">
|
||||
<label [for]="field.name" class="input-group__label">
|
||||
{{ field.label }}
|
||||
<span *ngIf="field.required" class="input-group__required">*</span>
|
||||
</label>
|
||||
<select
|
||||
[id]="field.name"
|
||||
[formControlName]="field.name"
|
||||
class="input-group__select"
|
||||
[class.input-group__select--error]="isFieldInvalid(field.name)"
|
||||
[attr.aria-describedby]="field.name + '-error'"
|
||||
[attr.aria-invalid]="isFieldInvalid(field.name)">
|
||||
<option value="" disabled>{{ field.placeholder || 'Select an option...' }}</option>
|
||||
<option *ngFor="let option of field.options" [value]="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<div
|
||||
*ngIf="isFieldInvalid(field.name)"
|
||||
[id]="field.name + '-error'"
|
||||
class="input-group__error"
|
||||
role="alert">
|
||||
{{ getFieldError(field.name) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Checkbox -->
|
||||
<div *ngIf="field.type === 'checkbox'" class="checkbox-group">
|
||||
<label class="checkbox-group__label">
|
||||
<input
|
||||
type="checkbox"
|
||||
[formControlName]="field.name"
|
||||
class="checkbox-group__input"
|
||||
[attr.aria-describedby]="field.name + '-error'"
|
||||
[attr.aria-invalid]="isFieldInvalid(field.name)">
|
||||
<span class="checkbox-group__checkmark"></span>
|
||||
<span class="checkbox-group__text">
|
||||
{{ field.label }}
|
||||
<span *ngIf="field.required" class="checkbox-group__required">*</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div
|
||||
*ngIf="isFieldInvalid(field.name)"
|
||||
[id]="field.name + '-error'"
|
||||
class="checkbox-group__error"
|
||||
role="alert">
|
||||
{{ getFieldError(field.name) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="contact-form__submit">
|
||||
<ui-button
|
||||
type="submit"
|
||||
variant="filled"
|
||||
[size]="'large'"
|
||||
[disabled]="contactForm.invalid || isLoading"
|
||||
[loading]="isLoading"
|
||||
class="contact-form__button">
|
||||
{{ config.submitText }}
|
||||
</ui-button>
|
||||
</div>
|
||||
</form>
|
||||
</ui-container>
|
||||
</div>
|
||||
`,
|
||||
styleUrls: ['./contact-form.component.scss']
|
||||
})
|
||||
export class ContactFormComponent implements OnInit {
|
||||
@Input() config!: ContactFormConfig;
|
||||
@Output() submit = new EventEmitter<any>();
|
||||
|
||||
contactForm: FormGroup = new FormGroup({});
|
||||
isLoading = false;
|
||||
showSuccess = false;
|
||||
|
||||
constructor(private fb: FormBuilder) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.buildForm();
|
||||
}
|
||||
|
||||
private buildForm() {
|
||||
const formControls: { [key: string]: any } = {};
|
||||
|
||||
this.config.fields.forEach(field => {
|
||||
const validators = [];
|
||||
|
||||
if (field.required) {
|
||||
if (field.type === 'checkbox') {
|
||||
validators.push(Validators.requiredTrue);
|
||||
} else {
|
||||
validators.push(Validators.required);
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === 'email') {
|
||||
validators.push(Validators.email);
|
||||
}
|
||||
|
||||
if (field.validation) {
|
||||
if (field.validation.minLength) {
|
||||
validators.push(Validators.minLength(field.validation.minLength));
|
||||
}
|
||||
if (field.validation.maxLength) {
|
||||
validators.push(Validators.maxLength(field.validation.maxLength));
|
||||
}
|
||||
if (field.validation.pattern) {
|
||||
validators.push(Validators.pattern(field.validation.pattern));
|
||||
}
|
||||
if (field.validation.custom) {
|
||||
validators.push((control: AbstractControl) => {
|
||||
const error = field.validation!.custom!(control.value);
|
||||
return error ? { custom: error } : null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const defaultValue = field.type === 'checkbox' ? false : '';
|
||||
formControls[field.name] = [defaultValue, validators];
|
||||
});
|
||||
|
||||
this.contactForm = this.fb.group(formControls);
|
||||
}
|
||||
|
||||
trackByField(index: number, field: FormField): string {
|
||||
return field.name;
|
||||
}
|
||||
|
||||
isFieldInvalid(fieldName: string): boolean {
|
||||
const control = this.contactForm.get(fieldName);
|
||||
return !!(control && control.invalid && control.touched);
|
||||
}
|
||||
|
||||
getFieldError(fieldName: string): string {
|
||||
const control = this.contactForm.get(fieldName);
|
||||
if (!control || !control.errors) return '';
|
||||
|
||||
const field = this.config.fields.find(f => f.name === fieldName);
|
||||
|
||||
if (control.errors['required']) {
|
||||
return `${field?.label} is required`;
|
||||
}
|
||||
if (control.errors['email']) {
|
||||
return 'Please enter a valid email address';
|
||||
}
|
||||
if (control.errors['minlength']) {
|
||||
return `${field?.label} must be at least ${control.errors['minlength'].requiredLength} characters`;
|
||||
}
|
||||
if (control.errors['maxlength']) {
|
||||
return `${field?.label} must not exceed ${control.errors['maxlength'].requiredLength} characters`;
|
||||
}
|
||||
if (control.errors['pattern']) {
|
||||
return `${field?.label} format is invalid`;
|
||||
}
|
||||
if (control.errors['custom']) {
|
||||
return control.errors['custom'];
|
||||
}
|
||||
if (control.errors['requiredTrue']) {
|
||||
return `${field?.label} must be checked`;
|
||||
}
|
||||
|
||||
return 'Invalid input';
|
||||
}
|
||||
|
||||
async onSubmit() {
|
||||
if (this.contactForm.valid) {
|
||||
this.isLoading = true;
|
||||
|
||||
try {
|
||||
this.submit.emit(this.contactForm.value);
|
||||
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
this.showSuccess = true;
|
||||
this.contactForm.reset();
|
||||
} catch (error) {
|
||||
console.error('Contact form submission failed:', error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
} else {
|
||||
// Mark all fields as touched to show validation errors
|
||||
this.contactForm.markAllAsTouched();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
@use '../../../../../ui-design-system/src/styles/semantic/index' as *;
|
||||
|
||||
.cta-section {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 6rem 0;
|
||||
|
||||
&--gradient {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 50%, #8b5cf6 100%);
|
||||
}
|
||||
|
||||
&--pattern {
|
||||
background-color: #f8fafc;
|
||||
background-image: radial-gradient(circle at 2px 2px, #e2e8f0 2px, transparent 0);
|
||||
background-size: 60px 60px;
|
||||
}
|
||||
|
||||
&--image {
|
||||
background-color: #f8fafc;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&--solid {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
&__content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
text-align: center;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
&__urgency {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.countdown {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&__item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__value {
|
||||
display: block;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.limited-offer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: #f59e0b;
|
||||
color: #000;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
|
||||
&__text {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
&__remaining {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.social-proof__text {
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 3rem;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: 1.25rem;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-bottom: 3rem;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__button {
|
||||
min-width: 160px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ButtonComponent, ContainerComponent } from 'ui-essentials';
|
||||
import { CTASectionConfig } from '../../interfaces/conversion.interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-cta-section',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ButtonComponent,
|
||||
ContainerComponent,
|
||||
],
|
||||
template: `
|
||||
<section
|
||||
class="cta-section"
|
||||
[class.cta-section--gradient]="config.backgroundType === 'gradient'"
|
||||
[class.cta-section--pattern]="config.backgroundType === 'pattern'"
|
||||
[class.cta-section--image]="config.backgroundType === 'image'"
|
||||
[class.cta-section--solid]="config.backgroundType === 'solid'">
|
||||
|
||||
<ui-container>
|
||||
<div class="cta-section__content">
|
||||
|
||||
<!-- Urgency Indicator -->
|
||||
<div
|
||||
*ngIf="config.urgency"
|
||||
class="cta-section__urgency"
|
||||
[class.cta-section__urgency--countdown]="config.urgency.type === 'countdown'"
|
||||
[class.cta-section__urgency--limited]="config.urgency.type === 'limited-offer'"
|
||||
[class.cta-section__urgency--social]="config.urgency.type === 'social-proof'">
|
||||
|
||||
<div *ngIf="config.urgency.type === 'countdown' && config.urgency.endDate" class="countdown">
|
||||
<div class="countdown__item">
|
||||
<span class="countdown__value">{{ getTimeRemaining().days }}</span>
|
||||
<span class="countdown__label">Days</span>
|
||||
</div>
|
||||
<div class="countdown__item">
|
||||
<span class="countdown__value">{{ getTimeRemaining().hours }}</span>
|
||||
<span class="countdown__label">Hours</span>
|
||||
</div>
|
||||
<div class="countdown__item">
|
||||
<span class="countdown__value">{{ getTimeRemaining().minutes }}</span>
|
||||
<span class="countdown__label">Minutes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="config.urgency.type === 'limited-offer'" class="limited-offer">
|
||||
<span class="limited-offer__text">{{ config.urgency.text }}</span>
|
||||
<span *ngIf="config.urgency.remaining" class="limited-offer__remaining">
|
||||
Only {{ config.urgency.remaining }} left!
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="config.urgency.type === 'social-proof'" class="social-proof">
|
||||
<span class="social-proof__text">{{ config.urgency.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<h2 class="cta-section__title">{{ config.title }}</h2>
|
||||
|
||||
<p *ngIf="config.description" class="cta-section__description">
|
||||
{{ config.description }}
|
||||
</p>
|
||||
|
||||
<!-- CTA Buttons -->
|
||||
<div class="cta-section__actions">
|
||||
<ui-button
|
||||
[variant]="config.ctaPrimary.variant || 'filled'"
|
||||
[size]="config.ctaPrimary.size || 'large'"
|
||||
[disabled]="config.ctaPrimary.disabled || false"
|
||||
[loading]="config.ctaPrimary.loading || false"
|
||||
(click)="config.ctaPrimary.action()"
|
||||
class="cta-section__button cta-section__button--primary">
|
||||
{{ config.ctaPrimary.text }}
|
||||
</ui-button>
|
||||
|
||||
<ui-button
|
||||
*ngIf="config.ctaSecondary"
|
||||
[variant]="config.ctaSecondary.variant || 'outlined'"
|
||||
[size]="config.ctaSecondary.size || 'large'"
|
||||
[disabled]="config.ctaSecondary.disabled || false"
|
||||
[loading]="config.ctaSecondary.loading || false"
|
||||
(click)="config.ctaSecondary.action()"
|
||||
class="cta-section__button cta-section__button--secondary">
|
||||
{{ config.ctaSecondary.text }}
|
||||
</ui-button>
|
||||
</div>
|
||||
</div>
|
||||
</ui-container>
|
||||
</section>
|
||||
`,
|
||||
styleUrls: ['./cta-section.component.scss']
|
||||
})
|
||||
export class CTASectionComponent {
|
||||
@Input() config!: CTASectionConfig;
|
||||
|
||||
getTimeRemaining(): { days: number; hours: number; minutes: number; seconds: number } {
|
||||
if (!this.config.urgency?.endDate) {
|
||||
return { days: 0, hours: 0, minutes: 0, seconds: 0 };
|
||||
}
|
||||
|
||||
const now = new Date().getTime();
|
||||
const end = this.config.urgency.endDate.getTime();
|
||||
const difference = end - now;
|
||||
|
||||
if (difference > 0) {
|
||||
return {
|
||||
days: Math.floor(difference / (1000 * 60 * 60 * 24)),
|
||||
hours: Math.floor((difference % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)),
|
||||
minutes: Math.floor((difference % (1000 * 60 * 60)) / (1000 * 60)),
|
||||
seconds: Math.floor((difference % (1000 * 60)) / 1000)
|
||||
};
|
||||
}
|
||||
|
||||
return { days: 0, hours: 0, minutes: 0, seconds: 0 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './cta-section.component';
|
||||
export * from './pricing-table.component';
|
||||
export * from './newsletter-signup.component';
|
||||
export * from './contact-form.component';
|
||||
@@ -0,0 +1,127 @@
|
||||
@use '../../../../../ui-design-system/src/styles/semantic/index' as *;
|
||||
|
||||
.newsletter-signup {
|
||||
&__content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: 1rem;
|
||||
color: #6b7280;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__success {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
&__form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__privacy {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
line-height: 1.4;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.success-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
background: #dcfce7;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
&__icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #10b981;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__text {
|
||||
color: #166534;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&__error {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #dc2626;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
&::before {
|
||||
content: '⚠';
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.email-input {
|
||||
display: flex;
|
||||
border-radius: 0.5rem;
|
||||
border: 2px solid #d1d5db;
|
||||
background: #ffffff;
|
||||
transition: border-color 0.2s;
|
||||
overflow: hidden;
|
||||
|
||||
&:focus-within {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
&__field {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 1rem;
|
||||
color: #1f2937;
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
&__button {
|
||||
border-radius: 0;
|
||||
min-width: 120px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ButtonComponent, FlexComponent, CheckboxComponent } from 'ui-essentials';
|
||||
import { NewsletterSignupConfig } from '../../interfaces/conversion.interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-newsletter-signup',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
ButtonComponent,
|
||||
FlexComponent,
|
||||
CheckboxComponent
|
||||
],
|
||||
template: `
|
||||
<div
|
||||
class="newsletter-signup"
|
||||
[class.newsletter-signup--inline]="config.variant === 'inline'"
|
||||
[class.newsletter-signup--modal]="config.variant === 'modal'"
|
||||
[class.newsletter-signup--sidebar]="config.variant === 'sidebar'"
|
||||
[class.newsletter-signup--footer]="config.variant === 'footer'">
|
||||
|
||||
<div class="newsletter-signup__content">
|
||||
<!-- Header -->
|
||||
<div class="newsletter-signup__header">
|
||||
<h3 class="newsletter-signup__title">{{ config.title }}</h3>
|
||||
<p *ngIf="config.description" class="newsletter-signup__description">
|
||||
{{ config.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div *ngIf="showSuccess" class="newsletter-signup__success">
|
||||
<div class="success-message">
|
||||
<span class="success-message__icon">✓</span>
|
||||
<span class="success-message__text">{{ config.successMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form
|
||||
*ngIf="!showSuccess"
|
||||
[formGroup]="signupForm"
|
||||
(ngSubmit)="onSubmit()"
|
||||
class="newsletter-signup__form">
|
||||
|
||||
<!-- Email Input -->
|
||||
<div class="form-group">
|
||||
<div class="email-input">
|
||||
<input
|
||||
type="email"
|
||||
formControlName="email"
|
||||
[placeholder]="config.placeholder"
|
||||
class="email-input__field"
|
||||
[class.email-input__field--error]="emailControl?.invalid && emailControl?.touched"
|
||||
[attr.aria-label]="config.placeholder">
|
||||
|
||||
<ui-button
|
||||
type="submit"
|
||||
variant="filled"
|
||||
[size]="'medium'"
|
||||
[disabled]="signupForm.invalid || isLoading"
|
||||
[loading]="isLoading"
|
||||
class="email-input__button">
|
||||
{{ config.ctaText }}
|
||||
</ui-button>
|
||||
</div>
|
||||
|
||||
<!-- Email Validation Error -->
|
||||
<div
|
||||
*ngIf="emailControl?.invalid && emailControl?.touched"
|
||||
class="form-group__error">
|
||||
<span *ngIf="emailControl?.errors?.['required']">Email is required</span>
|
||||
<span *ngIf="emailControl?.errors?.['email']">Please enter a valid email address</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Privacy Checkbox -->
|
||||
<div *ngIf="config.showPrivacyCheckbox" class="form-group">
|
||||
<ui-checkbox
|
||||
formControlName="privacy"
|
||||
[required]="true"
|
||||
class="privacy-checkbox">
|
||||
<span class="privacy-checkbox__text">
|
||||
I agree to receive marketing communications and accept the
|
||||
<a href="/privacy" target="_blank" class="privacy-checkbox__link">privacy policy</a>
|
||||
</span>
|
||||
</ui-checkbox>
|
||||
|
||||
<div
|
||||
*ngIf="privacyControl?.invalid && privacyControl?.touched"
|
||||
class="form-group__error">
|
||||
<span>You must accept the privacy policy to continue</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Privacy Text -->
|
||||
<p *ngIf="config.privacyText && !config.showPrivacyCheckbox" class="newsletter-signup__privacy">
|
||||
{{ config.privacyText }}
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styleUrls: ['./newsletter-signup.component.scss']
|
||||
})
|
||||
export class NewsletterSignupComponent {
|
||||
@Input() config!: NewsletterSignupConfig;
|
||||
@Output() signup = new EventEmitter<{ email: string; privacyAccepted?: boolean }>();
|
||||
|
||||
signupForm: FormGroup;
|
||||
isLoading = false;
|
||||
showSuccess = false;
|
||||
|
||||
constructor(private fb: FormBuilder) {
|
||||
this.signupForm = this.fb.group({
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
privacy: [false]
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
if (this.config.showPrivacyCheckbox) {
|
||||
this.signupForm.get('privacy')?.setValidators([Validators.requiredTrue]);
|
||||
}
|
||||
}
|
||||
|
||||
get emailControl() {
|
||||
return this.signupForm.get('email');
|
||||
}
|
||||
|
||||
get privacyControl() {
|
||||
return this.signupForm.get('privacy');
|
||||
}
|
||||
|
||||
async onSubmit() {
|
||||
if (this.signupForm.valid) {
|
||||
this.isLoading = true;
|
||||
|
||||
try {
|
||||
const formValue = this.signupForm.value;
|
||||
this.signup.emit({
|
||||
email: formValue.email,
|
||||
privacyAccepted: formValue.privacy
|
||||
});
|
||||
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
this.showSuccess = true;
|
||||
this.signupForm.reset();
|
||||
} catch (error) {
|
||||
console.error('Newsletter signup failed:', error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
} else {
|
||||
// Mark all fields as touched to show validation errors
|
||||
this.signupForm.markAllAsTouched();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
@use '../../../../../ui-design-system/src/styles/semantic/index' as *;
|
||||
|
||||
.pricing-table {
|
||||
padding: 6rem 0;
|
||||
background: #ffffff;
|
||||
|
||||
&__header {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
&__plan {
|
||||
background: #f8fafc;
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
&--popular,
|
||||
&--highlighted {
|
||||
border-color: #3b82f6;
|
||||
transform: scale(1.05);
|
||||
z-index: 1;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05) translateY(-4px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.billing-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
&__label {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
transition: color 0.2s;
|
||||
|
||||
&--active {
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&__discount {
|
||||
display: inline-block;
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.pricing-plan {
|
||||
&__badge {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
&__header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
&__description {
|
||||
color: #6b7280;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
&__price {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
&__currency {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
&__amount {
|
||||
font-size: 3rem;
|
||||
font-weight: bold;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
&__suffix {
|
||||
font-size: 1rem;
|
||||
color: #6b7280;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
&__features {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 3rem 0;
|
||||
}
|
||||
|
||||
&__feature {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&--highlight {
|
||||
background: #eff6ff;
|
||||
margin: 0 -1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&--excluded {
|
||||
opacity: 0.6;
|
||||
|
||||
.pricing-plan__feature-text {
|
||||
text-decoration: line-through;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__feature-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
margin-top: 2px;
|
||||
|
||||
&--check {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
|
||||
&::after {
|
||||
content: '✓';
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
&--cross {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
|
||||
&::after {
|
||||
content: '✕';
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__feature-text {
|
||||
font-size: 1rem;
|
||||
color: #1f2937;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__cta {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__button {
|
||||
width: 100%;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ButtonComponent, ContainerComponent, FlexComponent, SwitchComponent } from 'ui-essentials';
|
||||
import { PricingTableConfig, PricingPlan } from '../../interfaces/conversion.interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-pricing-table',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ButtonComponent,
|
||||
ContainerComponent,
|
||||
FlexComponent,
|
||||
SwitchComponent
|
||||
],
|
||||
template: `
|
||||
<section class="pricing-table">
|
||||
<ui-container>
|
||||
|
||||
<!-- Billing Toggle -->
|
||||
<div class="pricing-table__header">
|
||||
<div class="billing-toggle">
|
||||
<span
|
||||
class="billing-toggle__label"
|
||||
[class.billing-toggle__label--active]="!isYearly">
|
||||
{{ config.billingToggle.monthlyLabel }}
|
||||
</span>
|
||||
|
||||
<ui-switch
|
||||
[(ngModel)]="isYearly"
|
||||
class="billing-toggle__switch">
|
||||
</ui-switch>
|
||||
|
||||
<span
|
||||
class="billing-toggle__label"
|
||||
[class.billing-toggle__label--active]="isYearly">
|
||||
{{ config.billingToggle.yearlyLabel }}
|
||||
<span *ngIf="config.billingToggle.discountText" class="billing-toggle__discount">
|
||||
{{ config.billingToggle.discountText }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pricing Plans Grid -->
|
||||
<div class="pricing-table__grid">
|
||||
<div
|
||||
*ngFor="let plan of config.plans"
|
||||
class="pricing-table__plan"
|
||||
[class.pricing-table__plan--popular]="plan.popular"
|
||||
[class.pricing-table__plan--highlighted]="config.highlightedPlan === plan.id">
|
||||
|
||||
<!-- Plan Badge -->
|
||||
<div *ngIf="plan.badge || plan.popular" class="pricing-plan__badge">
|
||||
{{ plan.badge || 'Most Popular' }}
|
||||
</div>
|
||||
|
||||
<!-- Plan Header -->
|
||||
<div class="pricing-plan__header">
|
||||
<h3 class="pricing-plan__name">{{ plan.name }}</h3>
|
||||
<p *ngIf="plan.description" class="pricing-plan__description">
|
||||
{{ plan.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Plan Price -->
|
||||
<div class="pricing-plan__price">
|
||||
<span class="pricing-plan__currency">{{ plan.price.currency }}</span>
|
||||
<span class="pricing-plan__amount">
|
||||
{{ isYearly ? plan.price.yearly : plan.price.monthly }}
|
||||
</span>
|
||||
<span *ngIf="plan.price.suffix" class="pricing-plan__suffix">
|
||||
{{ plan.price.suffix }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Plan Features -->
|
||||
<ul class="pricing-plan__features">
|
||||
<li
|
||||
*ngFor="let feature of plan.features"
|
||||
class="pricing-plan__feature"
|
||||
[class.pricing-plan__feature--included]="feature.included"
|
||||
[class.pricing-plan__feature--excluded]="!feature.included"
|
||||
[class.pricing-plan__feature--highlight]="feature.highlight">
|
||||
|
||||
<span class="pricing-plan__feature-icon"
|
||||
[class.pricing-plan__feature-icon--check]="feature.included"
|
||||
[class.pricing-plan__feature-icon--cross]="!feature.included">
|
||||
</span>
|
||||
|
||||
<span class="pricing-plan__feature-text">
|
||||
{{ feature.name }}
|
||||
<span *ngIf="feature.description" class="pricing-plan__feature-description">
|
||||
{{ feature.description }}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Plan CTA -->
|
||||
<div class="pricing-plan__cta">
|
||||
<ui-button
|
||||
[variant]="plan.popular ? 'filled' : (plan.cta.variant || 'outlined')"
|
||||
[size]="plan.cta.size || 'large'"
|
||||
[disabled]="plan.cta.disabled || false"
|
||||
[loading]="plan.cta.loading || false"
|
||||
(click)="plan.cta.action()"
|
||||
class="pricing-plan__button">
|
||||
{{ plan.cta.text }}
|
||||
</ui-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features Comparison Table (if enabled) -->
|
||||
<div *ngIf="config.featuresComparison" class="pricing-table__comparison">
|
||||
<h3 class="comparison__title">Feature Comparison</h3>
|
||||
<div class="comparison__table">
|
||||
<div class="comparison__header">
|
||||
<div class="comparison__cell comparison__cell--feature">Features</div>
|
||||
<div *ngFor="let plan of config.plans" class="comparison__cell comparison__cell--plan">
|
||||
{{ plan.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngFor="let featureName of getAllFeatureNames()" class="comparison__row">
|
||||
<div class="comparison__cell comparison__cell--feature">{{ featureName }}</div>
|
||||
<div *ngFor="let plan of config.plans" class="comparison__cell comparison__cell--value">
|
||||
<span
|
||||
class="comparison__icon"
|
||||
[class.comparison__icon--check]="planHasFeature(plan, featureName)"
|
||||
[class.comparison__icon--cross]="!planHasFeature(plan, featureName)">
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ui-container>
|
||||
</section>
|
||||
`,
|
||||
styleUrls: ['./pricing-table.component.scss']
|
||||
})
|
||||
export class PricingTableComponent {
|
||||
@Input() config!: PricingTableConfig;
|
||||
|
||||
isYearly = false;
|
||||
|
||||
getAllFeatureNames(): string[] {
|
||||
const allFeatures = new Set<string>();
|
||||
this.config.plans.forEach(plan => {
|
||||
plan.features.forEach(feature => {
|
||||
allFeatures.add(feature.name);
|
||||
});
|
||||
});
|
||||
return Array.from(allFeatures);
|
||||
}
|
||||
|
||||
planHasFeature(plan: PricingPlan, featureName: string): boolean {
|
||||
const feature = plan.features.find(f => f.name === featureName);
|
||||
return feature?.included || false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
@use '../../../../../ui-design-system/src/styles/semantic/index' as *;
|
||||
|
||||
.ui-lp-feature-grid {
|
||||
padding: $semantic-spacing-layout-section-lg 0;
|
||||
background: $semantic-color-surface-primary;
|
||||
|
||||
// Header Section
|
||||
&__header {
|
||||
text-align: center;
|
||||
margin-bottom: $semantic-spacing-layout-section-md;
|
||||
max-width: 800px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: map-get($semantic-typography-heading-h2, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h2, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h2, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h2, line-height);
|
||||
color: $semantic-color-text-primary;
|
||||
margin-bottom: $semantic-spacing-content-heading;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-family: map-get($semantic-typography-body-large, font-family);
|
||||
font-size: map-get($semantic-typography-body-large, font-size);
|
||||
font-weight: map-get($semantic-typography-body-large, font-weight);
|
||||
line-height: map-get($semantic-typography-body-large, line-height);
|
||||
color: $semantic-color-text-secondary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Grid Container
|
||||
&__items {
|
||||
display: grid;
|
||||
gap: $semantic-spacing-grid-gap-lg;
|
||||
|
||||
// Column Variants
|
||||
&--auto {
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
}
|
||||
|
||||
&--2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
&--3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
&--4 {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
// Responsive breakdowns
|
||||
@media (max-width: $semantic-breakpoint-lg - 1) {
|
||||
&--4 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
&--3 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $semantic-breakpoint-md - 1) {
|
||||
&--2,
|
||||
&--3,
|
||||
&--4 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
gap: $semantic-spacing-grid-gap-md;
|
||||
}
|
||||
}
|
||||
|
||||
// Individual Item
|
||||
&__item {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: all $semantic-motion-duration-normal $semantic-motion-easing-ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
// Card Variant
|
||||
&--card &__item {
|
||||
padding: $semantic-spacing-component-xl;
|
||||
background: $semantic-color-surface-elevated;
|
||||
border-radius: $semantic-border-card-radius;
|
||||
box-shadow: $semantic-shadow-card-rest;
|
||||
|
||||
&:hover {
|
||||
box-shadow: $semantic-shadow-card-hover;
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal Variant
|
||||
&--minimal &__item {
|
||||
padding: $semantic-spacing-component-lg;
|
||||
background: transparent;
|
||||
|
||||
&:hover {
|
||||
background: $semantic-color-surface-hover;
|
||||
border-radius: $semantic-border-card-radius;
|
||||
}
|
||||
}
|
||||
|
||||
// Bordered Variant
|
||||
&--bordered &__item {
|
||||
padding: $semantic-spacing-component-lg;
|
||||
background: $semantic-color-surface-primary;
|
||||
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
|
||||
border-radius: $semantic-border-card-radius;
|
||||
|
||||
&:hover {
|
||||
border-color: $semantic-color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
// Icon Styling
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
width: $semantic-sizing-icon-navigation;
|
||||
height: $semantic-sizing-icon-navigation;
|
||||
margin-bottom: $semantic-spacing-component-md;
|
||||
|
||||
fa-icon,
|
||||
i {
|
||||
font-size: $semantic-sizing-icon-navigation;
|
||||
color: $semantic-color-primary;
|
||||
}
|
||||
|
||||
.ui-lp-feature-grid--minimal & {
|
||||
margin-bottom: $semantic-spacing-component-sm;
|
||||
}
|
||||
}
|
||||
|
||||
// Image Styling
|
||||
&__image {
|
||||
margin-bottom: $semantic-spacing-component-md;
|
||||
border-radius: $semantic-border-image-radius;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
transition: transform $semantic-motion-duration-normal $semantic-motion-easing-ease;
|
||||
}
|
||||
|
||||
.ui-lp-feature-grid__item:hover & img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
// Content Area
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $semantic-spacing-component-sm;
|
||||
}
|
||||
|
||||
&__item-title {
|
||||
font-family: map-get($semantic-typography-heading-h4, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h4, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h4, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h4, line-height);
|
||||
color: $semantic-color-text-primary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-family: map-get($semantic-typography-body-medium, font-family);
|
||||
font-size: map-get($semantic-typography-body-medium, font-size);
|
||||
font-weight: map-get($semantic-typography-body-medium, font-weight);
|
||||
line-height: map-get($semantic-typography-body-medium, line-height);
|
||||
color: $semantic-color-text-secondary;
|
||||
margin: 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
// Link Styling
|
||||
&__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $semantic-spacing-component-xs;
|
||||
font-family: map-get($semantic-typography-button-medium, font-family);
|
||||
font-size: map-get($semantic-typography-button-medium, font-size);
|
||||
font-weight: map-get($semantic-typography-button-medium, font-weight);
|
||||
line-height: map-get($semantic-typography-button-medium, line-height);
|
||||
color: $semantic-color-primary;
|
||||
text-decoration: none;
|
||||
margin-top: $semantic-spacing-component-sm;
|
||||
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||
|
||||
&:hover {
|
||||
color: $semantic-color-primary-hover;
|
||||
gap: $semantic-spacing-component-sm;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $semantic-color-primary;
|
||||
outline-offset: 2px;
|
||||
border-radius: $semantic-border-button-radius;
|
||||
}
|
||||
}
|
||||
|
||||
&__link-icon {
|
||||
font-size: $semantic-sizing-icon-button;
|
||||
transition: transform $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||
|
||||
.ui-lp-feature-grid__link:hover & {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
}
|
||||
|
||||
// Spacing Variants
|
||||
&--tight &__items {
|
||||
gap: $semantic-spacing-grid-gap-md;
|
||||
}
|
||||
|
||||
&--loose &__items {
|
||||
gap: $semantic-spacing-grid-gap-xl;
|
||||
}
|
||||
|
||||
// List Layout Variant
|
||||
&--list &__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $semantic-spacing-component-lg;
|
||||
}
|
||||
|
||||
&--list &__item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: $semantic-spacing-component-lg;
|
||||
|
||||
.ui-lp-feature-grid__icon {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ui-lp-feature-grid__content {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Animation States
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
&__item {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
animation: fadeInUp $semantic-motion-duration-slow $semantic-motion-easing-ease forwards;
|
||||
animation-delay: var(--animation-delay, 0ms);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
&__item {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Animation Keyframes
|
||||
@keyframes fadeInUp {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ContainerComponent } from 'ui-essentials';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core';
|
||||
import { faArrowRight } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FeatureGridConfig, FeatureItem, FeatureLink } from '../../interfaces/feature.interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-lp-feature-grid',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ContainerComponent, FaIconComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<section
|
||||
class="ui-lp-feature-grid"
|
||||
[class]="getComponentClasses()"
|
||||
[attr.aria-label]="'Features section'">
|
||||
|
||||
<ui-container [size]="'xl'" [padding]="'lg'">
|
||||
@if (config().title || config().subtitle) {
|
||||
<div class="ui-lp-feature-grid__header">
|
||||
@if (config().title) {
|
||||
<h2 class="ui-lp-feature-grid__title">{{ config().title }}</h2>
|
||||
}
|
||||
@if (config().subtitle) {
|
||||
<p class="ui-lp-feature-grid__subtitle">{{ config().subtitle }}</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div
|
||||
class="ui-lp-feature-grid__items"
|
||||
[class.ui-lp-feature-grid__items--auto]="config().columns === 'auto'"
|
||||
[class.ui-lp-feature-grid__items--2]="config().columns === 2"
|
||||
[class.ui-lp-feature-grid__items--3]="config().columns === 3"
|
||||
[class.ui-lp-feature-grid__items--4]="config().columns === 4">
|
||||
|
||||
@for (feature of config().features; track feature.id; let index = $index) {
|
||||
<div
|
||||
class="ui-lp-feature-grid__item"
|
||||
[attr.data-animation-delay]="index * 100"
|
||||
(click)="handleFeatureClick(feature)">
|
||||
|
||||
@if (config().showIcons && feature.icon) {
|
||||
<div class="ui-lp-feature-grid__icon">
|
||||
@if (feature.iconType === 'fa') {
|
||||
<fa-icon [icon]="getIconDefinition(feature.icon)"></fa-icon>
|
||||
} @else {
|
||||
<i [class]="feature.icon" aria-hidden="true"></i>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (feature.image) {
|
||||
<div class="ui-lp-feature-grid__image">
|
||||
<img
|
||||
[src]="feature.image"
|
||||
[alt]="feature.title"
|
||||
loading="lazy">
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="ui-lp-feature-grid__content">
|
||||
<h3 class="ui-lp-feature-grid__item-title">{{ feature.title }}</h3>
|
||||
<p class="ui-lp-feature-grid__description">{{ feature.description }}</p>
|
||||
|
||||
@if (feature.link) {
|
||||
<a
|
||||
class="ui-lp-feature-grid__link"
|
||||
[href]="feature.link.url"
|
||||
[target]="feature.link.target || '_self'"
|
||||
(click)="handleLinkClick($event, feature.link)">
|
||||
{{ feature.link.text }}
|
||||
<fa-icon [icon]="faArrowRight" class="ui-lp-feature-grid__link-icon"></fa-icon>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ui-container>
|
||||
</section>
|
||||
`,
|
||||
styleUrl: './feature-grid.component.scss'
|
||||
})
|
||||
export class FeatureGridComponent {
|
||||
config = signal<FeatureGridConfig>({
|
||||
features: [],
|
||||
layout: 'grid',
|
||||
columns: 'auto',
|
||||
variant: 'card',
|
||||
showIcons: true,
|
||||
animationType: 'fade',
|
||||
spacing: 'normal'
|
||||
});
|
||||
|
||||
faArrowRight = faArrowRight;
|
||||
|
||||
@Input() set configuration(value: FeatureGridConfig) {
|
||||
this.config.set(value);
|
||||
}
|
||||
|
||||
@Output() featureClicked = new EventEmitter<FeatureItem>();
|
||||
@Output() linkClicked = new EventEmitter<{ feature: FeatureItem; link: FeatureLink }>();
|
||||
|
||||
getComponentClasses(): string {
|
||||
const classes = ['ui-lp-feature-grid'];
|
||||
|
||||
if (this.config().variant) {
|
||||
classes.push(`ui-lp-feature-grid--${this.config().variant}`);
|
||||
}
|
||||
if (this.config().layout) {
|
||||
classes.push(`ui-lp-feature-grid--${this.config().layout}`);
|
||||
}
|
||||
if (this.config().spacing) {
|
||||
classes.push(`ui-lp-feature-grid--${this.config().spacing}`);
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
handleFeatureClick(feature: FeatureItem): void {
|
||||
this.featureClicked.emit(feature);
|
||||
}
|
||||
|
||||
handleLinkClick(event: Event, link: FeatureLink): void {
|
||||
event.stopPropagation();
|
||||
this.linkClicked.emit({
|
||||
feature: this.config().features.find(f => f.link === link)!,
|
||||
link
|
||||
});
|
||||
}
|
||||
|
||||
getIconDefinition(iconName: string): IconDefinition {
|
||||
// This would need to be expanded with a proper icon mapping service
|
||||
// For now, return a default icon
|
||||
return faArrowRight;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './feature-grid.component';
|
||||
@@ -0,0 +1,169 @@
|
||||
@use '../../../../../ui-design-system/src/styles/semantic/index' as *;
|
||||
|
||||
.ui-lp-hero {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
// Min Height Variants
|
||||
&--full {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
&--large {
|
||||
min-height: 80vh;
|
||||
}
|
||||
|
||||
&--medium {
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
// Background Variants
|
||||
&--solid {
|
||||
background: $semantic-color-surface-primary;
|
||||
}
|
||||
|
||||
&--gradient {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
$semantic-color-primary,
|
||||
$semantic-color-secondary
|
||||
);
|
||||
}
|
||||
|
||||
&--animated {
|
||||
background: $semantic-color-surface-primary;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Content Container
|
||||
&__content {
|
||||
position: relative;
|
||||
z-index: $semantic-z-index-dropdown;
|
||||
padding: $semantic-spacing-layout-section-lg 0;
|
||||
}
|
||||
|
||||
// Alignment Variants
|
||||
&--left &__content {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&--center &__content {
|
||||
text-align: center;
|
||||
margin: 0 auto;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
&--right &__content {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
// Typography
|
||||
&__title {
|
||||
font-family: map-get($semantic-typography-heading-h1, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h1, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h1, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h1, line-height);
|
||||
color: $semantic-color-text-primary;
|
||||
margin-bottom: $semantic-spacing-content-heading;
|
||||
|
||||
.ui-lp-hero--gradient &,
|
||||
.ui-lp-hero--image & {
|
||||
color: $semantic-color-on-primary;
|
||||
}
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-family: map-get($semantic-typography-body-large, font-family);
|
||||
font-size: map-get($semantic-typography-body-large, font-size);
|
||||
font-weight: map-get($semantic-typography-body-large, font-weight);
|
||||
line-height: map-get($semantic-typography-body-large, line-height);
|
||||
color: $semantic-color-text-secondary;
|
||||
margin-bottom: $semantic-spacing-content-paragraph;
|
||||
|
||||
.ui-lp-hero--gradient &,
|
||||
.ui-lp-hero--image & {
|
||||
color: $semantic-color-on-primary;
|
||||
opacity: $semantic-opacity-subtle;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: $semantic-spacing-component-md;
|
||||
margin-top: $semantic-spacing-layout-section-sm;
|
||||
|
||||
.ui-lp-hero--center & {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ui-lp-hero--right & {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
// Animated Background
|
||||
&__animated-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
-45deg,
|
||||
$semantic-color-primary,
|
||||
$semantic-color-secondary,
|
||||
$semantic-color-primary,
|
||||
$semantic-color-secondary
|
||||
);
|
||||
background-size: 400% 400%;
|
||||
animation: gradientShift 15s ease infinite;
|
||||
z-index: $semantic-z-index-dropdown - 1;
|
||||
}
|
||||
|
||||
// Responsive Design
|
||||
@media (max-width: $semantic-breakpoint-md - 1) {
|
||||
&--full {
|
||||
min-height: 100svh; // Use small viewport height for mobile
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: map-get($semantic-typography-heading-h2, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h2, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h2, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h2, line-height);
|
||||
}
|
||||
|
||||
&__actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $semantic-breakpoint-sm - 1) {
|
||||
&__title {
|
||||
font-family: map-get($semantic-typography-heading-h3, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h3, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h3, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h3, line-height);
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-family: map-get($semantic-typography-body-medium, font-family);
|
||||
font-size: map-get($semantic-typography-body-medium, font-size);
|
||||
font-weight: map-get($semantic-typography-body-medium, font-weight);
|
||||
line-height: map-get($semantic-typography-body-medium, line-height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keyframes for animated background
|
||||
@keyframes gradientShift {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ButtonComponent } from 'ui-essentials';
|
||||
import { ContainerComponent } from 'ui-essentials';
|
||||
import { HeroConfig } from '../../interfaces/hero.interfaces';
|
||||
import { CTAButton } from '../../interfaces/shared.interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-lp-hero',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ButtonComponent, ContainerComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<section
|
||||
class="ui-lp-hero"
|
||||
[class]="heroClasses()"
|
||||
[attr.aria-label]="'Hero section'">
|
||||
|
||||
<ui-container [size]="'xl'" [padding]="'lg'">
|
||||
<div class="ui-lp-hero__content">
|
||||
<h1 class="ui-lp-hero__title">{{ config().title }}</h1>
|
||||
|
||||
@if (config().subtitle) {
|
||||
<p class="ui-lp-hero__subtitle">{{ config().subtitle }}</p>
|
||||
}
|
||||
|
||||
@if (config().ctaPrimary || config().ctaSecondary) {
|
||||
<div class="ui-lp-hero__actions">
|
||||
@if (config().ctaPrimary) {
|
||||
<ui-button
|
||||
[variant]="config().ctaPrimary!.variant"
|
||||
[size]="config().ctaPrimary!.size || 'large'"
|
||||
(clicked)="handleCTAClick(config().ctaPrimary!)">
|
||||
{{ config().ctaPrimary!.text }}
|
||||
</ui-button>
|
||||
}
|
||||
|
||||
@if (config().ctaSecondary) {
|
||||
<ui-button
|
||||
[variant]="config().ctaSecondary!.variant"
|
||||
[size]="config().ctaSecondary!.size || 'large'"
|
||||
(clicked)="handleCTAClick(config().ctaSecondary!)">
|
||||
{{ config().ctaSecondary!.text }}
|
||||
</ui-button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ui-container>
|
||||
|
||||
@if (config().backgroundType === 'animated') {
|
||||
<div class="ui-lp-hero__animated-bg" aria-hidden="true"></div>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styleUrl: './hero-section.component.scss'
|
||||
})
|
||||
export class HeroSectionComponent {
|
||||
config = signal<HeroConfig>({
|
||||
title: '',
|
||||
alignment: 'center',
|
||||
backgroundType: 'solid',
|
||||
minHeight: 'large'
|
||||
});
|
||||
|
||||
@Input() set configuration(value: HeroConfig) {
|
||||
this.config.set(value);
|
||||
}
|
||||
|
||||
@Output() ctaClicked = new EventEmitter<CTAButton>();
|
||||
|
||||
/**
|
||||
* Computed classes for the hero element based on configuration
|
||||
*/
|
||||
heroClasses(): string {
|
||||
const config = this.config();
|
||||
const classes = ['ui-lp-hero'];
|
||||
|
||||
if (config.backgroundType) {
|
||||
classes.push(`ui-lp-hero--${config.backgroundType}`);
|
||||
}
|
||||
|
||||
if (config.alignment) {
|
||||
classes.push(`ui-lp-hero--${config.alignment}`);
|
||||
}
|
||||
|
||||
if (config.minHeight) {
|
||||
classes.push(`ui-lp-hero--${config.minHeight}`);
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle CTA button clicks
|
||||
*/
|
||||
handleCTAClick(cta: CTAButton): void {
|
||||
cta.action();
|
||||
this.ctaClicked.emit(cta);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
@use '../../../../../ui-design-system/src/styles/semantic/index' as *;
|
||||
|
||||
.ui-lp-hero-split {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
// Min Height Variants
|
||||
&--full {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
&--large {
|
||||
min-height: 80vh;
|
||||
}
|
||||
|
||||
&--medium {
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
// Background Variants
|
||||
&--solid {
|
||||
background: $semantic-color-surface-primary;
|
||||
}
|
||||
|
||||
&--gradient {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
$semantic-color-primary,
|
||||
$semantic-color-secondary
|
||||
);
|
||||
}
|
||||
|
||||
// Main Wrapper
|
||||
&__wrapper {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: inherit;
|
||||
}
|
||||
|
||||
// Split Ratio Variants
|
||||
&--50-50 &__left,
|
||||
&--50-50 &__right {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&--60-40 &__left {
|
||||
flex: 0 0 60%;
|
||||
}
|
||||
|
||||
&--60-40 &__right {
|
||||
flex: 0 0 40%;
|
||||
}
|
||||
|
||||
&--40-60 &__left {
|
||||
flex: 0 0 40%;
|
||||
}
|
||||
|
||||
&--40-60 &__right {
|
||||
flex: 0 0 60%;
|
||||
}
|
||||
|
||||
&--70-30 &__left {
|
||||
flex: 0 0 70%;
|
||||
}
|
||||
|
||||
&--70-30 &__right {
|
||||
flex: 0 0 30%;
|
||||
}
|
||||
|
||||
&--30-70 &__left {
|
||||
flex: 0 0 30%;
|
||||
}
|
||||
|
||||
&--30-70 &__right {
|
||||
flex: 0 0 70%;
|
||||
}
|
||||
|
||||
// Left and Right Sections
|
||||
&__left,
|
||||
&__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $semantic-spacing-layout-section-lg;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__left {
|
||||
background: $semantic-color-surface-primary;
|
||||
|
||||
.ui-lp-hero-split--gradient & {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
$semantic-color-primary,
|
||||
rgba($semantic-color-primary, 0.8)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&__right {
|
||||
background: $semantic-color-surface-secondary;
|
||||
|
||||
.ui-lp-hero-split--gradient & {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba($semantic-color-secondary, 0.8),
|
||||
$semantic-color-secondary
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Content Types
|
||||
&__default-content {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
&__text-content {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: $semantic-color-text-primary;
|
||||
margin-bottom: $semantic-spacing-content-heading;
|
||||
}
|
||||
|
||||
p {
|
||||
color: $semantic-color-text-secondary;
|
||||
margin-bottom: $semantic-spacing-content-paragraph;
|
||||
}
|
||||
|
||||
.ui-lp-hero-split--gradient & {
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: $semantic-color-on-primary;
|
||||
}
|
||||
|
||||
p {
|
||||
color: $semantic-color-on-primary;
|
||||
opacity: $semantic-opacity-subtle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__image-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
height: auto;
|
||||
object-fit: cover;
|
||||
border-radius: $semantic-border-card-radius;
|
||||
}
|
||||
|
||||
&__video-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__video {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border-radius: $semantic-border-card-radius;
|
||||
}
|
||||
|
||||
// Placeholder
|
||||
&__placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
opacity: $semantic-opacity-subtle;
|
||||
}
|
||||
|
||||
&__placeholder-icon {
|
||||
font-size: $semantic-sizing-icon-navigation * 2;
|
||||
margin-bottom: $semantic-spacing-component-md;
|
||||
}
|
||||
|
||||
&__placeholder-text {
|
||||
font-family: map-get($semantic-typography-body-medium, font-family);
|
||||
font-size: map-get($semantic-typography-body-medium, font-size);
|
||||
color: $semantic-color-text-secondary;
|
||||
}
|
||||
|
||||
// Typography
|
||||
&__title {
|
||||
font-family: map-get($semantic-typography-heading-h1, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h1, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h1, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h1, line-height);
|
||||
color: $semantic-color-text-primary;
|
||||
margin-bottom: $semantic-spacing-content-heading;
|
||||
|
||||
.ui-lp-hero-split--gradient & {
|
||||
color: $semantic-color-on-primary;
|
||||
}
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-family: map-get($semantic-typography-body-large, font-family);
|
||||
font-size: map-get($semantic-typography-body-large, font-size);
|
||||
font-weight: map-get($semantic-typography-body-large, font-weight);
|
||||
line-height: map-get($semantic-typography-body-large, line-height);
|
||||
color: $semantic-color-text-secondary;
|
||||
margin-bottom: $semantic-spacing-content-paragraph;
|
||||
|
||||
.ui-lp-hero-split--gradient & {
|
||||
color: $semantic-color-on-primary;
|
||||
opacity: $semantic-opacity-subtle;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: $semantic-spacing-component-md;
|
||||
margin-top: $semantic-spacing-layout-section-sm;
|
||||
|
||||
.ui-lp-hero-split--center & {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ui-lp-hero-split--right & {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive Design
|
||||
@media (max-width: $semantic-breakpoint-lg - 1) {
|
||||
&__left,
|
||||
&__right {
|
||||
padding: $semantic-spacing-layout-section-md;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: map-get($semantic-typography-heading-h2, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h2, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h2, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h2, line-height);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $semantic-breakpoint-md - 1) {
|
||||
&--full {
|
||||
min-height: 100svh;
|
||||
}
|
||||
|
||||
&__wrapper {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
// Reset flex ratios for mobile
|
||||
&__left,
|
||||
&__right {
|
||||
flex: 1 !important;
|
||||
min-height: 50vh;
|
||||
padding: $semantic-spacing-layout-section-sm;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $semantic-breakpoint-sm - 1) {
|
||||
&__left,
|
||||
&__right {
|
||||
min-height: 40vh;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: map-get($semantic-typography-heading-h3, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h3, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h3, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h3, line-height);
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-family: map-get($semantic-typography-body-medium, font-family);
|
||||
font-size: map-get($semantic-typography-body-medium, font-size);
|
||||
font-weight: map-get($semantic-typography-body-medium, font-weight);
|
||||
line-height: map-get($semantic-typography-body-medium, line-height);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ButtonComponent } from 'ui-essentials';
|
||||
import { ContainerComponent } from 'ui-essentials';
|
||||
import { HeroSplitConfig } from '../../interfaces/hero.interfaces';
|
||||
import { CTAButton } from '../../interfaces/shared.interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-lp-hero-split',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ButtonComponent, ContainerComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<section
|
||||
class="ui-lp-hero-split"
|
||||
[class]="heroClasses()"
|
||||
[attr.aria-label]="'Split screen hero section'">
|
||||
|
||||
<div class="ui-lp-hero-split__wrapper">
|
||||
<!-- Left Content -->
|
||||
<div class="ui-lp-hero-split__left">
|
||||
@if (config().leftContent) {
|
||||
@switch (config().leftContent!.type) {
|
||||
@case ('text') {
|
||||
<div class="ui-lp-hero-split__text-content" [innerHTML]="config().leftContent!.content"></div>
|
||||
}
|
||||
@case ('image') {
|
||||
<div class="ui-lp-hero-split__image-content">
|
||||
<img [src]="config().leftContent!.content" [alt]="'Left content image'" class="ui-lp-hero-split__img" />
|
||||
</div>
|
||||
}
|
||||
@case ('video') {
|
||||
<div class="ui-lp-hero-split__video-content">
|
||||
<video [src]="config().leftContent!.content" class="ui-lp-hero-split__video" controls></video>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
<!-- Default text content -->
|
||||
<div class="ui-lp-hero-split__default-content">
|
||||
<h1 class="ui-lp-hero-split__title">{{ config().title }}</h1>
|
||||
|
||||
@if (config().subtitle) {
|
||||
<p class="ui-lp-hero-split__subtitle">{{ config().subtitle }}</p>
|
||||
}
|
||||
|
||||
@if (config().ctaPrimary || config().ctaSecondary) {
|
||||
<div class="ui-lp-hero-split__actions">
|
||||
@if (config().ctaPrimary) {
|
||||
<ui-button
|
||||
[variant]="config().ctaPrimary!.variant"
|
||||
[size]="config().ctaPrimary!.size || 'large'"
|
||||
(clicked)="handleCTAClick(config().ctaPrimary!)">
|
||||
{{ config().ctaPrimary!.text }}
|
||||
</ui-button>
|
||||
}
|
||||
|
||||
@if (config().ctaSecondary) {
|
||||
<ui-button
|
||||
[variant]="config().ctaSecondary!.variant"
|
||||
[size]="config().ctaSecondary!.size || 'large'"
|
||||
(clicked)="handleCTAClick(config().ctaSecondary!)">
|
||||
{{ config().ctaSecondary!.text }}
|
||||
</ui-button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Right Content -->
|
||||
<div class="ui-lp-hero-split__right">
|
||||
@if (config().rightContent) {
|
||||
@switch (config().rightContent!.type) {
|
||||
@case ('text') {
|
||||
<div class="ui-lp-hero-split__text-content" [innerHTML]="config().rightContent!.content"></div>
|
||||
}
|
||||
@case ('image') {
|
||||
<div class="ui-lp-hero-split__image-content">
|
||||
<img [src]="config().rightContent!.content" [alt]="'Right content image'" class="ui-lp-hero-split__img" />
|
||||
</div>
|
||||
}
|
||||
@case ('video') {
|
||||
<div class="ui-lp-hero-split__video-content">
|
||||
<video [src]="config().rightContent!.content" class="ui-lp-hero-split__video" controls></video>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
<!-- Placeholder for right content -->
|
||||
<div class="ui-lp-hero-split__placeholder">
|
||||
<div class="ui-lp-hero-split__placeholder-icon">🖼️</div>
|
||||
<p class="ui-lp-hero-split__placeholder-text">Right content area</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styleUrl: './hero-split-screen.component.scss'
|
||||
})
|
||||
export class HeroSplitScreenComponent {
|
||||
config = signal<HeroSplitConfig>({
|
||||
title: '',
|
||||
alignment: 'left',
|
||||
backgroundType: 'solid',
|
||||
minHeight: 'large',
|
||||
splitRatio: '50-50'
|
||||
});
|
||||
|
||||
@Input() set configuration(value: HeroSplitConfig) {
|
||||
this.config.set(value);
|
||||
}
|
||||
|
||||
@Output() ctaClicked = new EventEmitter<CTAButton>();
|
||||
|
||||
/**
|
||||
* Computed classes for the hero element based on configuration
|
||||
*/
|
||||
heroClasses(): string {
|
||||
const config = this.config();
|
||||
const classes = ['ui-lp-hero-split'];
|
||||
|
||||
if (config.backgroundType) {
|
||||
classes.push(`ui-lp-hero-split--${config.backgroundType}`);
|
||||
}
|
||||
|
||||
if (config.alignment) {
|
||||
classes.push(`ui-lp-hero-split--${config.alignment}`);
|
||||
}
|
||||
|
||||
if (config.minHeight) {
|
||||
classes.push(`ui-lp-hero-split--${config.minHeight}`);
|
||||
}
|
||||
|
||||
if (config.splitRatio) {
|
||||
classes.push(`ui-lp-hero-split--${config.splitRatio}`);
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle CTA button clicks
|
||||
*/
|
||||
handleCTAClick(cta: CTAButton): void {
|
||||
cta.action();
|
||||
this.ctaClicked.emit(cta);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
@use '../../../../../ui-design-system/src/styles/semantic/index' as *;
|
||||
|
||||
.ui-lp-hero-image {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
// Min Height Variants
|
||||
&--full {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
&--large {
|
||||
min-height: 80vh;
|
||||
}
|
||||
|
||||
&--medium {
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
// Background Variants
|
||||
&--solid {
|
||||
background: $semantic-color-surface-primary;
|
||||
}
|
||||
|
||||
&--gradient {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
$semantic-color-primary,
|
||||
$semantic-color-secondary
|
||||
);
|
||||
}
|
||||
|
||||
// Wrapper for flex layout
|
||||
&__wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $semantic-spacing-layout-section-lg;
|
||||
width: 100%;
|
||||
padding: $semantic-spacing-layout-section-lg 0;
|
||||
}
|
||||
|
||||
// Image Position Variants
|
||||
&--image-left &__wrapper {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
&--image-right &__wrapper {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
// Content Section
|
||||
&__content {
|
||||
flex: 1;
|
||||
min-width: 0; // Prevent flex item from overflowing
|
||||
|
||||
.ui-lp-hero-image--left & {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ui-lp-hero-image--center & {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ui-lp-hero-image--right & {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
// Media Section
|
||||
&__media {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: $semantic-border-card-radius;
|
||||
box-shadow: $semantic-shadow-card-rest;
|
||||
}
|
||||
|
||||
// Typography
|
||||
&__title {
|
||||
font-family: map-get($semantic-typography-heading-h1, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h1, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h1, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h1, line-height);
|
||||
color: $semantic-color-text-primary;
|
||||
margin-bottom: $semantic-spacing-content-heading;
|
||||
|
||||
.ui-lp-hero-image--gradient & {
|
||||
color: $semantic-color-on-primary;
|
||||
}
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-family: map-get($semantic-typography-body-large, font-family);
|
||||
font-size: map-get($semantic-typography-body-large, font-size);
|
||||
font-weight: map-get($semantic-typography-body-large, font-weight);
|
||||
line-height: map-get($semantic-typography-body-large, line-height);
|
||||
color: $semantic-color-text-secondary;
|
||||
margin-bottom: $semantic-spacing-content-paragraph;
|
||||
|
||||
.ui-lp-hero-image--gradient & {
|
||||
color: $semantic-color-on-primary;
|
||||
opacity: $semantic-opacity-subtle;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: $semantic-spacing-component-md;
|
||||
margin-top: $semantic-spacing-layout-section-sm;
|
||||
|
||||
.ui-lp-hero-image--center &__content & {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ui-lp-hero-image--right &__content & {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive Design
|
||||
@media (max-width: $semantic-breakpoint-lg - 1) {
|
||||
&__wrapper {
|
||||
gap: $semantic-spacing-layout-section-md;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: map-get($semantic-typography-heading-h2, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h2, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h2, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h2, line-height);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $semantic-breakpoint-md - 1) {
|
||||
&--full {
|
||||
min-height: 100svh;
|
||||
}
|
||||
|
||||
// Mobile image positioning
|
||||
&--mobile-above &__wrapper {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&--mobile-below &__wrapper {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
&--mobile-hidden &__media {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__wrapper {
|
||||
gap: $semantic-spacing-layout-section-sm;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__content {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: center !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $semantic-breakpoint-sm - 1) {
|
||||
&__title {
|
||||
font-family: map-get($semantic-typography-heading-h3, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h3, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h3, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h3, line-height);
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-family: map-get($semantic-typography-body-medium, font-family);
|
||||
font-size: map-get($semantic-typography-body-medium, font-size);
|
||||
font-weight: map-get($semantic-typography-body-medium, font-weight);
|
||||
line-height: map-get($semantic-typography-body-medium, line-height);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ButtonComponent } from 'ui-essentials';
|
||||
import { ContainerComponent } from 'ui-essentials';
|
||||
import { HeroImageConfig } from '../../interfaces/hero.interfaces';
|
||||
import { CTAButton } from '../../interfaces/shared.interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-lp-hero-image',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ButtonComponent, ContainerComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<section
|
||||
class="ui-lp-hero-image"
|
||||
[class]="heroClasses()"
|
||||
[attr.aria-label]="'Hero section with image'">
|
||||
|
||||
<ui-container [size]="'xl'" [padding]="'lg'">
|
||||
<div class="ui-lp-hero-image__wrapper">
|
||||
<div class="ui-lp-hero-image__content">
|
||||
<h1 class="ui-lp-hero-image__title">{{ config().title }}</h1>
|
||||
|
||||
@if (config().subtitle) {
|
||||
<p class="ui-lp-hero-image__subtitle">{{ config().subtitle }}</p>
|
||||
}
|
||||
|
||||
@if (config().ctaPrimary || config().ctaSecondary) {
|
||||
<div class="ui-lp-hero-image__actions">
|
||||
@if (config().ctaPrimary) {
|
||||
<ui-button
|
||||
[variant]="config().ctaPrimary!.variant"
|
||||
[size]="config().ctaPrimary!.size || 'large'"
|
||||
(clicked)="handleCTAClick(config().ctaPrimary!)">
|
||||
{{ config().ctaPrimary!.text }}
|
||||
</ui-button>
|
||||
}
|
||||
|
||||
@if (config().ctaSecondary) {
|
||||
<ui-button
|
||||
[variant]="config().ctaSecondary!.variant"
|
||||
[size]="config().ctaSecondary!.size || 'large'"
|
||||
(clicked)="handleCTAClick(config().ctaSecondary!)">
|
||||
{{ config().ctaSecondary!.text }}
|
||||
</ui-button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (config().imageUrl) {
|
||||
<div class="ui-lp-hero-image__media">
|
||||
<img
|
||||
[src]="config().imageUrl"
|
||||
[alt]="config().imageAlt || 'Hero image'"
|
||||
class="ui-lp-hero-image__img"
|
||||
loading="eager"
|
||||
decoding="async" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ui-container>
|
||||
</section>
|
||||
`,
|
||||
styleUrl: './hero-with-image.component.scss'
|
||||
})
|
||||
export class HeroWithImageComponent {
|
||||
config = signal<HeroImageConfig>({
|
||||
title: '',
|
||||
alignment: 'left',
|
||||
backgroundType: 'solid',
|
||||
minHeight: 'large',
|
||||
imagePosition: 'right',
|
||||
imageMobile: 'below'
|
||||
});
|
||||
|
||||
@Input() set configuration(value: HeroImageConfig) {
|
||||
this.config.set(value);
|
||||
}
|
||||
|
||||
@Output() ctaClicked = new EventEmitter<CTAButton>();
|
||||
|
||||
/**
|
||||
* Computed classes for the hero element based on configuration
|
||||
*/
|
||||
heroClasses(): string {
|
||||
const config = this.config();
|
||||
const classes = ['ui-lp-hero-image'];
|
||||
|
||||
if (config.backgroundType) {
|
||||
classes.push(`ui-lp-hero-image--${config.backgroundType}`);
|
||||
}
|
||||
|
||||
if (config.alignment) {
|
||||
classes.push(`ui-lp-hero-image--${config.alignment}`);
|
||||
}
|
||||
|
||||
if (config.minHeight) {
|
||||
classes.push(`ui-lp-hero-image--${config.minHeight}`);
|
||||
}
|
||||
|
||||
if (config.imagePosition) {
|
||||
classes.push(`ui-lp-hero-image--image-${config.imagePosition}`);
|
||||
}
|
||||
|
||||
if (config.imageMobile) {
|
||||
classes.push(`ui-lp-hero-image--mobile-${config.imageMobile}`);
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle CTA button clicks
|
||||
*/
|
||||
handleCTAClick(cta: CTAButton): void {
|
||||
cta.action();
|
||||
this.ctaClicked.emit(cta);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './hero-section.component';
|
||||
export * from './hero-with-image.component';
|
||||
export * from './hero-split-screen.component';
|
||||
20
projects/ui-landing-pages/src/lib/components/index.ts
Normal file
20
projects/ui-landing-pages/src/lib/components/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// Hero Components
|
||||
export * from './heroes';
|
||||
|
||||
// Feature Components
|
||||
export * from './features';
|
||||
|
||||
// Social Proof Components
|
||||
export * from './social-proof';
|
||||
|
||||
// Conversion Components
|
||||
export * from './conversion';
|
||||
|
||||
// Navigation Components
|
||||
export * from './navigation';
|
||||
|
||||
// Content Components
|
||||
export * from './content';
|
||||
|
||||
// Template Components
|
||||
export * from './templates';
|
||||
@@ -0,0 +1,452 @@
|
||||
@use '../../../../../ui-design-system/src/styles/semantic/index' as *;
|
||||
|
||||
.ui-lp-footer {
|
||||
width: 100%;
|
||||
background: $semantic-color-surface-secondary;
|
||||
color: $semantic-color-text-primary;
|
||||
|
||||
// Dark theme
|
||||
&--theme-dark {
|
||||
background: $semantic-color-inverse-surface;
|
||||
color: $semantic-color-text-primary;
|
||||
}
|
||||
|
||||
// Main Footer Content
|
||||
&__main {
|
||||
padding: $semantic-spacing-layout-section-lg 0;
|
||||
}
|
||||
|
||||
&__grid {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
// Brand Section
|
||||
&__brand-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $semantic-spacing-component-xl;
|
||||
|
||||
@media (max-width: $semantic-breakpoint-md - 1) {
|
||||
gap: $semantic-spacing-component-lg;
|
||||
}
|
||||
}
|
||||
|
||||
// Logo
|
||||
&__logo {
|
||||
margin-bottom: $semantic-spacing-component-md;
|
||||
}
|
||||
|
||||
&__logo-link,
|
||||
&__logo-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: opacity $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||
|
||||
&:hover {
|
||||
opacity: $semantic-opacity-hover;
|
||||
}
|
||||
}
|
||||
|
||||
&__logo-text {
|
||||
font-family: map-get($semantic-typography-heading-h3, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h3, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h3, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h3, line-height);
|
||||
color: $semantic-color-text-primary;
|
||||
|
||||
.ui-lp-footer--theme-dark & {
|
||||
color: $semantic-color-text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
&__logo-image {
|
||||
max-height: 40px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
// Newsletter Section
|
||||
&__newsletter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $semantic-spacing-component-md;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
&__newsletter-title {
|
||||
font-family: map-get($semantic-typography-heading-h4, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h4, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h4, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h4, line-height);
|
||||
color: $semantic-color-text-primary;
|
||||
margin: 0;
|
||||
|
||||
.ui-lp-footer--theme-dark & {
|
||||
color: $semantic-color-text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
&__newsletter-description {
|
||||
font-family: map-get($semantic-typography-body-medium, font-family);
|
||||
font-size: map-get($semantic-typography-body-medium, font-size);
|
||||
font-weight: map-get($semantic-typography-body-medium, font-weight);
|
||||
line-height: map-get($semantic-typography-body-medium, line-height);
|
||||
color: $semantic-color-text-secondary;
|
||||
margin: 0;
|
||||
|
||||
.ui-lp-footer--theme-dark & {
|
||||
color: rgba($semantic-color-text-primary, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
&__newsletter-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $semantic-spacing-component-sm;
|
||||
}
|
||||
|
||||
&__newsletter-input-group {
|
||||
display: flex;
|
||||
gap: $semantic-spacing-component-sm;
|
||||
|
||||
@media (max-width: $semantic-breakpoint-sm - 1) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
&__newsletter-input {
|
||||
flex: 1;
|
||||
padding: $semantic-spacing-component-md;
|
||||
border: 1px solid $semantic-color-border-subtle;
|
||||
border-radius: $semantic-border-input-radius;
|
||||
background: $semantic-color-surface-primary;
|
||||
color: $semantic-color-text-primary;
|
||||
font-family: map-get($semantic-typography-body-medium, font-family);
|
||||
font-size: map-get($semantic-typography-body-medium, font-size);
|
||||
line-height: map-get($semantic-typography-body-medium, line-height);
|
||||
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||
|
||||
&::placeholder {
|
||||
color: $semantic-color-text-secondary;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $semantic-color-focus;
|
||||
box-shadow: 0 0 0 2px rgba($semantic-color-focus, 0.2);
|
||||
}
|
||||
|
||||
&:invalid {
|
||||
border-color: $semantic-color-error;
|
||||
}
|
||||
|
||||
.ui-lp-footer--theme-dark & {
|
||||
background: $semantic-color-inverse-surface;
|
||||
border-color: $semantic-color-border-secondary;
|
||||
color: $semantic-color-text-primary;
|
||||
|
||||
&::placeholder {
|
||||
color: rgba($semantic-color-text-primary, 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__newsletter-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__newsletter-status {
|
||||
padding: $semantic-spacing-component-sm;
|
||||
border-radius: $semantic-border-input-radius;
|
||||
font-family: map-get($semantic-typography-body-small, font-family);
|
||||
font-size: map-get($semantic-typography-body-small, font-size);
|
||||
font-weight: map-get($semantic-typography-body-small, font-weight);
|
||||
line-height: map-get($semantic-typography-body-small, line-height);
|
||||
|
||||
&--success {
|
||||
background: rgba($semantic-color-success, 0.1);
|
||||
color: $semantic-color-success;
|
||||
border: 1px solid rgba($semantic-color-success, 0.2);
|
||||
}
|
||||
|
||||
&--error {
|
||||
background: rgba($semantic-color-error, 0.1);
|
||||
color: $semantic-color-error;
|
||||
border: 1px solid rgba($semantic-color-error, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// Social Links
|
||||
&__social {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $semantic-spacing-component-md;
|
||||
}
|
||||
|
||||
&__social-title {
|
||||
font-family: map-get($semantic-typography-heading-h5, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h5, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h5, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h5, line-height);
|
||||
color: $semantic-color-text-primary;
|
||||
margin: 0;
|
||||
|
||||
.ui-lp-footer--theme-dark & {
|
||||
color: $semantic-color-text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
&__social-links {
|
||||
display: flex;
|
||||
gap: $semantic-spacing-component-md;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__social-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: $semantic-sizing-button-height-md;
|
||||
height: $semantic-sizing-button-height-md;
|
||||
border-radius: $semantic-border-button-radius;
|
||||
background: $semantic-color-surface-elevated;
|
||||
color: $semantic-color-text-secondary;
|
||||
text-decoration: none;
|
||||
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||
|
||||
&:hover {
|
||||
background: $semantic-color-primary;
|
||||
color: $semantic-color-on-primary;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $semantic-color-focus;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.ui-lp-footer--theme-dark & {
|
||||
background: $semantic-color-inverse-surface;
|
||||
color: rgba($semantic-color-text-primary, 0.7);
|
||||
|
||||
&:hover {
|
||||
background: $semantic-color-primary;
|
||||
color: $semantic-color-on-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__social-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
// Footer Columns
|
||||
&__column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $semantic-spacing-component-md;
|
||||
}
|
||||
|
||||
&__column-title {
|
||||
font-family: map-get($semantic-typography-heading-h5, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h5, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h5, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h5, line-height);
|
||||
color: $semantic-color-text-primary;
|
||||
margin: 0;
|
||||
margin-bottom: $semantic-spacing-component-sm;
|
||||
|
||||
.ui-lp-footer--theme-dark & {
|
||||
color: $semantic-color-text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
&__column-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $semantic-spacing-component-sm;
|
||||
}
|
||||
|
||||
&__column-item {
|
||||
// No specific styles needed
|
||||
}
|
||||
|
||||
&__column-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $semantic-spacing-component-xs;
|
||||
text-decoration: none;
|
||||
color: $semantic-color-text-secondary;
|
||||
font-family: map-get($semantic-typography-body-medium, font-family);
|
||||
font-size: map-get($semantic-typography-body-medium, font-size);
|
||||
font-weight: map-get($semantic-typography-body-medium, font-weight);
|
||||
line-height: map-get($semantic-typography-body-medium, line-height);
|
||||
transition: color $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||
|
||||
&:hover {
|
||||
color: $semantic-color-primary;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $semantic-color-focus;
|
||||
outline-offset: 2px;
|
||||
border-radius: $semantic-border-input-radius;
|
||||
}
|
||||
|
||||
.ui-lp-footer--theme-dark & {
|
||||
color: rgba($semantic-color-text-primary, 0.8);
|
||||
|
||||
&:hover {
|
||||
color: $semantic-color-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__link-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
// Footer Bottom
|
||||
&__bottom {
|
||||
padding: $semantic-spacing-layout-section-sm 0;
|
||||
background: rgba($semantic-color-inverse-surface, 0.05);
|
||||
border-top: 1px solid $semantic-color-border-subtle;
|
||||
|
||||
.ui-lp-footer--theme-dark & {
|
||||
background: rgba($semantic-color-surface-primary, 0.05);
|
||||
border-top-color: rgba($semantic-color-border-secondary, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&__bottom-content {
|
||||
@media (max-width: $semantic-breakpoint-sm - 1) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__copyright {
|
||||
font-family: map-get($semantic-typography-body-small, font-family);
|
||||
font-size: map-get($semantic-typography-body-small, font-size);
|
||||
font-weight: map-get($semantic-typography-body-small, font-weight);
|
||||
line-height: map-get($semantic-typography-body-small, line-height);
|
||||
color: $semantic-color-text-secondary;
|
||||
|
||||
.ui-lp-footer--theme-dark & {
|
||||
color: rgba($semantic-color-text-primary, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
// Legal Links
|
||||
&__legal-links {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $semantic-spacing-component-sm;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__legal-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $semantic-spacing-component-sm;
|
||||
}
|
||||
|
||||
&__legal-link {
|
||||
text-decoration: none;
|
||||
color: $semantic-color-text-secondary;
|
||||
font-family: map-get($semantic-typography-body-small, font-family);
|
||||
font-size: map-get($semantic-typography-body-small, font-size);
|
||||
font-weight: map-get($semantic-typography-body-small, font-weight);
|
||||
line-height: map-get($semantic-typography-body-small, line-height);
|
||||
transition: color $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||
|
||||
&:hover {
|
||||
color: $semantic-color-primary;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $semantic-color-focus;
|
||||
outline-offset: 2px;
|
||||
border-radius: $semantic-border-input-radius;
|
||||
}
|
||||
|
||||
.ui-lp-footer--theme-dark & {
|
||||
color: rgba($semantic-color-text-primary, 0.7);
|
||||
|
||||
&:hover {
|
||||
color: $semantic-color-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__legal-separator {
|
||||
color: $semantic-color-text-tertiary;
|
||||
user-select: none;
|
||||
|
||||
.ui-lp-footer--theme-dark & {
|
||||
color: rgba($semantic-color-text-primary, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive Design
|
||||
@media (max-width: $semantic-breakpoint-lg - 1) {
|
||||
&__grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
}
|
||||
|
||||
&__brand-section {
|
||||
grid-column: 1 / -1;
|
||||
margin-bottom: $semantic-spacing-component-lg;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $semantic-breakpoint-md - 1) {
|
||||
&__main {
|
||||
padding: $semantic-spacing-layout-section-md 0;
|
||||
}
|
||||
|
||||
&__grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: $semantic-spacing-component-xl;
|
||||
}
|
||||
|
||||
&__newsletter {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
&__social-links {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $semantic-breakpoint-sm - 1) {
|
||||
&__main {
|
||||
padding: $semantic-spacing-layout-section-sm 0;
|
||||
}
|
||||
|
||||
&__newsletter-input-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__newsletter-button {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
&__bottom-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__legal-links {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { ReactiveFormsModule, FormControl, Validators } from '@angular/forms';
|
||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||
import { ButtonComponent } from 'ui-essentials';
|
||||
import { ContainerComponent } from 'ui-essentials';
|
||||
import { FlexComponent } from 'ui-essentials';
|
||||
import { GridContainerComponent } from 'ui-essentials';
|
||||
import { DividerComponent } from 'ui-essentials';
|
||||
import { FooterConfig, FooterLink } from '../../interfaces/navigation.interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-lp-footer',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
ReactiveFormsModule,
|
||||
FontAwesomeModule,
|
||||
ButtonComponent,
|
||||
ContainerComponent,
|
||||
FlexComponent,
|
||||
GridContainerComponent,
|
||||
DividerComponent
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<footer
|
||||
class="ui-lp-footer"
|
||||
[class.ui-lp-footer--theme-dark]="config().theme === 'dark'"
|
||||
[attr.aria-label]="'Site footer'">
|
||||
|
||||
<!-- Main Footer Content -->
|
||||
<div class="ui-lp-footer__main">
|
||||
<ui-container [size]="'xl'" [padding]="'md'">
|
||||
<ui-grid-container
|
||||
[style.grid-template-columns]="getGridColumns()"
|
||||
[gap]="'lg'"
|
||||
class="ui-lp-footer__grid">
|
||||
|
||||
<!-- Company/Brand Section -->
|
||||
@if (config().logo || config().newsletter) {
|
||||
<div class="ui-lp-footer__brand-section">
|
||||
@if (config().logo) {
|
||||
<div class="ui-lp-footer__logo">
|
||||
@if (config().logo!.imageUrl) {
|
||||
<a
|
||||
[routerLink]="config().logo?.url || '/'"
|
||||
class="ui-lp-footer__logo-link">
|
||||
<img
|
||||
[src]="config().logo?.imageUrl"
|
||||
[alt]="config().logo?.text || 'Logo'"
|
||||
[width]="config().logo?.width || 120"
|
||||
[height]="config().logo?.height || 40"
|
||||
class="ui-lp-footer__logo-image">
|
||||
</a>
|
||||
} @else if (config().logo!.text) {
|
||||
<a
|
||||
[routerLink]="config().logo?.url || '/'"
|
||||
class="ui-lp-footer__logo-text">
|
||||
{{ config().logo?.text }}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (config().newsletter) {
|
||||
<div class="ui-lp-footer__newsletter">
|
||||
<h3 class="ui-lp-footer__newsletter-title">
|
||||
{{ config().newsletter!.title }}
|
||||
</h3>
|
||||
|
||||
@if (config().newsletter!.description) {
|
||||
<p class="ui-lp-footer__newsletter-description">
|
||||
{{ config().newsletter!.description }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<form class="ui-lp-footer__newsletter-form" (ngSubmit)="handleNewsletterSubmit()">
|
||||
<div class="ui-lp-footer__newsletter-input-group">
|
||||
<input
|
||||
type="email"
|
||||
[formControl]="emailControl"
|
||||
[placeholder]="config().newsletter!.placeholder"
|
||||
class="ui-lp-footer__newsletter-input"
|
||||
[attr.aria-label]="'Email address'">
|
||||
<ui-button
|
||||
type="submit"
|
||||
[variant]="'filled'"
|
||||
[size]="'medium'"
|
||||
[disabled]="emailControl.invalid"
|
||||
class="ui-lp-footer__newsletter-button">
|
||||
{{ config().newsletter!.buttonText }}
|
||||
</ui-button>
|
||||
</div>
|
||||
|
||||
@if (newsletterStatus()) {
|
||||
<div
|
||||
class="ui-lp-footer__newsletter-status"
|
||||
[class.ui-lp-footer__newsletter-status--success]="newsletterStatus() === 'success'"
|
||||
[class.ui-lp-footer__newsletter-status--error]="newsletterStatus() === 'error'">
|
||||
@switch (newsletterStatus()) {
|
||||
@case ('success') {
|
||||
Thank you for subscribing!
|
||||
}
|
||||
@case ('error') {
|
||||
There was an error. Please try again.
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (config().socialLinks && config().socialLinks!.length > 0) {
|
||||
<div class="ui-lp-footer__social">
|
||||
<h4 class="ui-lp-footer__social-title">Follow Us</h4>
|
||||
<div class="ui-lp-footer__social-links">
|
||||
@for (social of config().socialLinks || []; track social.id) {
|
||||
<a
|
||||
[href]="social.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ui-lp-footer__social-link"
|
||||
[attr.aria-label]="social.label || social.platform">
|
||||
@if (social.icon) {
|
||||
<i [class]="social.icon" class="ui-lp-footer__social-icon" aria-hidden="true"></i>
|
||||
} @else {
|
||||
<i [class]="getSocialIcon(social.platform)" class="ui-lp-footer__social-icon" aria-hidden="true"></i>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Footer Columns -->
|
||||
@for (column of config().columns; track column.id) {
|
||||
<div class="ui-lp-footer__column">
|
||||
<h4 class="ui-lp-footer__column-title">{{ column.title }}</h4>
|
||||
<ul class="ui-lp-footer__column-list">
|
||||
@for (item of column.items; track item.id) {
|
||||
<li class="ui-lp-footer__column-item">
|
||||
<a
|
||||
[href]="item.url"
|
||||
[routerLink]="item.route"
|
||||
[target]="item.target || '_self'"
|
||||
class="ui-lp-footer__column-link"
|
||||
(click)="handleLinkClick(item)">
|
||||
@if (item.icon) {
|
||||
<span class="ui-lp-footer__link-icon">{{ item.icon }}</span>
|
||||
}
|
||||
{{ item.label }}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</ui-grid-container>
|
||||
</ui-container>
|
||||
</div>
|
||||
|
||||
<!-- Footer Bottom -->
|
||||
@if (config().showDivider !== false) {
|
||||
<ui-divider></ui-divider>
|
||||
}
|
||||
|
||||
<div class="ui-lp-footer__bottom">
|
||||
<ui-container [size]="'xl'" [padding]="'md'">
|
||||
<ui-flex
|
||||
[justify]="'between'"
|
||||
[align]="'center'"
|
||||
[wrap]="'wrap'"
|
||||
[gap]="'md'"
|
||||
class="ui-lp-footer__bottom-content">
|
||||
|
||||
<!-- Copyright -->
|
||||
@if (config().copyright) {
|
||||
<div class="ui-lp-footer__copyright">
|
||||
{{ config().copyright }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Legal Links -->
|
||||
@if (config().legalLinks && config().legalLinks!.length > 0) {
|
||||
<ul class="ui-lp-footer__legal-links">
|
||||
@for (link of config().legalLinks || []; track link.id; let last = $last) {
|
||||
<li class="ui-lp-footer__legal-item">
|
||||
<a
|
||||
[href]="link.url"
|
||||
[routerLink]="link.route"
|
||||
[target]="link.target || '_self'"
|
||||
class="ui-lp-footer__legal-link"
|
||||
(click)="handleLinkClick(link)">
|
||||
{{ link.label }}
|
||||
</a>
|
||||
@if (!last) {
|
||||
<span class="ui-lp-footer__legal-separator" aria-hidden="true">•</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</ui-flex>
|
||||
</ui-container>
|
||||
</div>
|
||||
</footer>
|
||||
`,
|
||||
styleUrl: './footer-section.component.scss'
|
||||
})
|
||||
export class FooterSectionComponent {
|
||||
config = signal<FooterConfig>({
|
||||
columns: [],
|
||||
theme: 'light',
|
||||
showDivider: true
|
||||
});
|
||||
|
||||
emailControl = new FormControl('', [Validators.required, Validators.email]);
|
||||
newsletterStatus = signal<'success' | 'error' | null>(null);
|
||||
|
||||
@Input() set configuration(value: FooterConfig) {
|
||||
this.config.set(value);
|
||||
}
|
||||
|
||||
@Output() linkClicked = new EventEmitter<FooterLink>();
|
||||
@Output() newsletterSubmitted = new EventEmitter<string>();
|
||||
|
||||
getGridColumns(): string {
|
||||
let columns = this.config().columns.length;
|
||||
|
||||
// Add brand section if logo or newsletter exists
|
||||
if (this.config().logo || this.config().newsletter) {
|
||||
columns += 1;
|
||||
}
|
||||
|
||||
const gridColumns = Math.min(columns, 5);
|
||||
return `repeat(${gridColumns}, 1fr)`;
|
||||
}
|
||||
|
||||
handleLinkClick(link: FooterLink): void {
|
||||
if (link.action) {
|
||||
link.action();
|
||||
}
|
||||
this.linkClicked.emit(link);
|
||||
}
|
||||
|
||||
handleNewsletterSubmit(): void {
|
||||
if (this.emailControl.valid && this.emailControl.value) {
|
||||
const email = this.emailControl.value;
|
||||
|
||||
const newsletter = this.config().newsletter;
|
||||
if (newsletter && newsletter.onSubmit) {
|
||||
try {
|
||||
newsletter.onSubmit(email);
|
||||
this.newsletterStatus.set('success');
|
||||
this.emailControl.reset();
|
||||
this.newsletterSubmitted.emit(email);
|
||||
} catch (error) {
|
||||
console.error('Newsletter submission error:', error);
|
||||
this.newsletterStatus.set('error');
|
||||
}
|
||||
}
|
||||
|
||||
// Reset status after 5 seconds
|
||||
setTimeout(() => {
|
||||
this.newsletterStatus.set(null);
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
getSocialIcon(platform: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
facebook: 'fab fa-facebook',
|
||||
twitter: 'fab fa-twitter',
|
||||
instagram: 'fab fa-instagram',
|
||||
linkedin: 'fab fa-linkedin',
|
||||
youtube: 'fab fa-youtube',
|
||||
github: 'fab fa-github',
|
||||
dribbble: 'fab fa-dribbble'
|
||||
};
|
||||
|
||||
return icons[platform] || 'fas fa-link';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './landing-header.component';
|
||||
export * from './footer-section.component';
|
||||
@@ -0,0 +1,431 @@
|
||||
@use '../../../../../ui-design-system/src/styles/semantic/index' as *;
|
||||
|
||||
.ui-lp-header {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
z-index: $semantic-z-index-sticky;
|
||||
transition: all $semantic-motion-duration-normal $semantic-motion-easing-ease;
|
||||
background: $semantic-color-surface-primary;
|
||||
border-bottom: 1px solid $semantic-color-border-secondary;
|
||||
|
||||
// Transparent variant
|
||||
&--transparent {
|
||||
background: transparent;
|
||||
border-bottom: 1px solid transparent;
|
||||
|
||||
&.ui-lp-header--scrolled {
|
||||
background: rgba($semantic-color-surface-primary, 0.95);
|
||||
border-bottom-color: $semantic-color-border-secondary;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: $semantic-shadow-card-rest;
|
||||
}
|
||||
}
|
||||
|
||||
// Sticky positioning
|
||||
&--sticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
// Dark theme
|
||||
&--theme-dark {
|
||||
background: $semantic-color-inverse-surface;
|
||||
border-bottom-color: $semantic-color-border-secondary;
|
||||
color: $semantic-color-text-primary;
|
||||
|
||||
&.ui-lp-header--transparent {
|
||||
&.ui-lp-header--scrolled {
|
||||
background: rgba($semantic-color-inverse-surface, 0.95);
|
||||
border-bottom-color: $semantic-color-border-secondary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content container
|
||||
&__content {
|
||||
min-height: 64px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// Logo Section
|
||||
&__logo-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__logo-link,
|
||||
&__logo-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: opacity $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||
|
||||
&:hover {
|
||||
opacity: $semantic-opacity-hover;
|
||||
}
|
||||
}
|
||||
|
||||
&__logo-text {
|
||||
font-family: map-get($semantic-typography-heading-h3, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h3, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h3, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h3, line-height);
|
||||
color: $semantic-color-text-primary;
|
||||
|
||||
.ui-lp-header--theme-dark & {
|
||||
color: $semantic-color-text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
&__logo-image {
|
||||
max-height: 48px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
// Desktop Navigation
|
||||
&__nav--desktop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@media (max-width: $semantic-breakpoint-md - 1) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__nav-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
gap: $semantic-spacing-component-lg;
|
||||
}
|
||||
|
||||
&__nav-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $semantic-spacing-component-xs;
|
||||
padding: $semantic-spacing-component-sm $semantic-spacing-component-md;
|
||||
text-decoration: none;
|
||||
color: $semantic-color-text-primary;
|
||||
font-family: map-get($semantic-typography-body-medium, font-family);
|
||||
font-size: map-get($semantic-typography-body-medium, font-size);
|
||||
font-weight: map-get($semantic-typography-body-medium, font-weight);
|
||||
line-height: map-get($semantic-typography-body-medium, line-height);
|
||||
border-radius: $semantic-border-button-radius;
|
||||
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: $semantic-color-surface-hover;
|
||||
color: $semantic-color-primary;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $semantic-color-focus;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.ui-lp-header--theme-dark & {
|
||||
color: $semantic-color-text-primary;
|
||||
|
||||
&:hover {
|
||||
background: rgba($semantic-color-surface-hover, 0.1);
|
||||
color: $semantic-color-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__nav-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: transform $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||
|
||||
.ui-lp-header__nav-link--dropdown[aria-expanded="true"] & {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
&__nav-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&__nav-badge {
|
||||
padding: $semantic-spacing-component-xs $semantic-spacing-component-sm;
|
||||
background: $semantic-color-warning;
|
||||
color: $semantic-color-on-primary;
|
||||
border-radius: 9999px;
|
||||
font-family: map-get($semantic-typography-body-small, font-family);
|
||||
font-size: map-get($semantic-typography-body-small, font-size);
|
||||
font-weight: map-get($semantic-typography-body-small, font-weight);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
// Dropdown Menu
|
||||
&__dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 200px;
|
||||
background: $semantic-color-surface-elevated;
|
||||
border: 1px solid $semantic-color-border-secondary;
|
||||
border-radius: $semantic-border-card-radius;
|
||||
box-shadow: $semantic-shadow-dropdown;
|
||||
padding: $semantic-spacing-component-sm 0;
|
||||
z-index: $semantic-z-index-dropdown;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
animation: dropdownFadeIn $semantic-motion-duration-normal $semantic-motion-easing-ease;
|
||||
|
||||
.ui-lp-header--theme-dark & {
|
||||
background: $semantic-color-inverse-surface;
|
||||
border-color: $semantic-color-border-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
&__dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $semantic-spacing-component-sm;
|
||||
padding: $semantic-spacing-component-sm $semantic-spacing-component-md;
|
||||
text-decoration: none;
|
||||
color: $semantic-color-text-primary;
|
||||
font-family: map-get($semantic-typography-body-medium, font-family);
|
||||
font-size: map-get($semantic-typography-body-medium, font-size);
|
||||
font-weight: map-get($semantic-typography-body-medium, font-weight);
|
||||
line-height: map-get($semantic-typography-body-medium, line-height);
|
||||
transition: background $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||
|
||||
&:hover {
|
||||
background: $semantic-color-surface-hover;
|
||||
}
|
||||
|
||||
.ui-lp-header--theme-dark & {
|
||||
color: $semantic-color-text-primary;
|
||||
|
||||
&:hover {
|
||||
background: rgba($semantic-color-surface-hover, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Actions Section
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $semantic-spacing-component-md;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__cta-button {
|
||||
@media (max-width: $semantic-breakpoint-sm - 1) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__cta-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&__mobile-toggle {
|
||||
display: none;
|
||||
|
||||
@media (max-width: $semantic-breakpoint-md - 1) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile Navigation
|
||||
&__nav--mobile {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background: $semantic-color-surface-primary;
|
||||
border-bottom: 1px solid $semantic-color-border-secondary;
|
||||
box-shadow: $semantic-shadow-card-hover;
|
||||
z-index: $semantic-z-index-dropdown;
|
||||
max-height: calc(100vh - 64px);
|
||||
overflow-y: auto;
|
||||
|
||||
&.ui-lp-header--mobile-menu-open & {
|
||||
display: block;
|
||||
animation: slideDown $semantic-motion-duration-normal $semantic-motion-easing-ease;
|
||||
}
|
||||
|
||||
.ui-lp-header--theme-dark & {
|
||||
background: $semantic-color-inverse-surface;
|
||||
border-bottom-color: $semantic-color-border-secondary;
|
||||
}
|
||||
|
||||
@media (max-width: $semantic-breakpoint-md - 1) {
|
||||
.ui-lp-header--mobile-menu-open & {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__mobile-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: $semantic-spacing-component-md 0;
|
||||
}
|
||||
|
||||
&__mobile-item {
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid $semantic-color-border-subtle;
|
||||
}
|
||||
}
|
||||
|
||||
&__mobile-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $semantic-spacing-component-md;
|
||||
text-decoration: none;
|
||||
color: $semantic-color-text-primary;
|
||||
font-family: map-get($semantic-typography-body-large, font-family);
|
||||
font-size: map-get($semantic-typography-body-large, font-size);
|
||||
font-weight: map-get($semantic-typography-body-large, font-weight);
|
||||
line-height: map-get($semantic-typography-body-large, line-height);
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
transition: background $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||
|
||||
&:hover {
|
||||
background: $semantic-color-surface-hover;
|
||||
}
|
||||
|
||||
.ui-lp-header--theme-dark & {
|
||||
color: $semantic-color-text-primary;
|
||||
|
||||
&:hover {
|
||||
background: rgba($semantic-color-surface-hover, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__mobile-submenu {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: $semantic-color-surface-secondary;
|
||||
|
||||
.ui-lp-header--theme-dark & {
|
||||
background: rgba($semantic-color-surface-secondary, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&__mobile-sublink {
|
||||
display: block;
|
||||
padding: $semantic-spacing-component-sm $semantic-spacing-component-lg;
|
||||
text-decoration: none;
|
||||
color: $semantic-color-text-secondary;
|
||||
font-family: map-get($semantic-typography-body-medium, font-family);
|
||||
font-size: map-get($semantic-typography-body-medium, font-size);
|
||||
font-weight: map-get($semantic-typography-body-medium, font-weight);
|
||||
line-height: map-get($semantic-typography-body-medium, line-height);
|
||||
transition: background $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||
|
||||
&:hover {
|
||||
background: $semantic-color-surface-hover;
|
||||
color: $semantic-color-text-primary;
|
||||
}
|
||||
|
||||
.ui-lp-header--theme-dark & {
|
||||
color: rgba($semantic-color-text-primary, 0.7);
|
||||
|
||||
&:hover {
|
||||
background: rgba($semantic-color-surface-hover, 0.1);
|
||||
color: $semantic-color-text-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__mobile-cta {
|
||||
padding: $semantic-spacing-component-md;
|
||||
margin-top: $semantic-spacing-component-md;
|
||||
border-top: 1px solid $semantic-color-border-subtle;
|
||||
}
|
||||
|
||||
// Mobile Menu Backdrop
|
||||
&__backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba($semantic-color-inverse-surface, $semantic-opacity-backdrop);
|
||||
z-index: $semantic-z-index-overlay - 1;
|
||||
animation: backdropFadeIn $semantic-motion-duration-normal $semantic-motion-easing-ease;
|
||||
}
|
||||
|
||||
// Mobile menu open state
|
||||
&--mobile-menu-open {
|
||||
.ui-lp-header__nav--mobile {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive Design
|
||||
@media (max-width: $semantic-breakpoint-sm - 1) {
|
||||
&__logo-text {
|
||||
font-family: map-get($semantic-typography-heading-h4, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h4, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h4, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h4, line-height);
|
||||
}
|
||||
|
||||
&__content {
|
||||
min-height: 64px-sm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Animations
|
||||
@keyframes dropdownFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes backdropFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, signal, computed, inject, OnInit, OnDestroy, HostListener } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
import { faBars, faXmark } from '@fortawesome/free-solid-svg-icons';
|
||||
import { LandingHeaderConfig, NavigationItem, LogoConfig } from '../../interfaces/navigation.interfaces';
|
||||
import { CTAButton } from '../../interfaces/shared.interfaces';
|
||||
import { ButtonComponent, ContainerComponent, FlexComponent, IconButtonComponent } from 'ui-essentials';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-lp-header',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
ButtonComponent,
|
||||
ContainerComponent,
|
||||
FlexComponent,
|
||||
IconButtonComponent
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<header
|
||||
class="ui-lp-header"
|
||||
[class.ui-lp-header--transparent]="config().transparent"
|
||||
[class.ui-lp-header--sticky]="config().sticky"
|
||||
[class.ui-lp-header--scrolled]="isScrolled()"
|
||||
[class.ui-lp-header--mobile-menu-open]="mobileMenuOpen()"
|
||||
[class.ui-lp-header--theme-dark]="config().theme === 'dark'"
|
||||
[attr.aria-label]="'Main navigation'">
|
||||
|
||||
<ui-container [size]="config().size || 'xl'" [padding]="'md'">
|
||||
<ui-flex [justify]="'between'" [align]="'center'" class="ui-lp-header__content">
|
||||
|
||||
<!-- Logo Section -->
|
||||
<div class="ui-lp-header__logo-section">
|
||||
@if (config().logo.imageUrl) {
|
||||
<a
|
||||
[href]="config().logo.url || '/'"
|
||||
[routerLink]="config().logo.url || '/'"
|
||||
class="ui-lp-header__logo-link">
|
||||
<img
|
||||
[src]="config().logo.imageUrl"
|
||||
[alt]="config().logo.text || 'Logo'"
|
||||
[width]="config().logo.width || 120"
|
||||
[height]="config().logo.height || 40"
|
||||
class="ui-lp-header__logo-image">
|
||||
</a>
|
||||
} @else if (config().logo.text) {
|
||||
<a
|
||||
[href]="config().logo.url || '/'"
|
||||
[routerLink]="config().logo.url || '/'"
|
||||
class="ui-lp-header__logo-text">
|
||||
{{ config().logo.text }}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<nav class="ui-lp-header__nav ui-lp-header__nav--desktop" [attr.aria-label]="'Primary navigation'">
|
||||
<ul class="ui-lp-header__nav-list">
|
||||
@for (item of config().navigation; track item.id) {
|
||||
<li class="ui-lp-header__nav-item">
|
||||
@if (item.children && item.children.length > 0) {
|
||||
<!-- Dropdown Menu Item -->
|
||||
<button
|
||||
class="ui-lp-header__nav-link ui-lp-header__nav-link--dropdown"
|
||||
[attr.aria-expanded]="false"
|
||||
[attr.aria-haspopup]="'true'"
|
||||
(click)="toggleDropdown(item.id)"
|
||||
type="button">
|
||||
{{ item.label }}
|
||||
<span class="ui-lp-header__nav-arrow" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@if (openDropdown() === item.id) {
|
||||
<div class="ui-lp-header__dropdown" role="menu">
|
||||
@for (child of item.children; track child.id) {
|
||||
<a
|
||||
[href]="child.url"
|
||||
[routerLink]="child.route"
|
||||
[target]="child.target || '_self'"
|
||||
class="ui-lp-header__dropdown-item"
|
||||
role="menuitem"
|
||||
(click)="handleNavClick(child)">
|
||||
@if (child.icon) {
|
||||
<span class="ui-lp-header__nav-icon">{{ child.icon }}</span>
|
||||
}
|
||||
{{ child.label }}
|
||||
@if (child.badge) {
|
||||
<span class="ui-lp-header__nav-badge">{{ child.badge }}</span>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<!-- Regular Menu Item -->
|
||||
<a
|
||||
[href]="item.url"
|
||||
[routerLink]="item.route"
|
||||
[target]="item.target || '_self'"
|
||||
class="ui-lp-header__nav-link"
|
||||
(click)="handleNavClick(item)">
|
||||
@if (item.icon) {
|
||||
<span class="ui-lp-header__nav-icon">{{ item.icon }}</span>
|
||||
}
|
||||
{{ item.label }}
|
||||
@if (item.badge) {
|
||||
<span class="ui-lp-header__nav-badge">{{ item.badge }}</span>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Actions Section -->
|
||||
<div class="ui-lp-header__actions">
|
||||
@if (config().ctaButton) {
|
||||
<ui-button
|
||||
[variant]="config().ctaButton!.variant"
|
||||
[size]="config().ctaButton!.size || 'medium'"
|
||||
class="ui-lp-header__cta-button"
|
||||
(clicked)="handleCTAClick()">
|
||||
@if (config().ctaButton!.icon) {
|
||||
<span class="ui-lp-header__cta-icon">{{ config().ctaButton!.icon }}</span>
|
||||
}
|
||||
{{ config().ctaButton!.text }}
|
||||
</ui-button>
|
||||
}
|
||||
|
||||
<!-- Mobile Menu Toggle -->
|
||||
@if (config().showMobileMenu !== false) {
|
||||
<ui-icon-button
|
||||
[icon]="mobileMenuOpen() ? faXmark : faBars"
|
||||
[size]="'medium'"
|
||||
class="ui-lp-header__mobile-toggle"
|
||||
[attr.aria-expanded]="mobileMenuOpen()"
|
||||
[attr.aria-label]="mobileMenuOpen() ? 'Close menu' : 'Open menu'"
|
||||
(clicked)="toggleMobileMenu()">
|
||||
</ui-icon-button>
|
||||
}
|
||||
</div>
|
||||
</ui-flex>
|
||||
</ui-container>
|
||||
|
||||
<!-- Mobile Navigation -->
|
||||
@if (mobileMenuOpen() && config().showMobileMenu !== false) {
|
||||
<nav class="ui-lp-header__nav ui-lp-header__nav--mobile" [attr.aria-label]="'Mobile navigation'">
|
||||
<ui-container [size]="config().size || 'xl'" [padding]="'md'">
|
||||
<ul class="ui-lp-header__mobile-list">
|
||||
@for (item of config().navigation; track item.id) {
|
||||
<li class="ui-lp-header__mobile-item">
|
||||
@if (item.children && item.children.length > 0) {
|
||||
<!-- Mobile Dropdown -->
|
||||
<button
|
||||
class="ui-lp-header__mobile-link ui-lp-header__mobile-link--dropdown"
|
||||
[attr.aria-expanded]="openMobileDropdown() === item.id"
|
||||
(click)="toggleMobileDropdown(item.id)"
|
||||
type="button">
|
||||
{{ item.label }}
|
||||
</button>
|
||||
|
||||
@if (openMobileDropdown() === item.id) {
|
||||
<ul class="ui-lp-header__mobile-submenu">
|
||||
@for (child of item.children; track child.id) {
|
||||
<li>
|
||||
<a
|
||||
[href]="child.url"
|
||||
[routerLink]="child.route"
|
||||
[target]="child.target || '_self'"
|
||||
class="ui-lp-header__mobile-sublink"
|
||||
(click)="handleNavClick(child)">
|
||||
{{ child.label }}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
} @else {
|
||||
<!-- Regular Mobile Item -->
|
||||
<a
|
||||
[href]="item.url"
|
||||
[routerLink]="item.route"
|
||||
[target]="item.target || '_self'"
|
||||
class="ui-lp-header__mobile-link"
|
||||
(click)="handleNavClick(item)">
|
||||
{{ item.label }}
|
||||
</a>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
|
||||
@if (config().ctaButton) {
|
||||
<li class="ui-lp-header__mobile-cta">
|
||||
<ui-button
|
||||
[variant]="config().ctaButton!.variant"
|
||||
[size]="'large'"
|
||||
[fullWidth]="true"
|
||||
(clicked)="handleCTAClick()">
|
||||
{{ config().ctaButton!.text }}
|
||||
</ui-button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</ui-container>
|
||||
</nav>
|
||||
}
|
||||
|
||||
<!-- Mobile Menu Backdrop -->
|
||||
@if (mobileMenuOpen()) {
|
||||
<div
|
||||
class="ui-lp-header__backdrop"
|
||||
(click)="closeMobileMenu()"
|
||||
aria-hidden="true">
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
`,
|
||||
styleUrl: './landing-header.component.scss'
|
||||
})
|
||||
export class LandingHeaderComponent implements OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
// FontAwesome icons
|
||||
faXmark = faXmark;
|
||||
faBars = faBars;
|
||||
|
||||
config = signal<LandingHeaderConfig>({
|
||||
logo: { text: 'Logo' },
|
||||
navigation: [],
|
||||
transparent: false,
|
||||
sticky: true,
|
||||
showMobileMenu: true,
|
||||
size: 'xl',
|
||||
theme: 'light'
|
||||
});
|
||||
|
||||
isScrolled = signal<boolean>(false);
|
||||
mobileMenuOpen = signal<boolean>(false);
|
||||
openDropdown = signal<string | null>(null);
|
||||
openMobileDropdown = signal<string | null>(null);
|
||||
|
||||
@Input() set configuration(value: LandingHeaderConfig) {
|
||||
this.config.set(value);
|
||||
}
|
||||
|
||||
@Output() navigationClicked = new EventEmitter<NavigationItem>();
|
||||
@Output() ctaClicked = new EventEmitter<CTAButton>();
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.config().sticky) {
|
||||
this.setupScrollListener();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
@HostListener('window:scroll', [])
|
||||
onWindowScroll(): void {
|
||||
if (this.config().sticky) {
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
||||
this.isScrolled.set(scrollTop > 10);
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
onDocumentClick(event: Event): void {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest('.ui-lp-header__nav-link--dropdown')) {
|
||||
this.openDropdown.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('window:resize', [])
|
||||
onWindowResize(): void {
|
||||
if (window.innerWidth > 768) {
|
||||
this.mobileMenuOpen.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private setupScrollListener(): void {
|
||||
// Initial scroll check
|
||||
this.onWindowScroll();
|
||||
}
|
||||
|
||||
toggleMobileMenu(): void {
|
||||
this.mobileMenuOpen.set(!this.mobileMenuOpen());
|
||||
this.openMobileDropdown.set(null);
|
||||
|
||||
// Prevent body scroll when mobile menu is open
|
||||
document.body.style.overflow = this.mobileMenuOpen() ? 'hidden' : '';
|
||||
}
|
||||
|
||||
closeMobileMenu(): void {
|
||||
this.mobileMenuOpen.set(false);
|
||||
this.openMobileDropdown.set(null);
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
toggleDropdown(itemId: string): void {
|
||||
this.openDropdown.set(this.openDropdown() === itemId ? null : itemId);
|
||||
}
|
||||
|
||||
toggleMobileDropdown(itemId: string): void {
|
||||
this.openMobileDropdown.set(this.openMobileDropdown() === itemId ? null : itemId);
|
||||
}
|
||||
|
||||
handleNavClick(item: NavigationItem): void {
|
||||
if (item.action) {
|
||||
item.action();
|
||||
}
|
||||
this.navigationClicked.emit(item);
|
||||
this.closeMobileMenu();
|
||||
this.openDropdown.set(null);
|
||||
}
|
||||
|
||||
handleCTAClick(): void {
|
||||
const cta = this.config().ctaButton;
|
||||
if (cta) {
|
||||
cta.action();
|
||||
this.ctaClicked.emit(cta);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './testimonial-carousel.component';
|
||||
export * from './logo-cloud.component';
|
||||
export * from './statistics-display.component';
|
||||
@@ -0,0 +1,220 @@
|
||||
@use '../../../../../ui-design-system/src/styles/semantic/index' as *;
|
||||
|
||||
.ui-lp-logo-cloud {
|
||||
padding: $semantic-spacing-layout-section-md 0;
|
||||
background: $semantic-color-surface-primary;
|
||||
|
||||
// Header Section
|
||||
&__header {
|
||||
text-align: center;
|
||||
margin-bottom: $semantic-spacing-layout-section-sm;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: map-get($semantic-typography-heading-h3, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h3, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h3, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h3, line-height);
|
||||
color: $semantic-color-text-primary;
|
||||
margin-bottom: $semantic-spacing-content-heading;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-family: map-get($semantic-typography-body-medium, font-family);
|
||||
font-size: map-get($semantic-typography-body-medium, font-size);
|
||||
font-weight: map-get($semantic-typography-body-medium, font-weight);
|
||||
line-height: map-get($semantic-typography-body-medium, line-height);
|
||||
color: $semantic-color-text-secondary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Container Layouts
|
||||
&__container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// Row Layout
|
||||
&__container--row {
|
||||
flex-wrap: wrap;
|
||||
gap: $semantic-spacing-component-xl;
|
||||
|
||||
@media (max-width: $semantic-breakpoint-lg - 1) {
|
||||
gap: $semantic-spacing-component-lg;
|
||||
}
|
||||
|
||||
@media (max-width: $semantic-breakpoint-md - 1) {
|
||||
gap: $semantic-spacing-component-md;
|
||||
}
|
||||
}
|
||||
|
||||
// Grid Layout
|
||||
&__container--grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--items-per-row, 5), 1fr);
|
||||
gap: $semantic-spacing-component-xl;
|
||||
width: 100%;
|
||||
|
||||
@media (max-width: $semantic-breakpoint-lg - 1) {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: $semantic-spacing-component-lg;
|
||||
}
|
||||
|
||||
@media (max-width: $semantic-breakpoint-md - 1) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: $semantic-spacing-component-md;
|
||||
}
|
||||
|
||||
@media (max-width: $semantic-breakpoint-sm - 1) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: $semantic-spacing-component-sm;
|
||||
}
|
||||
}
|
||||
|
||||
// Marquee Layout
|
||||
&__container--marquee {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__marquee {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__marquee-track {
|
||||
display: flex;
|
||||
gap: $semantic-spacing-component-xl;
|
||||
animation: marqueeScroll 30s linear infinite;
|
||||
width: calc(200% + #{$semantic-spacing-component-xl});
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
width: auto;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
// Logo Items
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $semantic-spacing-component-md;
|
||||
border-radius: $semantic-border-card-radius;
|
||||
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||
|
||||
&:hover {
|
||||
background: $semantic-color-surface-hover;
|
||||
}
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $semantic-color-primary;
|
||||
outline-offset: 2px;
|
||||
border-radius: $semantic-border-button-radius;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
max-height: var(--max-height, 80px);
|
||||
max-width: 200px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
transition: all $semantic-motion-duration-normal $semantic-motion-easing-ease;
|
||||
}
|
||||
|
||||
// Animation delays for staggered entrance
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
animation: fadeInUp $semantic-motion-duration-slow $semantic-motion-easing-ease forwards;
|
||||
animation-delay: var(--animation-delay, 0ms);
|
||||
}
|
||||
}
|
||||
|
||||
// Grayscale Effects
|
||||
&--grayscale &__item img,
|
||||
&__logo--grayscale {
|
||||
filter: grayscale(100%);
|
||||
opacity: $semantic-opacity-subtle;
|
||||
}
|
||||
|
||||
&--hover &__item:hover img,
|
||||
&--hover &__item:hover &__logo--grayscale {
|
||||
filter: grayscale(0%);
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
// Responsive adjustments for marquee
|
||||
.ui-lp-logo-cloud--marquee &__item {
|
||||
flex-shrink: 0;
|
||||
min-width: 180px;
|
||||
|
||||
@media (max-width: $semantic-breakpoint-md - 1) {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
@media (max-width: $semantic-breakpoint-sm - 1) {
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
// Small screen optimizations
|
||||
@media (max-width: $semantic-breakpoint-md - 1) {
|
||||
&__item {
|
||||
padding: $semantic-spacing-component-sm;
|
||||
|
||||
img {
|
||||
max-height: 60px;
|
||||
max-width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
&__marquee-track {
|
||||
gap: $semantic-spacing-component-lg;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $semantic-breakpoint-sm - 1) {
|
||||
&__item img {
|
||||
max-height: 50px;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
&__marquee-track {
|
||||
gap: $semantic-spacing-component-md;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keyframes
|
||||
@keyframes marqueeScroll {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ContainerComponent } from 'ui-essentials';
|
||||
import { LogoCloudConfig, LogoItem } from '../../interfaces/social-proof.interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-lp-logo-cloud',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ContainerComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<section
|
||||
class="ui-lp-logo-cloud"
|
||||
[class]="getComponentClasses()"
|
||||
[attr.aria-label]="'Partners and clients'">
|
||||
|
||||
<ui-container [size]="'xl'" [padding]="'lg'">
|
||||
@if (config().title || config().subtitle) {
|
||||
<div class="ui-lp-logo-cloud__header">
|
||||
@if (config().title) {
|
||||
<h2 class="ui-lp-logo-cloud__title">{{ config().title }}</h2>
|
||||
}
|
||||
@if (config().subtitle) {
|
||||
<p class="ui-lp-logo-cloud__subtitle">{{ config().subtitle }}</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div
|
||||
class="ui-lp-logo-cloud__container"
|
||||
[class.ui-lp-logo-cloud__container--row]="config().layout === 'row'"
|
||||
[class.ui-lp-logo-cloud__container--grid]="config().layout === 'grid'"
|
||||
[class.ui-lp-logo-cloud__container--marquee]="config().layout === 'marquee'"
|
||||
[style.--items-per-row]="config().itemsPerRow"
|
||||
[style.--max-height]="config().maxHeight ? config().maxHeight + 'px' : null">
|
||||
|
||||
@if (config().layout === 'marquee') {
|
||||
<div class="ui-lp-logo-cloud__marquee">
|
||||
<div class="ui-lp-logo-cloud__marquee-track">
|
||||
@for (logo of duplicatedLogos(); track logo.id + '-1') {
|
||||
<div class="ui-lp-logo-cloud__item">
|
||||
@if (logo.url) {
|
||||
<a
|
||||
[href]="logo.url"
|
||||
[attr.aria-label]="'Visit ' + logo.name"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
(click)="handleLogoClick(logo)">
|
||||
<img
|
||||
[src]="logo.logo"
|
||||
[alt]="logo.name"
|
||||
[class.ui-lp-logo-cloud__logo--grayscale]="logo.grayscale"
|
||||
loading="lazy">
|
||||
</a>
|
||||
} @else {
|
||||
<img
|
||||
[src]="logo.logo"
|
||||
[alt]="logo.name"
|
||||
[class.ui-lp-logo-cloud__logo--grayscale]="logo.grayscale"
|
||||
loading="lazy">
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
@for (logo of config().logos; track logo.id; let index = $index) {
|
||||
<div
|
||||
class="ui-lp-logo-cloud__item"
|
||||
[attr.data-animation-delay]="index * 50">
|
||||
@if (logo.url) {
|
||||
<a
|
||||
[href]="logo.url"
|
||||
[attr.aria-label]="'Visit ' + logo.name"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
(click)="handleLogoClick(logo)">
|
||||
<img
|
||||
[src]="logo.logo"
|
||||
[alt]="logo.name"
|
||||
[class.ui-lp-logo-cloud__logo--grayscale]="logo.grayscale"
|
||||
loading="lazy">
|
||||
</a>
|
||||
} @else {
|
||||
<img
|
||||
[src]="logo.logo"
|
||||
[alt]="logo.name"
|
||||
[class.ui-lp-logo-cloud__logo--grayscale]="logo.grayscale"
|
||||
loading="lazy">
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</ui-container>
|
||||
</section>
|
||||
`,
|
||||
styleUrl: './logo-cloud.component.scss'
|
||||
})
|
||||
export class LogoCloudComponent {
|
||||
config = signal<LogoCloudConfig>({
|
||||
logos: [],
|
||||
layout: 'row',
|
||||
itemsPerRow: 5,
|
||||
grayscale: true,
|
||||
hoverEffect: true,
|
||||
maxHeight: 80
|
||||
});
|
||||
|
||||
duplicatedLogos = computed(() => {
|
||||
const logos = this.config().logos;
|
||||
return [...logos, ...logos]; // Duplicate for seamless marquee
|
||||
});
|
||||
|
||||
@Input() set configuration(value: LogoCloudConfig) {
|
||||
this.config.set(value);
|
||||
}
|
||||
|
||||
@Output() logoClicked = new EventEmitter<LogoItem>();
|
||||
|
||||
getComponentClasses(): string {
|
||||
const classes = ['ui-lp-logo-cloud'];
|
||||
|
||||
if (this.config().layout) {
|
||||
classes.push(`ui-lp-logo-cloud--${this.config().layout}`);
|
||||
}
|
||||
if (this.config().grayscale) {
|
||||
classes.push('ui-lp-logo-cloud--grayscale');
|
||||
}
|
||||
if (this.config().hoverEffect) {
|
||||
classes.push('ui-lp-logo-cloud--hover');
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
handleLogoClick(logo: LogoItem): void {
|
||||
this.logoClicked.emit(logo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
@use '../../../../../ui-design-system/src/styles/semantic/index' as *;
|
||||
|
||||
.ui-lp-statistics-display {
|
||||
padding: $semantic-spacing-layout-section-lg 0;
|
||||
|
||||
// Background Variants
|
||||
&--transparent {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&--surface {
|
||||
background: $semantic-color-surface-elevated;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: $semantic-color-primary;
|
||||
color: $semantic-color-on-primary;
|
||||
|
||||
.ui-lp-statistics-display__title,
|
||||
.ui-lp-statistics-display__value,
|
||||
.ui-lp-statistics-display__label {
|
||||
color: $semantic-color-on-primary;
|
||||
}
|
||||
|
||||
.ui-lp-statistics-display__subtitle,
|
||||
.ui-lp-statistics-display__description {
|
||||
color: $semantic-color-on-primary;
|
||||
opacity: $semantic-opacity-subtle;
|
||||
}
|
||||
}
|
||||
|
||||
// Header Section
|
||||
&__header {
|
||||
text-align: center;
|
||||
margin-bottom: $semantic-spacing-layout-section-md;
|
||||
max-width: 800px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: map-get($semantic-typography-heading-h2, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h2, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h2, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h2, line-height);
|
||||
color: $semantic-color-text-primary;
|
||||
margin-bottom: $semantic-spacing-content-heading;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-family: map-get($semantic-typography-body-large, font-family);
|
||||
font-size: map-get($semantic-typography-body-large, font-size);
|
||||
font-weight: map-get($semantic-typography-body-large, font-weight);
|
||||
line-height: map-get($semantic-typography-body-large, line-height);
|
||||
color: $semantic-color-text-secondary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Items Container
|
||||
&__items {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// Row Layout
|
||||
&__items--row {
|
||||
flex-wrap: wrap;
|
||||
gap: $semantic-spacing-component-xl;
|
||||
|
||||
@media (max-width: $semantic-breakpoint-lg - 1) {
|
||||
gap: $semantic-spacing-component-lg;
|
||||
}
|
||||
|
||||
@media (max-width: $semantic-breakpoint-md - 1) {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $semantic-spacing-component-lg;
|
||||
}
|
||||
}
|
||||
|
||||
// Grid Layout
|
||||
&__items--grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: $semantic-spacing-component-xl;
|
||||
width: 100%;
|
||||
|
||||
@media (max-width: $semantic-breakpoint-lg - 1) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: $semantic-spacing-component-lg;
|
||||
}
|
||||
|
||||
@media (max-width: $semantic-breakpoint-md - 1) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: $semantic-spacing-component-lg;
|
||||
}
|
||||
}
|
||||
|
||||
// Individual Item
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: $semantic-spacing-component-md;
|
||||
text-align: left;
|
||||
|
||||
.ui-lp-statistics-display--row & {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
animation: fadeInUp $semantic-motion-duration-slow $semantic-motion-easing-ease forwards;
|
||||
animation-delay: var(--animation-delay, 0ms);
|
||||
}
|
||||
}
|
||||
|
||||
// Icon
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: $semantic-sizing-icon-navigation;
|
||||
height: $semantic-sizing-icon-navigation;
|
||||
flex-shrink: 0;
|
||||
|
||||
fa-icon {
|
||||
font-size: $semantic-sizing-icon-navigation;
|
||||
color: $semantic-color-primary;
|
||||
}
|
||||
|
||||
.ui-lp-statistics-display--primary & fa-icon {
|
||||
color: $semantic-color-on-primary;
|
||||
}
|
||||
|
||||
.ui-lp-statistics-display--row & {
|
||||
margin-bottom: $semantic-spacing-component-sm;
|
||||
}
|
||||
}
|
||||
|
||||
// Content Area
|
||||
&__content {
|
||||
flex: 1;
|
||||
|
||||
.ui-lp-statistics-display--row & {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__value {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: $semantic-spacing-component-xs;
|
||||
margin-bottom: $semantic-spacing-component-xs;
|
||||
|
||||
.ui-lp-statistics-display--row & {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__number {
|
||||
font-family: map-get($semantic-typography-heading-h1, font-family);
|
||||
font-size: 3.5rem;
|
||||
font-weight: map-get($semantic-typography-heading-h1, font-weight);
|
||||
line-height: 1;
|
||||
color: $semantic-color-text-primary;
|
||||
|
||||
@media (max-width: $semantic-breakpoint-md - 1) {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: $semantic-breakpoint-sm - 1) {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__prefix,
|
||||
&__suffix {
|
||||
font-family: map-get($semantic-typography-heading-h3, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h3, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h3, font-weight);
|
||||
color: $semantic-color-text-primary;
|
||||
|
||||
@media (max-width: $semantic-breakpoint-md - 1) {
|
||||
font-family: map-get($semantic-typography-heading-h4, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h4, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h4, font-weight);
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-family: map-get($semantic-typography-heading-h4, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h4, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h4, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h4, line-height);
|
||||
color: $semantic-color-text-primary;
|
||||
margin-bottom: $semantic-spacing-component-xs;
|
||||
|
||||
@media (max-width: $semantic-breakpoint-md - 1) {
|
||||
font-family: map-get($semantic-typography-heading-h5, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h5, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h5, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h5, line-height);
|
||||
}
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-family: map-get($semantic-typography-body-small, font-family);
|
||||
font-size: map-get($semantic-typography-body-small, font-size);
|
||||
font-weight: map-get($semantic-typography-body-small, font-weight);
|
||||
line-height: map-get($semantic-typography-body-small, line-height);
|
||||
color: $semantic-color-text-secondary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Variant Styles
|
||||
// Card Variant
|
||||
&--card &__item {
|
||||
padding: $semantic-spacing-component-xl;
|
||||
background: $semantic-color-surface-elevated;
|
||||
border-radius: $semantic-border-card-radius;
|
||||
box-shadow: $semantic-shadow-card-rest;
|
||||
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
|
||||
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: $semantic-shadow-card-hover;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
&--card.ui-lp-statistics-display--primary &__item {
|
||||
background: $semantic-color-surface-elevated;
|
||||
border-color: $semantic-color-primary;
|
||||
|
||||
.ui-lp-statistics-display__title,
|
||||
.ui-lp-statistics-display__value,
|
||||
.ui-lp-statistics-display__label {
|
||||
color: $semantic-color-text-primary;
|
||||
}
|
||||
|
||||
.ui-lp-statistics-display__subtitle,
|
||||
.ui-lp-statistics-display__description {
|
||||
color: $semantic-color-text-secondary;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Highlighted Variant
|
||||
&--highlighted &__item {
|
||||
position: relative;
|
||||
padding: $semantic-spacing-component-lg;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
background: $semantic-color-primary;
|
||||
border-radius: $semantic-border-card-radius;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive Adjustments
|
||||
@media (max-width: $semantic-breakpoint-md - 1) {
|
||||
padding: $semantic-spacing-layout-section-md 0;
|
||||
|
||||
&__header {
|
||||
margin-bottom: $semantic-spacing-layout-section-sm;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: map-get($semantic-typography-heading-h3, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h3, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h3, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h3, line-height);
|
||||
}
|
||||
|
||||
&__item {
|
||||
gap: $semantic-spacing-component-sm;
|
||||
}
|
||||
|
||||
&--card &__item {
|
||||
padding: $semantic-spacing-component-lg;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $semantic-breakpoint-sm - 1) {
|
||||
&__items--row {
|
||||
gap: $semantic-spacing-component-md;
|
||||
}
|
||||
|
||||
&__items--grid {
|
||||
gap: $semantic-spacing-component-md;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: $semantic-sizing-icon-button;
|
||||
height: $semantic-sizing-icon-button;
|
||||
|
||||
fa-icon {
|
||||
font-size: $semantic-sizing-icon-button;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Animation states
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ui-lp-statistics-display__item {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, signal, computed, OnInit, OnDestroy, ElementRef, ViewChild, AfterViewInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ContainerComponent } from 'ui-essentials';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core';
|
||||
import { faArrowUp, faUsers, faDollarSign, faChartBar } from '@fortawesome/free-solid-svg-icons';
|
||||
import { StatisticsConfig, StatisticItem } from '../../interfaces/social-proof.interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-lp-statistics-display',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ContainerComponent, FaIconComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<section
|
||||
class="ui-lp-statistics-display"
|
||||
[class]="getComponentClasses()"
|
||||
[attr.aria-label]="'Statistics section'"
|
||||
#sectionElement>
|
||||
|
||||
<ui-container [size]="'xl'" [padding]="'lg'">
|
||||
@if (config().title || config().subtitle) {
|
||||
<div class="ui-lp-statistics-display__header">
|
||||
@if (config().title) {
|
||||
<h2 class="ui-lp-statistics-display__title">{{ config().title }}</h2>
|
||||
}
|
||||
@if (config().subtitle) {
|
||||
<p class="ui-lp-statistics-display__subtitle">{{ config().subtitle }}</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div
|
||||
class="ui-lp-statistics-display__items"
|
||||
[class.ui-lp-statistics-display__items--row]="config().layout === 'row'"
|
||||
[class.ui-lp-statistics-display__items--grid]="config().layout === 'grid'">
|
||||
|
||||
@for (statistic of config().statistics; track statistic.id; let index = $index) {
|
||||
<div
|
||||
class="ui-lp-statistics-display__item"
|
||||
[attr.data-animation-delay]="index * 100">
|
||||
|
||||
@if (statistic.icon) {
|
||||
<div class="ui-lp-statistics-display__icon">
|
||||
<fa-icon [icon]="getIconDefinition(statistic.icon)"></fa-icon>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="ui-lp-statistics-display__content">
|
||||
<div class="ui-lp-statistics-display__value">
|
||||
@if (statistic.prefix) {
|
||||
<span class="ui-lp-statistics-display__prefix">{{ statistic.prefix }}</span>
|
||||
}
|
||||
|
||||
<span
|
||||
class="ui-lp-statistics-display__number"
|
||||
[attr.data-target]="statistic.animateValue ? getNumericValue(statistic.value) : null"
|
||||
[attr.data-animate]="statistic.animateValue">
|
||||
{{ displayValue(statistic) }}
|
||||
</span>
|
||||
|
||||
@if (statistic.suffix) {
|
||||
<span class="ui-lp-statistics-display__suffix">{{ statistic.suffix }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="ui-lp-statistics-display__label">{{ statistic.label }}</div>
|
||||
|
||||
@if (statistic.description) {
|
||||
<div class="ui-lp-statistics-display__description">{{ statistic.description }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ui-container>
|
||||
</section>
|
||||
`,
|
||||
styleUrl: './statistics-display.component.scss'
|
||||
})
|
||||
export class StatisticsDisplayComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
@ViewChild('sectionElement') sectionElement!: ElementRef<HTMLElement>;
|
||||
|
||||
config = signal<StatisticsConfig>({
|
||||
statistics: [],
|
||||
layout: 'row',
|
||||
variant: 'minimal',
|
||||
animateOnScroll: true,
|
||||
backgroundColor: 'transparent'
|
||||
});
|
||||
|
||||
private intersectionObserver?: IntersectionObserver;
|
||||
private animatedItems = new Set<string>();
|
||||
|
||||
@Input() set configuration(value: StatisticsConfig) {
|
||||
this.config.set(value);
|
||||
}
|
||||
|
||||
@Output() statisticClicked = new EventEmitter<StatisticItem>();
|
||||
|
||||
getComponentClasses(): string {
|
||||
const classes = ['ui-lp-statistics-display'];
|
||||
|
||||
if (this.config().layout) {
|
||||
classes.push(`ui-lp-statistics-display--${this.config().layout}`);
|
||||
}
|
||||
if (this.config().variant) {
|
||||
classes.push(`ui-lp-statistics-display--${this.config().variant}`);
|
||||
}
|
||||
if (this.config().backgroundColor) {
|
||||
classes.push(`ui-lp-statistics-display--${this.config().backgroundColor}`);
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.config().animateOnScroll && 'IntersectionObserver' in window) {
|
||||
this.setupIntersectionObserver();
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
if (!this.config().animateOnScroll) {
|
||||
this.animateAllCounters();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.intersectionObserver) {
|
||||
this.intersectionObserver.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
displayValue(statistic: StatisticItem): string {
|
||||
if (typeof statistic.value === 'number' && !statistic.animateValue) {
|
||||
return this.formatNumber(statistic.value);
|
||||
}
|
||||
return statistic.value.toString();
|
||||
}
|
||||
|
||||
getNumericValue(value: number | string): number {
|
||||
return typeof value === 'number' ? value : parseInt(value.toString(), 10) || 0;
|
||||
}
|
||||
|
||||
getIconDefinition(iconName: string): IconDefinition {
|
||||
const iconMap: { [key: string]: IconDefinition } = {
|
||||
'arrow-up': faArrowUp,
|
||||
'users': faUsers,
|
||||
'dollar-sign': faDollarSign,
|
||||
'chart-bar': faChartBar
|
||||
};
|
||||
return iconMap[iconName] || faChartBar;
|
||||
}
|
||||
|
||||
private setupIntersectionObserver(): void {
|
||||
this.intersectionObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
this.animateCountersInView();
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.5 }
|
||||
);
|
||||
}
|
||||
|
||||
private animateCountersInView(): void {
|
||||
if (!this.sectionElement) return;
|
||||
|
||||
const numberElements = this.sectionElement.nativeElement.querySelectorAll(
|
||||
'[data-animate="true"]:not(.animated)'
|
||||
);
|
||||
|
||||
numberElements.forEach((element) => {
|
||||
const target = parseInt(element.getAttribute('data-target') || '0', 10);
|
||||
const statId = element.closest('.ui-lp-statistics-display__item')?.getAttribute('data-id');
|
||||
|
||||
if (statId && !this.animatedItems.has(statId)) {
|
||||
this.animatedItems.add(statId);
|
||||
element.classList.add('animated');
|
||||
this.animateCounter(element as HTMLElement, target);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private animateAllCounters(): void {
|
||||
if (!this.sectionElement) return;
|
||||
|
||||
const numberElements = this.sectionElement.nativeElement.querySelectorAll('[data-animate="true"]');
|
||||
|
||||
numberElements.forEach((element) => {
|
||||
const target = parseInt(element.getAttribute('data-target') || '0', 10);
|
||||
this.animateCounter(element as HTMLElement, target);
|
||||
});
|
||||
}
|
||||
|
||||
private animateCounter(element: HTMLElement, target: number): void {
|
||||
const duration = 2000; // 2 seconds
|
||||
const steps = 60;
|
||||
const stepValue = target / steps;
|
||||
const stepDuration = duration / steps;
|
||||
|
||||
let current = 0;
|
||||
let step = 0;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
current = Math.min(target, Math.floor(stepValue * step));
|
||||
element.textContent = this.formatNumber(current);
|
||||
|
||||
step++;
|
||||
|
||||
if (step > steps || current >= target) {
|
||||
clearInterval(timer);
|
||||
element.textContent = this.formatNumber(target);
|
||||
}
|
||||
}, stepDuration);
|
||||
}
|
||||
|
||||
private formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
@use '../../../../../ui-design-system/src/styles/semantic/index' as *;
|
||||
|
||||
.ui-lp-testimonial-carousel {
|
||||
padding: $semantic-spacing-layout-section-lg 0;
|
||||
background: $semantic-color-surface-primary;
|
||||
|
||||
// Header Section
|
||||
&__header {
|
||||
text-align: center;
|
||||
margin-bottom: $semantic-spacing-layout-section-md;
|
||||
max-width: 800px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: map-get($semantic-typography-heading-h2, font-family);
|
||||
font-size: map-get($semantic-typography-heading-h2, font-size);
|
||||
font-weight: map-get($semantic-typography-heading-h2, font-weight);
|
||||
line-height: map-get($semantic-typography-heading-h2, line-height);
|
||||
color: $semantic-color-text-primary;
|
||||
margin-bottom: $semantic-spacing-content-heading;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-family: map-get($semantic-typography-body-large, font-family);
|
||||
font-size: map-get($semantic-typography-body-large, font-size);
|
||||
font-weight: map-get($semantic-typography-body-large, font-weight);
|
||||
line-height: map-get($semantic-typography-body-large, line-height);
|
||||
color: $semantic-color-text-secondary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Carousel Container
|
||||
&__container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $semantic-spacing-component-lg;
|
||||
}
|
||||
|
||||
&__viewport {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
border-radius: $semantic-border-card-radius;
|
||||
}
|
||||
|
||||
&__track {
|
||||
display: flex;
|
||||
transition: transform $semantic-motion-duration-normal $semantic-motion-easing-ease;
|
||||
}
|
||||
|
||||
&__slide {
|
||||
flex-shrink: 0;
|
||||
padding: 0 $semantic-spacing-component-sm;
|
||||
}
|
||||
|
||||
// Navigation Buttons
|
||||
&__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: none;
|
||||
border-radius: $semantic-border-button-radius;
|
||||
background: $semantic-color-surface-elevated;
|
||||
color: $semantic-color-text-primary;
|
||||
cursor: pointer;
|
||||
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||
box-shadow: $semantic-shadow-card-rest;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: $semantic-color-surface-hover;
|
||||
box-shadow: $semantic-shadow-card-hover;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: $semantic-opacity-disabled;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $semantic-color-primary;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
fa-icon {
|
||||
font-size: $semantic-sizing-icon-button;
|
||||
}
|
||||
}
|
||||
|
||||
// Testimonial Item
|
||||
&__item {
|
||||
padding: $semantic-spacing-component-xl;
|
||||
background: $semantic-color-surface-elevated;
|
||||
border-radius: $semantic-border-card-radius;
|
||||
box-shadow: $semantic-shadow-card-rest;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $semantic-spacing-component-lg;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// Card Variant (default)
|
||||
&--card &__item {
|
||||
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
|
||||
}
|
||||
|
||||
// Minimal Variant
|
||||
&--minimal &__item {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
padding: $semantic-spacing-component-lg;
|
||||
}
|
||||
|
||||
// Quote Variant
|
||||
&--quote &__item {
|
||||
background: $semantic-color-primary;
|
||||
color: $semantic-color-on-primary;
|
||||
|
||||
.ui-lp-testimonial-carousel__text {
|
||||
color: $semantic-color-on-primary;
|
||||
}
|
||||
|
||||
.ui-lp-testimonial-carousel__author-name {
|
||||
color: $semantic-color-on-primary;
|
||||
}
|
||||
|
||||
.ui-lp-testimonial-carousel__author-title {
|
||||
color: $semantic-color-on-primary;
|
||||
opacity: $semantic-opacity-subtle;
|
||||
}
|
||||
}
|
||||
|
||||
&__quote-icon {
|
||||
position: absolute;
|
||||
top: $semantic-spacing-component-md;
|
||||
left: $semantic-spacing-component-md;
|
||||
|
||||
fa-icon {
|
||||
font-size: 2rem;
|
||||
color: $semantic-color-on-primary;
|
||||
opacity: $semantic-opacity-subtle;
|
||||
}
|
||||
}
|
||||
|
||||
// Content
|
||||
&__content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $semantic-spacing-component-md;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-family: map-get($semantic-typography-body-large, font-family);
|
||||
font-size: map-get($semantic-typography-body-large, font-size);
|
||||
font-weight: map-get($semantic-typography-body-large, font-weight);
|
||||
line-height: map-get($semantic-typography-body-large, line-height);
|
||||
color: $semantic-color-text-primary;
|
||||
margin: 0;
|
||||
|
||||
.ui-lp-testimonial-carousel--quote & {
|
||||
font-style: italic;
|
||||
padding-top: $semantic-spacing-component-lg;
|
||||
}
|
||||
}
|
||||
|
||||
// Rating
|
||||
&__rating {
|
||||
display: flex;
|
||||
gap: $semantic-spacing-component-xs;
|
||||
}
|
||||
|
||||
&__star {
|
||||
font-size: $semantic-sizing-icon-button;
|
||||
color: $semantic-color-warning;
|
||||
|
||||
.ui-lp-testimonial-carousel--quote & {
|
||||
color: $semantic-color-on-primary;
|
||||
}
|
||||
}
|
||||
|
||||
// Author
|
||||
&__author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $semantic-spacing-component-md;
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: $semantic-border-avatar-radius;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
&__author-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__author-name {
|
||||
font-family: map-get($semantic-typography-button-medium, font-family);
|
||||
font-size: map-get($semantic-typography-button-medium, font-size);
|
||||
font-weight: map-get($semantic-typography-button-medium, font-weight);
|
||||
line-height: map-get($semantic-typography-button-medium, line-height);
|
||||
color: $semantic-color-text-primary;
|
||||
margin-bottom: $semantic-spacing-component-xs;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $semantic-spacing-component-xs;
|
||||
}
|
||||
|
||||
&__verified {
|
||||
font-size: $semantic-sizing-icon-button;
|
||||
color: $semantic-color-success;
|
||||
|
||||
.ui-lp-testimonial-carousel--quote & {
|
||||
color: $semantic-color-on-primary;
|
||||
}
|
||||
}
|
||||
|
||||
&__author-title {
|
||||
font-family: map-get($semantic-typography-body-small, font-family);
|
||||
font-size: map-get($semantic-typography-body-small, font-size);
|
||||
font-weight: map-get($semantic-typography-body-small, font-weight);
|
||||
line-height: map-get($semantic-typography-body-small, line-height);
|
||||
color: $semantic-color-text-secondary;
|
||||
}
|
||||
|
||||
// Dots Navigation
|
||||
&__dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: $semantic-spacing-component-sm;
|
||||
margin-top: $semantic-spacing-component-xl;
|
||||
}
|
||||
|
||||
&__dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: $semantic-color-border-subtle;
|
||||
cursor: pointer;
|
||||
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||
|
||||
&--active {
|
||||
background: $semantic-color-primary;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: $semantic-color-primary;
|
||||
opacity: $semantic-opacity-subtle;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $semantic-color-primary;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive Design
|
||||
@media (max-width: $semantic-breakpoint-lg - 1) {
|
||||
&__container {
|
||||
gap: $semantic-spacing-component-md;
|
||||
}
|
||||
|
||||
&__nav {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
fa-icon {
|
||||
font-size: $semantic-sizing-icon-inline;
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
padding: $semantic-spacing-component-lg;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $semantic-breakpoint-md - 1) {
|
||||
&__container {
|
||||
flex-direction: column;
|
||||
gap: $semantic-spacing-component-lg;
|
||||
}
|
||||
|
||||
&__nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__viewport {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__slide {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__item {
|
||||
padding: $semantic-spacing-component-md;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-family: map-get($semantic-typography-body-medium, font-family);
|
||||
font-size: map-get($semantic-typography-body-medium, font-size);
|
||||
font-weight: map-get($semantic-typography-body-medium, font-weight);
|
||||
line-height: map-get($semantic-typography-body-medium, line-height);
|
||||
}
|
||||
}
|
||||
|
||||
// Animation states for scroll-based reveal
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
&__item {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
animation: fadeInUp $semantic-motion-duration-slow $semantic-motion-easing-ease forwards;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
&__track {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
&__item {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, signal, computed, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ContainerComponent } from 'ui-essentials';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core';
|
||||
import { faQuoteLeft, faStar, faChevronLeft, faChevronRight, faCheck } from '@fortawesome/free-solid-svg-icons';
|
||||
import { TestimonialCarouselConfig, TestimonialItem } from '../../interfaces/social-proof.interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-lp-testimonial-carousel',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ContainerComponent, FaIconComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<section
|
||||
class="ui-lp-testimonial-carousel"
|
||||
[class]="getTestimonialCarouselClasses()"
|
||||
[attr.aria-label]="'Testimonials section'">
|
||||
|
||||
<ui-container [size]="'xl'" [padding]="'lg'">
|
||||
@if (config().title || config().subtitle) {
|
||||
<div class="ui-lp-testimonial-carousel__header">
|
||||
@if (config().title) {
|
||||
<h2 class="ui-lp-testimonial-carousel__title">{{ config().title }}</h2>
|
||||
}
|
||||
@if (config().subtitle) {
|
||||
<p class="ui-lp-testimonial-carousel__subtitle">{{ config().subtitle }}</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="ui-lp-testimonial-carousel__container">
|
||||
@if (config().showNavigation && config().testimonials.length > config().itemsPerView!) {
|
||||
<button
|
||||
class="ui-lp-testimonial-carousel__nav ui-lp-testimonial-carousel__nav--prev"
|
||||
(click)="previousSlide()"
|
||||
[disabled]="currentSlide() === 0"
|
||||
[attr.aria-label]="'Previous testimonial'">
|
||||
<fa-icon [icon]="faChevronLeft"></fa-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
<div class="ui-lp-testimonial-carousel__viewport">
|
||||
<div
|
||||
class="ui-lp-testimonial-carousel__track"
|
||||
[style.transform]="'translateX(' + translateX() + '%)'">
|
||||
|
||||
@for (testimonial of config().testimonials; track testimonial.id; let index = $index) {
|
||||
<div
|
||||
class="ui-lp-testimonial-carousel__slide"
|
||||
[style.width]="slideWidth() + '%'">
|
||||
|
||||
<div class="ui-lp-testimonial-carousel__item">
|
||||
@if (config().variant === 'quote') {
|
||||
<div class="ui-lp-testimonial-carousel__quote-icon">
|
||||
<fa-icon [icon]="faQuoteLeft"></fa-icon>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="ui-lp-testimonial-carousel__content">
|
||||
<p class="ui-lp-testimonial-carousel__text">{{ testimonial.content }}</p>
|
||||
|
||||
@if (config().showRatings && testimonial.rating) {
|
||||
<div class="ui-lp-testimonial-carousel__rating">
|
||||
@for (star of getRatingArray(testimonial.rating); track $index) {
|
||||
<fa-icon [icon]="faStar" class="ui-lp-testimonial-carousel__star"></fa-icon>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="ui-lp-testimonial-carousel__author">
|
||||
@if (testimonial.avatar) {
|
||||
<div class="ui-lp-testimonial-carousel__avatar">
|
||||
<img
|
||||
[src]="testimonial.avatar"
|
||||
[alt]="testimonial.name"
|
||||
loading="lazy">
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="ui-lp-testimonial-carousel__author-info">
|
||||
<div class="ui-lp-testimonial-carousel__author-name">
|
||||
{{ testimonial.name }}
|
||||
@if (testimonial.verified) {
|
||||
<fa-icon [icon]="faCheck" class="ui-lp-testimonial-carousel__verified"></fa-icon>
|
||||
}
|
||||
</div>
|
||||
@if (testimonial.title || testimonial.company) {
|
||||
<div class="ui-lp-testimonial-carousel__author-title">
|
||||
@if (testimonial.title) {
|
||||
<span>{{ testimonial.title }}</span>
|
||||
}
|
||||
@if (testimonial.title && testimonial.company) {
|
||||
<span> at </span>
|
||||
}
|
||||
@if (testimonial.company) {
|
||||
<span>{{ testimonial.company }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (config().showNavigation && config().testimonials.length > config().itemsPerView!) {
|
||||
<button
|
||||
class="ui-lp-testimonial-carousel__nav ui-lp-testimonial-carousel__nav--next"
|
||||
(click)="nextSlide()"
|
||||
[disabled]="currentSlide() >= maxSlide()"
|
||||
[attr.aria-label]="'Next testimonial'">
|
||||
<fa-icon [icon]="faChevronRight"></fa-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (config().showDots && config().testimonials.length > config().itemsPerView!) {
|
||||
<div class="ui-lp-testimonial-carousel__dots">
|
||||
@for (dot of dotsArray(); track $index; let index = $index) {
|
||||
<button
|
||||
class="ui-lp-testimonial-carousel__dot"
|
||||
[class.ui-lp-testimonial-carousel__dot--active]="currentSlide() === index"
|
||||
(click)="goToSlide(index)"
|
||||
[attr.aria-label]="'Go to testimonial ' + (index + 1)">
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ui-container>
|
||||
</section>
|
||||
`,
|
||||
styleUrl: './testimonial-carousel.component.scss'
|
||||
})
|
||||
export class TestimonialCarouselComponent implements OnInit, OnDestroy {
|
||||
config = signal<TestimonialCarouselConfig>({
|
||||
testimonials: [],
|
||||
autoPlay: false,
|
||||
autoPlayDelay: 5000,
|
||||
showDots: true,
|
||||
showNavigation: true,
|
||||
itemsPerView: 1,
|
||||
variant: 'card',
|
||||
showRatings: true
|
||||
});
|
||||
|
||||
currentSlide = signal(0);
|
||||
private autoPlayInterval?: number;
|
||||
|
||||
faQuoteLeft = faQuoteLeft;
|
||||
faStar = faStar;
|
||||
faChevronLeft = faChevronLeft;
|
||||
faChevronRight = faChevronRight;
|
||||
faCheck = faCheck;
|
||||
|
||||
slideWidth = computed(() => 100 / this.config().itemsPerView!);
|
||||
translateX = computed(() => -this.currentSlide() * this.slideWidth());
|
||||
maxSlide = computed(() => Math.max(0, this.config().testimonials.length - this.config().itemsPerView!));
|
||||
dotsArray = computed(() => Array(this.maxSlide() + 1).fill(0));
|
||||
|
||||
@Input() set configuration(value: TestimonialCarouselConfig) {
|
||||
this.config.set(value);
|
||||
}
|
||||
|
||||
@Output() testimonialClicked = new EventEmitter<TestimonialItem>();
|
||||
|
||||
getTestimonialCarouselClasses(): string {
|
||||
const classes = [
|
||||
'ui-lp-testimonial-carousel',
|
||||
`ui-lp-testimonial-carousel--${this.config().variant}`,
|
||||
];
|
||||
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.config().autoPlay) {
|
||||
this.startAutoPlay();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stopAutoPlay();
|
||||
}
|
||||
|
||||
nextSlide(): void {
|
||||
const nextIndex = this.currentSlide() + 1;
|
||||
if (nextIndex <= this.maxSlide()) {
|
||||
this.currentSlide.set(nextIndex);
|
||||
} else if (this.config().autoPlay) {
|
||||
this.currentSlide.set(0);
|
||||
}
|
||||
}
|
||||
|
||||
previousSlide(): void {
|
||||
const prevIndex = this.currentSlide() - 1;
|
||||
if (prevIndex >= 0) {
|
||||
this.currentSlide.set(prevIndex);
|
||||
}
|
||||
}
|
||||
|
||||
goToSlide(index: number): void {
|
||||
this.currentSlide.set(index);
|
||||
}
|
||||
|
||||
getRatingArray(rating: number): number[] {
|
||||
return Array(Math.floor(rating)).fill(0);
|
||||
}
|
||||
|
||||
private startAutoPlay(): void {
|
||||
this.autoPlayInterval = window.setInterval(() => {
|
||||
this.nextSlide();
|
||||
}, this.config().autoPlayDelay);
|
||||
}
|
||||
|
||||
private stopAutoPlay(): void {
|
||||
if (this.autoPlayInterval) {
|
||||
clearInterval(this.autoPlayInterval);
|
||||
this.autoPlayInterval = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
@use '../../../../../ui-design-system/src/styles/semantic/index' as *;
|
||||
|
||||
.ui-lp-agency-template {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__main {
|
||||
flex: 1;
|
||||
|
||||
// Agency-focused styling with professional appearance
|
||||
> *:nth-child(odd) {
|
||||
background: $semantic-color-surface-primary;
|
||||
}
|
||||
|
||||
> *:nth-child(even) {
|
||||
background: $semantic-color-surface-secondary;
|
||||
}
|
||||
|
||||
// Special treatment for hero section
|
||||
> ui-lp-hero-split {
|
||||
background: unset;
|
||||
}
|
||||
|
||||
// Team section gets special emphasis
|
||||
> ui-lp-team-grid {
|
||||
background: $semantic-color-surface-elevated;
|
||||
border: 1px solid $semantic-color-border-subtle;
|
||||
}
|
||||
|
||||
// Timeline section styling
|
||||
> ui-lp-timeline {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
$semantic-color-border-subtle,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced section spacing for agency layout
|
||||
section {
|
||||
padding: $semantic-spacing-layout-section-xl 0;
|
||||
}
|
||||
|
||||
// Professional section dividers
|
||||
section + section {
|
||||
border-top: 1px solid $semantic-color-border-subtle;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100px;
|
||||
height: 1px;
|
||||
background: $semantic-color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive adjustments for agency layout
|
||||
@media (max-width: $semantic-breakpoint-lg - 1) {
|
||||
section {
|
||||
padding: $semantic-spacing-layout-section-lg 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $semantic-breakpoint-md - 1) {
|
||||
&__main {
|
||||
> ui-lp-team-grid {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
section {
|
||||
padding: $semantic-spacing-layout-section-md 0;
|
||||
}
|
||||
|
||||
section + section::before {
|
||||
width: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $semantic-breakpoint-sm - 1) {
|
||||
&__main {
|
||||
> * {
|
||||
background: $semantic-color-surface-primary !important;
|
||||
}
|
||||
}
|
||||
|
||||
section {
|
||||
padding: $semantic-spacing-layout-section-sm 0;
|
||||
}
|
||||
|
||||
section + section {
|
||||
border-top: none;
|
||||
margin-top: $semantic-spacing-layout-section-sm;
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { HeroSplitScreenComponent } from '../heroes/hero-split-screen.component';
|
||||
import { FeatureGridComponent } from '../features/feature-grid.component';
|
||||
import { TeamGridComponent } from '../content/team-grid.component';
|
||||
import { TimelineSectionComponent } from '../content/timeline-section.component';
|
||||
import { TestimonialCarouselComponent } from '../social-proof/testimonial-carousel.component';
|
||||
import { CTASectionComponent } from '../conversion/cta-section.component';
|
||||
import { LandingHeaderComponent } from '../navigation/landing-header.component';
|
||||
import { FooterSectionComponent } from '../navigation/footer-section.component';
|
||||
import { AgencyTemplateConfig } from '../../interfaces/templates.interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-lp-agency-template',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
HeroSplitScreenComponent,
|
||||
FeatureGridComponent,
|
||||
TeamGridComponent,
|
||||
TimelineSectionComponent,
|
||||
TestimonialCarouselComponent,
|
||||
CTASectionComponent,
|
||||
LandingHeaderComponent,
|
||||
FooterSectionComponent
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<div class="ui-lp-agency-template">
|
||||
@if (config().header) {
|
||||
<ui-lp-header [configuration]="config().header!"></ui-lp-header>
|
||||
}
|
||||
|
||||
<main class="ui-lp-agency-template__main">
|
||||
<!-- Hero Section -->
|
||||
<ui-lp-hero-split [configuration]="config().hero"></ui-lp-hero-split>
|
||||
|
||||
<!-- Services Section -->
|
||||
<ui-lp-feature-grid [configuration]="config().services"></ui-lp-feature-grid>
|
||||
|
||||
<!-- Company Timeline -->
|
||||
<ui-lp-timeline [configuration]="config().timeline"></ui-lp-timeline>
|
||||
|
||||
<!-- Team Section -->
|
||||
<ui-lp-team-grid [configuration]="config().team"></ui-lp-team-grid>
|
||||
|
||||
<!-- Client Testimonials -->
|
||||
<ui-lp-testimonial-carousel [configuration]="config().testimonials"></ui-lp-testimonial-carousel>
|
||||
|
||||
<!-- Contact CTA Section -->
|
||||
<ui-cta-section [config]="config().cta"></ui-cta-section>
|
||||
</main>
|
||||
|
||||
@if (config().footer) {
|
||||
<ui-lp-footer [configuration]="config().footer!"></ui-lp-footer>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styleUrl: './agency-template.component.scss'
|
||||
})
|
||||
export class AgencyTemplateComponent {
|
||||
config = signal<AgencyTemplateConfig>({
|
||||
hero: {
|
||||
title: 'Your Vision, Our Expertise',
|
||||
subtitle: 'We create exceptional digital experiences that drive results',
|
||||
alignment: 'left',
|
||||
backgroundType: 'gradient',
|
||||
minHeight: 'large'
|
||||
},
|
||||
services: {
|
||||
title: 'Our Services',
|
||||
subtitle: 'Comprehensive solutions for your digital needs',
|
||||
features: []
|
||||
},
|
||||
team: {
|
||||
title: 'Meet Our Team',
|
||||
subtitle: 'The talented individuals behind our success',
|
||||
members: [],
|
||||
columns: 3,
|
||||
showSocial: true,
|
||||
showBio: true
|
||||
},
|
||||
timeline: {
|
||||
title: 'Our Journey',
|
||||
subtitle: 'Milestones that define our growth',
|
||||
items: [],
|
||||
orientation: 'vertical',
|
||||
showDates: true
|
||||
},
|
||||
testimonials: {
|
||||
title: 'Client Success Stories',
|
||||
subtitle: 'What our partners say about working with us',
|
||||
testimonials: []
|
||||
},
|
||||
cta: {
|
||||
title: 'Ready to Start Your Project?',
|
||||
description: 'Let\'s discuss how we can bring your vision to life',
|
||||
ctaPrimary: { text: 'Get Started', variant: 'filled', action: () => {} },
|
||||
backgroundType: 'gradient'
|
||||
}
|
||||
});
|
||||
|
||||
@Input() set configuration(value: AgencyTemplateConfig) {
|
||||
this.config.set(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './saas-template.component';
|
||||
export * from './product-template.component';
|
||||
export * from './agency-template.component';
|
||||
@@ -0,0 +1,54 @@
|
||||
@use '../../../../../ui-design-system/src/styles/semantic/index' as *;
|
||||
|
||||
.ui-lp-product-template {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__main {
|
||||
flex: 1;
|
||||
|
||||
// Product-focused styling with cleaner backgrounds
|
||||
> *:nth-child(odd) {
|
||||
background: $semantic-color-surface-primary;
|
||||
}
|
||||
|
||||
> *:nth-child(even) {
|
||||
background: $semantic-color-surface-elevated;
|
||||
}
|
||||
|
||||
// Hero maintains its special background (image/gradient)
|
||||
> ui-lp-hero-image {
|
||||
background: unset;
|
||||
}
|
||||
}
|
||||
|
||||
// Product template specific spacing
|
||||
section {
|
||||
padding: $semantic-spacing-layout-section-lg 0;
|
||||
}
|
||||
|
||||
// Enhanced visual separation between sections
|
||||
section + section {
|
||||
border-top: 1px solid $semantic-color-border-subtle;
|
||||
}
|
||||
|
||||
// Responsive adjustments
|
||||
@media (max-width: $semantic-breakpoint-md - 1) {
|
||||
section {
|
||||
padding: $semantic-spacing-layout-section-md 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $semantic-breakpoint-sm - 1) {
|
||||
&__main {
|
||||
> * {
|
||||
background: $semantic-color-surface-primary !important;
|
||||
}
|
||||
}
|
||||
|
||||
section {
|
||||
padding: $semantic-spacing-layout-section-sm 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { HeroWithImageComponent } from '../heroes/hero-with-image.component';
|
||||
import { FeatureGridComponent } from '../features/feature-grid.component';
|
||||
import { TestimonialCarouselComponent } from '../social-proof/testimonial-carousel.component';
|
||||
import { PricingTableComponent } from '../conversion/pricing-table.component';
|
||||
import { CTASectionComponent } from '../conversion/cta-section.component';
|
||||
import { LandingHeaderComponent } from '../navigation/landing-header.component';
|
||||
import { FooterSectionComponent } from '../navigation/footer-section.component';
|
||||
import { ProductTemplateConfig } from '../../interfaces/templates.interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-lp-product-template',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
HeroWithImageComponent,
|
||||
FeatureGridComponent,
|
||||
TestimonialCarouselComponent,
|
||||
PricingTableComponent,
|
||||
CTASectionComponent,
|
||||
LandingHeaderComponent,
|
||||
FooterSectionComponent
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<div class="ui-lp-product-template">
|
||||
@if (config().header) {
|
||||
<ui-lp-header [configuration]="config().header!"></ui-lp-header>
|
||||
}
|
||||
|
||||
<main class="ui-lp-product-template__main">
|
||||
<!-- Hero Section with Product Image -->
|
||||
<ui-lp-hero-image [configuration]="config().hero"></ui-lp-hero-image>
|
||||
|
||||
<!-- Features Section -->
|
||||
<ui-lp-feature-grid [configuration]="config().features"></ui-lp-feature-grid>
|
||||
|
||||
<!-- Customer Testimonials -->
|
||||
<ui-lp-testimonial-carousel [configuration]="config().testimonials"></ui-lp-testimonial-carousel>
|
||||
|
||||
<!-- Pricing Section -->
|
||||
<ui-pricing-table [config]="config().pricing"></ui-pricing-table>
|
||||
|
||||
<!-- Final CTA Section -->
|
||||
<ui-cta-section [config]="config().cta"></ui-cta-section>
|
||||
</main>
|
||||
|
||||
@if (config().footer) {
|
||||
<ui-lp-footer [configuration]="config().footer!"></ui-lp-footer>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styleUrl: './product-template.component.scss'
|
||||
})
|
||||
export class ProductTemplateComponent {
|
||||
config = signal<ProductTemplateConfig>({
|
||||
hero: {
|
||||
title: 'Introducing Our Latest Product',
|
||||
subtitle: 'Experience the future of innovation',
|
||||
alignment: 'left',
|
||||
backgroundType: 'image',
|
||||
minHeight: 'large'
|
||||
},
|
||||
features: {
|
||||
title: 'Key Features',
|
||||
features: []
|
||||
},
|
||||
testimonials: {
|
||||
title: 'Customer Reviews',
|
||||
testimonials: []
|
||||
},
|
||||
pricing: {
|
||||
plans: [],
|
||||
billingToggle: { monthlyLabel: 'Monthly', yearlyLabel: 'Yearly' },
|
||||
featuresComparison: false
|
||||
},
|
||||
cta: {
|
||||
title: 'Start Your Journey',
|
||||
description: 'Order now and get free shipping worldwide',
|
||||
ctaPrimary: { text: 'Order Now', variant: 'filled', action: () => {} },
|
||||
backgroundType: 'gradient'
|
||||
}
|
||||
});
|
||||
|
||||
@Input() set configuration(value: ProductTemplateConfig) {
|
||||
this.config.set(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
@use '../../../../../ui-design-system/src/styles/semantic/index' as *;
|
||||
|
||||
.ui-lp-saas-template {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__main {
|
||||
flex: 1;
|
||||
|
||||
// Alternating background colors for sections
|
||||
> *:nth-child(odd) {
|
||||
background: $semantic-color-surface-primary;
|
||||
}
|
||||
|
||||
> *:nth-child(even) {
|
||||
background: $semantic-color-surface-secondary;
|
||||
}
|
||||
|
||||
// Override for hero to maintain gradient/special background
|
||||
> ui-lp-hero {
|
||||
background: unset;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure proper spacing between sections
|
||||
section + section {
|
||||
border-top: 1px solid $semantic-color-border-subtle;
|
||||
}
|
||||
|
||||
// Responsive adjustments
|
||||
@media (max-width: $semantic-breakpoint-sm - 1) {
|
||||
&__main {
|
||||
// Reduce alternating backgrounds on mobile for better readability
|
||||
> * {
|
||||
background: $semantic-color-surface-primary !important;
|
||||
}
|
||||
|
||||
section + section {
|
||||
border-top: none;
|
||||
margin-top: $semantic-spacing-layout-section-sm;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { HeroSectionComponent } from '../heroes/hero-section.component';
|
||||
import { FeatureGridComponent } from '../features/feature-grid.component';
|
||||
import { StatisticsDisplayComponent } from '../social-proof/statistics-display.component';
|
||||
import { TestimonialCarouselComponent } from '../social-proof/testimonial-carousel.component';
|
||||
import { PricingTableComponent } from '../conversion/pricing-table.component';
|
||||
import { CTASectionComponent } from '../conversion/cta-section.component';
|
||||
import { FAQSectionComponent } from '../content/faq-section.component';
|
||||
import { LandingHeaderComponent } from '../navigation/landing-header.component';
|
||||
import { FooterSectionComponent } from '../navigation/footer-section.component';
|
||||
import { SaaSTemplateConfig } from '../../interfaces/templates.interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-lp-saas-template',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
HeroSectionComponent,
|
||||
FeatureGridComponent,
|
||||
StatisticsDisplayComponent,
|
||||
TestimonialCarouselComponent,
|
||||
PricingTableComponent,
|
||||
CTASectionComponent,
|
||||
FAQSectionComponent,
|
||||
LandingHeaderComponent,
|
||||
FooterSectionComponent
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<div class="ui-lp-saas-template">
|
||||
@if (config().header) {
|
||||
<ui-lp-header [configuration]="config().header!"></ui-lp-header>
|
||||
}
|
||||
|
||||
<main class="ui-lp-saas-template__main">
|
||||
<!-- Hero Section -->
|
||||
<ui-lp-hero [configuration]="config().hero"></ui-lp-hero>
|
||||
|
||||
<!-- Statistics Section -->
|
||||
<ui-lp-statistics-display [configuration]="config().socialProof"></ui-lp-statistics-display>
|
||||
|
||||
<!-- Features Section -->
|
||||
<ui-lp-feature-grid [configuration]="config().features"></ui-lp-feature-grid>
|
||||
|
||||
<!-- Testimonials Section -->
|
||||
<ui-lp-testimonial-carousel [configuration]="config().testimonials"></ui-lp-testimonial-carousel>
|
||||
|
||||
<!-- Pricing Section -->
|
||||
<ui-pricing-table [config]="config().pricing"></ui-pricing-table>
|
||||
|
||||
<!-- FAQ Section -->
|
||||
<ui-lp-faq [configuration]="config().faq"></ui-lp-faq>
|
||||
|
||||
<!-- Final CTA Section -->
|
||||
<ui-cta-section [config]="config().cta"></ui-cta-section>
|
||||
</main>
|
||||
|
||||
@if (config().footer) {
|
||||
<ui-lp-footer [configuration]="config().footer!"></ui-lp-footer>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styleUrl: './saas-template.component.scss'
|
||||
})
|
||||
export class SaaSTemplateComponent {
|
||||
config = signal<SaaSTemplateConfig>({
|
||||
hero: {
|
||||
title: 'Build Amazing SaaS Applications',
|
||||
subtitle: 'The complete toolkit for modern software development',
|
||||
alignment: 'center',
|
||||
backgroundType: 'gradient',
|
||||
minHeight: 'full'
|
||||
},
|
||||
features: {
|
||||
title: 'Why Choose Our Platform',
|
||||
features: []
|
||||
},
|
||||
socialProof: {
|
||||
title: 'Trusted by Industry Leaders',
|
||||
statistics: []
|
||||
},
|
||||
testimonials: {
|
||||
title: 'What Our Customers Say',
|
||||
testimonials: []
|
||||
},
|
||||
pricing: {
|
||||
plans: [],
|
||||
billingToggle: { monthlyLabel: 'Monthly', yearlyLabel: 'Yearly' },
|
||||
featuresComparison: false
|
||||
},
|
||||
faq: {
|
||||
title: 'Frequently Asked Questions',
|
||||
items: []
|
||||
},
|
||||
cta: {
|
||||
title: 'Ready to Get Started?',
|
||||
description: 'Join thousands of developers building the future',
|
||||
ctaPrimary: { text: 'Get Started', variant: 'filled', action: () => {} },
|
||||
backgroundType: 'gradient'
|
||||
}
|
||||
});
|
||||
|
||||
@Input() set configuration(value: SaaSTemplateConfig) {
|
||||
this.config.set(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
export interface FAQItem {
|
||||
id: string;
|
||||
question: string;
|
||||
answer: string;
|
||||
isOpen?: boolean;
|
||||
}
|
||||
|
||||
export interface FAQConfig {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
items: FAQItem[];
|
||||
searchEnabled?: boolean;
|
||||
expandMultiple?: boolean;
|
||||
theme?: 'default' | 'bordered' | 'minimal';
|
||||
}
|
||||
|
||||
export interface TeamMember {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
bio?: string;
|
||||
image?: string;
|
||||
social?: TeamSocialLinks;
|
||||
}
|
||||
|
||||
export interface TeamSocialLinks {
|
||||
linkedin?: string;
|
||||
twitter?: string;
|
||||
github?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
}
|
||||
|
||||
export interface TeamConfig {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
members: TeamMember[];
|
||||
columns?: 2 | 3 | 4;
|
||||
showSocial?: boolean;
|
||||
showBio?: boolean;
|
||||
}
|
||||
|
||||
export interface TimelineItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
date?: string;
|
||||
status?: 'completed' | 'current' | 'upcoming';
|
||||
icon?: string;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
export interface TimelineConfig {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
items: TimelineItem[];
|
||||
orientation?: 'vertical' | 'horizontal';
|
||||
theme?: 'default' | 'minimal' | 'connected';
|
||||
showDates?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
export interface ConversionCTAButton {
|
||||
text: string;
|
||||
variant?: 'filled' | 'tonal' | 'outlined';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
action: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export interface CTASectionConfig {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
ctaPrimary: ConversionCTAButton;
|
||||
ctaSecondary?: ConversionCTAButton;
|
||||
backgroundType: 'gradient' | 'pattern' | 'image' | 'solid';
|
||||
urgency?: UrgencyConfig;
|
||||
maxWidth?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
}
|
||||
|
||||
export interface UrgencyConfig {
|
||||
type: 'countdown' | 'limited-offer' | 'social-proof';
|
||||
text: string;
|
||||
endDate?: Date;
|
||||
remaining?: number;
|
||||
}
|
||||
|
||||
export interface PricingTableConfig {
|
||||
plans: PricingPlan[];
|
||||
billingToggle: BillingToggle;
|
||||
featuresComparison: boolean;
|
||||
highlightedPlan?: string;
|
||||
maxWidth?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
}
|
||||
|
||||
export interface PricingPlan {
|
||||
id: string;
|
||||
name: string;
|
||||
price: PriceStructure;
|
||||
features: PricingFeature[];
|
||||
cta: ConversionCTAButton;
|
||||
badge?: string;
|
||||
popular?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface PriceStructure {
|
||||
monthly: number;
|
||||
yearly: number;
|
||||
currency: string;
|
||||
suffix?: string; // per user, per month, etc.
|
||||
}
|
||||
|
||||
export interface PricingFeature {
|
||||
name: string;
|
||||
included: boolean;
|
||||
description?: string;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
export interface BillingToggle {
|
||||
monthlyLabel: string;
|
||||
yearlyLabel: string;
|
||||
discountText?: string;
|
||||
}
|
||||
|
||||
export interface NewsletterSignupConfig {
|
||||
title: string;
|
||||
description?: string;
|
||||
placeholder: string;
|
||||
ctaText: string;
|
||||
privacyText?: string;
|
||||
successMessage: string;
|
||||
variant: 'inline' | 'modal' | 'sidebar' | 'footer';
|
||||
showPrivacyCheckbox?: boolean;
|
||||
}
|
||||
|
||||
export interface ContactFormConfig {
|
||||
fields: FormField[];
|
||||
submitText: string;
|
||||
successMessage: string;
|
||||
layout: 'single-column' | 'two-column' | 'inline';
|
||||
validation: ValidationRules;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface FormField {
|
||||
type: 'text' | 'email' | 'tel' | 'textarea' | 'select' | 'checkbox';
|
||||
name: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
required: boolean;
|
||||
options?: SelectOption[];
|
||||
rows?: number; // for textarea
|
||||
validation?: FieldValidation;
|
||||
}
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface FieldValidation {
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: string;
|
||||
custom?: (value: any) => string | null;
|
||||
}
|
||||
|
||||
export interface ValidationRules {
|
||||
[fieldName: string]: FieldValidation;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export interface FeatureItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
iconType?: 'fa' | 'material' | 'custom';
|
||||
image?: string;
|
||||
link?: FeatureLink;
|
||||
}
|
||||
|
||||
export interface FeatureLink {
|
||||
url: string;
|
||||
text: string;
|
||||
target?: '_blank' | '_self';
|
||||
}
|
||||
|
||||
export interface FeatureGridConfig {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
features: FeatureItem[];
|
||||
layout?: 'grid' | 'masonry' | 'list';
|
||||
columns?: 'auto' | 2 | 3 | 4;
|
||||
variant?: 'card' | 'minimal' | 'bordered';
|
||||
showIcons?: boolean;
|
||||
animationType?: 'fade' | 'slide' | 'scale' | 'none';
|
||||
spacing?: 'tight' | 'normal' | 'loose';
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { CTAButton } from './shared.interfaces';
|
||||
|
||||
/**
|
||||
* Configuration interface for hero section components
|
||||
*/
|
||||
export interface HeroConfig {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
ctaPrimary?: CTAButton;
|
||||
ctaSecondary?: CTAButton;
|
||||
backgroundType?: 'solid' | 'gradient' | 'image' | 'video' | 'animated';
|
||||
alignment?: 'left' | 'center' | 'right';
|
||||
minHeight?: 'full' | 'large' | 'medium';
|
||||
animationType?: 'fade' | 'slide' | 'zoom';
|
||||
}
|
||||
|
||||
/**
|
||||
* Hero with image specific configuration
|
||||
*/
|
||||
export interface HeroImageConfig extends HeroConfig {
|
||||
imageUrl?: string;
|
||||
imageAlt?: string;
|
||||
imagePosition?: 'left' | 'right';
|
||||
imageMobile?: 'above' | 'below' | 'hidden';
|
||||
}
|
||||
|
||||
/**
|
||||
* Split screen hero configuration
|
||||
*/
|
||||
export interface HeroSplitConfig extends HeroConfig {
|
||||
leftContent?: {
|
||||
type: 'text' | 'image' | 'video';
|
||||
content: string;
|
||||
};
|
||||
rightContent?: {
|
||||
type: 'text' | 'image' | 'video';
|
||||
content: string;
|
||||
};
|
||||
splitRatio?: '50-50' | '60-40' | '40-60' | '70-30' | '30-70';
|
||||
}
|
||||
8
projects/ui-landing-pages/src/lib/interfaces/index.ts
Normal file
8
projects/ui-landing-pages/src/lib/interfaces/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from './shared.interfaces';
|
||||
export * from './hero.interfaces';
|
||||
export * from './feature.interfaces';
|
||||
export * from './social-proof.interfaces';
|
||||
export * from './conversion.interfaces';
|
||||
export * from './navigation.interfaces';
|
||||
export * from './content.interfaces';
|
||||
export * from './templates.interfaces';
|
||||
@@ -0,0 +1,95 @@
|
||||
import { CTAButton } from './shared.interfaces';
|
||||
|
||||
export type { CTAButton };
|
||||
|
||||
export interface NavigationItem {
|
||||
id: string;
|
||||
label: string;
|
||||
url?: string;
|
||||
route?: string;
|
||||
target?: '_blank' | '_self';
|
||||
icon?: string;
|
||||
badge?: string;
|
||||
children?: NavigationItem[];
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
export interface MegaMenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
items: NavigationItem[];
|
||||
featured?: FeaturedContent;
|
||||
}
|
||||
|
||||
export interface FeaturedContent {
|
||||
title: string;
|
||||
description: string;
|
||||
imageUrl?: string;
|
||||
url: string;
|
||||
buttonText: string;
|
||||
}
|
||||
|
||||
export interface LandingHeaderConfig {
|
||||
logo: LogoConfig;
|
||||
navigation: NavigationItem[];
|
||||
megaMenu?: MegaMenuItem[];
|
||||
ctaButton?: CTAButton;
|
||||
transparent?: boolean;
|
||||
sticky?: boolean;
|
||||
showMobileMenu?: boolean;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | 'full';
|
||||
maxWidth?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | 'full';
|
||||
theme?: 'light' | 'dark' | 'auto';
|
||||
}
|
||||
|
||||
export interface LogoConfig {
|
||||
text?: string;
|
||||
imageUrl?: string;
|
||||
url?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export interface FooterColumn {
|
||||
id: string;
|
||||
title: string;
|
||||
items: FooterLink[];
|
||||
}
|
||||
|
||||
export interface FooterLink {
|
||||
id: string;
|
||||
label: string;
|
||||
url?: string;
|
||||
route?: string;
|
||||
icon?: string;
|
||||
badge?: string;
|
||||
target?: '_blank' | '_self';
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
export interface SocialLink {
|
||||
id: string;
|
||||
platform: 'facebook' | 'twitter' | 'instagram' | 'linkedin' | 'youtube' | 'github' | 'dribbble';
|
||||
url: string;
|
||||
icon?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface FooterConfig {
|
||||
columns: FooterColumn[];
|
||||
socialLinks?: SocialLink[];
|
||||
copyright?: string;
|
||||
logo?: LogoConfig;
|
||||
newsletter?: NewsletterConfig;
|
||||
legalLinks?: FooterLink[];
|
||||
theme?: 'light' | 'dark';
|
||||
showDivider?: boolean;
|
||||
}
|
||||
|
||||
export interface NewsletterConfig {
|
||||
title: string;
|
||||
description?: string;
|
||||
placeholder: string;
|
||||
buttonText: string;
|
||||
onSubmit: (email: string) => void;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Shared interface definitions used across multiple components
|
||||
*/
|
||||
|
||||
export interface CTAButton {
|
||||
text: string;
|
||||
variant: 'filled' | 'tonal' | 'outlined';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
icon?: string;
|
||||
action: () => void;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export interface TestimonialItem {
|
||||
id: string;
|
||||
name: string;
|
||||
title?: string;
|
||||
company?: string;
|
||||
content: string;
|
||||
rating?: number;
|
||||
avatar?: string;
|
||||
verified?: boolean;
|
||||
}
|
||||
|
||||
export interface TestimonialCarouselConfig {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
testimonials: TestimonialItem[];
|
||||
autoPlay?: boolean;
|
||||
autoPlayDelay?: number;
|
||||
showDots?: boolean;
|
||||
showNavigation?: boolean;
|
||||
itemsPerView?: 1 | 2 | 3;
|
||||
variant?: 'card' | 'minimal' | 'quote';
|
||||
showRatings?: boolean;
|
||||
}
|
||||
|
||||
export interface LogoItem {
|
||||
id: string;
|
||||
name: string;
|
||||
logo: string;
|
||||
url?: string;
|
||||
grayscale?: boolean;
|
||||
}
|
||||
|
||||
export interface LogoCloudConfig {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
logos: LogoItem[];
|
||||
layout?: 'row' | 'grid' | 'marquee';
|
||||
itemsPerRow?: 3 | 4 | 5 | 6;
|
||||
grayscale?: boolean;
|
||||
hoverEffect?: boolean;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
export interface StatisticItem {
|
||||
id: string;
|
||||
value: number | string;
|
||||
label: string;
|
||||
suffix?: string;
|
||||
prefix?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
animateValue?: boolean;
|
||||
}
|
||||
|
||||
export interface StatisticsConfig {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
statistics: StatisticItem[];
|
||||
layout?: 'row' | 'grid';
|
||||
variant?: 'minimal' | 'card' | 'highlighted';
|
||||
animateOnScroll?: boolean;
|
||||
backgroundColor?: 'transparent' | 'surface' | 'primary';
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { HeroConfig } from './hero.interfaces';
|
||||
import { FeatureGridConfig } from './feature.interfaces';
|
||||
import { TestimonialCarouselConfig, StatisticsConfig } from './social-proof.interfaces';
|
||||
import { PricingTableConfig, CTASectionConfig } from './conversion.interfaces';
|
||||
import { LandingHeaderConfig, FooterConfig } from './navigation.interfaces';
|
||||
import { FAQConfig, TeamConfig, TimelineConfig } from './content.interfaces';
|
||||
|
||||
export interface LandingPageSection {
|
||||
id: string;
|
||||
component: string;
|
||||
configuration: any;
|
||||
visible?: boolean;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface LandingPageTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: 'saas' | 'product' | 'agency' | 'custom';
|
||||
sections: LandingPageSection[];
|
||||
theme?: 'light' | 'dark' | 'gradient';
|
||||
metadata?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
keywords?: string[];
|
||||
author?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Pre-configured template interfaces
|
||||
export interface SaaSTemplateConfig {
|
||||
hero: HeroConfig;
|
||||
features: FeatureGridConfig;
|
||||
socialProof: StatisticsConfig;
|
||||
pricing: PricingTableConfig;
|
||||
testimonials: TestimonialCarouselConfig;
|
||||
faq: FAQConfig;
|
||||
cta: CTASectionConfig;
|
||||
header?: LandingHeaderConfig;
|
||||
footer?: FooterConfig;
|
||||
}
|
||||
|
||||
export interface ProductTemplateConfig {
|
||||
hero: HeroConfig;
|
||||
features: FeatureGridConfig;
|
||||
testimonials: TestimonialCarouselConfig;
|
||||
pricing: PricingTableConfig;
|
||||
cta: CTASectionConfig;
|
||||
header?: LandingHeaderConfig;
|
||||
footer?: FooterConfig;
|
||||
}
|
||||
|
||||
export interface AgencyTemplateConfig {
|
||||
hero: HeroConfig;
|
||||
services: FeatureGridConfig;
|
||||
team: TeamConfig;
|
||||
timeline: TimelineConfig;
|
||||
testimonials: TestimonialCarouselConfig;
|
||||
cta: CTASectionConfig;
|
||||
header?: LandingHeaderConfig;
|
||||
footer?: FooterConfig;
|
||||
}
|
||||
12
projects/ui-landing-pages/src/public-api.ts
Normal file
12
projects/ui-landing-pages/src/public-api.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Public API Surface of ui-landing-pages
|
||||
*/
|
||||
|
||||
// Components
|
||||
export * from './lib/components';
|
||||
|
||||
// Interfaces
|
||||
export * from './lib/interfaces';
|
||||
|
||||
// Services (future expansion)
|
||||
// export * from './lib/services';
|
||||
16
projects/ui-landing-pages/tsconfig.lib.json
Normal file
16
projects/ui-landing-pages/tsconfig.lib.json
Normal file
@@ -0,0 +1,16 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../out-tsc/lib",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"sourceMap": true,
|
||||
"types": []
|
||||
},
|
||||
"exclude": [
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
11
projects/ui-landing-pages/tsconfig.lib.prod.json
Normal file
11
projects/ui-landing-pages/tsconfig.lib.prod.json
Normal file
@@ -0,0 +1,11 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.lib.json",
|
||||
"compilerOptions": {
|
||||
"declarationMap": false
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"compilationMode": "partial"
|
||||
}
|
||||
}
|
||||
15
projects/ui-landing-pages/tsconfig.spec.json
Normal file
15
projects/ui-landing-pages/tsconfig.spec.json
Normal file
@@ -0,0 +1,15 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../out-tsc/spec",
|
||||
"types": [
|
||||
"jasmine"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.spec.ts",
|
||||
"**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user