few fixes (#19648)
## Summary - refresh the partner hero visual and testimonial presentation, including the partner-specific carousel and illustration assets - switch the testimonials top notch to the masked rendering approach used elsewhere for more precise shape control - extend halftone studio/export support and related geometry/state handling used by the updated partner visuals - include supporting website UI adjustments across navigation, pricing, plans, and Salesforce-related sections ## Testing - Not run (not requested)
This commit is contained in:
committed by
GitHub
parent
84b325876d
commit
12233e6c47
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -1645,9 +1645,9 @@ export function HalftoneCanvas({
|
||||
}
|
||||
|
||||
if (!isImageMode) {
|
||||
let baseRotationX = 0;
|
||||
let baseRotationY = 0;
|
||||
let baseRotationZ = 0;
|
||||
let baseRotationX = initialPoseReference.current?.rotationX ?? 0;
|
||||
let baseRotationY = initialPoseReference.current?.rotationY ?? 0;
|
||||
let baseRotationZ = initialPoseReference.current?.rotationZ ?? 0;
|
||||
let meshOffsetY = 0;
|
||||
let meshScale = 1;
|
||||
let lightAngle = activeSettings.lighting.angleDegrees;
|
||||
|
||||
@@ -54,8 +54,6 @@ describe('halftone export naming', () => {
|
||||
|
||||
expect(
|
||||
normalizeHalftoneStudioSettings(parsed.settings).halftone.hoverDashColor,
|
||||
).toBe(
|
||||
DEFAULT_HALFTONE_SETTINGS.halftone.hoverDashColor,
|
||||
);
|
||||
).toBe(DEFAULT_HALFTONE_SETTINGS.halftone.hoverDashColor);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2930,8 +2930,7 @@ export function generateReactComponent(
|
||||
modelFilenameOverride,
|
||||
);
|
||||
const pose = normalizeExportPose(initialPose);
|
||||
const normalizedComponentName =
|
||||
normalizeExportComponentName(componentName);
|
||||
const normalizedComponentName = normalizeExportComponentName(componentName);
|
||||
const defaultModelUrl =
|
||||
modelFilenameOverride ?? shape.filename ?? 'model.glb';
|
||||
const defaultImageUrl = imageFilename ?? 'image.png';
|
||||
@@ -3064,8 +3063,7 @@ export async function generateStandaloneHtml(
|
||||
modelFilenameOverride,
|
||||
);
|
||||
const pose = normalizeExportPose(initialPose);
|
||||
const normalizedComponentName =
|
||||
normalizeExportComponentName(componentName);
|
||||
const normalizedComponentName = normalizeExportComponentName(componentName);
|
||||
const defaultImageUrl = imageFilename ?? 'image.png';
|
||||
const embeddedImportedModelUrl =
|
||||
!isImageMode && shape.kind === 'imported' && importedFile
|
||||
|
||||
@@ -273,6 +273,26 @@ async function loadImportedGeometry(
|
||||
return parseGlbGeometry(buffer, '', label);
|
||||
}
|
||||
|
||||
export async function loadImportedGeometryFromUrl(
|
||||
loader: HalftoneModelLoader,
|
||||
modelUrl: string,
|
||||
label: string,
|
||||
) {
|
||||
const response = await fetch(modelUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load ${label} from ${modelUrl}.`);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
if (loader === 'fbx') {
|
||||
return parseFbxGeometry(buffer, '', label);
|
||||
}
|
||||
|
||||
return parseGlbGeometry(buffer, '', label);
|
||||
}
|
||||
|
||||
function makePolarShape(
|
||||
radiusFunction: (angle: number) => number,
|
||||
segments = 320,
|
||||
|
||||
@@ -405,9 +405,10 @@ export const LEGACY_HALFTONE_SETTING_KEYS = [
|
||||
'shadowCrush',
|
||||
] as const;
|
||||
|
||||
export function isRoundedBandHalftoneSettings(
|
||||
value: unknown,
|
||||
): value is Omit<HalftoneEffectSettings, 'hoverDashColor'> & {
|
||||
export function isRoundedBandHalftoneSettings(value: unknown): value is Omit<
|
||||
HalftoneEffectSettings,
|
||||
'hoverDashColor'
|
||||
> & {
|
||||
hoverDashColor?: string;
|
||||
} {
|
||||
if (!value || typeof value !== 'object') {
|
||||
|
||||
@@ -111,15 +111,17 @@ export default async function PartnerPage() {
|
||||
</ThreeCards.Root>
|
||||
|
||||
<Testimonials.Root
|
||||
backgroundColor={theme.colors.secondary.background[5]}
|
||||
color={theme.colors.primary.text[100]}
|
||||
backgroundColor={theme.colors.secondary.background[100]}
|
||||
color={theme.colors.secondary.text[100]}
|
||||
shapeBodyFillColor={theme.colors.secondary.background[100]}
|
||||
shapeFillColor={theme.colors.primary.background[100]}
|
||||
>
|
||||
<Testimonials.Carousel
|
||||
<Testimonials.PartnerCarousel
|
||||
eyebrow={TESTIMONIALS_DATA.eyebrow}
|
||||
testimonials={TESTIMONIALS_DATA.testimonials}
|
||||
>
|
||||
<Testimonials.PartnerVisual />
|
||||
</Testimonials.Carousel>
|
||||
</Testimonials.PartnerCarousel>
|
||||
</Testimonials.Root>
|
||||
|
||||
<Signoff.Root
|
||||
|
||||
@@ -1,129 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { HalftoneCanvas } from '@/app/halftone/_components/HalftoneCanvas';
|
||||
import { loadImportedGeometryFromUrl } from '@/app/halftone/_lib/geometry-registry';
|
||||
import type {
|
||||
HalftoneExportPose,
|
||||
HalftoneStudioSettings,
|
||||
} from '@/app/halftone/_lib/state';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
|
||||
const GLB_URL = '/illustrations/partner/testimonials/quote.glb';
|
||||
const PARTNER_QUOTE_LABEL = 'partner quote';
|
||||
const PREVIEW_DISTANCE = 6;
|
||||
const deg = THREE.MathUtils.degToRad;
|
||||
|
||||
const scanlineVertexShader = /* glsl */ `
|
||||
varying vec3 vWorldPosition;
|
||||
varying vec3 vWorldNormal;
|
||||
|
||||
void main() {
|
||||
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
|
||||
vWorldPosition = worldPosition.xyz;
|
||||
vWorldNormal = normalize(mat3(modelMatrix) * normal);
|
||||
gl_Position = projectionMatrix * viewMatrix * worldPosition;
|
||||
}
|
||||
`;
|
||||
|
||||
const scanlineFragmentShader = /* glsl */ `
|
||||
uniform vec3 uColor;
|
||||
uniform vec3 uLightDir;
|
||||
uniform float uStripeScale;
|
||||
|
||||
varying vec3 vWorldPosition;
|
||||
varying vec3 vWorldNormal;
|
||||
|
||||
void main() {
|
||||
vec3 normal = normalize(vWorldNormal);
|
||||
vec3 lightDir = normalize(uLightDir);
|
||||
float ndotl = max(dot(normal, lightDir), 0.06);
|
||||
|
||||
float y = vWorldPosition.y * uStripeScale;
|
||||
float cell = fract(y);
|
||||
|
||||
float shadowWeight = mix(1.0, 0.5, ndotl);
|
||||
float lineWidth = 0.58 * shadowWeight;
|
||||
float edge = 0.035;
|
||||
float band = 1.0 - smoothstep(lineWidth, lineWidth + edge, cell);
|
||||
|
||||
float highlight = pow(ndotl, 1.35);
|
||||
float dash = fract(vWorldPosition.x * 20.0 + vWorldPosition.z * 6.0);
|
||||
float dashMask = mix(
|
||||
1.0,
|
||||
smoothstep(0.15, 0.45, dash) * (1.0 - smoothstep(0.55, 0.88, dash)),
|
||||
highlight
|
||||
);
|
||||
band *= dashMask;
|
||||
|
||||
float speckle = fract(
|
||||
sin(dot(vWorldPosition.xz, vec2(127.1, 311.7))) * 43758.5453
|
||||
);
|
||||
band *= mix(1.0, 0.55 + 0.45 * step(0.4, speckle), highlight * 0.85);
|
||||
|
||||
if (band < 0.015) {
|
||||
discard;
|
||||
}
|
||||
|
||||
vec3 lit = uColor * mix(0.72, 1.18, ndotl);
|
||||
gl_FragColor = vec4(lit, band);
|
||||
}
|
||||
`;
|
||||
|
||||
function createScanlineMaterial(lightDirection: THREE.Vector3) {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uColor: { value: new THREE.Color('#1e5bff') },
|
||||
uLightDir: { value: lightDirection.clone() },
|
||||
uStripeScale: { value: 16.0 },
|
||||
},
|
||||
vertexShader: scanlineVertexShader,
|
||||
fragmentShader: scanlineFragmentShader,
|
||||
const PARTNER_QUOTE_SETTINGS: HalftoneStudioSettings = {
|
||||
sourceMode: 'shape',
|
||||
shapeKey: 'partnerTestimonialsQuote',
|
||||
lighting: {
|
||||
intensity: 1.5,
|
||||
fillIntensity: 0.15,
|
||||
ambientIntensity: 0.08,
|
||||
angleDegrees: 45,
|
||||
height: 2,
|
||||
},
|
||||
material: {
|
||||
surface: 'solid',
|
||||
color: '#d4d0c8',
|
||||
roughness: 0.42,
|
||||
metalness: 0.16,
|
||||
thickness: 150,
|
||||
refraction: 2,
|
||||
environmentPower: 5,
|
||||
},
|
||||
halftone: {
|
||||
enabled: true,
|
||||
scale: 24.72,
|
||||
power: -0.07,
|
||||
width: 0.46,
|
||||
imageContrast: 1,
|
||||
dashColor: '#4A38F5',
|
||||
hoverDashColor: '#4A38F5',
|
||||
},
|
||||
background: {
|
||||
transparent: true,
|
||||
depthWrite: true,
|
||||
depthTest: true,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
}
|
||||
|
||||
function disposeObjectSubtree(root: THREE.Object3D) {
|
||||
root.traverse((sceneObject) => {
|
||||
if (!(sceneObject instanceof THREE.Mesh)) {
|
||||
return;
|
||||
}
|
||||
|
||||
sceneObject.geometry?.dispose();
|
||||
|
||||
const material = sceneObject.material;
|
||||
if (Array.isArray(material)) {
|
||||
material.forEach((item) => item.dispose());
|
||||
} else {
|
||||
material?.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type MeshRestPose = {
|
||||
position: THREE.Vector3;
|
||||
quaternion: THREE.Quaternion;
|
||||
wobblePhase: number;
|
||||
color: 'transparent',
|
||||
},
|
||||
animation: {
|
||||
autoRotateEnabled: false,
|
||||
breatheEnabled: true,
|
||||
cameraParallaxEnabled: false,
|
||||
followHoverEnabled: true,
|
||||
followDragEnabled: false,
|
||||
floatEnabled: false,
|
||||
hoverHalftoneEnabled: false,
|
||||
hoverLightEnabled: false,
|
||||
dragFlowEnabled: false,
|
||||
lightSweepEnabled: false,
|
||||
rotateEnabled: false,
|
||||
autoSpeed: 0.2,
|
||||
autoWobble: 0.3,
|
||||
breatheAmount: 0.02,
|
||||
breatheSpeed: 0.2,
|
||||
cameraParallaxAmount: 0.3,
|
||||
cameraParallaxEase: 0.08,
|
||||
driftAmount: 8,
|
||||
hoverRange: 8,
|
||||
hoverEase: 0.02,
|
||||
hoverReturn: true,
|
||||
dragSens: 0.008,
|
||||
dragFriction: 0.08,
|
||||
dragMomentum: true,
|
||||
rotateAxis: '-y',
|
||||
rotatePreset: 'axis',
|
||||
rotateSpeed: 0.2,
|
||||
rotatePingPong: false,
|
||||
floatAmplitude: 0.16,
|
||||
floatSpeed: 0.8,
|
||||
lightSweepHeightRange: 0.25,
|
||||
lightSweepRange: 5,
|
||||
lightSweepSpeed: 0.55,
|
||||
springDamping: 0.72,
|
||||
springReturnEnabled: true,
|
||||
springStrength: 0.06,
|
||||
hoverHalftonePowerShift: 0.42,
|
||||
hoverHalftoneRadius: 0.2,
|
||||
hoverHalftoneWidthShift: -0.18,
|
||||
hoverLightIntensity: 0.8,
|
||||
hoverLightRadius: 0.2,
|
||||
dragFlowDecay: 0.08,
|
||||
dragFlowRadius: 0.24,
|
||||
dragFlowStrength: 1.8,
|
||||
hoverWarpStrength: 3,
|
||||
hoverWarpRadius: 0.15,
|
||||
dragWarpStrength: 5,
|
||||
waveEnabled: false,
|
||||
waveSpeed: 1,
|
||||
waveAmount: 2,
|
||||
},
|
||||
};
|
||||
|
||||
function applyScanlineMaterials(
|
||||
modelRoot: THREE.Object3D,
|
||||
lightDirection: THREE.Vector3,
|
||||
) {
|
||||
modelRoot.traverse((sceneObject) => {
|
||||
if (!(sceneObject instanceof THREE.Mesh)) {
|
||||
return;
|
||||
}
|
||||
const PARTNER_QUOTE_INITIAL_POSE: HalftoneExportPose = {
|
||||
autoElapsed: 126.7357000006041,
|
||||
rotateElapsed: 10.021299999999943,
|
||||
rotationX: deg(-80),
|
||||
rotationY: deg(10),
|
||||
rotationZ: deg(350),
|
||||
targetRotationX: 0,
|
||||
targetRotationY: 0,
|
||||
timeElapsed: 289.53700000066755,
|
||||
};
|
||||
|
||||
sceneObject.material = createScanlineMaterial(lightDirection);
|
||||
|
||||
const mesh = sceneObject;
|
||||
const rest: MeshRestPose = {
|
||||
position: mesh.position.clone(),
|
||||
quaternion: mesh.quaternion.clone(),
|
||||
wobblePhase: mesh.position.y * 4.2 + mesh.position.x * 1.7,
|
||||
};
|
||||
mesh.userData.partnerTestimonialsVisualRest = rest;
|
||||
});
|
||||
}
|
||||
const noopFirstInteraction = () => {};
|
||||
const noopPoseChange = (_pose: HalftoneExportPose) => {};
|
||||
|
||||
const VisualFrame = styled.div`
|
||||
background-color: transparent;
|
||||
@@ -139,214 +130,56 @@ const VisualFrame = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const CanvasMount = styled.div`
|
||||
const VisualPlaceholder = styled.div`
|
||||
display: block;
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
min-width: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export function Partner() {
|
||||
const mountReference = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = mountReference.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const [geometry, setGeometry] = useState<THREE.BufferGeometry | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let animationFrameId = 0;
|
||||
let loadedGeometry: THREE.BufferGeometry | null = null;
|
||||
|
||||
const pointer = { x: 0, y: 0, inside: false };
|
||||
const targetRotation = { x: 0, y: 0 };
|
||||
const lightDirectionWorld = new THREE.Vector3(4, 8, 6).normalize();
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100);
|
||||
camera.position.set(0, 0, 5.05);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setSize(width, height);
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
const canvas = renderer.domElement;
|
||||
canvas.style.cursor = 'pointer';
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.touchAction = 'none';
|
||||
canvas.style.width = '100%';
|
||||
container.appendChild(canvas);
|
||||
|
||||
const pivot = new THREE.Group();
|
||||
scene.add(pivot);
|
||||
|
||||
const clock = new THREE.Clock();
|
||||
|
||||
const dracoLoader = new DRACOLoader();
|
||||
dracoLoader.setDecoderPath(
|
||||
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/',
|
||||
);
|
||||
|
||||
const loader = new GLTFLoader();
|
||||
loader.setDRACOLoader(dracoLoader);
|
||||
|
||||
loader.load(
|
||||
GLB_URL,
|
||||
(gltf) => {
|
||||
void loadImportedGeometryFromUrl('glb', GLB_URL, PARTNER_QUOTE_LABEL).then(
|
||||
(nextGeometry) => {
|
||||
if (cancelled) {
|
||||
disposeObjectSubtree(gltf.scene);
|
||||
nextGeometry.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
const modelRoot = gltf.scene;
|
||||
const bounds = new THREE.Box3().setFromObject(modelRoot);
|
||||
const center = bounds.getCenter(new THREE.Vector3());
|
||||
const size = bounds.getSize(new THREE.Vector3());
|
||||
const maxAxis = Math.max(size.x, size.y, size.z, 0.001);
|
||||
const scale = 2.85 / maxAxis;
|
||||
|
||||
modelRoot.position.sub(center);
|
||||
modelRoot.scale.setScalar(scale);
|
||||
|
||||
applyScanlineMaterials(modelRoot, lightDirectionWorld);
|
||||
pivot.add(modelRoot);
|
||||
|
||||
const renderFrame = () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
animationFrameId = window.requestAnimationFrame(renderFrame);
|
||||
const delta = Math.min(clock.getDelta(), 0.1);
|
||||
|
||||
const rotationDamp = 6.8;
|
||||
const influence = pointer.inside ? 1 : 0.38;
|
||||
targetRotation.y = pointer.x * 0.78 * influence;
|
||||
targetRotation.x = pointer.y * 0.62 * influence;
|
||||
|
||||
pivot.rotation.y = THREE.MathUtils.damp(
|
||||
pivot.rotation.y,
|
||||
targetRotation.y,
|
||||
rotationDamp,
|
||||
delta,
|
||||
);
|
||||
pivot.rotation.x = THREE.MathUtils.damp(
|
||||
pivot.rotation.x,
|
||||
targetRotation.x,
|
||||
rotationDamp,
|
||||
delta,
|
||||
);
|
||||
|
||||
const hoverLift = pointer.inside ? 1 : 0;
|
||||
pivot.scale.setScalar(
|
||||
THREE.MathUtils.damp(pivot.scale.x, 1 + hoverLift * 0.12, 7, delta),
|
||||
);
|
||||
|
||||
const mx = pointer.x * (pointer.inside ? 1 : 0.32);
|
||||
const my = pointer.y * (pointer.inside ? 1 : 0.32);
|
||||
|
||||
modelRoot.traverse((sceneObject) => {
|
||||
if (!(sceneObject instanceof THREE.Mesh)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rest = sceneObject.userData.partnerTestimonialsVisualRest as
|
||||
| MeshRestPose
|
||||
| undefined;
|
||||
if (!rest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const phase = rest.wobblePhase;
|
||||
const wobble = pointer.inside ? 1 : 0.36;
|
||||
sceneObject.position.x =
|
||||
rest.position.x + mx * 0.22 * Math.sin(phase * 1.8);
|
||||
sceneObject.position.z =
|
||||
rest.position.z + my * 0.19 * Math.cos(phase * 1.4);
|
||||
sceneObject.position.y =
|
||||
rest.position.y + (mx + my) * 0.055 * Math.sin(phase * 2.5);
|
||||
|
||||
const twist = (mx * 0.34 + my * 0.24) * wobble * Math.sin(phase);
|
||||
sceneObject.quaternion.copy(rest.quaternion);
|
||||
sceneObject.rotateY(twist);
|
||||
});
|
||||
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
|
||||
renderFrame();
|
||||
loadedGeometry = nextGeometry;
|
||||
setGeometry(nextGeometry);
|
||||
},
|
||||
(error) => {
|
||||
console.error(error);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
const setPointerFromEvent = (event: PointerEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const normalizedX = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
const normalizedY = -(((event.clientY - rect.top) / rect.height) * 2 - 1);
|
||||
pointer.x = THREE.MathUtils.clamp(normalizedX, -1, 1);
|
||||
pointer.y = THREE.MathUtils.clamp(normalizedY, -1, 1);
|
||||
};
|
||||
|
||||
const handlePointerEnter = () => {
|
||||
pointer.inside = true;
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
pointer.inside = false;
|
||||
pointer.x = 0;
|
||||
pointer.y = 0;
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
setPointerFromEvent(event);
|
||||
};
|
||||
|
||||
canvas.addEventListener('pointerenter', handlePointerEnter);
|
||||
canvas.addEventListener('pointerleave', handlePointerLeave);
|
||||
canvas.addEventListener('pointermove', handlePointerMove);
|
||||
|
||||
const handleResize = () => {
|
||||
if (!mountReference.current || cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextWidth = mountReference.current.clientWidth;
|
||||
const nextHeight = mountReference.current.clientHeight;
|
||||
camera.aspect = nextWidth / nextHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(nextWidth, nextHeight);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener('resize', handleResize);
|
||||
canvas.removeEventListener('pointerenter', handlePointerEnter);
|
||||
canvas.removeEventListener('pointerleave', handlePointerLeave);
|
||||
canvas.removeEventListener('pointermove', handlePointerMove);
|
||||
window.cancelAnimationFrame(animationFrameId);
|
||||
disposeObjectSubtree(scene);
|
||||
renderer.dispose();
|
||||
dracoLoader.dispose();
|
||||
|
||||
if (canvas.parentNode === container) {
|
||||
container.removeChild(canvas);
|
||||
}
|
||||
loadedGeometry?.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<VisualFrame>
|
||||
<CanvasMount aria-hidden ref={mountReference} />
|
||||
{geometry ? (
|
||||
<HalftoneCanvas
|
||||
geometry={geometry}
|
||||
imageElement={null}
|
||||
initialPose={PARTNER_QUOTE_INITIAL_POSE}
|
||||
onFirstInteraction={noopFirstInteraction}
|
||||
onPoseChange={noopPoseChange}
|
||||
previewDistance={PREVIEW_DISTANCE}
|
||||
settings={PARTNER_QUOTE_SETTINGS}
|
||||
/>
|
||||
) : (
|
||||
<VisualPlaceholder aria-hidden />
|
||||
)}
|
||||
</VisualFrame>
|
||||
);
|
||||
}
|
||||
|
||||
+762
@@ -0,0 +1,762 @@
|
||||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import * as THREE from 'three';
|
||||
|
||||
const PREVIEW_DISTANCE = 3.2;
|
||||
const SOURCE_PREVIEW_DISTANCE = 6.1;
|
||||
const REFERENCE_PREVIEW_DISTANCE = 4;
|
||||
const VIRTUAL_RENDER_HEIGHT = 768;
|
||||
|
||||
const HALFTONE_TILE_SIZE = 10;
|
||||
const HALFTONE_POWER = -0.1;
|
||||
const HALFTONE_WIDTH = 0.45;
|
||||
const HALFTONE_CONTRAST = 1;
|
||||
const HALFTONE_DASH_COLOR = '#959595';
|
||||
const HALFTONE_HOVER_COLOR = '#4A38F5';
|
||||
const HALFTONE_HOVER_RADIUS = 0.6;
|
||||
const HALFTONE_HOVER_POWER_SHIFT = 0.2;
|
||||
const HALFTONE_HOVER_WIDTH_SHIFT = -0.18;
|
||||
const HALFTONE_HOVER_LIGHT_INTENSITY = 0;
|
||||
const HALFTONE_HOVER_LIGHT_RADIUS = 0.2;
|
||||
|
||||
const IMAGE_POINTER_FOLLOW = 0.38;
|
||||
const IMAGE_POINTER_VELOCITY_DAMPING = 0.82;
|
||||
const MIN_FOOTPRINT_SCALE = 0.001;
|
||||
|
||||
const passThroughVertexShader = `
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const imagePassthroughFragmentShader = `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tImage;
|
||||
uniform vec2 imageSize;
|
||||
uniform vec2 viewportSize;
|
||||
uniform float zoom;
|
||||
uniform float contrast;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
float imageAspect = imageSize.x / imageSize.y;
|
||||
float viewAspect = viewportSize.x / viewportSize.y;
|
||||
|
||||
vec2 uv = vUv;
|
||||
|
||||
if (imageAspect > viewAspect) {
|
||||
float scale = viewAspect / imageAspect;
|
||||
uv.y = (uv.y - 0.5) / scale + 0.5;
|
||||
} else {
|
||||
float scale = imageAspect / viewAspect;
|
||||
uv.x = (uv.x - 0.5) / scale + 0.5;
|
||||
}
|
||||
|
||||
uv = (uv - 0.5) / zoom + 0.5;
|
||||
|
||||
float inBounds = step(0.0, uv.x) * step(uv.x, 1.0)
|
||||
* step(0.0, uv.y) * step(uv.y, 1.0);
|
||||
|
||||
vec4 color = texture2D(tImage, clamp(uv, 0.0, 1.0));
|
||||
vec3 contrastColor = clamp((color.rgb - 0.5) * contrast + 0.5, 0.0, 1.0);
|
||||
|
||||
gl_FragColor = vec4(contrastColor, inBounds);
|
||||
}
|
||||
`;
|
||||
|
||||
const halftoneFragmentShader = `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tScene;
|
||||
uniform vec2 effectResolution;
|
||||
uniform vec2 logicalResolution;
|
||||
uniform float tile;
|
||||
uniform float s_3;
|
||||
uniform float s_4;
|
||||
uniform vec3 dashColor;
|
||||
uniform vec3 hoverDashColor;
|
||||
uniform float time;
|
||||
uniform float waveAmount;
|
||||
uniform float waveSpeed;
|
||||
uniform float footprintScale;
|
||||
uniform vec2 interactionUv;
|
||||
uniform vec2 interactionVelocity;
|
||||
uniform vec2 dragOffset;
|
||||
uniform float hoverHalftoneActive;
|
||||
uniform float hoverHalftonePowerShift;
|
||||
uniform float hoverHalftoneRadius;
|
||||
uniform float hoverHalftoneWidthShift;
|
||||
uniform float hoverLightStrength;
|
||||
uniform float hoverLightRadius;
|
||||
uniform float hoverFlowStrength;
|
||||
uniform float hoverFlowRadius;
|
||||
uniform float dragFlowStrength;
|
||||
uniform float cropToBounds;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
float distSegment(in vec2 p, in vec2 a, in vec2 b) {
|
||||
vec2 pa = p - a;
|
||||
vec2 ba = b - a;
|
||||
float denom = max(dot(ba, ba), 0.000001);
|
||||
float h = clamp(dot(pa, ba) / denom, 0.0, 1.0);
|
||||
return length(pa - ba * h);
|
||||
}
|
||||
|
||||
float lineSimpleEt(in vec2 p, in float r, in float thickness) {
|
||||
vec2 a = vec2(0.5) + vec2(-r, 0.0);
|
||||
vec2 b = vec2(0.5) + vec2(r, 0.0);
|
||||
float distToSegment = distSegment(p, a, b);
|
||||
float halfThickness = thickness * r;
|
||||
return distToSegment - halfThickness;
|
||||
}
|
||||
|
||||
void main() {
|
||||
if (cropToBounds > 0.5) {
|
||||
vec4 boundsCheck = texture2D(tScene, vUv);
|
||||
if (boundsCheck.a < 0.01) {
|
||||
gl_FragColor = vec4(0.0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
vec2 fragCoord =
|
||||
(gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution;
|
||||
float halftoneSize = max(tile * max(footprintScale, 0.001), 1.0);
|
||||
vec2 pointerPx = interactionUv * logicalResolution;
|
||||
vec2 fragDelta = fragCoord - pointerPx;
|
||||
float fragDist = length(fragDelta);
|
||||
vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);
|
||||
float velocityMagnitude = length(interactionVelocity);
|
||||
vec2 motionDir = velocityMagnitude > 0.001
|
||||
? interactionVelocity / velocityMagnitude
|
||||
: vec2(0.0, 0.0);
|
||||
float motionBias = velocityMagnitude > 0.001
|
||||
? dot(-radialDir, motionDir) * 0.5 + 0.5
|
||||
: 0.5;
|
||||
|
||||
float hoverLightMask = 0.0;
|
||||
if (hoverLightStrength > 0.0) {
|
||||
float lightRadiusPx = hoverLightRadius * logicalResolution.y;
|
||||
hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);
|
||||
}
|
||||
|
||||
float hoverHalftoneMask = 0.0;
|
||||
if (hoverHalftoneActive > 0.0) {
|
||||
float hoverHalftoneRadiusPx = hoverHalftoneRadius * logicalResolution.y;
|
||||
hoverHalftoneMask = smoothstep(hoverHalftoneRadiusPx, 0.0, fragDist);
|
||||
}
|
||||
|
||||
float hoverFlowMask = 0.0;
|
||||
if (hoverFlowStrength > 0.0) {
|
||||
float hoverRadiusPx = hoverFlowRadius * logicalResolution.y;
|
||||
hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist);
|
||||
}
|
||||
|
||||
vec2 hoverDisplacement =
|
||||
radialDir * hoverFlowStrength * hoverFlowMask * halftoneSize * 0.55 +
|
||||
motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * halftoneSize * 1.15;
|
||||
vec2 travelDisplacement = dragOffset * dragFlowStrength * 0.45;
|
||||
vec2 effectCoord = fragCoord + hoverDisplacement + travelDisplacement;
|
||||
|
||||
float bandRow = floor(effectCoord.y / halftoneSize);
|
||||
float waveOffset =
|
||||
waveAmount * sin(time * waveSpeed + bandRow * 0.5) * halftoneSize;
|
||||
effectCoord.x += waveOffset;
|
||||
|
||||
vec2 cellIndex = floor(effectCoord / halftoneSize);
|
||||
vec2 sampleUv = clamp(
|
||||
(cellIndex + 0.5) * halftoneSize / logicalResolution,
|
||||
vec2(0.0),
|
||||
vec2(1.0)
|
||||
);
|
||||
vec2 cellUv = fract(effectCoord / halftoneSize);
|
||||
|
||||
vec4 sceneSample = texture2D(tScene, sampleUv);
|
||||
float mask = smoothstep(0.02, 0.08, sceneSample.a);
|
||||
float localPower = clamp(
|
||||
s_3 + hoverHalftonePowerShift * hoverHalftoneMask,
|
||||
-1.5,
|
||||
1.5
|
||||
);
|
||||
float localWidth = clamp(
|
||||
s_4 + hoverHalftoneWidthShift * hoverHalftoneMask,
|
||||
0.05,
|
||||
1.4
|
||||
);
|
||||
float lightLift =
|
||||
hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22;
|
||||
float bandRadius = clamp(
|
||||
(
|
||||
(
|
||||
sceneSample.r +
|
||||
sceneSample.g +
|
||||
sceneSample.b +
|
||||
localPower * length(vec2(0.5))
|
||||
) *
|
||||
(1.0 / 3.0)
|
||||
) + lightLift,
|
||||
0.0,
|
||||
1.0
|
||||
) * 1.86 * 0.5;
|
||||
|
||||
float alpha = 0.0;
|
||||
if (bandRadius > 0.0001) {
|
||||
float signedDistance = lineSimpleEt(cellUv, bandRadius, localWidth);
|
||||
float edge = 0.02;
|
||||
alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask;
|
||||
}
|
||||
|
||||
vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverHalftoneMask);
|
||||
vec3 color = activeDashColor * alpha;
|
||||
gl_FragColor = vec4(color, alpha);
|
||||
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`;
|
||||
|
||||
const OverlayMount = styled.div`
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
type PointerState = {
|
||||
mouseX: number;
|
||||
mouseY: number;
|
||||
pointerInside: boolean;
|
||||
pointerVelocityX: number;
|
||||
pointerVelocityY: number;
|
||||
smoothedMouseX: number;
|
||||
smoothedMouseY: number;
|
||||
};
|
||||
|
||||
function clampRectToViewport(
|
||||
rect: { height: number; width: number; x: number; y: number },
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
) {
|
||||
const minX = Math.max(rect.x, 0);
|
||||
const minY = Math.max(rect.y, 0);
|
||||
const maxX = Math.min(rect.x + rect.width, viewportWidth);
|
||||
const maxY = Math.min(rect.y + rect.height, viewportHeight);
|
||||
|
||||
if (maxX <= minX || maxY <= minY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
height: maxY - minY,
|
||||
width: maxX - minX,
|
||||
x: minX,
|
||||
y: minY,
|
||||
};
|
||||
}
|
||||
|
||||
function getRectArea(rect: { height: number; width: number } | null) {
|
||||
if (!rect) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(rect.width, 0) * Math.max(rect.height, 0);
|
||||
}
|
||||
|
||||
function getImagePreviewZoom(previewDistance: number) {
|
||||
return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001);
|
||||
}
|
||||
|
||||
function getContainedImageRect({
|
||||
imageHeight,
|
||||
imageWidth,
|
||||
viewportHeight,
|
||||
viewportWidth,
|
||||
zoom,
|
||||
}: {
|
||||
imageHeight: number;
|
||||
imageWidth: number;
|
||||
viewportHeight: number;
|
||||
viewportWidth: number;
|
||||
zoom: number;
|
||||
}) {
|
||||
if (
|
||||
imageWidth <= 0 ||
|
||||
imageHeight <= 0 ||
|
||||
viewportWidth <= 0 ||
|
||||
viewportHeight <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const imageAspect = imageWidth / imageHeight;
|
||||
const viewAspect = viewportWidth / viewportHeight;
|
||||
|
||||
let fittedWidth = viewportWidth;
|
||||
let fittedHeight = viewportHeight;
|
||||
|
||||
if (imageAspect > viewAspect) {
|
||||
fittedHeight = viewportWidth / imageAspect;
|
||||
} else {
|
||||
fittedWidth = viewportHeight * imageAspect;
|
||||
}
|
||||
|
||||
const scaledWidth = fittedWidth * zoom;
|
||||
const scaledHeight = fittedHeight * zoom;
|
||||
|
||||
return clampRectToViewport(
|
||||
{
|
||||
height: scaledHeight,
|
||||
width: scaledWidth,
|
||||
x: (viewportWidth - scaledWidth) * 0.5,
|
||||
y: (viewportHeight - scaledHeight) * 0.5,
|
||||
},
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
);
|
||||
}
|
||||
|
||||
function getFootprintScaleFromRects(
|
||||
currentRect: { height: number; width: number } | null,
|
||||
referenceRect: { height: number; width: number } | null,
|
||||
) {
|
||||
const currentArea = getRectArea(currentRect);
|
||||
const referenceArea = getRectArea(referenceRect);
|
||||
|
||||
if (currentArea <= 0 || referenceArea <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.max(Math.sqrt(currentArea / referenceArea), MIN_FOOTPRINT_SCALE);
|
||||
}
|
||||
|
||||
function getImageFootprintScale({
|
||||
imageHeight,
|
||||
imageWidth,
|
||||
previewDistance,
|
||||
viewportHeight,
|
||||
viewportWidth,
|
||||
}: {
|
||||
imageHeight: number;
|
||||
imageWidth: number;
|
||||
previewDistance: number;
|
||||
viewportHeight: number;
|
||||
viewportWidth: number;
|
||||
}) {
|
||||
const currentRect = getContainedImageRect({
|
||||
imageHeight,
|
||||
imageWidth,
|
||||
viewportHeight,
|
||||
viewportWidth,
|
||||
zoom: getImagePreviewZoom(previewDistance),
|
||||
});
|
||||
const referenceRect = getContainedImageRect({
|
||||
imageHeight,
|
||||
imageWidth,
|
||||
viewportHeight,
|
||||
viewportWidth,
|
||||
zoom: 1,
|
||||
});
|
||||
|
||||
return getFootprintScaleFromRects(currentRect, referenceRect);
|
||||
}
|
||||
|
||||
function getRelativeImageScale({
|
||||
imageHeight,
|
||||
imageWidth,
|
||||
previewDistance,
|
||||
referencePreviewDistance,
|
||||
viewportHeight,
|
||||
viewportWidth,
|
||||
}: {
|
||||
imageHeight: number;
|
||||
imageWidth: number;
|
||||
previewDistance: number;
|
||||
referencePreviewDistance: number;
|
||||
viewportHeight: number;
|
||||
viewportWidth: number;
|
||||
}) {
|
||||
const currentRect = getContainedImageRect({
|
||||
imageHeight,
|
||||
imageWidth,
|
||||
viewportHeight,
|
||||
viewportWidth,
|
||||
zoom: getImagePreviewZoom(previewDistance),
|
||||
});
|
||||
const referenceRect = getContainedImageRect({
|
||||
imageHeight,
|
||||
imageWidth,
|
||||
viewportHeight,
|
||||
viewportWidth,
|
||||
zoom: getImagePreviewZoom(referencePreviewDistance),
|
||||
});
|
||||
|
||||
return getFootprintScaleFromRects(currentRect, referenceRect);
|
||||
}
|
||||
|
||||
function createRenderTarget(width: number, height: number) {
|
||||
return new THREE.WebGLRenderTarget(width, height, {
|
||||
format: THREE.RGBAFormat,
|
||||
magFilter: THREE.LinearFilter,
|
||||
minFilter: THREE.LinearFilter,
|
||||
});
|
||||
}
|
||||
|
||||
function loadImage(imageUrl: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.decoding = 'async';
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () =>
|
||||
reject(new Error(`Failed to load hero image: ${imageUrl}`));
|
||||
image.src = imageUrl;
|
||||
});
|
||||
}
|
||||
|
||||
async function mountHalftoneOverlay({
|
||||
container,
|
||||
imageUrl,
|
||||
}: {
|
||||
container: HTMLDivElement;
|
||||
imageUrl: string;
|
||||
}): Promise<() => void> {
|
||||
const image = await loadImage(imageUrl);
|
||||
|
||||
const getWidth = () => Math.max(container.clientWidth, 1);
|
||||
const getHeight = () => Math.max(container.clientHeight, 1);
|
||||
const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight());
|
||||
const getVirtualWidth = () =>
|
||||
Math.max(
|
||||
Math.round(getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1))),
|
||||
1,
|
||||
);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
alpha: true,
|
||||
antialias: false,
|
||||
powerPreference: 'high-performance',
|
||||
});
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setPixelRatio(1);
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
|
||||
|
||||
const canvas = renderer.domElement;
|
||||
canvas.setAttribute('aria-hidden', 'true');
|
||||
canvas.style.cursor = 'crosshair';
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.width = '100%';
|
||||
container.appendChild(canvas);
|
||||
|
||||
const imageTexture = new THREE.Texture(image);
|
||||
imageTexture.colorSpace = THREE.SRGBColorSpace;
|
||||
imageTexture.generateMipmaps = false;
|
||||
imageTexture.magFilter = THREE.LinearFilter;
|
||||
imageTexture.minFilter = THREE.LinearFilter;
|
||||
imageTexture.needsUpdate = true;
|
||||
|
||||
const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight());
|
||||
const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
|
||||
const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
|
||||
const imageMaterial = new THREE.ShaderMaterial({
|
||||
fragmentShader: imagePassthroughFragmentShader,
|
||||
uniforms: {
|
||||
contrast: { value: HALFTONE_CONTRAST },
|
||||
imageSize: { value: new THREE.Vector2(image.width, image.height) },
|
||||
tImage: { value: imageTexture },
|
||||
viewportSize: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
zoom: { value: getImagePreviewZoom(PREVIEW_DISTANCE) },
|
||||
},
|
||||
vertexShader: passThroughVertexShader,
|
||||
});
|
||||
|
||||
const imageScene = new THREE.Scene();
|
||||
imageScene.add(new THREE.Mesh(fullScreenGeometry, imageMaterial));
|
||||
|
||||
const halftoneMaterial = new THREE.ShaderMaterial({
|
||||
fragmentShader: halftoneFragmentShader,
|
||||
transparent: true,
|
||||
uniforms: {
|
||||
cropToBounds: { value: 1 },
|
||||
dashColor: { value: new THREE.Color(HALFTONE_DASH_COLOR) },
|
||||
dragFlowStrength: { value: 0 },
|
||||
dragOffset: { value: new THREE.Vector2(0, 0) },
|
||||
effectResolution: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
footprintScale: { value: 1 },
|
||||
hoverDashColor: { value: new THREE.Color(HALFTONE_HOVER_COLOR) },
|
||||
hoverFlowRadius: { value: 0.18 },
|
||||
hoverFlowStrength: { value: 0 },
|
||||
hoverHalftoneActive: { value: 0 },
|
||||
hoverHalftonePowerShift: { value: 0 },
|
||||
hoverHalftoneRadius: { value: HALFTONE_HOVER_RADIUS },
|
||||
hoverHalftoneWidthShift: { value: 0 },
|
||||
hoverLightRadius: { value: HALFTONE_HOVER_LIGHT_RADIUS },
|
||||
hoverLightStrength: { value: 0 },
|
||||
interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
|
||||
interactionVelocity: { value: new THREE.Vector2(0, 0) },
|
||||
logicalResolution: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
s_3: { value: HALFTONE_POWER },
|
||||
s_4: { value: HALFTONE_WIDTH },
|
||||
tScene: { value: sceneTarget.texture },
|
||||
tile: { value: HALFTONE_TILE_SIZE },
|
||||
time: { value: 0 },
|
||||
waveAmount: { value: 0 },
|
||||
waveSpeed: { value: 1 },
|
||||
},
|
||||
vertexShader: passThroughVertexShader,
|
||||
});
|
||||
|
||||
const postScene = new THREE.Scene();
|
||||
postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial));
|
||||
|
||||
const updateViewportUniforms = ({
|
||||
effectHeight,
|
||||
effectWidth,
|
||||
logicalHeight,
|
||||
logicalWidth,
|
||||
}: {
|
||||
effectHeight: number;
|
||||
effectWidth: number;
|
||||
logicalHeight: number;
|
||||
logicalWidth: number;
|
||||
}) => {
|
||||
halftoneMaterial.uniforms.effectResolution.value.set(
|
||||
effectWidth,
|
||||
effectHeight,
|
||||
);
|
||||
halftoneMaterial.uniforms.logicalResolution.value.set(
|
||||
logicalWidth,
|
||||
logicalHeight,
|
||||
);
|
||||
imageMaterial.uniforms.viewportSize.value.set(logicalWidth, logicalHeight);
|
||||
};
|
||||
|
||||
const getHalftoneScale = () =>
|
||||
getImageFootprintScale({
|
||||
imageHeight: image.height,
|
||||
imageWidth: image.width,
|
||||
previewDistance: PREVIEW_DISTANCE,
|
||||
viewportHeight: getVirtualHeight(),
|
||||
viewportWidth: getVirtualWidth(),
|
||||
});
|
||||
|
||||
const getHoverScale = () =>
|
||||
getRelativeImageScale({
|
||||
imageHeight: image.height,
|
||||
imageWidth: image.width,
|
||||
previewDistance: PREVIEW_DISTANCE,
|
||||
referencePreviewDistance: SOURCE_PREVIEW_DISTANCE,
|
||||
viewportHeight: getVirtualHeight(),
|
||||
viewportWidth: getVirtualWidth(),
|
||||
});
|
||||
|
||||
const pointer: PointerState = {
|
||||
mouseX: 0.5,
|
||||
mouseY: 0.5,
|
||||
pointerInside: false,
|
||||
pointerVelocityX: 0,
|
||||
pointerVelocityY: 0,
|
||||
smoothedMouseX: 0.5,
|
||||
smoothedMouseY: 0.5,
|
||||
};
|
||||
|
||||
const syncSize = () => {
|
||||
const virtualWidth = getVirtualWidth();
|
||||
const virtualHeight = getVirtualHeight();
|
||||
|
||||
renderer.setSize(virtualWidth, virtualHeight, false);
|
||||
sceneTarget.setSize(virtualWidth, virtualHeight);
|
||||
updateViewportUniforms({
|
||||
effectHeight: virtualHeight,
|
||||
effectWidth: virtualWidth,
|
||||
logicalHeight: virtualHeight,
|
||||
logicalWidth: virtualWidth,
|
||||
});
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(syncSize);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
const updatePointerPosition = (
|
||||
event: PointerEvent,
|
||||
options?: { resetVelocity?: boolean },
|
||||
) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const width = Math.max(rect.width, 1);
|
||||
const height = Math.max(rect.height, 1);
|
||||
|
||||
const nextMouseX = THREE.MathUtils.clamp(
|
||||
(event.clientX - rect.left) / width,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
const nextMouseY = THREE.MathUtils.clamp(
|
||||
(event.clientY - rect.top) / height,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
const deltaX = nextMouseX - pointer.mouseX;
|
||||
const deltaY = nextMouseY - pointer.mouseY;
|
||||
|
||||
pointer.mouseX = nextMouseX;
|
||||
pointer.mouseY = nextMouseY;
|
||||
pointer.pointerInside =
|
||||
event.clientX >= rect.left &&
|
||||
event.clientX <= rect.right &&
|
||||
event.clientY >= rect.top &&
|
||||
event.clientY <= rect.bottom;
|
||||
|
||||
if (options?.resetVelocity) {
|
||||
pointer.pointerVelocityX = 0;
|
||||
pointer.pointerVelocityY = 0;
|
||||
pointer.smoothedMouseX = nextMouseX;
|
||||
pointer.smoothedMouseY = nextMouseY;
|
||||
return;
|
||||
}
|
||||
|
||||
pointer.pointerVelocityX = deltaX;
|
||||
pointer.pointerVelocityY = deltaY;
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const resetVelocity = !pointer.pointerInside;
|
||||
updatePointerPosition(
|
||||
event,
|
||||
resetVelocity ? { resetVelocity: true } : undefined,
|
||||
);
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
pointer.pointerInside = false;
|
||||
pointer.pointerVelocityX = 0;
|
||||
pointer.pointerVelocityY = 0;
|
||||
};
|
||||
|
||||
canvas.addEventListener('pointermove', handlePointerMove);
|
||||
canvas.addEventListener('pointerleave', handlePointerLeave);
|
||||
|
||||
let animationFrameId = 0;
|
||||
|
||||
const renderFrame = (timestamp: number) => {
|
||||
animationFrameId = window.requestAnimationFrame(renderFrame);
|
||||
halftoneMaterial.uniforms.time.value = timestamp / 1000;
|
||||
const hoverScale = getHoverScale();
|
||||
|
||||
pointer.smoothedMouseX +=
|
||||
(pointer.mouseX - pointer.smoothedMouseX) * IMAGE_POINTER_FOLLOW;
|
||||
pointer.smoothedMouseY +=
|
||||
(pointer.mouseY - pointer.smoothedMouseY) * IMAGE_POINTER_FOLLOW;
|
||||
pointer.pointerVelocityX *= IMAGE_POINTER_VELOCITY_DAMPING;
|
||||
pointer.pointerVelocityY *= IMAGE_POINTER_VELOCITY_DAMPING;
|
||||
|
||||
halftoneMaterial.uniforms.interactionUv.value.set(
|
||||
pointer.smoothedMouseX,
|
||||
1 - pointer.smoothedMouseY,
|
||||
);
|
||||
halftoneMaterial.uniforms.interactionVelocity.value.set(
|
||||
pointer.pointerVelocityX * getVirtualWidth(),
|
||||
-pointer.pointerVelocityY * getVirtualHeight(),
|
||||
);
|
||||
halftoneMaterial.uniforms.hoverHalftoneActive.value = pointer.pointerInside
|
||||
? 1
|
||||
: 0;
|
||||
halftoneMaterial.uniforms.hoverHalftonePowerShift.value =
|
||||
pointer.pointerInside ? HALFTONE_HOVER_POWER_SHIFT : 0;
|
||||
halftoneMaterial.uniforms.hoverHalftoneRadius.value =
|
||||
HALFTONE_HOVER_RADIUS * hoverScale;
|
||||
halftoneMaterial.uniforms.hoverHalftoneWidthShift.value =
|
||||
pointer.pointerInside ? HALFTONE_HOVER_WIDTH_SHIFT : 0;
|
||||
halftoneMaterial.uniforms.hoverLightStrength.value = pointer.pointerInside
|
||||
? HALFTONE_HOVER_LIGHT_INTENSITY
|
||||
: 0;
|
||||
halftoneMaterial.uniforms.hoverLightRadius.value =
|
||||
HALFTONE_HOVER_LIGHT_RADIUS * hoverScale;
|
||||
halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale();
|
||||
imageMaterial.uniforms.zoom.value = getImagePreviewZoom(PREVIEW_DISTANCE);
|
||||
|
||||
renderer.setRenderTarget(sceneTarget);
|
||||
renderer.render(imageScene, orthographicCamera);
|
||||
|
||||
renderer.setRenderTarget(null);
|
||||
renderer.clear();
|
||||
renderer.render(postScene, orthographicCamera);
|
||||
};
|
||||
|
||||
renderFrame(0);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrameId);
|
||||
resizeObserver.disconnect();
|
||||
canvas.removeEventListener('pointermove', handlePointerMove);
|
||||
canvas.removeEventListener('pointerleave', handlePointerLeave);
|
||||
halftoneMaterial.dispose();
|
||||
imageMaterial.dispose();
|
||||
imageTexture.dispose();
|
||||
fullScreenGeometry.dispose();
|
||||
sceneTarget.dispose();
|
||||
renderer.dispose();
|
||||
|
||||
if (canvas.parentNode === container) {
|
||||
container.removeChild(canvas);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
type PartnerHalftoneOverlayProps = {
|
||||
imageUrl: string;
|
||||
};
|
||||
|
||||
export function PartnerHalftoneOverlay({
|
||||
imageUrl,
|
||||
}: PartnerHalftoneOverlayProps) {
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = mountRef.current;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
const disposePromise = mountHalftoneOverlay({ container, imageUrl }).catch(
|
||||
(error) => {
|
||||
console.error(error);
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
|
||||
void disposePromise.then((dispose) => {
|
||||
if (!disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispose?.();
|
||||
});
|
||||
};
|
||||
}, [imageUrl]);
|
||||
|
||||
return <OverlayMount ref={mountRef} />;
|
||||
}
|
||||
+9
-14
@@ -1,33 +1,28 @@
|
||||
import { theme } from '@/theme';
|
||||
import { css } from '@linaria/core';
|
||||
import { styled } from '@linaria/react';
|
||||
import NextImage from 'next/image';
|
||||
|
||||
import { PartnerHalftoneOverlay } from './PartnerHalftoneOverlay';
|
||||
|
||||
const HERO_IMAGE_URL = '/images/partner/hero/partners-hero.webp';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
background-color: ${theme.colors.secondary.background[100]};
|
||||
border-radius: ${theme.radius(1)};
|
||||
height: 462px;
|
||||
margin-top: ${theme.spacing(6)};
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const partnerHeroImageClassName = css`
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
@media (max-width: ${theme.breakpoints.md - 1}px) {
|
||||
height: 360px;
|
||||
}
|
||||
`;
|
||||
|
||||
export function PartnerVisual() {
|
||||
return (
|
||||
<StyledContainer>
|
||||
<NextImage
|
||||
alt="Twenty partners"
|
||||
className={partnerHeroImageClassName}
|
||||
fill
|
||||
priority
|
||||
sizes="100vw"
|
||||
src="/images/partner/hero/hero.png"
|
||||
/>
|
||||
<PartnerHalftoneOverlay imageUrl={HERO_IMAGE_URL} />
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
const TESTIMONIALS_SHAPE_PATH =
|
||||
'M0 4a4 4 0 0 1 4-4h344.32c4.197 0 8.369.66 12.361 1.958l49.5 16.084A40 40 0 0 0 422.542 20h517.7c4.293 0 8.559-.691 12.633-2.047l47.785-15.906A40 40 0 0 1 1013.29 0H1356a4 4 0 0 1 4 4v16H0z';
|
||||
const TESTIMONIALS_SHAPE_CLIP_PATH =
|
||||
'polygon(0% 20%, 0.294% 0%, 25.318% 0%, 31.069% 100%, 69.135% 100%, 74.507% 0%, 99.706% 0%, 100% 20%, 100% 100%, 0% 100%)';
|
||||
|
||||
interface TestimonialsShapeProps {
|
||||
bodyFillColor?: string;
|
||||
fillColor: string;
|
||||
}
|
||||
|
||||
export function TestimonialsShape({ fillColor }: TestimonialsShapeProps) {
|
||||
export function TestimonialsShape({
|
||||
bodyFillColor,
|
||||
fillColor,
|
||||
}: TestimonialsShapeProps) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
@@ -18,17 +22,18 @@ export function TestimonialsShape({ fillColor }: TestimonialsShapeProps) {
|
||||
zIndex: -1,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="100%"
|
||||
height="20"
|
||||
viewBox="0 0 1360 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
preserveAspectRatio="none"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
<path d={TESTIMONIALS_SHAPE_PATH} fill={fillColor} />
|
||||
</svg>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: fillColor,
|
||||
clipPath: TESTIMONIALS_SHAPE_CLIP_PATH,
|
||||
height: 20,
|
||||
left: 0,
|
||||
overflow: 'hidden',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -36,7 +41,7 @@ export function TestimonialsShape({ fillColor }: TestimonialsShapeProps) {
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: fillColor,
|
||||
backgroundColor: bodyFillColor ?? fillColor,
|
||||
borderBottomLeftRadius: 4,
|
||||
borderBottomRightRadius: 4,
|
||||
}}
|
||||
|
||||
+357
@@ -0,0 +1,357 @@
|
||||
'use client';
|
||||
|
||||
import { Body, Eyebrow, Heading, IconButton } from '@/design-system/components';
|
||||
import type { EyebrowType } from '@/design-system/components/Eyebrow/types/Eyebrow';
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from '@/icons';
|
||||
import type { TestimonialCardType } from '@/sections/Testimonials/types/TestimonialCard';
|
||||
import { theme } from '@/theme';
|
||||
import { css } from '@linaria/core';
|
||||
import { styled } from '@linaria/react';
|
||||
import NextImage from 'next/image';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { Separator } from '../Separator/Separator';
|
||||
|
||||
const DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const nameTextClassName = css`
|
||||
color: ${theme.colors.secondary.text[100]};
|
||||
`;
|
||||
|
||||
const handleTextClassName = css`
|
||||
color: ${theme.colors.secondary.text[100]};
|
||||
`;
|
||||
|
||||
const dateTextClassName = css`
|
||||
color: ${theme.colors.secondary.text[80]};
|
||||
`;
|
||||
|
||||
const quoteHeadingClassName = css`
|
||||
color: ${theme.colors.secondary.text[100]};
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const StyledCarousel = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
position: relative;
|
||||
row-gap: ${theme.spacing(8)};
|
||||
z-index: 0;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
align-items: stretch;
|
||||
column-gap: ${theme.spacing(15)};
|
||||
grid-template-columns: minmax(0, 328px) auto minmax(0, 1fr);
|
||||
row-gap: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const LeftColumn = styled.div`
|
||||
display: grid;
|
||||
row-gap: ${theme.spacing(6)};
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
align-content: space-between;
|
||||
min-height: 580px;
|
||||
row-gap: ${theme.spacing(12)};
|
||||
}
|
||||
`;
|
||||
|
||||
const AuthorCard = styled.div`
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
row-gap: ${theme.spacing(6)};
|
||||
`;
|
||||
|
||||
const PortraitFrame = styled.div`
|
||||
aspect-ratio: 1;
|
||||
border-radius: ${theme.radius(2)};
|
||||
max-width: 328px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const AuthorMeta = styled.div`
|
||||
display: grid;
|
||||
max-width: 253px;
|
||||
row-gap: ${theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const NameHandleRow = styled.div`
|
||||
align-items: center;
|
||||
column-gap: ${theme.spacing(2)};
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const HandleText = styled.div`
|
||||
border-left: 1px solid ${theme.colors.secondary.border[100]};
|
||||
line-height: 0;
|
||||
padding-left: ${theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const CounterText = styled.p`
|
||||
color: ${theme.colors.secondary.text[100]};
|
||||
font-family: ${theme.font.family.sans};
|
||||
font-size: ${theme.font.size(10)};
|
||||
font-weight: ${theme.font.weight.light};
|
||||
letter-spacing: -0.04em;
|
||||
line-height: ${theme.lineHeight(11.5)};
|
||||
margin: 0;
|
||||
text-align: start;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
font-size: ${theme.font.size(12)};
|
||||
line-height: ${theme.lineHeight(14)};
|
||||
text-align: center;
|
||||
}
|
||||
`;
|
||||
|
||||
const RightColumn = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto auto auto;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
row-gap: ${theme.spacing(8)};
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
grid-template-rows: auto minmax(392px, 1fr) auto;
|
||||
min-height: 642px;
|
||||
row-gap: ${theme.spacing(14)};
|
||||
}
|
||||
`;
|
||||
|
||||
const QuoteArea = styled.div`
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
min-height: 392px;
|
||||
}
|
||||
`;
|
||||
|
||||
const QuoteStack = styled.div`
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const QuoteWrapper = styled.div`
|
||||
grid-area: 1 / 1;
|
||||
max-width: 900px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(8px);
|
||||
transition:
|
||||
opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
visibility: hidden;
|
||||
|
||||
&[data-active='true'] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
visibility: visible;
|
||||
}
|
||||
`;
|
||||
|
||||
const QuoteDecoration = styled.div`
|
||||
display: none;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
display: block;
|
||||
height: 544px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: 48px;
|
||||
top: 56px;
|
||||
width: 646px;
|
||||
z-index: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const QuoteDecorationVisual = styled.div`
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
position: absolute;
|
||||
right: 36px;
|
||||
top: -112px;
|
||||
transform: scale(1.9);
|
||||
transform-origin: top right;
|
||||
}
|
||||
`;
|
||||
|
||||
const FooterRow = styled.div`
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const NavGroup = styled.div`
|
||||
column-gap: ${theme.spacing(2)};
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
justify-content: start;
|
||||
`;
|
||||
|
||||
type PartnerCarouselProps = {
|
||||
children?: ReactNode;
|
||||
eyebrow: EyebrowType;
|
||||
testimonials: TestimonialCardType[];
|
||||
};
|
||||
|
||||
export function PartnerCarousel({
|
||||
children,
|
||||
eyebrow,
|
||||
testimonials,
|
||||
}: PartnerCarouselProps) {
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
const total = testimonials.length;
|
||||
if (total === 0) return null;
|
||||
|
||||
const hasPrevious = index > 0;
|
||||
const hasNext = index < total - 1;
|
||||
const current = testimonials[index];
|
||||
|
||||
const goToPrevious = () => {
|
||||
if (hasPrevious) setIndex(index - 1);
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
if (hasNext) setIndex(index + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledCarousel
|
||||
aria-label="Partner testimonials"
|
||||
aria-roledescription="carousel"
|
||||
role="region"
|
||||
>
|
||||
<LeftColumn>
|
||||
<AuthorCard>
|
||||
<PortraitFrame>
|
||||
<NextImage
|
||||
alt={current.author.avatar.alt || ''}
|
||||
fill
|
||||
priority
|
||||
sizes="(min-width: 921px) 328px, 100vw"
|
||||
src={current.author.avatar.src}
|
||||
style={{
|
||||
filter: 'grayscale(1) contrast(1.1)',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
</PortraitFrame>
|
||||
|
||||
<AuthorMeta>
|
||||
<NameHandleRow>
|
||||
<Body
|
||||
as="span"
|
||||
body={current.author.name}
|
||||
className={nameTextClassName}
|
||||
size="sm"
|
||||
weight="medium"
|
||||
/>
|
||||
<HandleText>
|
||||
<Body
|
||||
as="span"
|
||||
body={current.author.handle}
|
||||
className={handleTextClassName}
|
||||
size="sm"
|
||||
/>
|
||||
</HandleText>
|
||||
</NameHandleRow>
|
||||
|
||||
<Body
|
||||
as="p"
|
||||
body={{ text: DATE_FORMATTER.format(current.author.date) }}
|
||||
className={dateTextClassName}
|
||||
size="xs"
|
||||
/>
|
||||
</AuthorMeta>
|
||||
</AuthorCard>
|
||||
|
||||
<CounterText aria-live="polite">
|
||||
{index + 1}/{total}
|
||||
</CounterText>
|
||||
</LeftColumn>
|
||||
|
||||
<Separator colorScheme="secondary" />
|
||||
|
||||
<RightColumn>
|
||||
<Eyebrow colorScheme="secondary" heading={eyebrow.heading} />
|
||||
|
||||
<QuoteArea>
|
||||
<QuoteStack>
|
||||
{testimonials.map((testimonial, testimonialIndex) => (
|
||||
<QuoteWrapper
|
||||
key={testimonialIndex}
|
||||
data-active={testimonialIndex === index}
|
||||
>
|
||||
<Heading
|
||||
as="h2"
|
||||
className={quoteHeadingClassName}
|
||||
segments={testimonial.heading}
|
||||
size="md"
|
||||
weight="light"
|
||||
/>
|
||||
</QuoteWrapper>
|
||||
))}
|
||||
</QuoteStack>
|
||||
|
||||
{children ? (
|
||||
<QuoteDecoration>
|
||||
<QuoteDecorationVisual>{children}</QuoteDecorationVisual>
|
||||
</QuoteDecoration>
|
||||
) : null}
|
||||
</QuoteArea>
|
||||
|
||||
<FooterRow>
|
||||
<NavGroup>
|
||||
<IconButton
|
||||
ariaLabel="Previous testimonial"
|
||||
borderColor={
|
||||
hasPrevious
|
||||
? theme.colors.secondary.border[20]
|
||||
: theme.colors.secondary.border[10]
|
||||
}
|
||||
icon={ArrowLeftIcon}
|
||||
iconFillColor="transparent"
|
||||
iconSize={14}
|
||||
iconStrokeColor={
|
||||
hasPrevious
|
||||
? theme.colors.secondary.text[80]
|
||||
: theme.colors.secondary.text[20]
|
||||
}
|
||||
size={48}
|
||||
onClick={goToPrevious}
|
||||
/>
|
||||
<IconButton
|
||||
ariaLabel="Next testimonial"
|
||||
borderColor={
|
||||
hasNext
|
||||
? theme.colors.secondary.border[20]
|
||||
: theme.colors.secondary.border[10]
|
||||
}
|
||||
icon={ArrowRightIcon}
|
||||
iconFillColor="transparent"
|
||||
iconSize={14}
|
||||
iconStrokeColor={
|
||||
hasNext
|
||||
? theme.colors.secondary.text[80]
|
||||
: theme.colors.secondary.text[20]
|
||||
}
|
||||
size={48}
|
||||
onClick={goToNext}
|
||||
/>
|
||||
</NavGroup>
|
||||
</FooterRow>
|
||||
</RightColumn>
|
||||
</StyledCarousel>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ type RootProps = {
|
||||
backgroundColor: string;
|
||||
children: ReactNode;
|
||||
color: string;
|
||||
shapeBodyFillColor?: string;
|
||||
shapeFillColor?: string;
|
||||
};
|
||||
|
||||
@@ -36,11 +37,17 @@ export function Root({
|
||||
backgroundColor,
|
||||
children,
|
||||
color,
|
||||
shapeBodyFillColor,
|
||||
shapeFillColor,
|
||||
}: RootProps) {
|
||||
return (
|
||||
<StyledSection style={{ backgroundColor, color }}>
|
||||
{shapeFillColor && <TestimonialsShape fillColor={shapeFillColor} />}
|
||||
{shapeFillColor && (
|
||||
<TestimonialsShape
|
||||
bodyFillColor={shapeBodyFillColor}
|
||||
fillColor={shapeFillColor}
|
||||
/>
|
||||
)}
|
||||
<StyledContainer>{children}</StyledContainer>
|
||||
</StyledSection>
|
||||
);
|
||||
|
||||
+13
-4
@@ -18,8 +18,8 @@ const StyledSeparator = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const SeparatorLine = styled.div`
|
||||
background-color: ${theme.colors.primary.border[20]};
|
||||
const SeparatorLine = styled.div<{ color: string }>`
|
||||
background-color: ${(props) => props.color};
|
||||
height: 1px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
@@ -47,13 +47,22 @@ const SeparatorIcon = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
export function Separator() {
|
||||
type SeparatorProps = {
|
||||
colorScheme?: 'primary' | 'secondary';
|
||||
};
|
||||
|
||||
export function Separator({ colorScheme = 'primary' }: SeparatorProps) {
|
||||
const lineColor =
|
||||
colorScheme === 'secondary'
|
||||
? theme.colors.secondary.border[20]
|
||||
: theme.colors.primary.border[20];
|
||||
|
||||
return (
|
||||
<StyledSeparator>
|
||||
<SeparatorIcon>
|
||||
<PlusIcon size={12} strokeColor={theme.colors.highlight[100]} />
|
||||
</SeparatorIcon>
|
||||
<SeparatorLine />
|
||||
<SeparatorLine color={lineColor} />
|
||||
<SeparatorIcon>
|
||||
<PlusIcon size={12} strokeColor={theme.colors.highlight[100]} />
|
||||
</SeparatorIcon>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Hourglass } from '@/illustrations/Testimonials/Hourglass';
|
||||
import { Partner as PartnerVisual } from '@/illustrations/Testimonials/Partner';
|
||||
import { Carousel } from './Carousel/Carousel';
|
||||
import { PartnerCarousel } from './PartnerCarousel/PartnerCarousel';
|
||||
import { Root } from './Root/Root';
|
||||
import { Separator } from './Separator/Separator';
|
||||
|
||||
export const Testimonials = {
|
||||
Carousel,
|
||||
HomeVisual: Hourglass,
|
||||
PartnerCarousel,
|
||||
PartnerVisual,
|
||||
Root,
|
||||
Separator,
|
||||
|
||||
Reference in New Issue
Block a user