Files
gw-svelte/src/lib/components/TestimonialsSection.svelte
T
2026-05-06 16:47:15 +12:00

707 lines
18 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from 'svelte';
import { reveal } from '$lib/actions/reveal';
import Icon from '$lib/components/Icon.svelte';
import { getImageMetadata } from '$lib/image-metadata';
import type { TestimonialContent } from '$lib/types';
export let testimonials: TestimonialContent[];
export let heading = 'Why people choose us!';
export let blurb = 'Busy parents get peace of mind. Dogs come home tired and happy. See why 30+ Auckland families trust the Tiny Gang — follow along on Instagram for daily adventures, wagging tails and the odd zoomie.';
export let instagramHref = 'https://www.instagram.com/goodwalk.nz/';
export let instagramLabel = 'goodwalk.nz';
type TestimonialSlide = TestimonialContent & { imageUrl: string };
const wordpressTestimonials: Record<string, TestimonialSlide> = {
Kate: {
reviewer: 'Kate',
detail: "Archie's mum",
quote:
'Love Aless! She is so amazing with my slightly hyper and anxious dog. She is great with communication if anything on either of our ends need to change. Archie love his walks, and I love the photos she posts of him.',
imageUrl: '/images/archie-auckland-dog-walking-review.png'
},
Estelle: {
reviewer: 'Estelle',
detail: "Monty's mum",
quote:
'GoodWalk was the best dog walking service for my little pooch ! Aless was very helpful - basically doubled as a second mum to Monty. She always provided feedback on his outings and assisted where possible with any additional training that she felt he could work on and made recommendations where necessary which i feel is what every dog mum wants and needs!',
imageUrl: '/images/monty-auckland-dog-walking-review.png'
},
Ross: {
reviewer: 'Ross',
detail: "Otis's Dad",
quote:
'Truly the best dog walker in Auckland! I feel so lucky to have found Aless and my little terrier Otis absolutely adores her. He enjoys his regular weekly walks and always comes back happy & tired. Love the updates on social media so I can see how my dog is enjoying his day! Aless makes logistics so easy too. Highly highly recommend, theres a reason she has 5 stars!',
imageUrl: '/images/otis-auckland-dog-walking-review.png'
},
Nina: {
reviewer: 'Nina',
detail: "Wallace's mum",
quote:
'Alessandra has been walking and spending time with my pup since she was 10 weeks old, coming over and doing puppy visits through to transitioning her to pack walks with her little doggo friends. I know Alassandra loves and cares for my dog as much as I do and my dog has a great time! Cant recommend enough',
imageUrl: '/images/wallace-auckland-dog-walking-review.png'
}
};
let activeIndex = 0;
let paused = false;
let inView = false;
let prefersReducedMotion = false;
let carouselEl: HTMLDivElement | undefined;
$: slides = testimonials
.map((testimonial) => wordpressTestimonials[testimonial.reviewer] ?? testimonial)
.filter((testimonial): testimonial is TestimonialSlide => Boolean(testimonial.imageUrl));
$: if (activeIndex >= slides.length) {
activeIndex = 0;
}
function dogNameFromDetail(detail: string) {
const match = detail.match(/^([^']+)/);
return match ? match[1].trim() : '';
}
function testimonialAlt(testimonial: TestimonialSlide) {
const dog = dogNameFromDetail(testimonial.detail);
return dog
? `${dog}, a happy Goodwalk dog walking client in Auckland`
: `${testimonial.reviewer}'s dog after a Goodwalk Auckland dog walk`;
}
function showPrevious() {
if (!slides.length) {
return;
}
activeIndex = (activeIndex - 1 + slides.length) % slides.length;
}
function showNext() {
if (!slides.length) {
return;
}
activeIndex = (activeIndex + 1) % slides.length;
}
onMount(() => {
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
prefersReducedMotion = motionQuery.matches;
const onMotionChange = (event: MediaQueryListEvent) => {
prefersReducedMotion = event.matches;
};
motionQuery.addEventListener('change', onMotionChange);
const observer = carouselEl
? new IntersectionObserver(
([entry]) => {
inView = entry.isIntersecting;
},
{ threshold: 0.25 }
)
: null;
if (observer && carouselEl) {
observer.observe(carouselEl);
}
const interval = window.setInterval(() => {
if (!paused && !prefersReducedMotion && inView && slides.length > 1) {
showNext();
}
}, 9000);
return () => {
window.clearInterval(interval);
motionQuery.removeEventListener('change', onMotionChange);
observer?.disconnect();
};
});
</script>
<section id="testimonials" use:reveal={{ delay: 40 }} class="reveal-block">
<div class="testimonials-inner">
<h2 class="section-heading">{heading}</h2>
<div class="testimonials-intro">
<p>{blurb}</p>
</div>
{#if slides.length}
<div
bind:this={carouselEl}
class="testimonials-carousel"
role="region"
aria-label="Customer testimonials"
on:mouseenter={() => (paused = true)}
on:mouseleave={() => (paused = false)}
on:focusin={() => (paused = true)}
on:focusout={() => (paused = false)}
>
<button
class="testimonial-arrow testimonial-arrow-left"
type="button"
aria-label="Previous testimonial"
on:click={showPrevious}
>
<Icon name="fas fa-chevron-left" />
</button>
<div class="testimonial-stage">
<div class="testimonial-woof" aria-hidden="true">
<span class="testimonial-woof-text">WOOF</span>
<span class="testimonial-ray testimonial-ray-1"></span>
<span class="testimonial-ray testimonial-ray-2"></span>
<span class="testimonial-ray testimonial-ray-3"></span>
</div>
{#each slides as testimonial, index}
<article class:testimonial-slide-active={index === activeIndex} class="testimonial-slide">
<div class="testimonial-photo-wrap">
<div class="testimonial-photo-frame">
{#if index === activeIndex}
{@const imageMeta = getImageMetadata(testimonial.imageUrl)}
<img
class="testimonial-photo"
src={testimonial.imageUrl}
alt={testimonialAlt(testimonial)}
width={imageMeta?.width}
height={imageMeta?.height}
loading="lazy"
decoding="async"
/>
{/if}
</div>
</div>
<div class="testimonial-copy">
<span class="testimonial-quote-mark">"</span>
<blockquote class="testimonial-quote">{testimonial.quote}</blockquote>
<div class="testimonial-author">
<span class="testimonial-author-name">{testimonial.reviewer}</span>
<span class="testimonial-author-detail">{testimonial.detail}</span>
</div>
<div class="testimonial-divider"></div>
<div class="testimonial-mobile-controls" aria-label="Testimonial navigation">
<button
class="testimonial-arrow testimonial-arrow-inline"
type="button"
aria-label="Previous testimonial"
on:click={showPrevious}
>
<Icon name="fas fa-chevron-left" />
</button>
<button
class="testimonial-arrow testimonial-arrow-inline"
type="button"
aria-label="Next testimonial"
on:click={showNext}
>
<Icon name="fas fa-chevron-right" />
</button>
</div>
<a
class="testimonial-google"
href="https://g.page/r/CUsvrWPhkYrAEB0/"
target="_blank"
rel="noopener"
>
<img
class="testimonial-google-logo"
src="/images/google-g-logo.svg"
alt=""
width="18"
height="19"
/>
<span>30+ five-star Google reviews</span>
</a>
</div>
</article>
{/each}
</div>
<button
class="testimonial-arrow testimonial-arrow-right"
type="button"
aria-label="Next testimonial"
on:click={showNext}
>
<Icon name="fas fa-chevron-right" />
</button>
</div>
{/if}
<a href={instagramHref} target="_blank" rel="noopener" class="testimonials-instagram-link">
<Icon name="fab fa-instagram" />
<span>{instagramLabel}</span>
</a>
</div>
</section>
<style>
.testimonials-intro {
max-width: 760px;
margin: 18px auto 0;
text-align: center;
}
.testimonials-intro p {
margin: 0;
color: #4c5056;
font-size: 17px;
line-height: 1.65;
}
.testimonials-instagram-link {
display: flex;
width: fit-content;
align-items: center;
gap: 10px;
margin: 18px auto 0;
padding: 10px 16px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.06);
color: var(--green);
font-weight: 700;
text-decoration: none;
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.06);
transition:
transform 0.16s cubic-bezier(0.22, 1, 0.36, 1),
background 0.2s ease,
box-shadow 0.2s ease;
}
:global(.testimonials-instagram-link .icon) {
font-size: 18px;
}
@media (hover: hover) {
.testimonials-instagram-link:hover {
transform: translateY(-2px);
background: rgba(33, 48, 33, 0.09);
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.06),
0 10px 22px rgba(17, 20, 24, 0.08);
}
}
.testimonials-instagram-link:active {
transform: translateY(1px) scale(0.985);
}
.testimonials-carousel {
position: relative;
margin-top: 48px;
}
@media (max-width: 768px) {
.testimonials-intro {
margin-top: 14px;
}
.testimonials-intro p {
font-size: 15px;
line-height: 1.55;
}
.testimonials-instagram-link {
margin: 14px auto 0;
padding: 9px 14px;
font-size: 15px;
}
}
.testimonial-arrow {
transition:
transform 0.16s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.2s ease,
background 0.2s ease;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
@media (hover: hover) {
.testimonial-arrow:hover {
transform: translateY(-50%) scale(1.05);
box-shadow: 0 14px 28px rgba(17, 20, 24, 0.12);
}
}
.testimonial-arrow:active {
transform: translateY(-50%) scale(0.95);
}
:global(.reveal-ready.reveal-block) {
opacity: 0;
transform: translate3d(0, var(--reveal-distance, 24px), 0);
transition:
opacity 0.55s ease,
transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
transition-delay: var(--reveal-delay, 0ms);
}
:global(.reveal-visible.reveal-block) {
opacity: 1;
transform: translate3d(0, 0, 0);
}
.testimonial-stage {
position: relative;
overflow: hidden;
border-radius: 28px;
background: #fff;
box-shadow: 0 10px 30px rgba(20, 24, 20, 0.06);
min-height: 620px;
}
.testimonial-slide {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: 45% 55%;
align-items: stretch;
opacity: 0;
pointer-events: none;
transition:
opacity 0.35s ease,
transform 0.35s ease;
transform: translateX(18px);
}
.testimonial-slide-active {
opacity: 1;
pointer-events: auto;
transform: translateX(0);
}
.testimonial-photo-wrap {
display: flex;
align-items: flex-start;
justify-content: center;
padding: 32px 24px 0 24px;
}
.testimonial-photo-frame {
width: min(100%, 340px);
}
.testimonial-photo {
display: block;
width: 100%;
margin: 0 auto;
aspect-ratio: 1 / 1;
object-fit: cover;
}
.testimonial-copy {
align-self: start;
padding: 118px 112px 76px 10px;
}
.testimonial-quote-mark {
display: block;
font-family: Georgia, serif;
font-size: 72px;
line-height: 0.6;
color: var(--yellow);
margin-bottom: 20px;
user-select: none;
}
.testimonial-copy .testimonial-quote {
max-width: 500px;
margin: 0;
font-size: 17px;
font-style: italic;
font-weight: 400;
line-height: 1.6;
letter-spacing: 0;
color: #2e3031;
}
.testimonial-author {
display: flex;
align-items: center;
gap: 10px;
margin-top: 24px;
}
.testimonial-author-name {
font-family: var(--font-head);
font-size: 15px;
font-weight: 700;
color: #1a1a1a;
}
.testimonial-author-detail {
font-size: 14px;
color: #6b7280;
}
.testimonial-author-detail::before {
content: '—';
margin-right: 6px;
}
.testimonial-divider {
width: 100%;
max-width: 690px;
height: 1px;
margin: 44px 0 0;
background: #e7e7e7;
}
.testimonial-google {
display: inline-flex;
align-items: center;
gap: 12px;
margin-top: 28px;
padding: 10px 20px;
border-radius: 999px;
background: #f8f8f8;
color: #0a304e;
font-size: 14px;
line-height: 1.3;
box-shadow: 0 0 0 1px rgba(10, 48, 78, 0.06);
}
.testimonial-google-logo {
width: 18px;
height: 19px;
flex: 0 0 auto;
}
.testimonial-google:hover {
background: #efe6d5;
}
.testimonial-mobile-controls {
display: none;
}
.testimonial-woof {
position: absolute;
top: 40px;
right: 60px;
z-index: 2;
color: #2e3031;
transform: rotate(-6deg);
transform-origin: center center;
}
.testimonial-woof-text {
display: inline-block;
font-family: 'Fredoka One', var(--font-head), sans-serif;
font-size: 32px;
line-height: 1;
letter-spacing: 0.02em;
}
.testimonial-ray {
position: absolute;
border-radius: 999px;
background: #ffd100;
}
.testimonial-ray-1 {
top: -12px;
right: -48px;
width: 32px;
height: 11px;
transform: rotate(-35deg);
}
.testimonial-ray-2 {
top: 6px;
right: -60px;
width: 46px;
height: 13px;
transform: rotate(-35deg);
}
.testimonial-ray-3 {
top: 24px;
right: -50px;
width: 36px;
height: 11px;
transform: rotate(-35deg);
}
.testimonial-arrow {
position: absolute;
top: 50%;
z-index: 3;
display: inline-flex;
align-items: center;
justify-content: center;
width: 58px;
height: 58px;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 20px;
background: rgba(255, 255, 255, 0.95);
color: #111;
font-size: 22px;
transform: translateY(-50%);
box-shadow: 0 12px 28px rgba(20, 24, 20, 0.07);
}
.testimonial-arrow:hover {
background: #fff;
}
.testimonial-arrow-left {
left: -38px;
}
.testimonial-arrow-right {
right: -38px;
}
@media (max-width: 1024px) {
.testimonial-stage {
min-height: 560px;
}
.testimonial-photo-wrap {
padding: 88px 16px 64px 44px;
}
.testimonial-copy {
padding: 96px 72px 64px 8px;
}
.testimonial-copy .testimonial-quote {
max-width: 460px;
font-size: 17px;
}
.testimonial-woof {
right: 40px;
}
}
@media (max-width: 767px) {
.testimonials-carousel {
margin-top: 32px;
}
.testimonial-stage {
min-height: unset;
padding-bottom: 0;
}
.testimonial-slide {
position: relative;
display: none;
grid-template-columns: 1fr;
transform: none;
}
.testimonial-slide-active {
display: grid;
}
.testimonial-photo-wrap {
justify-content: center;
padding: 48px 22px 16px;
}
.testimonial-photo-frame {
width: min(100%, 220px);
}
.testimonial-photo {
aspect-ratio: 1 / 1;
}
.testimonial-copy {
padding: 8px 28px 32px;
align-self: start;
}
.testimonial-quote-mark {
font-size: 44px;
margin-bottom: 8px;
}
.testimonial-copy .testimonial-quote {
font-size: 16px;
line-height: 1.55;
}
.testimonial-divider {
margin-top: 28px;
}
.testimonial-mobile-controls {
display: inline-flex;
align-items: center;
gap: 12px;
margin-top: 20px;
}
.testimonial-arrow-inline {
position: static;
width: 48px;
height: 48px;
font-size: 18px;
transform: none;
box-shadow: 0 10px 22px rgba(20, 24, 20, 0.08);
}
.testimonial-google {
margin-top: 20px;
font-size: 16px;
gap: 10px;
padding: 10px 14px;
}
.testimonial-google :global(.icon) {
font-size: 20px;
}
.testimonial-woof {
top: 24px;
right: 22px;
}
.testimonial-woof-text {
font-size: 22px;
}
.testimonial-ray {
right: -28px;
width: 9px;
}
.testimonial-ray-1 {
top: -7px;
height: 34px;
}
.testimonial-ray-2 {
top: 10px;
right: -38px;
width: 34px;
height: 9px;
}
.testimonial-ray-3 {
top: 35px;
right: -28px;
width: 27px;
height: 8px;
}
.testimonial-arrow-left,
.testimonial-arrow-right {
display: none;
}
}
</style>