<!-- Error rendering component -->
<!-- Error: Template /02-icons/devices/guardvision.twig not found -->
<!-- Error: Error: Template /02-icons/devices/guardvision.twig not found
at /home/runner/work/verisure-styleguide/verisure-styleguide/node_modules/@frctl/twig/src/adapter.js:156:24
at new Promise (<anonymous>)
at TwigAdapter.render (/home/runner/work/verisure-styleguide/verisure-styleguide/node_modules/@frctl/twig/src/adapter.js:134:16)
at ComponentSource._renderVariant (/home/runner/work/verisure-styleguide/verisure-styleguide/node_modules/@frctl/fractal/src/api/components/source.js:212:30)
at _renderVariant.next (<anonymous>)
at onFulfilled (/home/runner/work/verisure-styleguide/verisure-styleguide/node_modules/co/index.js:65:19) -->
No notes defined.
<section class="supt-section-devices">
<div class="container">
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-6">
<h2 class="supt-section-devices__title">{{ title }}</h2>
</div>
<div class="col-12">
<div class="supt-section-devices__navigation">
<div class="supt-section-devices__prev">
{% include "atoms/buttons/button/button.twig" with {
"icon": "chevron",
"modifiers": [
"icon"
],
"attributes": {
"aria-label": "Previous slide",
"disabled": "true"
}
} only %}
</div>
<div class="swiper supt-section-devices__thumbs">
<div class="swiper-wrapper">
{% for device in devices %}
<div class="swiper-slide supt-section-devices__thumb">
{% include "02-icons/devices/"~ device.icon ~".twig" %}
<p class="supt-section-devices__thumb__title">{{ device.name }}</p>
</div>
{% endfor %}
</div>
</div>
<div class="supt-section-devices__next">
{% include "atoms/buttons/button/button.twig" with {
"icon": "chevron",
"modifiers": [
"icon"
],
"attributes": {
"aria-label": "Next slide"
}
} only %}
</div>
</div>
</div>
<div class="col-12 col-lg-10">
<div class="supt-section-devices__devices">
{% for index, device in devices %}
<div class="supt-section-devices__device" data-index="{{ index }}">
<div class="row justify-content-center">
<div class="col-12 col-sm-10 col-md-6">
<div class="supt-section-devices__device__image">
<img src="{{ device.image }}" alt="{{ device.name }}">
</div>
</div>
<div class="col-12 col-sm-10 col-md-6 col-xl-4">
<div class="supt-section-devices__device__content">
<h3 class="supt-section-devices__device__name">{{ device.fullName ?? device.name }}</h3>
<ul class="supt-section-devices__device__features">
{% for feature in device.features %}
<li class="supt-section-devices__device__feature">
{% include "02-icons/"~ feature.icon ~"-icon.twig" %}
<span>
{{ feature.name }}
</span>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</section>
{
"title": "Verisure hardware is designed to deliver the best security, operational quality and efficiency",
"devices": [
{
"icon": "guardvision",
"name": "GuardVision Indoor Camera Detector",
"fullName": "GuardVision Indoor Camera Detector (Orion)",
"features": [
{
"name": "HD Camera",
"icon": "microphone"
},
{
"name": "Night vision",
"icon": "ai"
},
{
"name": "Privacy-first design: all alarm images are encrypted and securely stored in the cloud",
"icon": "privacy"
}
]
},
{
"icon": "central-unit",
"name": "Central Unit",
"fullName": "Central Unit",
"features": [
{
"name": "HD Camera",
"icon": "microphone"
},
{
"name": "Night vision",
"icon": "ai"
},
{
"name": "Privacy-first design: all alarm images are encrypted and securely stored in the cloud",
"icon": "privacy"
}
]
},
{
"icon": "guardvision-business",
"name": "Guardvision Business ",
"fullName": "Guardvision Business",
"features": [
{
"name": "HD Camera",
"icon": "microphone"
},
{
"name": "Night vision",
"icon": "ai"
},
{
"name": "Privacy-first design: all alarm images are encrypted and securely stored in the cloud",
"icon": "privacy"
}
]
}
]
}
import { animate } from 'animejs';
import Swiper from 'swiper';
import { Navigation } from 'swiper/modules';
/**
* Slider Section Timeline
*/
export class SectionDevices {
$element: HTMLElement;
private $prevButton: HTMLElement;
private $nextButton: HTMLElement;
private $sliderThumbs: HTMLElement;
private $devices: HTMLElement[] | null;
private previousIndex = 0;
private sliderThumbs: Swiper | null = null;
private newDevicesTimeout: NodeJS.Timeout | null = null;
private devices: { device: HTMLElement; image: HTMLElement; content: HTMLElement }[] = [];
lastSlidableIndex = 0;
constructor($element: HTMLElement) {
this.$element = $element;
this.$prevButton = this.$element.querySelector(
'.supt-section-devices__prev button'
) as HTMLElement;
this.$nextButton = this.$element.querySelector(
'.supt-section-devices__next button'
) as HTMLElement;
this.$sliderThumbs = this.$element.querySelector(
'.supt-section-devices__thumbs'
) as HTMLElement;
this.$devices = Array.from(this.$element.querySelectorAll('.supt-section-devices__device'));
this.devices = Array.from(this.$devices).map(device => ({
device: device,
image: device.querySelector('.supt-section-devices__device__image') as HTMLElement,
content: device.querySelector('.supt-section-devices__device__content') as HTMLElement,
}));
this.initializeSlider();
this.initializeNavigation();
this.resize();
window.addEventListener('resize', this.resize.bind(this));
}
private initializeSlider(): void {
if (!this.$sliderThumbs || !this.$devices) {
return;
}
// Initialize Swiper
this.sliderThumbs = new Swiper(this.$sliderThumbs, {
modules: [Navigation],
// navigation: {
// nextEl: '.supt-section-devices__next button',
// prevEl: '.supt-section-devices__prev button',
// },
slidesPerView: 3.3,
// slidesPerGroup: 3,
spaceBetween: 4,
breakpoints: {
768: {
slidesPerView: 3,
// slidesPerGroup: 5,
spaceBetween: 6,
},
1024: {
slidesPerView: 5,
// slidesPerGroup: 5,
spaceBetween: 6,
},
1440: {
spaceBetween: 10,
slidesPerView: 7,
// slidesPerGroup: 7,
},
},
on: {
init: () => {
// Initialize with first slide
this.previousIndex = 0;
this.updateTimelineCards(0, 'next');
this.addSlideClickListeners();
},
// Update cards when slide changes
slideChange: () => {
if (!this.sliderThumbs) return;
const direction = this.sliderThumbs.activeIndex > this.previousIndex ? 'next' : 'prev';
this.updateTimelineCards(this.sliderThumbs.activeIndex, direction);
this.previousIndex = this.sliderThumbs.activeIndex;
},
},
});
}
private initializeNavigation(): void {
if (!this.$prevButton || !this.$nextButton || !this.sliderThumbs) return;
this.$prevButton.addEventListener('click', e => {
e.preventDefault();
if (!this.sliderThumbs) return;
// Slide to the previous slide, if it's not slidable, update the active index in order to update the current device
if (this.sliderThumbs.activeIndex > this.lastSlidableIndex) {
this.setSlideActive(this.sliderThumbs.activeIndex - 1);
} else {
this.sliderThumbs.slidePrev();
}
});
this.$nextButton.addEventListener('click', e => {
e.preventDefault();
if (!this.sliderThumbs) return;
// Slide to the next slide, if it's not slidable, update the active index in order to update the current device
if (this.sliderThumbs.activeIndex >= this.lastSlidableIndex) {
this.setSlideActive(this.sliderThumbs.activeIndex + 1);
} else {
this.sliderThumbs.slideNext();
}
});
}
private updateNavigationButtonsStatus(): void {
if (!this.$prevButton || !this.$nextButton || !this.sliderThumbs) return;
if (this.sliderThumbs.activeIndex === 0) {
this.$prevButton.setAttribute('disabled', 'disabled');
} else {
this.$prevButton.removeAttribute('disabled');
}
if (this.sliderThumbs.activeIndex === this.sliderThumbs.slides.length - 1) {
this.$nextButton.setAttribute('disabled', 'disabled');
} else {
this.$nextButton.removeAttribute('disabled');
}
}
private updateTimelineCards(index: number, direction: 'next' | 'prev'): void {
if (!this.$devices) return;
if (this.newDevicesTimeout) {
clearTimeout(this.newDevicesTimeout);
}
// Update navigation buttons status
this.updateNavigationButtonsStatus();
this.devices.forEach(device => {
device.device.classList.remove('-active');
// Hide image + content
animate([device.image, device.content], {
opacity: 0,
translateY: direction === 'next' ? '-20px' : '20px',
duration: 300,
easing: 'easeInQuart',
});
});
const currentDevice = this.devices.filter(
device => device.device.dataset.index === index.toString()
)[0];
currentDevice.device.classList.add('-active');
this.newDevicesTimeout = setTimeout(() => {
// Image animation
animate(currentDevice.image, {
opacity: [0, 1],
translateY: direction === 'next' ? ['20px', '0px'] : ['-20px', '0px'],
duration: 450,
easing: 'easeOutQuart',
});
// Content animation slightly after images
animate(currentDevice.content, {
opacity: [0, 1],
translateY: direction === 'next' ? ['24px', '0px'] : ['-24px', '0px'],
duration: 450,
delay: 120,
easing: 'easeOutQuart',
});
}, 200);
}
private addSlideClickListeners(): void {
if (!this.$sliderThumbs) return;
const slides = this.$sliderThumbs.querySelectorAll('.swiper-slide');
slides.forEach((slide, index) => {
slide.addEventListener('click', () => {
this.setSlideActive(index);
});
});
}
private setSlideActive(index: number): void {
if (!this.sliderThumbs) return;
// Always update the active slide, even if it's already visible
if (this.sliderThumbs.activeIndex !== index) {
// Force update Swiper's activeIndex and classes manually
this.sliderThumbs.activeIndex = index;
this.sliderThumbs.updateSlidesClasses();
}
// Always trigger the device update animation
const direction = index > this.previousIndex ? 'next' : 'prev';
this.updateTimelineCards(index, direction);
this.previousIndex = index;
}
resize() {
if (this.sliderThumbs) {
const slidesPerView = Math.ceil(this.sliderThumbs.params.slidesPerView as number);
this.lastSlidableIndex = this.sliderThumbs.slides.length - slidesPerView;
}
}
}
.supt-section-devices {
@mixin clamp padding-block, $spacing-16, $spacing-32, $breakpoint-xs, $breakpoint-xl;
background: $color-grey-background;
&__title {
@extend %t-h2;
text-align: center;
margin-bottom: 0;
}
/* Navigation container */
&__navigation {
@mixin clamp gap, $spacing-5, $spacing-10, $breakpoint-xs, $breakpoint-xl;
@mixin clamp margin-block, $spacing-6, $spacing-12, $breakpoint-xs, $breakpoint-xl;
display: flex;
align-items: flex-start;
position: relative;
}
/* Navigation buttons */
&__prev,
&__next {
display: none;
@media (min-width: $breakpoint-md) {
display: block;
}
&:has(.supt-button__link[disabled]) {
opacity: 0.5;
pointer-events: none;
}
}
&__prev {
@media (min-width: $breakpoint-md) {
left: auto;
}
.supt-icon-chevron {
rotate: 90deg;
translate: 0px 1px;
}
}
&__next {
@media (min-width: $breakpoint-md) {
right: auto;
}
.supt-icon-chevron {
rotate: -90deg;
translate: 1px 1px;
}
}
/* Arrow icon sizing */
&__arrow-icon {
width: 2.625rem;
height: 2.625rem;
color: white;
@media (min-width: $breakpoint-md) {
width: 2.875rem;
height: 2.875rem;
}
@media (min-width: 992px) {
width: 3.125rem;
height: 3.125rem;
}
}
/* Slider styles */
&__thumbs {
margin-inline: calc(-1 * var(--supt-container-padding-inline));
padding-inline: var(--supt-container-padding-inline);
@media (min-width: $breakpoint-md) {
margin-inline: 0;
padding-inline: 0;
width: 100%;
}
}
/* Slide styles */
&__thumb {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 8px;
cursor: pointer;
transition: color 0.2s ease;
/*
width: 100px;
@media (min-width: $breakpoint-md) {
width: 108px;
} */
&:hover {
.supt-section-devices__thumb__title {
color: $color-grey-5;
}
}
&.swiper-slide-active {
.supt-section-devices__thumb__title {
color: $color-main;
}
}
&__title {
@extend %t-body-xs;
font-weight: 500;
color: $color-grey-4;
transition: color 0.3s;
}
}
&__devices {
position: relative;
height: 100%;
}
&__device {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
&:first-child {
position: static;
}
&.-active {
pointer-events: auto;
}
&__content,
&__image {
opacity: 0;
}
&__image {
@mixin clamp padding, $spacing-6, $spacing-12, $breakpoint-xs, $breakpoint-xl;
aspect-ratio: 615 / 518;
background-color: $color-white;
border-radius: $border-radius-brand;
overflow: hidden;
img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
}
&__content {
padding: 0 24px;
margin-top: 20px;
@media (min-width: $breakpoint-md) {
margin-top: 0;
padding-right: 0;
padding-left: 34px;
}
}
&__name {
@extend %t-h4;
color: $color-main;
}
&__features {
display: flex;
flex-direction: column;
gap: 24px;
margin-top: 24px;
}
&__feature {
display: flex;
align-items: center;
gap: 20px;
@extend %t-body-sm;
font-weight: 500;
color: $color-black;
svg {
@mixin clamp width, 28, 48, $breakpoint-xs, $breakpoint-xl;
@mixin clamp height, 28, 48, $breakpoint-xs, $breakpoint-xl;
flex-shrink: 0;
margin-bottom: auto;
}
}
}
}