Files
twenty/packages/twenty-website/src/sections/testimonials/TestimonialsCarousel.tsx
T
Abdullah. 569d887d1e [Website] Cut over to the rebuilt site (#21825)
Renaming the package so any further PRs directed to the website are
targeted to the reworked code instead of diverging. Once merged, I will
start preparing this for deployment to dev to test before releasing to
prod. Any improvements will also be applied to this package.

I avoided making significant changes to API routes so nothing breaks,
but will test it thoroughly today to confirm. That said, everything is
ported - double checked.

Big diff PR, impossible to review, but last one! No more rebuilds.
2026-06-19 10:22:46 +02:00

265 lines
6.1 KiB
TypeScript

'use client';
import { msg } from '@lingui/core/macro';
import { ArrowLeft, ArrowRight } from '@/icons';
import { useLingui } from '@lingui/react';
import { styled } from '@linaria/react';
import { useState } from 'react';
import {
EASING,
FONT_WEIGHT,
fontFamily,
mediaUp,
semanticColor,
spacing,
typeRampDeclarations,
} from '@/tokens';
import { HalftoneModel } from '@/platform/visuals/rigs/HalftoneModel';
import { Body, Eyebrow, IconButton, MarkedDivider } from '@/ui';
import { HOURGLASS_VISUAL } from './hourglass-config';
import { type TestimonialRecord } from './testimonials.data';
const CarouselGrid = styled.div`
display: grid;
grid-template-columns: 1fr;
position: relative;
row-gap: ${spacing(6)};
z-index: 0;
${mediaUp('md')} {
align-items: stretch;
column-gap: ${spacing(15)};
grid-template-columns: auto auto minmax(0, 1fr);
row-gap: 0;
}
`;
const LeftColumn = styled.div`
align-items: center;
column-gap: ${spacing(4)};
display: grid;
grid-template-columns: auto 1fr;
${mediaUp('md')} {
grid-template-columns: 1fr;
grid-template-rows: auto auto;
row-gap: ${spacing(12)};
}
`;
const CounterSlot = styled.div`
order: 1;
${mediaUp('md')} {
order: 2;
}
`;
// The authored canvas box, exact (as the old site sizes it): the scene's
// percent-height chain needs a definite height, and the fixed box keeps
// the visual's load from ever shifting layout.
const VisualSlot = styled.div`
align-self: start;
height: 279px;
justify-self: start;
order: 2;
width: 198px;
${mediaUp('md')} {
height: 476px;
order: 1;
width: 336px;
}
`;
const CounterText = styled.p`
${typeRampDeclarations('headingMd')}
font-family: ${fontFamily('sans')};
font-weight: ${FONT_WEIGHT.light};
white-space: nowrap;
${mediaUp('md')} {
text-align: center;
}
`;
const RightColumn = styled.div`
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
min-width: 0;
row-gap: ${spacing(6)};
${mediaUp('md')} {
padding-block: ${spacing(8)};
row-gap: ${spacing(14)};
}
`;
const QuoteStack = styled.div`
display: grid;
`;
const QuoteSlide = styled.div`
grid-area: 1 / 1;
opacity: 0;
pointer-events: none;
transform: translateY(8px);
transition:
opacity 0.5s ${EASING.gentle},
transform 0.5s ${EASING.gentle};
visibility: hidden;
&[data-active] {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
visibility: visible;
}
`;
// Testimonials are quotes, not section titles — rendered as a paragraph that
// borrows the headingMd display ramp rather than carrying a heading tag.
const QuoteText = styled.p`
${typeRampDeclarations('headingMd')}
font-family: ${fontFamily('sans')};
font-weight: ${FONT_WEIGHT.light};
text-wrap: balance;
`;
const FooterRow = styled.div`
align-items: flex-start;
align-self: end;
display: grid;
grid-template-columns: 1fr;
row-gap: ${spacing(4)};
${mediaUp('md')} {
align-items: center;
grid-template-columns: auto 1fr;
}
`;
const NavGroup = styled.div`
column-gap: ${spacing(2)};
display: grid;
grid-auto-flow: column;
justify-content: start;
order: 2;
${mediaUp('md')} {
order: 1;
}
`;
const AuthorBlock = styled.div`
display: grid;
grid-template-columns: 1fr;
justify-items: start;
order: 1;
row-gap: ${spacing(1)};
text-align: left;
${mediaUp('md')} {
justify-self: end;
order: 2;
}
`;
const SeparatorSlot = styled.div`
/* This divider reads structural: the old section draws it at the strong
tier (0.2), unlike the footer's 0.1. */
--marked-divider-line: ${semanticColor.lineStrong};
width: 100%;
${mediaUp('md')} {
height: 100%;
width: auto;
}
`;
export function TestimonialsCarousel({
testimonials,
}: {
testimonials: readonly TestimonialRecord[];
}) {
const { i18n } = useLingui();
const [index, setIndex] = useState(0);
const total = testimonials.length;
const hasPrevious = index > 0;
const hasNext = index < total - 1;
const current = testimonials[index];
return (
<CarouselGrid
aria-label={i18n._(msg`Testimonials`)}
aria-roledescription={i18n._(msg`carousel`)}
role="region"
>
<LeftColumn>
<CounterSlot>
<CounterText aria-live="polite">
{index + 1}/{total}
</CounterText>
</CounterSlot>
<VisualSlot data-illustration="hourglass">
<HalftoneModel
initialPose={HOURGLASS_VISUAL.initialPose}
modelUrl={HOURGLASS_VISUAL.modelUrl}
settings={HOURGLASS_VISUAL.settings}
/>
</VisualSlot>
</LeftColumn>
<SeparatorSlot>
<MarkedDivider />
</SeparatorSlot>
<RightColumn>
<Eyebrow>{i18n._(msg`They are the real sales`)}</Eyebrow>
<QuoteStack>
{testimonials.map((testimonial, testimonialIndex) => (
<QuoteSlide
data-active={testimonialIndex === index ? '' : undefined}
key={testimonial.author.name.id}
>
<QuoteText>{i18n._(testimonial.quote)}</QuoteText>
</QuoteSlide>
))}
</QuoteStack>
<FooterRow>
<NavGroup>
<IconButton
ariaLabel={i18n._(msg`Previous testimonial`)}
disabled={!hasPrevious}
onClick={() => hasPrevious && setIndex(index - 1)}
sizePx={48}
>
<ArrowLeft sizePx={14} />
</IconButton>
<IconButton
ariaLabel={i18n._(msg`Next testimonial`)}
disabled={!hasNext}
onClick={() => hasNext && setIndex(index + 1)}
sizePx={48}
>
<ArrowRight sizePx={14} />
</IconButton>
</NavGroup>
<AuthorBlock>
<Body size="sm" weight="medium">
{i18n._(current.author.name)}
</Body>
<Body muted size="xs">
{i18n._(current.author.designation)}
</Body>
</AuthorBlock>
</FooterRow>
</RightColumn>
</CarouselGrid>
);
}