mirror of
				https://github.com/KevinMidboe/planetposen-frontend.git
				synced 2025-10-29 13:10:12 +00:00 
			
		
		
		
	Shop gets fancy image carousel & navigate back arrow
This commit is contained in:
		
							
								
								
									
										215
									
								
								src/lib/components/ImageCarousel.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										215
									
								
								src/lib/components/ImageCarousel.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,215 @@ | ||||
| <script lang="ts"> | ||||
|   import { fade } from 'svelte/transition'; | ||||
|   import type { IProduct, IImage } from '$lib/interfaces/IProduct'; | ||||
|   import { onMount } from 'svelte'; | ||||
|  | ||||
|   export let product: IProduct; | ||||
|  | ||||
|   function start(event: MouseEvent | TouchEvent) { | ||||
|     isDown = true; | ||||
|     imageFrameElement.classList.add('active'); | ||||
|     startX = event.pageX - imageFrameElement.offsetLeft; | ||||
|     scrollLeft = imageFrameElement.scrollLeft; | ||||
|     imageFrameElement.style.cursor = 'grabbing'; | ||||
|   } | ||||
|  | ||||
|   function end() { | ||||
|     isDown = false; | ||||
|     imageFrameElement.style.cursor = 'pointer'; | ||||
|   } | ||||
|  | ||||
|   function move(event: MouseEvent | TouchEvent) { | ||||
|     if (!isDown) return; // stop the fn from running | ||||
|     // event.preventDefault(); | ||||
|  | ||||
|     const x = event.pageX - imageFrameElement.offsetLeft; | ||||
|     const walk = (x - startX) * 2; | ||||
|     imageFrameElement.scrollLeft = scrollLeft - walk; | ||||
|   } | ||||
|  | ||||
|   function scrollToIndex(index = selected) { | ||||
|     selected = index; | ||||
|  | ||||
|     const image = imageFrameElement.children[selected] as HTMLImageElement; | ||||
|     const imageWidth = image.getBoundingClientRect()?.width; | ||||
|     imageFrameElement.scrollTo({ | ||||
|       left: imageWidth * selected, | ||||
|       behavior: 'smooth' | ||||
|     }); | ||||
|  | ||||
|     updateHeight(); | ||||
|   } | ||||
|  | ||||
|   function updateHeight() { | ||||
|     const image = imageFrameElement.children[selected] as HTMLImageElement; | ||||
|     const imageHeight = image.getBoundingClientRect()?.height; | ||||
|     imageFrameElement.style.maxHeight = `${imageHeight}px`; | ||||
|   } | ||||
|  | ||||
|   let images: IImage[] = product?.images || []; | ||||
|   let selected = 0; | ||||
|   let isDown = false; | ||||
|   let startX = 0; | ||||
|   let scrollLeft = 0; | ||||
|   let imageFrameElement: HTMLElement; | ||||
|   let productStyles = `background-color: ${product?.primary_color || '#E6E0DC'}; | ||||
| color: ${product?.primary_color === '#231B1D' ? '#f3efeb' : '#37301e'}`; | ||||
|  | ||||
|   let observer: IntersectionObserver; | ||||
|  | ||||
|   onMount(() => { | ||||
|     updateHeight(); | ||||
|  | ||||
|     imageFrameElement.addEventListener('mousedown', start); | ||||
|     imageFrameElement.addEventListener('mouseleave', end); | ||||
|     imageFrameElement.addEventListener('mouseup', end); | ||||
|     imageFrameElement.addEventListener('mousemove', move); | ||||
|  | ||||
|     //   touchEvents | ||||
|  | ||||
|     imageFrameElement.addEventListener('touchstart', start); | ||||
|     imageFrameElement.addEventListener('touchend', end); | ||||
|     imageFrameElement.addEventListener('touchcancel', end); | ||||
|     imageFrameElement.addEventListener('touchmove', move); | ||||
|  | ||||
|     if (typeof IntersectionObserver !== 'undefined') { | ||||
|       const rootMargin = '0px -20% 0px -20%'; | ||||
|  | ||||
|       observer = new IntersectionObserver( | ||||
|         (entries) => { | ||||
|           if (entries[0]?.isIntersecting) { | ||||
|             const target = entries[0]?.target as HTMLElement; | ||||
|             const targetIndex = Number(target?.dataset?.index); | ||||
|             if (targetIndex === NaN) return; | ||||
|  | ||||
|             selected = targetIndex; | ||||
|             updateHeight(); | ||||
|           } | ||||
|         }, | ||||
|         { | ||||
|           root: imageFrameElement, | ||||
|           rootMargin | ||||
|         } | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     const imageChildren = imageFrameElement.children; | ||||
|     for (let i = 0; i < imageChildren.length; i++) { | ||||
|       observer.observe(imageChildren[i]); | ||||
|     } | ||||
|   }); | ||||
| </script> | ||||
|  | ||||
| {#if images?.length > 0} | ||||
|   <div class="carousel" in:fade="{{ duration: 400 }}"> | ||||
|     <div bind:this="{imageFrameElement}" class="image-frame" style="{productStyles}"> | ||||
|       {#each images as { image_id, url }, index (image_id)} | ||||
|         <div class="image-wrapper" data-index="{index}"> | ||||
|           <img src="{url}" alt="" draggable="false" /> | ||||
|         </div> | ||||
|       {/each} | ||||
|     </div> | ||||
|  | ||||
|     {#if images?.length > 1} | ||||
|       <div class="thumbnails"> | ||||
|         {#each images as image, index} | ||||
|           <!-- svelte-ignore a11y-no-noninteractive-tabindex --> | ||||
|           <img | ||||
|             class="{index === selected ? 'selected' : ''}" | ||||
|             on:click="{() => scrollToIndex(index)}" | ||||
|             on:keypress="{(e) => e.code === 'Enter' && scrollToIndex(index)}" | ||||
|             tabindex="0" | ||||
|             src="{image.url}" | ||||
|             alt="{`Clickable product image thumbnail number ${index + 1}`}" | ||||
|           /> | ||||
|         {/each} | ||||
|       </div> | ||||
|     {/if} | ||||
|   </div> | ||||
| {/if} | ||||
|  | ||||
| <style lang="scss"> | ||||
|   @import '../../styles/media-queries.scss'; | ||||
|  | ||||
|   .image-frame { | ||||
|     overflow-x: scroll; | ||||
|     overflow-y: hidden; | ||||
|     scroll-behavior: smooth; | ||||
|     scroll-snap-type: x mandatory; | ||||
|     position: relative; | ||||
|     place-items: center; | ||||
|     margin: 1.5rem; | ||||
|     padding: 2.5rem 1.5rem; | ||||
|     background-color: #e6e0dc; | ||||
|     color: #37301e; | ||||
|     transition: all 0.3s ease-in-out; | ||||
|     cursor: pointer; | ||||
|     white-space: nowrap; | ||||
|     gap: 3rem; | ||||
|     max-height: 100px; | ||||
|     -ms-overflow-style: none; /* Internet Explorer 10+ */ | ||||
|     scrollbar-width: none; /* Firefox */ | ||||
|     -webkit-user-select: none; | ||||
|     user-select: none; | ||||
|  | ||||
|     .image-wrapper { | ||||
|       display: inline-block; | ||||
|       // background-color: red; | ||||
|       padding: 0.5rem; | ||||
|       min-width: 90%; | ||||
|  | ||||
|       &:first-of-type { | ||||
|         margin-left: 1rem; | ||||
|       } | ||||
|       &:last-of-type { | ||||
|         margin-right: 1rem; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     img { | ||||
|       transition: all 0.3s ease-in-out; | ||||
|       scroll-snap-align: center; | ||||
|       vertical-align: top; | ||||
|       width: 100%; | ||||
|       transform: scale(0.98); | ||||
|     } | ||||
|  | ||||
|     &:hover { | ||||
|       margin: 1rem; | ||||
|       padding: 3rem 2rem; | ||||
|  | ||||
|       img { | ||||
|         transform: scale(1); | ||||
|         box-shadow: 3px 3px 13px 3px rgba(0, 0, 0, 0.2); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &::-webkit-scrollbar { | ||||
|       display: none; /* Safari and Chrome */ | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .thumbnails { | ||||
|     display: flex; | ||||
|     flex-wrap: wrap; | ||||
|     justify-content: center; | ||||
|  | ||||
|     img { | ||||
|       width: 4rem; | ||||
|       height: 4rem; | ||||
|       margin: 0 0.5rem; | ||||
|       cursor: pointer; | ||||
|       border: 2px solid transparent; | ||||
|       transition: all 0.4s ease-in-out; | ||||
|       object-fit: cover; | ||||
|  | ||||
|       &.selected { | ||||
|         border-color: black; | ||||
|       } | ||||
|  | ||||
|       &:not(&.selected) { | ||||
|         filter: grayscale(90%); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| </style> | ||||
| @@ -2,7 +2,6 @@ | ||||
|   import type { IProduct } from '$lib/interfaces/IProduct'; | ||||
| 
 | ||||
|   export let product: IProduct; | ||||
|   export let large = false; | ||||
| </script> | ||||
| 
 | ||||
| <a href="/shop/{product?.product_no}" class="product-tile"> | ||||
| @@ -12,15 +11,13 @@ | ||||
|       product?.primary_color === '#231B1D' ? '#f3efeb' : '#37301e' | ||||
|     }`}" | ||||
|   > | ||||
|     {#if !large} | ||||
|       <h3>{product?.name}</h3> | ||||
|     {/if} | ||||
|     <h3>{product?.name}</h3> | ||||
| 
 | ||||
|     <div class="{`image-frame ${large ? 'large' : null}`}"> | ||||
|     <div class="image-frame"> | ||||
|       <img src="{product?.image}" alt="{product?.name}" /> | ||||
|     </div> | ||||
| 
 | ||||
|     {#if !large} | ||||
|     {#if product?.subtext?.length > 3} | ||||
|       <p class="subtext">{product?.subtext}</p> | ||||
|     {/if} | ||||
|   </div> | ||||
| @@ -73,34 +70,16 @@ | ||||
|       display: grid; | ||||
|       place-items: center; | ||||
| 
 | ||||
|       &.large img { | ||||
|         width: 90%; | ||||
|       } | ||||
| 
 | ||||
|       img { | ||||
|         margin: 3rem 0; | ||||
|         width: 66%; | ||||
|         transition: all 0.6s ease; | ||||
| 
 | ||||
|         @include mobile { | ||||
|           width: 75%; | ||||
|           margin: 2rem 0; | ||||
|           width: 90%; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     // TODO grid view on mobile | ||||
|     // @include mobile { | ||||
|     //   margin: 0.5rem; | ||||
|     //   padding: 0.5rem; | ||||
| 
 | ||||
|     //   h3 { | ||||
|     //     margin: 0; | ||||
|     //   } | ||||
| 
 | ||||
|     //   .image-frame img { | ||||
|     //     width: 82%; | ||||
|     //     margin: 1rem 0; | ||||
|     //   } | ||||
|     // } | ||||
|   } | ||||
| </style> | ||||
							
								
								
									
										43
									
								
								src/lib/icons/IconArrow.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/lib/icons/IconArrow.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | ||||
| <script lang="ts"> | ||||
|   import { goto } from '$app/navigation'; | ||||
|  | ||||
|   export let pointLeft: boolean = false; | ||||
|   let defaultRoute = '/shop'; | ||||
|  | ||||
|   function navigateBack() { | ||||
|     window.history.length > 1 ? window.history.back() : goto(defaultRoute); | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <svg | ||||
|   tabindex="0" | ||||
|   role="button" | ||||
|   aria-roledescription="navigate back" | ||||
|   on:click="{navigateBack}" | ||||
|   on:keypress="{(e) => e.code === 'Enter' && navigateBack()}" | ||||
|   style="{pointLeft ? 'transform: rotateZ(180deg)' : ''}" | ||||
|   class="navigate-back" | ||||
|   width="30" | ||||
|   height="25.2" | ||||
|   viewBox="0 0 20 17" | ||||
|   fill="none" | ||||
|   xmlns="http://www.w3.org/2000/svg" | ||||
| > | ||||
|   <path | ||||
|     fill-rule="evenodd" | ||||
|     clip-rule="evenodd" | ||||
|     d="M11.5437 16.5L9.88125 14.8284L14.9875 9.69403H0.5L0.5 7.30597L14.9875 7.30597L9.88125 2.17164L11.5437 0.5L19.5 8.5L11.5437 16.5Z" | ||||
|     fill="var(--color-theme-1)"></path> | ||||
| </svg> | ||||
|  | ||||
| <style lang="scss"> | ||||
|   .navigate-back { | ||||
|     cursor: pointer; | ||||
|     transition: all 0.3s ease; | ||||
|     scale: 0.95; | ||||
|  | ||||
|     &:hover { | ||||
|       scale: 1; | ||||
|     } | ||||
|   } | ||||
| </style> | ||||
| @@ -2,7 +2,7 @@ import type { IProductsDTO } from '$lib/interfaces/ApiResponse'; | ||||
| import type { PageServerLoad } from './$types'; | ||||
|  | ||||
| export const load: PageServerLoad = async ({ fetch }) => { | ||||
|   const res = await fetch('/api/products'); | ||||
|   const res = await fetch('/api/v1/products'); | ||||
|   const products: IProductsDTO = await res.json(); | ||||
|  | ||||
|   return products; | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| <script lang="ts"> | ||||
|   import type { PageData } from './$types'; | ||||
|   import ProductTile from './ProductTile.svelte'; | ||||
|   import ProductTile from '$lib/components/ProductTile.svelte'; | ||||
|   import PageMeta from '$lib/components/PageMeta.svelte'; | ||||
|   import type { IProduct } from '$lib/interfaces/IProduct'; | ||||
|  | ||||
|   | ||||
| @@ -5,7 +5,7 @@ import type { PageServerLoad } from './$types'; | ||||
| export const load: PageServerLoad = async ({ fetch, params }) => { | ||||
|   const { id } = params; | ||||
|  | ||||
|   const res = await fetch(`/api/product/${id}`); | ||||
|   const res = await fetch(`/api/v1/product/${id}`); | ||||
|   const productResponse: IProductDTO = await res.json(); | ||||
|   const jsonld = generateProductJsonLd(productResponse?.product); | ||||
|  | ||||
|   | ||||
| @@ -1,10 +1,12 @@ | ||||
| <script lang="ts"> | ||||
|   import ProductTile from '../ProductTile.svelte'; | ||||
|   import ProductTile from '$lib/components/ProductTile.svelte'; | ||||
|   import ProductVariationSelect from '$lib/components/ProductVariationSelect.svelte'; | ||||
|   import QuantitySelect from '$lib/components/QuantitySelect.svelte'; | ||||
|   import SizesSection from './SizesSection.svelte'; | ||||
|   import Button from '$lib/components/Button.svelte'; | ||||
|   import PageMeta from '$lib/components/PageMeta.svelte'; | ||||
|   import ImageCarousel from '$lib/components/ImageCarousel.svelte'; | ||||
|   import IconArrow from '$lib/icons/IconArrow.svelte'; | ||||
|   import type { PageData } from './$types'; | ||||
|   import type { IProduct, IVariation } from '$lib/interfaces/IProduct'; | ||||
|  | ||||
| @@ -42,13 +44,15 @@ | ||||
| </script> | ||||
|  | ||||
| <PageMeta title="{pageTitle}" description="{product.description}" /> | ||||
|  | ||||
| <IconArrow pointLeft="{true}" /> | ||||
| <div class="product-container"> | ||||
|   <ProductTile product="{product}" large="{true}" /> | ||||
|   <ImageCarousel product="{product}" /> | ||||
|  | ||||
|   <div class="details"> | ||||
|     <h2 class="name">{product.name}</h2> | ||||
|     <p class="subtext">{product.subtext}</p> | ||||
|     <p class="subtext">{product.description}</p> | ||||
|     <p>{product.subtext}</p> | ||||
|     <p class="description">{product.description}</p> | ||||
|     <p class="price">NOK {selectedVariation?.price} (Ink. Moms)</p> | ||||
|  | ||||
|     <ProductVariationSelect | ||||
| @@ -97,6 +101,10 @@ | ||||
|         font-size: 2rem; | ||||
|       } | ||||
|  | ||||
|       .description { | ||||
|         white-space: pre-line; | ||||
|       } | ||||
|  | ||||
|       .price { | ||||
|         margin-top: 3rem; | ||||
|         font-size: 1.5rem; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user