<!-- 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"
        }
      ]
    }
  ]
}
  • Content:
    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;
    		}
    	}
    }
    
  • URL: /components/raw/section-devices/index.ts
  • Filesystem Path: src/components/organisms/section-devices/index.ts
  • Size: 6.4 KB
  • Content:
    .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;
    			}
    		}
    	}
    }
    
  • URL: /components/raw/section-devices/section-devices.css
  • Filesystem Path: src/components/organisms/section-devices/section-devices.css
  • Size: 3.4 KB
  • Handle: @section-devices
  • Preview:
  • Filesystem Path: src/components/organisms/section-devices/section-devices.twig