Abdullah.
2026-04-19 14:12:59 +05:00
committed by GitHub
parent 066003cb04
commit dbf43d792c
18 changed files with 375 additions and 99 deletions
+1
View File
@@ -0,0 +1 @@
PARTNER_APPLICATION_WEBHOOK_URL=
+1
View File
@@ -26,6 +26,7 @@ yarn-debug.log*
yarn-error.log*
# local env files
.env
.env*.local
# vercel
+2 -1
View File
@@ -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;
@@ -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();
@@ -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>
);
+1
View File
@@ -60946,6 +60946,7 @@ __metadata:
remark-gfm: "npm:^4.0.1"
sharp: "npm:^0.33.5"
three: "npm:^0.183.2"
zod: "npm:^4.1.11"
languageName: unknown
linkType: soft