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:
skyai_dev
2025-09-06 13:52:41 +10:00
parent 5346d6d0c9
commit 246c62fd49
113 changed files with 13015 additions and 165 deletions

View 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.

View 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"
}
}

View 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
}

View File

@@ -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;
}
}
}

View File

@@ -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
});
}
}

View File

@@ -0,0 +1,3 @@
export * from './faq-section.component';
export * from './team-grid.component';
export * from './timeline-section.component';

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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(' ');
}
}

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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;
}
}

View File

@@ -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 };
}
}

View File

@@ -0,0 +1,4 @@
export * from './cta-section.component';
export * from './pricing-table.component';
export * from './newsletter-signup.component';
export * from './contact-form.component';

View File

@@ -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;
}
}

View File

@@ -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();
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1 @@
export * from './feature-grid.component';

View File

@@ -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%;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,3 @@
export * from './hero-section.component';
export * from './hero-with-image.component';
export * from './hero-split-screen.component';

View 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';

View File

@@ -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;
}
}
}

View File

@@ -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';
}
}

View File

@@ -0,0 +1,2 @@
export * from './landing-header.component';
export * from './footer-section.component';

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,3 @@
export * from './testimonial-carousel.component';
export * from './logo-cloud.component';
export * from './statistics-display.component';

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,3 @@
export * from './saas-template.component';
export * from './product-template.component';
export * from './agency-template.component';

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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';
}

View File

@@ -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';
}

View 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';

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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';
}

View File

@@ -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;
}

View 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';

View 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"
]
}

View 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"
}
}

View 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"
]
}