[Website] Fix testimonials shape, diamond direction, and integrate partner application form. (#19835)
Closes the following issues. https://github.com/twentyhq/core-team-issues/issues/2368 https://github.com/twentyhq/core-team-issues/issues/2369 https://github.com/twentyhq/core-team-issues/issues/2374 https://github.com/twentyhq/core-team-issues/issues/2375
This commit is contained in:
@@ -0,0 +1 @@
|
||||
PARTNER_APPLICATION_WEBHOOK_URL=
|
||||
@@ -26,6 +26,7 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
"react-dom": "19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"three": "^0.183.2"
|
||||
"three": "^0.183.2",
|
||||
"zod": "^4.1.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
@@ -210,7 +210,6 @@ export default async function HomePage() {
|
||||
|
||||
<Testimonials.Root
|
||||
backgroundColor={theme.colors.secondary.background[5]}
|
||||
backgroundShapeSrc="/images/home/testimonials/background-shape.webp"
|
||||
color={theme.colors.primary.text[100]}
|
||||
>
|
||||
<Testimonials.Carousel
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { splitFullName } from '@/lib/partner-application/split-full-name';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const partnerApplicationRequestSchema = z.strictObject({
|
||||
email: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { error: 'Email is required.' })
|
||||
.pipe(z.email({ error: 'Invalid email address.' })),
|
||||
name: z.string().trim().min(1, { error: 'Name is required.' }),
|
||||
});
|
||||
|
||||
const webhookUrlSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.pipe(z.httpUrl({ error: 'Invalid webhook URL.' }));
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const webhookUrlResult = webhookUrlSchema.safeParse(
|
||||
process.env.PARTNER_APPLICATION_WEBHOOK_URL,
|
||||
);
|
||||
|
||||
if (!webhookUrlResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Partner application webhook is not configured.' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const webhookUrl = webhookUrlResult.data;
|
||||
|
||||
let raw: unknown;
|
||||
|
||||
try {
|
||||
raw = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const bodyResult = partnerApplicationRequestSchema.safeParse(raw);
|
||||
|
||||
if (!bodyResult.success) {
|
||||
const message =
|
||||
bodyResult.error.issues[0]?.message ?? 'Invalid request body.';
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
|
||||
const { name, email } = bodyResult.data;
|
||||
const { firstName, lastName } = splitFullName(name);
|
||||
|
||||
const webhookPayload = {
|
||||
Email: email,
|
||||
FirstName: firstName,
|
||||
LastName: lastName,
|
||||
};
|
||||
|
||||
try {
|
||||
const upstreamResponse = await fetch(webhookUrl, {
|
||||
body: JSON.stringify(webhookPayload),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!upstreamResponse.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Partner application could not be submitted.' },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Partner application could not be submitted.' },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,12 @@ export const PARTNER_APPLICATION_MODAL_COPY = {
|
||||
messageHint:
|
||||
'Tell us about the custom solutions or integrations you plan to build.',
|
||||
},
|
||||
footnote:
|
||||
"Our partner team typically reviews applications within 48 hours. Once approved, you'll get access to our partner portal and developer resources.",
|
||||
submit: 'Submit application',
|
||||
submitInFlight: 'Submitting…',
|
||||
validation: {
|
||||
incompleteForm: 'Please complete all required fields before submitting.',
|
||||
invalidEmail: 'Enter a valid email address.',
|
||||
submitFailed:
|
||||
'We could not submit your application. Please try again in a moment.',
|
||||
},
|
||||
} as const;
|
||||
|
||||
+147
-66
@@ -7,6 +7,7 @@ import {
|
||||
} from '@/app/partner/_constants/partner-application-modal';
|
||||
import { buttonBaseStyles } from '@/design-system/components/Button/BaseButton';
|
||||
import { ButtonShape } from '@/design-system/components/Button/ButtonShape';
|
||||
import { Body, Heading } from '@/design-system/components';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import { IconChevronDown } from '@tabler/icons-react';
|
||||
@@ -23,10 +24,7 @@ const Overlay = styled.div`
|
||||
display: flex;
|
||||
inset: 0;
|
||||
justify-content: center;
|
||||
padding-bottom: ${theme.spacing(4)};
|
||||
padding-left: ${theme.spacing(4)};
|
||||
padding-right: ${theme.spacing(4)};
|
||||
padding-top: ${theme.spacing(4)};
|
||||
padding: ${theme.spacing(3)};
|
||||
position: fixed;
|
||||
z-index: 300;
|
||||
`;
|
||||
@@ -41,18 +39,14 @@ const Panel = styled.div`
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
overflow-y: auto;
|
||||
padding-bottom: clamp(16px, 4vh, 40px);
|
||||
padding-left: ${theme.spacing(4)};
|
||||
padding-right: ${theme.spacing(4)};
|
||||
padding-top: clamp(12px, 2vh, 16px);
|
||||
padding-block: clamp(12px, 2vh, 24px);
|
||||
padding-inline: ${theme.spacing(3)};
|
||||
position: relative;
|
||||
width: min(360px, 100%);
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
padding-bottom: clamp(16px, 4vh, 40px);
|
||||
padding-left: ${theme.spacing(6)};
|
||||
padding-right: ${theme.spacing(6)};
|
||||
padding-top: clamp(16px, 4vh, 40px);
|
||||
padding-block: clamp(12px, 2.5vh, 28px);
|
||||
padding-inline: ${theme.spacing(4)};
|
||||
width: min(902px, 100%);
|
||||
}
|
||||
`;
|
||||
@@ -63,37 +57,15 @@ const TitleBlock = styled.div`
|
||||
gap: clamp(8px, 2vh, 24px);
|
||||
`;
|
||||
|
||||
const Title = styled.h2`
|
||||
const TitleHeadingWrapper = styled.div`
|
||||
color: ${theme.colors.secondary.text[100]};
|
||||
font-family: ${theme.font.family.serif};
|
||||
font-size: clamp(${theme.font.size(12)}, 8vw, ${theme.font.size(15)});
|
||||
font-weight: ${theme.font.weight.light};
|
||||
line-height: 1.12;
|
||||
margin: 0;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
font-size: clamp(${theme.font.size(15)}, 4vw, ${theme.font.size(20)});
|
||||
line-height: 1.05;
|
||||
}
|
||||
`;
|
||||
|
||||
const TitleAccent = styled.span`
|
||||
display: block;
|
||||
font-family: ${theme.font.family.sans};
|
||||
font-weight: ${theme.font.weight.light};
|
||||
letter-spacing: -0.04em;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.div`
|
||||
const SubtitleStack = styled.div`
|
||||
color: ${theme.colors.secondary.text[60]};
|
||||
font-family: ${theme.font.family.sans};
|
||||
font-size: ${theme.font.size(4)};
|
||||
font-weight: ${theme.font.weight.regular};
|
||||
line-height: ${theme.lineHeight(5.5)};
|
||||
|
||||
& p {
|
||||
margin: 0;
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
`;
|
||||
|
||||
const Segments = styled.div`
|
||||
@@ -107,22 +79,24 @@ const Segments = styled.div`
|
||||
`;
|
||||
|
||||
const SegmentButton = styled.button`
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid ${theme.colors.secondary.border[10]};
|
||||
border-radius: ${theme.radius(2)};
|
||||
box-sizing: border-box;
|
||||
color: ${theme.colors.secondary.text[100]};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
font-family: ${theme.font.family.sans};
|
||||
font-size: ${theme.font.size(3.5)};
|
||||
font-weight: ${theme.font.weight.regular};
|
||||
height: clamp(40px, 5.5vh, 56px);
|
||||
justify-content: center;
|
||||
line-height: ${theme.lineHeight(3.5)};
|
||||
min-width: 0;
|
||||
padding-bottom: ${theme.spacing(1.5)};
|
||||
padding-left: ${theme.spacing(2)};
|
||||
padding-right: ${theme.spacing(2)};
|
||||
padding-top: ${theme.spacing(1.5)};
|
||||
|
||||
&[data-active='true'] {
|
||||
background: ${theme.colors.primary.background[100]};
|
||||
@@ -326,8 +300,8 @@ const TextArea = styled.textarea`
|
||||
}
|
||||
`;
|
||||
|
||||
const Footnote = styled.p`
|
||||
color: ${theme.colors.secondary.text[40]};
|
||||
const SubmitError = styled.p`
|
||||
color: #ff9a9a;
|
||||
font-family: ${theme.font.family.sans};
|
||||
font-size: ${theme.font.size(3)};
|
||||
font-weight: ${theme.font.weight.regular};
|
||||
@@ -339,6 +313,11 @@ const SubmitButton = styled.button`
|
||||
${buttonBaseStyles}
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
}
|
||||
`;
|
||||
|
||||
const SubmitLabel = styled.span`
|
||||
@@ -374,9 +353,12 @@ export function PartnerApplicationModal({
|
||||
}: PartnerApplicationModalProps) {
|
||||
const titleId = useId();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [programId, setProgramId] = useState<PartnerProgramId>('technology');
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleOverlayPointerDown = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
@@ -387,6 +369,19 @@ export function PartnerApplicationModal({
|
||||
[onClose],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSubmitError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
formRef.current?.reset();
|
||||
setProgramId('technology');
|
||||
setDropdownOpen(false);
|
||||
setSubmitError(null);
|
||||
setIsSubmitting(false);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
@@ -427,10 +422,67 @@ export function PartnerApplicationModal({
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(event: React.FormEvent<HTMLFormElement>) => {
|
||||
async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
||||
const nameValue = formData.get('name');
|
||||
const emailValue = formData.get('email');
|
||||
const companyValue = formData.get('company');
|
||||
const websiteValue = formData.get('website');
|
||||
const messageValue = formData.get('message');
|
||||
|
||||
const name = typeof nameValue === 'string' ? nameValue.trim() : '';
|
||||
const email = typeof emailValue === 'string' ? emailValue.trim() : '';
|
||||
const company =
|
||||
typeof companyValue === 'string' ? companyValue.trim() : '';
|
||||
const website =
|
||||
typeof websiteValue === 'string' ? websiteValue.trim() : '';
|
||||
const message =
|
||||
typeof messageValue === 'string' ? messageValue.trim() : '';
|
||||
|
||||
const validationCopy = PARTNER_APPLICATION_MODAL_COPY.validation;
|
||||
const emailLooksValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
|
||||
setSubmitError(null);
|
||||
|
||||
if (!name || !email || !company || !website || !message) {
|
||||
setSubmitError(validationCopy.incompleteForm);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!emailLooksValid) {
|
||||
setSubmitError(validationCopy.invalidEmail);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/partner-application', {
|
||||
body: JSON.stringify({ email, name }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
setSubmitError(validationCopy.submitFailed);
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch {
|
||||
setSubmitError(validationCopy.submitFailed);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[isSubmitting, onClose],
|
||||
);
|
||||
|
||||
if (!open) {
|
||||
@@ -448,17 +500,34 @@ export function PartnerApplicationModal({
|
||||
role="dialog"
|
||||
>
|
||||
<TitleBlock>
|
||||
<Title id={titleId}>
|
||||
{copy.titleSerif}
|
||||
<TitleAccent>{copy.titleSans}</TitleAccent>
|
||||
</Title>
|
||||
<Subtitle>
|
||||
<p>{copy.subtitleLine1}</p>
|
||||
<p>{copy.subtitleLine2}</p>
|
||||
</Subtitle>
|
||||
<TitleHeadingWrapper id={titleId}>
|
||||
<Heading
|
||||
as="h2"
|
||||
segments={[
|
||||
{ fontFamily: 'serif', text: copy.titleSerif, fontWeight: 'light' },
|
||||
{
|
||||
fontFamily: 'sans',
|
||||
text: copy.titleSans,
|
||||
fontWeight: 'light',
|
||||
newLine: true,
|
||||
},
|
||||
]}
|
||||
size="lg"
|
||||
weight="light"
|
||||
/>
|
||||
</TitleHeadingWrapper>
|
||||
<SubtitleStack>
|
||||
<Body body={{ text: copy.subtitleLine1 }} size="md" />
|
||||
<Body body={{ text: copy.subtitleLine2 }} size="md" />
|
||||
</SubtitleStack>
|
||||
</TitleBlock>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<form
|
||||
ref={formRef}
|
||||
autoComplete="off"
|
||||
noValidate
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<FormFields>
|
||||
<Segments role="radiogroup" aria-label={copy.selectLabel}>
|
||||
{PARTNER_PROGRAM_OPTIONS.map((option) => (
|
||||
@@ -524,57 +593,69 @@ export function PartnerApplicationModal({
|
||||
</MobileProgramField>
|
||||
|
||||
<TextInput
|
||||
autoComplete="name"
|
||||
aria-required="true"
|
||||
autoComplete="off"
|
||||
name="name"
|
||||
placeholder={copy.fields.name}
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
|
||||
<FieldRow>
|
||||
<TextInput
|
||||
autoComplete="email"
|
||||
aria-required="true"
|
||||
autoComplete="off"
|
||||
inputMode="email"
|
||||
name="email"
|
||||
placeholder={copy.fields.email}
|
||||
required
|
||||
type="email"
|
||||
type="text"
|
||||
/>
|
||||
<TextInput
|
||||
autoComplete="organization"
|
||||
aria-required="true"
|
||||
autoComplete="off"
|
||||
name="company"
|
||||
placeholder={copy.fields.company}
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
<TextInput
|
||||
aria-required="true"
|
||||
autoComplete="off"
|
||||
name="website"
|
||||
placeholder={copy.fields.website}
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
autoComplete="off"
|
||||
name="opportunities"
|
||||
placeholder={copy.fields.opportunities}
|
||||
type="text"
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
aria-required="true"
|
||||
autoComplete="off"
|
||||
name="message"
|
||||
placeholder={`${copy.fields.messageLabel}\n\n${copy.fields.messageHint}`}
|
||||
required
|
||||
/>
|
||||
|
||||
<FooterBlock>
|
||||
<Footnote>{copy.footnote}</Footnote>
|
||||
<SubmitButton type="submit">
|
||||
{submitError ? (
|
||||
<SubmitError role="alert">{submitError}</SubmitError>
|
||||
) : null}
|
||||
<SubmitButton
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
aria-busy={isSubmitting}
|
||||
>
|
||||
<ButtonShape
|
||||
fillColor={theme.colors.primary.background[100]}
|
||||
strokeColor="none"
|
||||
/>
|
||||
<SubmitLabel>{copy.submit}</SubmitLabel>
|
||||
<SubmitLabel>
|
||||
{isSubmitting ? copy.submitInFlight : copy.submit}
|
||||
</SubmitLabel>
|
||||
</SubmitButton>
|
||||
</FooterBlock>
|
||||
</FormFields>
|
||||
|
||||
@@ -112,8 +112,8 @@ export default async function PartnerPage() {
|
||||
|
||||
<Testimonials.Root
|
||||
backgroundColor={theme.colors.secondary.background[5]}
|
||||
backgroundShapeSrc="/images/partner/testimonials/background-shape.webp"
|
||||
color={theme.colors.secondary.text[100]}
|
||||
shapeFillColor={theme.colors.secondary.background[100]}
|
||||
>
|
||||
<Testimonials.PartnerCarousel
|
||||
eyebrow={TESTIMONIALS_DATA.eyebrow}
|
||||
|
||||
@@ -48,7 +48,7 @@ export const buttonBaseStyles = `
|
||||
|
||||
&[data-variant='outlined'][data-color='primary'] {
|
||||
--button-label-color: ${theme.colors.secondary.text[100]};
|
||||
--button-label-hover-color: ${theme.colors.primary.text[100]};
|
||||
--button-label-hover-color: ${theme.colors.secondary.text[100]};
|
||||
}
|
||||
|
||||
&:is(:hover, :focus-visible) {
|
||||
@@ -73,7 +73,7 @@ const Label = styled.span`
|
||||
color: var(--button-label-color);
|
||||
position: relative;
|
||||
transition: color 220ms ease;
|
||||
z-index: 1;
|
||||
z-index: 2;
|
||||
`;
|
||||
|
||||
export type BaseButtonProps = {
|
||||
@@ -126,6 +126,7 @@ export function BaseButton({
|
||||
case 'outlined.primary':
|
||||
fillColor = 'none';
|
||||
hoverFillColor = theme.colors.primary.background[100];
|
||||
hoverFillOpacity = 0.05;
|
||||
strokeColor = theme.colors.primary.background[100];
|
||||
break;
|
||||
case 'outlined.secondary':
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// @ts-nocheck
|
||||
'use client';
|
||||
|
||||
import { createSiteWebGlRenderer } from '@/lib/webgl';
|
||||
import { useEffect, useRef, type CSSProperties } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
|
||||
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
|
||||
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
import { createSiteWebGlRenderer } from '@/lib/webgl';
|
||||
|
||||
const DRACO_DECODER_PATH =
|
||||
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/';
|
||||
@@ -118,6 +118,7 @@ const DIAMOND_MODEL_URL = '/illustrations/home/three-cards/diamond.glb';
|
||||
const LEGACY_IMPORTED_GEOMETRY_SCALE_TARGET = 2.75;
|
||||
const previewDistance = 4.5;
|
||||
const VIRTUAL_RENDER_HEIGHT = 768;
|
||||
|
||||
const passThroughVertexShader =
|
||||
'\n varying vec2 vUv;\n\n void main() {\n vUv = uv;\n gl_Position = vec4(position, 1.0);\n }\n';
|
||||
const blurFragmentShader =
|
||||
@@ -134,19 +135,23 @@ function getModelOverrides(modelUrl) {
|
||||
}
|
||||
|
||||
return {
|
||||
animation: {
|
||||
autoRotateEnabled: false,
|
||||
},
|
||||
importedGeometry: {
|
||||
useLegacyNormalization: true,
|
||||
// The diamond GLB ships off-axis; baking this Z rotation into the
|
||||
// geometry lets it auto-rotate around the world Y axis like the other
|
||||
// models while still reading as a symmetric diamond on screen.
|
||||
postRotateZ: 1,
|
||||
},
|
||||
initialPose: {
|
||||
...initialPose,
|
||||
autoElapsed: 42.43333333333221,
|
||||
rotateElapsed: 89.98333333332951,
|
||||
rotationX: -8.99639917695435,
|
||||
rotationY: -8.99639917695435,
|
||||
timeElapsed: 851.4676000003166,
|
||||
autoElapsed: 0,
|
||||
rotateElapsed: 0,
|
||||
rotationX: 0,
|
||||
rotationY: 0,
|
||||
rotationZ: 0,
|
||||
targetRotationX: 0,
|
||||
targetRotationY: 0,
|
||||
timeElapsed: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -870,7 +875,7 @@ function createLoadingManager() {
|
||||
}
|
||||
|
||||
function normalizeImportedGeometry(geometry, options = {}) {
|
||||
const { useLegacyNormalization = false } = options;
|
||||
const { useLegacyNormalization = false, postRotateZ = 0 } = options;
|
||||
geometry.computeBoundingBox();
|
||||
|
||||
let boundingBox = geometry.boundingBox;
|
||||
@@ -907,6 +912,10 @@ function normalizeImportedGeometry(geometry, options = {}) {
|
||||
boundingBox?.getCenter(center);
|
||||
geometry.translate(-center.x, -center.y, -center.z);
|
||||
|
||||
if (postRotateZ !== 0) {
|
||||
geometry.rotateZ(postRotateZ);
|
||||
}
|
||||
|
||||
geometry.computeVertexNormals();
|
||||
geometry.computeBoundingBox();
|
||||
geometry.computeBoundingSphere();
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { splitFullName } from '@/lib/partner-application/split-full-name';
|
||||
|
||||
describe('splitFullName', () => {
|
||||
it('splits a standard "first last" name', () => {
|
||||
expect(splitFullName('John Doe')).toEqual({
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty lastName for a single-token name', () => {
|
||||
expect(splitFullName('Madonna')).toEqual({
|
||||
firstName: 'Madonna',
|
||||
lastName: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps middle and additional names in lastName', () => {
|
||||
expect(splitFullName('John Michael Doe')).toEqual({
|
||||
firstName: 'John',
|
||||
lastName: 'Michael Doe',
|
||||
});
|
||||
});
|
||||
|
||||
it('collapses arbitrary whitespace between tokens', () => {
|
||||
expect(splitFullName(' John Doe ')).toEqual({
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats tabs and newlines as separators', () => {
|
||||
expect(splitFullName('John\t\nDoe')).toEqual({
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty fields for an empty or whitespace-only string', () => {
|
||||
expect(splitFullName('')).toEqual({ firstName: '', lastName: '' });
|
||||
expect(splitFullName(' ')).toEqual({ firstName: '', lastName: '' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
export function splitFullName(fullName: string): {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
} {
|
||||
const tokens = fullName.trim().split(/\s+/).filter(Boolean);
|
||||
|
||||
if (tokens.length === 0) {
|
||||
return { firstName: '', lastName: '' };
|
||||
}
|
||||
|
||||
const [firstName, ...rest] = tokens;
|
||||
return { firstName, lastName: rest.join(' ') };
|
||||
}
|
||||
@@ -250,7 +250,12 @@ export function Content({ data }: ContentProps) {
|
||||
|
||||
{hasMoreRows && (
|
||||
<CtaRow>
|
||||
<ToggleButton onClick={() => setExpanded((prev) => !prev)}>
|
||||
<ToggleButton
|
||||
data-color="primary"
|
||||
data-variant="outlined"
|
||||
type="button"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
>
|
||||
<BaseButton
|
||||
color="primary"
|
||||
label={toggleLabel}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
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';
|
||||
|
||||
type TestimonialsShapeProps = {
|
||||
fillColor: string;
|
||||
};
|
||||
|
||||
export function TestimonialsShape({ fillColor }: TestimonialsShapeProps) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<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={{
|
||||
position: 'absolute',
|
||||
top: 19,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: fillColor,
|
||||
borderBottomLeftRadius: 4,
|
||||
borderBottomRightRadius: 4,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Container } from '@/design-system/components';
|
||||
import { TestimonialsShape } from '@/sections/Testimonials/TestimonialsShape';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import NextImage from 'next/image';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
const StyledSection = styled.section`
|
||||
@@ -12,6 +12,7 @@ const StyledSection = styled.section`
|
||||
`;
|
||||
|
||||
const BackgroundShape = styled.div`
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
@@ -38,31 +39,22 @@ const StyledContainer = styled(Container)`
|
||||
|
||||
type RootProps = {
|
||||
backgroundColor: string;
|
||||
backgroundShapeSrc?: string;
|
||||
children: ReactNode;
|
||||
color: string;
|
||||
shapeFillColor?: string;
|
||||
};
|
||||
|
||||
export function Root({
|
||||
backgroundColor,
|
||||
backgroundShapeSrc,
|
||||
children,
|
||||
color,
|
||||
shapeFillColor = theme.colors.primary.background[100],
|
||||
}: RootProps) {
|
||||
return (
|
||||
<StyledSection style={{ backgroundColor, color }}>
|
||||
{backgroundShapeSrc ? (
|
||||
<BackgroundShape>
|
||||
<NextImage
|
||||
alt=""
|
||||
sizes="100vw"
|
||||
src={backgroundShapeSrc}
|
||||
style={{ height: 'auto', width: '100%' }}
|
||||
width={1440}
|
||||
height={842}
|
||||
/>
|
||||
</BackgroundShape>
|
||||
) : null}
|
||||
<BackgroundShape>
|
||||
<TestimonialsShape fillColor={shapeFillColor} />
|
||||
</BackgroundShape>
|
||||
<StyledContainer>{children}</StyledContainer>
|
||||
</StyledSection>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user