few website updates (#19663)
## Summary - refresh pricing page content, plan cards, CTA styling, and Salesforce comparison visuals - update partner and hero/testimonial visuals, including pulled carousel-compatible partner testimonial data - improve halftone export and illustration mounting flows, plus related button and hydration fixes - add updated website illustration and pricing assets ## Testing - Not run (not requested) --------- Co-authored-by: Abdullah <125115953+mabdullahabaid@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
e041125426
commit
87f8e5ca19
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
@@ -22,7 +22,12 @@ export function TalkToUsButton({ color, label, variant }: TalkToUsButtonProps) {
|
||||
const { openContactCalModal } = useContactCalModal();
|
||||
|
||||
return (
|
||||
<StyledTrigger type="button" onClick={openContactCalModal}>
|
||||
<StyledTrigger
|
||||
data-color={color}
|
||||
data-variant={variant}
|
||||
type="button"
|
||||
onClick={openContactCalModal}
|
||||
>
|
||||
<BaseButton color={color} label={label} variant={variant} />
|
||||
</StyledTrigger>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactExportSettings } from '@/app/halftone/_lib/exporters';
|
||||
import { IconLayoutSidebarRightCollapse, IconShare } from '@tabler/icons-react';
|
||||
import { styled } from '@linaria/react';
|
||||
import type {
|
||||
@@ -33,6 +34,11 @@ type ControlsPanelProps = {
|
||||
onExportNameChange: (value: string) => void;
|
||||
onExportReact: () => void;
|
||||
onImportPreset: () => void;
|
||||
onReactAssetPublicUrlChange: (value: string) => void;
|
||||
onReactExportSettingChange: (
|
||||
key: keyof ReactExportSettings,
|
||||
value: boolean,
|
||||
) => void;
|
||||
onHalftoneChange: (
|
||||
value: Partial<HalftoneStudioSettings['halftone']>,
|
||||
) => void;
|
||||
@@ -49,10 +55,14 @@ type ControlsPanelProps = {
|
||||
onToggleVisibility: () => void;
|
||||
onUploadSource: () => void;
|
||||
previewDistance: number;
|
||||
reactAssetPublicUrl: string;
|
||||
reactExportSettings: ReactExportSettings;
|
||||
visible: boolean;
|
||||
selectedShape: HalftoneGeometrySpec | undefined;
|
||||
settings: HalftoneStudioSettings;
|
||||
shapeOptions: Array<{ label: string; value: string }>;
|
||||
defaultReactAssetPublicUrl: string;
|
||||
showReactAssetPublicUrl: boolean;
|
||||
};
|
||||
|
||||
const TABS: HalftoneTabId[] = ['design', 'animations', 'export'];
|
||||
@@ -129,6 +139,8 @@ export function ControlsPanel({
|
||||
onExportNameChange,
|
||||
onExportReact,
|
||||
onImportPreset,
|
||||
onReactAssetPublicUrlChange,
|
||||
onReactExportSettingChange,
|
||||
onHalftoneChange,
|
||||
onLightingChange,
|
||||
onMaterialChange,
|
||||
@@ -139,10 +151,14 @@ export function ControlsPanel({
|
||||
onToggleVisibility,
|
||||
onUploadSource,
|
||||
previewDistance,
|
||||
reactAssetPublicUrl,
|
||||
reactExportSettings,
|
||||
visible,
|
||||
selectedShape,
|
||||
settings,
|
||||
shapeOptions,
|
||||
defaultReactAssetPublicUrl,
|
||||
showReactAssetPublicUrl,
|
||||
}: ControlsPanelProps) {
|
||||
return (
|
||||
<PanelShell $collapsed={!visible}>
|
||||
@@ -226,8 +242,14 @@ export function ControlsPanel({
|
||||
onExportNameChange={onExportNameChange}
|
||||
onExportReact={onExportReact}
|
||||
onImportPreset={onImportPreset}
|
||||
onReactAssetPublicUrlChange={onReactAssetPublicUrlChange}
|
||||
onReactExportSettingChange={onReactExportSettingChange}
|
||||
reactAssetPublicUrl={reactAssetPublicUrl}
|
||||
reactExportSettings={reactExportSettings}
|
||||
selectedShape={selectedShape}
|
||||
settings={settings}
|
||||
defaultReactAssetPublicUrl={defaultReactAssetPublicUrl}
|
||||
showReactAssetPublicUrl={showReactAssetPublicUrl}
|
||||
/>
|
||||
) : null}
|
||||
</PanelShell>
|
||||
|
||||
@@ -13,11 +13,13 @@ import {
|
||||
} from '@/app/halftone/_lib/geometry-registry';
|
||||
import { REFERENCE_PREVIEW_DISTANCE } from '@/app/halftone/_lib/footprint';
|
||||
import {
|
||||
DEFAULT_REACT_EXPORT_SETTINGS,
|
||||
deriveExportComponentName,
|
||||
generateReactComponent,
|
||||
generateStandaloneHtml,
|
||||
getExportedModelFile,
|
||||
parseExportedPreset,
|
||||
type ReactExportSettings,
|
||||
} from '@/app/halftone/_lib/exporters';
|
||||
import {
|
||||
DEFAULT_IMAGE_HALFTONE_SETTINGS,
|
||||
@@ -203,6 +205,25 @@ function downloadText(filename: string, content: string) {
|
||||
downloadBlob(filename, new Blob([content], { type: 'text/plain' }));
|
||||
}
|
||||
|
||||
function getFilenameExtension(
|
||||
filename: string | null | undefined,
|
||||
fallbackExtension: string,
|
||||
) {
|
||||
const sanitizedFilename = filename?.split(/[?#]/, 1)[0] ?? '';
|
||||
const match = sanitizedFilename.match(/(\.[^.\\/]+)$/);
|
||||
|
||||
return match?.[1] ?? fallbackExtension;
|
||||
}
|
||||
|
||||
function getAssetFilenameFromUrl(assetUrl: string, fallbackFilename: string) {
|
||||
const sanitizedAssetUrl = assetUrl.split(/[?#]/, 1)[0].replace(/\/+$/, '');
|
||||
const assetFilename = sanitizedAssetUrl.split('/').filter(Boolean).pop();
|
||||
|
||||
return assetFilename && assetFilename.length > 0
|
||||
? assetFilename
|
||||
: fallbackFilename;
|
||||
}
|
||||
|
||||
function createInitialExportPose(): HalftoneExportPose {
|
||||
return {
|
||||
autoElapsed: 0,
|
||||
@@ -291,6 +312,9 @@ export function HalftoneStudio() {
|
||||
);
|
||||
const [exportBackground, setExportBackground] = useState(false);
|
||||
const [exportName, setExportName] = useState('');
|
||||
const [reactExportSettings, setReactExportSettings] =
|
||||
useState<ReactExportSettings>(DEFAULT_REACT_EXPORT_SETTINGS);
|
||||
const [reactAssetPublicUrl, setReactAssetPublicUrl] = useState('');
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imageElement, setImageElement] = useState<HTMLImageElement | null>(
|
||||
null,
|
||||
@@ -362,6 +386,38 @@ export function HalftoneStudio() {
|
||||
() => resolveExportArtifactNames(exportName, defaultExportName),
|
||||
[defaultExportName, exportName],
|
||||
);
|
||||
const reactExportUsesExternalAsset = useMemo(
|
||||
() =>
|
||||
state.settings.sourceMode === 'image' ||
|
||||
selectedShape?.kind === 'imported',
|
||||
[selectedShape?.kind, state.settings.sourceMode],
|
||||
);
|
||||
const defaultReactAssetPublicUrl = useMemo(() => {
|
||||
if (!reactExportUsesExternalAsset) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const assetExtension =
|
||||
state.settings.sourceMode === 'image'
|
||||
? getFilenameExtension(
|
||||
imageFile?.name ?? DEFAULT_IMAGE_FILENAME,
|
||||
'.svg',
|
||||
)
|
||||
: getFilenameExtension(
|
||||
selectedImportedFile?.name ?? selectedShape?.filename,
|
||||
selectedShape?.loader === 'fbx' ? '.fbx' : '.glb',
|
||||
);
|
||||
|
||||
return `/illustrations/generated/${exportArtifactNames.fileBaseName}${assetExtension}`;
|
||||
}, [
|
||||
exportArtifactNames.fileBaseName,
|
||||
imageFile?.name,
|
||||
reactExportUsesExternalAsset,
|
||||
selectedImportedFile?.name,
|
||||
selectedShape?.filename,
|
||||
selectedShape?.loader,
|
||||
state.settings.sourceMode,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
halftoneBySourceModeReference.current[state.settings.sourceMode] = {
|
||||
@@ -831,6 +887,16 @@ export function HalftoneStudio() {
|
||||
exportPoseReference.current = pose;
|
||||
}, []);
|
||||
|
||||
const handleReactExportSettingChange = useCallback(
|
||||
(key: keyof ReactExportSettings, value: boolean) => {
|
||||
setReactExportSettings((currentSettings) => ({
|
||||
...currentSettings,
|
||||
[key]: value,
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleExportReact = useCallback(() => {
|
||||
const componentName = exportArtifactNames.componentName;
|
||||
const kebabName = exportArtifactNames.fileBaseName;
|
||||
@@ -839,28 +905,45 @@ export function HalftoneStudio() {
|
||||
const exportBackgroundColor = exportBackground
|
||||
? state.settings.background.color
|
||||
: 'transparent';
|
||||
const effectiveReactAssetPublicUrl =
|
||||
reactExportSettings.includePublicAssetUrl && reactExportUsesExternalAsset
|
||||
? reactAssetPublicUrl.trim() || defaultReactAssetPublicUrl
|
||||
: undefined;
|
||||
|
||||
const modelFilename = importedFile
|
||||
const defaultModelFilename = importedFile
|
||||
? `${kebabName}${importedFile.name.replace(/^[^.]*/, '')}`
|
||||
: undefined;
|
||||
const modelFilename =
|
||||
effectiveReactAssetPublicUrl && defaultModelFilename
|
||||
? getAssetFilenameFromUrl(
|
||||
effectiveReactAssetPublicUrl,
|
||||
defaultModelFilename,
|
||||
)
|
||||
: defaultModelFilename;
|
||||
|
||||
const imageExportFilename = imageFile
|
||||
const defaultImageExportFilename = imageFile
|
||||
? `${kebabName}${imageFile.name.replace(/^[^.]*/, '')}`
|
||||
: undefined;
|
||||
const imageExportFilename =
|
||||
effectiveReactAssetPublicUrl && defaultImageExportFilename
|
||||
? getAssetFilenameFromUrl(
|
||||
effectiveReactAssetPublicUrl,
|
||||
defaultImageExportFilename,
|
||||
)
|
||||
: defaultImageExportFilename;
|
||||
|
||||
downloadText(
|
||||
`${componentName}.tsx`,
|
||||
generateReactComponent(
|
||||
state.settings,
|
||||
selectedShape,
|
||||
componentName,
|
||||
modelFilename,
|
||||
exportPoseReference.current,
|
||||
generateReactComponent(state.settings, selectedShape, componentName, {
|
||||
assetUrl: effectiveReactAssetPublicUrl,
|
||||
background: exportBackgroundColor,
|
||||
imageFilename: imageExportFilename,
|
||||
importedFile: importedFile ?? undefined,
|
||||
initialPose: exportPoseReference.current,
|
||||
modelFilenameOverride: modelFilename,
|
||||
exportSettings: reactExportSettings,
|
||||
previewDistance,
|
||||
importedFile ?? undefined,
|
||||
imageExportFilename,
|
||||
exportBackgroundColor,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
if (isImageMode && imageFile) {
|
||||
@@ -869,10 +952,14 @@ export function HalftoneStudio() {
|
||||
downloadBlob(modelFilename ?? importedFile.name, importedFile);
|
||||
}
|
||||
}, [
|
||||
defaultReactAssetPublicUrl,
|
||||
exportArtifactNames,
|
||||
exportBackground,
|
||||
imageFile,
|
||||
previewDistance,
|
||||
reactAssetPublicUrl,
|
||||
reactExportSettings,
|
||||
reactExportUsesExternalAsset,
|
||||
selectedImportedFile,
|
||||
selectedShape,
|
||||
state.settings,
|
||||
@@ -928,12 +1015,14 @@ export function HalftoneStudio() {
|
||||
state.settings,
|
||||
selectedShape,
|
||||
componentName,
|
||||
modelFilename,
|
||||
exportPoseReference.current,
|
||||
previewDistance,
|
||||
importedFile ?? undefined,
|
||||
imageExportFilename,
|
||||
exportBackgroundColor,
|
||||
{
|
||||
background: exportBackgroundColor,
|
||||
imageFilename: imageExportFilename,
|
||||
importedFile: importedFile ?? undefined,
|
||||
initialPose: exportPoseReference.current,
|
||||
modelFilenameOverride: modelFilename,
|
||||
previewDistance,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1124,6 +1213,8 @@ export function HalftoneStudio() {
|
||||
}}
|
||||
onExportNameChange={setExportName}
|
||||
onExportReact={handleExportReact}
|
||||
onReactAssetPublicUrlChange={setReactAssetPublicUrl}
|
||||
onReactExportSettingChange={handleReactExportSettingChange}
|
||||
onImportPreset={() => {
|
||||
void handleImportPreset();
|
||||
}}
|
||||
@@ -1150,9 +1241,16 @@ export function HalftoneStudio() {
|
||||
void handleUploadSource();
|
||||
}}
|
||||
previewDistance={previewDistance}
|
||||
reactAssetPublicUrl={reactAssetPublicUrl}
|
||||
reactExportSettings={reactExportSettings}
|
||||
selectedShape={selectedShape}
|
||||
settings={state.settings}
|
||||
shapeOptions={shapeOptions}
|
||||
defaultReactAssetPublicUrl={defaultReactAssetPublicUrl}
|
||||
showReactAssetPublicUrl={
|
||||
reactExportSettings.includePublicAssetUrl &&
|
||||
reactExportUsesExternalAsset
|
||||
}
|
||||
visible={controlsVisible}
|
||||
/>
|
||||
</ControlsPanelFrame>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactExportSettings } from '@/app/halftone/_lib/exporters';
|
||||
import { resolveExportArtifactNames } from '@/app/halftone/_lib/exportNames';
|
||||
import { formatAnimationName } from '@/app/halftone/_lib/formatters';
|
||||
import type {
|
||||
@@ -28,6 +29,48 @@ const RESOLUTION_OPTIONS = [
|
||||
];
|
||||
const DEFAULT_IMAGE_FILE_NAME = 'twenty-logo.svg';
|
||||
const DEFAULT_IMAGE_LABEL = 'Twenty image';
|
||||
const REACT_EXPORT_SETTING_OPTIONS: Array<{
|
||||
description: string;
|
||||
key: keyof ReactExportSettings;
|
||||
label: string;
|
||||
}> = [
|
||||
{
|
||||
key: 'includePublicAssetUrl',
|
||||
label: 'Use public asset URL',
|
||||
description:
|
||||
'Bakes the public asset path into the React export instead of using a relative file path.',
|
||||
},
|
||||
{
|
||||
key: 'includeStyledMount',
|
||||
label: 'Use Linaria wrapper',
|
||||
description:
|
||||
'Wraps the mount node with a StyledVisualMount using @linaria/react and keeps the Twenty-ready mount shape.',
|
||||
},
|
||||
{
|
||||
key: 'includeUseClientDirective',
|
||||
label: "Add 'use client'",
|
||||
description:
|
||||
'Prepends the Next.js client directive so the exported component can be dropped into the Twenty website directly.',
|
||||
},
|
||||
{
|
||||
key: 'includeTsNoCheck',
|
||||
label: 'Add @ts-nocheck',
|
||||
description:
|
||||
'Prepends // @ts-nocheck so generated self-contained files do not need hand-cleaning to satisfy strict TypeScript.',
|
||||
},
|
||||
{
|
||||
key: 'includeNamedAndDefaultExport',
|
||||
label: 'Named + default export',
|
||||
description:
|
||||
'Exports both a named component and a default export to match the current Twenty illustration import pattern.',
|
||||
},
|
||||
{
|
||||
key: 'includeRegistryComment',
|
||||
label: 'Add Twenty header comment',
|
||||
description:
|
||||
'Adds suggested destination and registry wiring comments at the top of the generated file.',
|
||||
},
|
||||
];
|
||||
|
||||
function sectionLabel(label: string, description: string) {
|
||||
return <LabelWithTooltip description={description} label={label} />;
|
||||
@@ -44,8 +87,17 @@ type ExportTabProps = {
|
||||
onExportNameChange: (value: string) => void;
|
||||
onExportReact: () => void;
|
||||
onImportPreset: () => void;
|
||||
onReactAssetPublicUrlChange: (value: string) => void;
|
||||
onReactExportSettingChange: (
|
||||
key: keyof ReactExportSettings,
|
||||
value: boolean,
|
||||
) => void;
|
||||
reactAssetPublicUrl: string;
|
||||
reactExportSettings: ReactExportSettings;
|
||||
selectedShape: HalftoneGeometrySpec | undefined;
|
||||
settings: HalftoneStudioSettings;
|
||||
defaultReactAssetPublicUrl: string;
|
||||
showReactAssetPublicUrl: boolean;
|
||||
};
|
||||
|
||||
export function ExportTab({
|
||||
@@ -59,8 +111,14 @@ export function ExportTab({
|
||||
onExportNameChange,
|
||||
onExportReact,
|
||||
onImportPreset,
|
||||
onReactAssetPublicUrlChange,
|
||||
onReactExportSettingChange,
|
||||
reactAssetPublicUrl,
|
||||
reactExportSettings,
|
||||
selectedShape,
|
||||
settings,
|
||||
defaultReactAssetPublicUrl,
|
||||
showReactAssetPublicUrl,
|
||||
}: ExportTabProps) {
|
||||
const [resolution, setResolution] = useState('1920x1080');
|
||||
const isImageMode = settings.sourceMode === 'image';
|
||||
@@ -170,6 +228,20 @@ export function ExportTab({
|
||||
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
|
||||
{`// Source: ${sourceLabel}`}
|
||||
</div>
|
||||
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
|
||||
{`// Public asset URL: ${reactExportSettings.includePublicAssetUrl ? 'on' : 'off'}`}
|
||||
</div>
|
||||
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
|
||||
{`// Linaria wrapper: ${reactExportSettings.includeStyledMount ? 'on' : 'off'}`}
|
||||
</div>
|
||||
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
|
||||
{`// use client: ${reactExportSettings.includeUseClientDirective ? 'on' : 'off'}`}
|
||||
</div>
|
||||
{showReactAssetPublicUrl ? (
|
||||
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
|
||||
{`// Asset URL: ${reactAssetPublicUrl || defaultReactAssetPublicUrl}`}
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
|
||||
{`// Animation: ${animationLabel}`}
|
||||
</div>
|
||||
@@ -190,6 +262,36 @@ export function ExportTab({
|
||||
</div>
|
||||
</ExportPreview>
|
||||
|
||||
{REACT_EXPORT_SETTING_OPTIONS.map((option) => (
|
||||
<ToggleControl
|
||||
checked={reactExportSettings[option.key]}
|
||||
key={option.key}
|
||||
label={sectionLabel(option.label, option.description)}
|
||||
onChange={(event) =>
|
||||
onReactExportSettingChange(option.key, event.target.checked)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{showReactAssetPublicUrl ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<SectionTitle $preserveCase>
|
||||
{sectionLabel(
|
||||
'Asset public URL',
|
||||
'Used as the baked-in public path for the downloaded image or model asset when "Use public asset URL" is enabled.',
|
||||
)}
|
||||
</SectionTitle>
|
||||
<ExportNameInput
|
||||
onChange={(event) =>
|
||||
onReactAssetPublicUrlChange(event.target.value)
|
||||
}
|
||||
placeholder={defaultReactAssetPublicUrl}
|
||||
type="text"
|
||||
value={reactAssetPublicUrl}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ExportButton onClick={onExportReact} type="button">
|
||||
Download React Component
|
||||
</ExportButton>
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
import {
|
||||
generateReactComponent,
|
||||
parseExportedPreset,
|
||||
generateStandaloneHtml,
|
||||
parseExportedPreset,
|
||||
type ReactExportSettings,
|
||||
} from '@/app/halftone/_lib/exporters';
|
||||
import { resolveExportArtifactNames } from '@/app/halftone/_lib/exportNames';
|
||||
import {
|
||||
DEFAULT_HALFTONE_SETTINGS,
|
||||
normalizeHalftoneStudioSettings,
|
||||
type HalftoneGeometrySpec,
|
||||
} from '@/app/halftone/_lib/state';
|
||||
|
||||
const IMPORTED_GLB_SHAPE: HalftoneGeometrySpec = {
|
||||
key: 'userUpload_connect',
|
||||
label: 'connect.glb',
|
||||
kind: 'imported',
|
||||
loader: 'glb',
|
||||
filename: 'connect.glb',
|
||||
};
|
||||
|
||||
const GENERIC_REACT_EXPORT_SETTINGS: ReactExportSettings = {
|
||||
includeNamedAndDefaultExport: false,
|
||||
includePublicAssetUrl: false,
|
||||
includeRegistryComment: false,
|
||||
includeTsNoCheck: false,
|
||||
includeUseClientDirective: false,
|
||||
includeStyledMount: false,
|
||||
};
|
||||
|
||||
describe('halftone export naming', () => {
|
||||
it('normalizes free-form export names into safe component and file names', () => {
|
||||
expect(resolveExportArtifactNames('hero export 2026')).toEqual({
|
||||
@@ -17,15 +36,17 @@ describe('halftone export naming', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('sanitizes generated React component identifiers', () => {
|
||||
it('sanitizes generated React component identifiers for the default Twenty export', () => {
|
||||
const output = generateReactComponent(
|
||||
DEFAULT_HALFTONE_SETTINGS,
|
||||
undefined,
|
||||
'hero export 2026',
|
||||
);
|
||||
|
||||
expect(output).toContain('// @ts-nocheck');
|
||||
expect(output).toContain('export function HeroExport2026({');
|
||||
expect(output).toContain('export default HeroExport2026;');
|
||||
expect(output).toContain('type HeroExport2026Props = {');
|
||||
expect(output).toContain('export default function HeroExport2026({');
|
||||
expect(output).not.toContain('type hero export 2026Props = {');
|
||||
});
|
||||
|
||||
@@ -56,4 +77,130 @@ describe('halftone export naming', () => {
|
||||
normalizeHalftoneStudioSettings(parsed.settings).halftone.hoverDashColor,
|
||||
).toBe(DEFAULT_HALFTONE_SETTINGS.halftone.hoverDashColor);
|
||||
});
|
||||
|
||||
it('uses the initial pose as the export runtime rotation baseline', async () => {
|
||||
const reactOutput = generateReactComponent(
|
||||
DEFAULT_HALFTONE_SETTINGS,
|
||||
undefined,
|
||||
'rotation baseline export',
|
||||
);
|
||||
const htmlOutput = await generateStandaloneHtml(
|
||||
DEFAULT_HALFTONE_SETTINGS,
|
||||
undefined,
|
||||
'rotation baseline export',
|
||||
);
|
||||
|
||||
expect(reactOutput).toContain('let baseRotationY = initialPose.rotationY;');
|
||||
expect(htmlOutput).toContain('let baseRotationY = initialPose.rotationY;');
|
||||
});
|
||||
});
|
||||
|
||||
describe('halftone react export presets', () => {
|
||||
it('emits a Twenty-ready GLB export with Draco support and no torus fallback', () => {
|
||||
const output = generateReactComponent(
|
||||
DEFAULT_HALFTONE_SETTINGS,
|
||||
IMPORTED_GLB_SHAPE,
|
||||
'PartnerConnect',
|
||||
{
|
||||
assetUrl: '/illustrations/generated/partner-connect.glb',
|
||||
modelFilenameOverride: 'partner-connect.glb',
|
||||
},
|
||||
);
|
||||
|
||||
expect(output).toContain('// @ts-nocheck');
|
||||
expect(output).toContain("'use client';");
|
||||
expect(output).toContain('DRACOLoader');
|
||||
expect(output).toContain('export function PartnerConnect({');
|
||||
expect(output).toContain('export default PartnerConnect;');
|
||||
expect(output).toContain(
|
||||
'modelUrl = "/illustrations/generated/partner-connect.glb"',
|
||||
);
|
||||
expect(output).toContain(
|
||||
'// Suggested public asset destination: public/illustrations/generated/partner-connect.glb',
|
||||
);
|
||||
expect(output).not.toContain("createBuiltinGeometry('torusKnot')");
|
||||
});
|
||||
|
||||
it('emits a Twenty-ready image export without model loaders', () => {
|
||||
const imageSettings = normalizeHalftoneStudioSettings({
|
||||
...DEFAULT_HALFTONE_SETTINGS,
|
||||
sourceMode: 'image',
|
||||
});
|
||||
const output = generateReactComponent(
|
||||
imageSettings,
|
||||
undefined,
|
||||
'PartnerLogo',
|
||||
{
|
||||
assetUrl: '/illustrations/generated/partner-logo.svg',
|
||||
imageFilename: 'partner-logo.svg',
|
||||
},
|
||||
);
|
||||
|
||||
expect(output).toContain(
|
||||
'imageUrl = "/illustrations/generated/partner-logo.svg"',
|
||||
);
|
||||
expect(output).not.toContain('GLTFLoader');
|
||||
expect(output).not.toContain('FBXLoader');
|
||||
expect(output).not.toContain('DRACOLoader');
|
||||
});
|
||||
|
||||
it('keeps pointer cancel wiring and removes window listeners from window in the model runtime', () => {
|
||||
const output = generateReactComponent(
|
||||
DEFAULT_HALFTONE_SETTINGS,
|
||||
IMPORTED_GLB_SHAPE,
|
||||
'PartnerConnect',
|
||||
{
|
||||
assetUrl: '/illustrations/generated/partner-connect.glb',
|
||||
modelFilenameOverride: 'partner-connect.glb',
|
||||
},
|
||||
);
|
||||
|
||||
expect(output).toContain('const handlePointerCancel = () =>');
|
||||
expect(output).toContain(
|
||||
"canvas.addEventListener('pointercancel', handlePointerCancel);",
|
||||
);
|
||||
expect(output).toContain(
|
||||
"window.removeEventListener('pointerup', handlePointerUp);",
|
||||
);
|
||||
expect(output).toContain(
|
||||
"window.removeEventListener('pointermove', handleWindowPointerMove);",
|
||||
);
|
||||
expect(output).not.toContain(
|
||||
"canvas.removeEventListener('pointerup', handlePointerUp);",
|
||||
);
|
||||
});
|
||||
|
||||
it('parses Twenty exports with header comments and named/default exports', () => {
|
||||
const output = generateReactComponent(
|
||||
DEFAULT_HALFTONE_SETTINGS,
|
||||
IMPORTED_GLB_SHAPE,
|
||||
'PartnerConnect',
|
||||
{
|
||||
assetUrl: '/illustrations/generated/partner-connect.glb',
|
||||
modelFilenameOverride: 'partner-connect.glb',
|
||||
},
|
||||
);
|
||||
const parsed = parseExportedPreset(output);
|
||||
|
||||
expect(parsed.componentName).toBe('PartnerConnect');
|
||||
expect(parsed.modelAssetReference).toBe(
|
||||
'/illustrations/generated/partner-connect.glb',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the generic React export available with relative asset paths', () => {
|
||||
const output = generateReactComponent(
|
||||
DEFAULT_HALFTONE_SETTINGS,
|
||||
IMPORTED_GLB_SHAPE,
|
||||
'PartnerConnect',
|
||||
{
|
||||
exportSettings: GENERIC_REACT_EXPORT_SETTINGS,
|
||||
modelFilenameOverride: 'partner-connect.glb',
|
||||
},
|
||||
);
|
||||
|
||||
expect(output).toContain('modelUrl = "./partner-connect.glb"');
|
||||
expect(output).toContain('export default function PartnerConnect({');
|
||||
expect(output).not.toContain("'use client';");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,35 @@ import {
|
||||
} from '@/app/halftone/_lib/state';
|
||||
import { GLASS_ENVIRONMENT_DATA_URL } from '@/app/halftone/_lib/glassEnvironmentData';
|
||||
|
||||
export type ReactExportSettings = {
|
||||
includeNamedAndDefaultExport: boolean;
|
||||
includePublicAssetUrl: boolean;
|
||||
includeRegistryComment: boolean;
|
||||
includeTsNoCheck: boolean;
|
||||
includeUseClientDirective: boolean;
|
||||
includeStyledMount: boolean;
|
||||
};
|
||||
|
||||
export type ReactExportOptions = {
|
||||
assetUrl?: string;
|
||||
background?: string;
|
||||
exportSettings?: Partial<ReactExportSettings>;
|
||||
imageFilename?: string;
|
||||
importedFile?: File;
|
||||
initialPose?: HalftoneExportPose;
|
||||
modelFilenameOverride?: string;
|
||||
previewDistance?: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_REACT_EXPORT_SETTINGS: ReactExportSettings = {
|
||||
includeNamedAndDefaultExport: true,
|
||||
includePublicAssetUrl: true,
|
||||
includeRegistryComment: true,
|
||||
includeTsNoCheck: true,
|
||||
includeUseClientDirective: true,
|
||||
includeStyledMount: true,
|
||||
};
|
||||
|
||||
const passThroughVertexShader = `
|
||||
varying vec2 vUv;
|
||||
|
||||
@@ -1021,7 +1050,7 @@ function createBuiltinGeometry(shapeKey) {
|
||||
}
|
||||
`;
|
||||
|
||||
const IMPORTED_RUNTIME_SOURCE = String.raw`
|
||||
const IMPORTED_RUNTIME_SHARED_SOURCE = String.raw`
|
||||
const EMPTY_TEXTURE_DATA_URL =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII=';
|
||||
|
||||
@@ -1098,6 +1127,9 @@ function extractMergedGeometry(root, emptyMessage) {
|
||||
|
||||
return normalizeImportedGeometry(mergeGeometries(geometries));
|
||||
}
|
||||
`;
|
||||
|
||||
const IMPORTED_FBX_RUNTIME_SOURCE = String.raw`
|
||||
|
||||
function parseFbxGeometry(buffer, label) {
|
||||
const originalWarn = console.warn;
|
||||
@@ -1117,10 +1149,26 @@ function parseFbxGeometry(buffer, label) {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const IMPORTED_GLB_RUNTIME_SOURCE = String.raw`
|
||||
const DRACO_DECODER_PATH =
|
||||
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/';
|
||||
|
||||
function parseGlbGeometry(buffer, label) {
|
||||
return new Promise((resolve, reject) => {
|
||||
new GLTFLoader(createLoadingManager()).parse(
|
||||
const loadingManager = createLoadingManager();
|
||||
const dracoLoader = new DRACOLoader(loadingManager);
|
||||
dracoLoader.setDecoderPath(DRACO_DECODER_PATH);
|
||||
|
||||
const loader = new GLTFLoader(loadingManager);
|
||||
loader.setDRACOLoader(dracoLoader);
|
||||
|
||||
const cleanup = () => {
|
||||
dracoLoader.dispose();
|
||||
};
|
||||
|
||||
loader.parse(
|
||||
buffer,
|
||||
'',
|
||||
(gltf) => {
|
||||
@@ -1133,14 +1181,29 @@ function parseGlbGeometry(buffer, label) {
|
||||
);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
},
|
||||
reject,
|
||||
(error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
`;
|
||||
|
||||
async function loadImportedGeometryFromUrl(loader, modelUrl, label) {
|
||||
function createImportedRuntimeSource(loader: HalftoneGeometrySpec['loader']) {
|
||||
const loaderSource =
|
||||
loader === 'fbx'
|
||||
? IMPORTED_FBX_RUNTIME_SOURCE
|
||||
: IMPORTED_GLB_RUNTIME_SOURCE;
|
||||
|
||||
return `${IMPORTED_RUNTIME_SHARED_SOURCE}
|
||||
${loaderSource}
|
||||
|
||||
async function loadImportedGeometryFromUrl(modelUrl, label) {
|
||||
const response = await fetch(modelUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -1149,13 +1212,14 @@ async function loadImportedGeometryFromUrl(loader, modelUrl, label) {
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
if (loader === 'fbx') {
|
||||
return parseFbxGeometry(buffer, label);
|
||||
${
|
||||
loader === 'fbx'
|
||||
? 'return parseFbxGeometry(buffer, label);'
|
||||
: 'return parseGlbGeometry(buffer, label);'
|
||||
}
|
||||
|
||||
return parseGlbGeometry(buffer, label);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
type ExportedShapeDescriptor = {
|
||||
filename: string | null;
|
||||
@@ -1175,6 +1239,147 @@ export type ParsedExportedPreset = {
|
||||
shape: ExportedShapeDescriptor;
|
||||
};
|
||||
|
||||
function getExportedShapeLoader(
|
||||
shape: ExportedShapeDescriptor,
|
||||
): HalftoneGeometrySpec['loader'] | null {
|
||||
if (shape.loader) {
|
||||
return shape.loader;
|
||||
}
|
||||
|
||||
const filename = shape.filename?.toLowerCase() ?? '';
|
||||
|
||||
if (filename.endsWith('.fbx')) {
|
||||
return 'fbx';
|
||||
}
|
||||
|
||||
if (filename.endsWith('.glb')) {
|
||||
return 'glb';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function createImportedGeometryRuntimeSource(shape: ExportedShapeDescriptor) {
|
||||
if (shape.kind !== 'imported') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const loader = getExportedShapeLoader(shape) ?? 'glb';
|
||||
|
||||
return createImportedRuntimeSource(loader);
|
||||
}
|
||||
|
||||
function toIllustrationRegistryKey(componentName: string) {
|
||||
return componentName.charAt(0).toLowerCase() + componentName.slice(1);
|
||||
}
|
||||
|
||||
function toPublicAssetDestination(assetUrl: string) {
|
||||
return assetUrl.startsWith('/') ? `public${assetUrl}` : assetUrl;
|
||||
}
|
||||
|
||||
function normalizeReactExportSettings(
|
||||
exportSettings?: Partial<ReactExportSettings>,
|
||||
): ReactExportSettings {
|
||||
return {
|
||||
...DEFAULT_REACT_EXPORT_SETTINGS,
|
||||
...exportSettings,
|
||||
};
|
||||
}
|
||||
|
||||
function getReactImportBlock(
|
||||
exportSettings: ReactExportSettings,
|
||||
isImageMode: boolean,
|
||||
shape: ExportedShapeDescriptor,
|
||||
) {
|
||||
const imports = new Set<string>();
|
||||
|
||||
imports.add("import { useEffect, useRef, type CSSProperties } from 'react';");
|
||||
imports.add("import * as THREE from 'three';");
|
||||
|
||||
if (exportSettings.includeStyledMount) {
|
||||
imports.add("import { styled } from '@linaria/react';");
|
||||
}
|
||||
|
||||
if (!isImageMode) {
|
||||
imports.add(
|
||||
"import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';",
|
||||
);
|
||||
}
|
||||
|
||||
const loader = getExportedShapeLoader(shape);
|
||||
|
||||
if (shape.kind === 'imported' && loader === 'fbx') {
|
||||
imports.add(
|
||||
"import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js';",
|
||||
);
|
||||
}
|
||||
|
||||
if (shape.kind === 'imported' && loader === 'glb') {
|
||||
imports.add(
|
||||
"import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';",
|
||||
);
|
||||
imports.add(
|
||||
"import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';",
|
||||
);
|
||||
}
|
||||
|
||||
return Array.from(imports).join('\n');
|
||||
}
|
||||
|
||||
function getStandaloneThreeImports(
|
||||
isImageMode: boolean,
|
||||
shape: ExportedShapeDescriptor,
|
||||
) {
|
||||
if (isImageMode) {
|
||||
return `import * as THREE from 'three';`;
|
||||
}
|
||||
|
||||
const imports = [
|
||||
`import * as THREE from 'three';`,
|
||||
`import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';`,
|
||||
];
|
||||
|
||||
const loader = getExportedShapeLoader(shape);
|
||||
|
||||
if (shape.kind === 'imported' && loader === 'fbx') {
|
||||
imports.push(
|
||||
`import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';`,
|
||||
);
|
||||
}
|
||||
|
||||
if (shape.kind === 'imported' && loader === 'glb') {
|
||||
imports.push(
|
||||
`import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';`,
|
||||
`import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';`,
|
||||
);
|
||||
}
|
||||
|
||||
return imports.join('\n ');
|
||||
}
|
||||
|
||||
function getTwentyReactHeaderComment(
|
||||
componentName: string,
|
||||
registryKey: string,
|
||||
assetUrl?: string,
|
||||
) {
|
||||
const lines = [
|
||||
`// Suggested component destination: src/illustrations/${componentName}.tsx`,
|
||||
];
|
||||
|
||||
if (assetUrl) {
|
||||
lines.push(
|
||||
`// Suggested public asset destination: ${toPublicAssetDestination(assetUrl)}`,
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
`// illustrations-registry.tsx: import { ${componentName} } from './${componentName}';`,
|
||||
`// illustrations-registry.tsx: ${registryKey}: ${componentName},`,
|
||||
);
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
function createDefaultExportPose(): HalftoneExportPose {
|
||||
return {
|
||||
autoElapsed: 0,
|
||||
@@ -1318,6 +1523,8 @@ export function parseExportedPreset(content: string): ParsedExportedPreset {
|
||||
const componentName =
|
||||
extractFirstMatch(content, [
|
||||
/export\s+default\s+function\s+([A-Za-z0-9_]+)/,
|
||||
/export\s+function\s+([A-Za-z0-9_]+)/,
|
||||
/export\s+default\s+([A-Za-z0-9_]+)\s*;/,
|
||||
/<title>([^<]+)<\/title>/i,
|
||||
]) ?? null;
|
||||
const modelAssetReference = extractFirstMatch(content, [
|
||||
@@ -1817,7 +2024,7 @@ ${HALFTONE_FOOTPRINT_RUNTIME_SOURCE}
|
||||
|
||||
${isImageMode ? '' : GEOMETRY_RUNTIME_SOURCE}
|
||||
|
||||
${isImageMode ? '' : IMPORTED_RUNTIME_SOURCE}
|
||||
${isImageMode ? '' : createImportedGeometryRuntimeSource(shape)}
|
||||
|
||||
${isImageMode ? '' : GLASS_MATERIAL_RUNTIME_SOURCE}
|
||||
|
||||
@@ -1891,8 +2098,12 @@ function resetInteractionState(interactionState) {
|
||||
}
|
||||
|
||||
async function createGeometry(modelUrl) {
|
||||
if (shape.kind === 'imported' && shape.loader && modelUrl) {
|
||||
return loadImportedGeometryFromUrl(shape.loader, modelUrl, shape.label);
|
||||
if (shape.kind === 'imported') {
|
||||
if (!modelUrl) {
|
||||
throw new Error('No model URL was provided for ' + shape.label + '.');
|
||||
}
|
||||
|
||||
return loadImportedGeometryFromUrl(modelUrl, shape.label);
|
||||
}
|
||||
|
||||
return createBuiltinGeometry(shape.key);
|
||||
@@ -1926,7 +2137,7 @@ async function mountHalftoneCanvas(options) {
|
||||
geometry = await createGeometry(modelUrl);
|
||||
} catch (error) {
|
||||
onError?.(error);
|
||||
geometry = createBuiltinGeometry('torusKnot');
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
|
||||
@@ -2204,6 +2415,14 @@ async function mountHalftoneCanvas(options) {
|
||||
interaction.velocityY = 0;
|
||||
};
|
||||
|
||||
const handlePointerCancel = () => {
|
||||
interaction.dragging = false;
|
||||
interaction.velocityX = 0;
|
||||
interaction.velocityY = 0;
|
||||
canvas.style.cursor = followDragEnabled ? 'grab' : 'default';
|
||||
handlePointerLeave();
|
||||
};
|
||||
|
||||
const handleWindowBlur = () => {
|
||||
handlePointerUp();
|
||||
handlePointerLeave();
|
||||
@@ -2211,6 +2430,7 @@ async function mountHalftoneCanvas(options) {
|
||||
|
||||
canvas.addEventListener('pointermove', handlePointerMove);
|
||||
canvas.addEventListener('pointerleave', handlePointerLeave);
|
||||
canvas.addEventListener('pointercancel', handlePointerCancel);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
window.addEventListener('pointermove', handleWindowPointerMove);
|
||||
window.addEventListener('blur', handleWindowBlur);
|
||||
@@ -2228,9 +2448,9 @@ async function mountHalftoneCanvas(options) {
|
||||
const elapsedTime = initialPose.timeElapsed + clock.getElapsed();
|
||||
halftoneMaterial.uniforms.time.value = elapsedTime;
|
||||
|
||||
let baseRotationX = 0;
|
||||
let baseRotationY = 0;
|
||||
let baseRotationZ = 0;
|
||||
let baseRotationX = initialPose.rotationX;
|
||||
let baseRotationY = initialPose.rotationY;
|
||||
let baseRotationZ = initialPose.rotationZ;
|
||||
let meshOffsetY = 0;
|
||||
let meshScale = 1;
|
||||
let lightAngle = settings.lighting.angleDegrees;
|
||||
@@ -2476,7 +2696,8 @@ async function mountHalftoneCanvas(options) {
|
||||
resizeObserver.disconnect();
|
||||
canvas.removeEventListener('pointermove', handlePointerMove);
|
||||
canvas.removeEventListener('pointerleave', handlePointerLeave);
|
||||
canvas.removeEventListener('pointerup', handlePointerUp);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
window.removeEventListener('pointermove', handleWindowPointerMove);
|
||||
canvas.removeEventListener('pointercancel', handlePointerCancel);
|
||||
window.removeEventListener('blur', handleWindowBlur);
|
||||
canvas.removeEventListener('pointerdown', handlePointerDown);
|
||||
@@ -2915,65 +3136,87 @@ export function generateReactComponent(
|
||||
settings: HalftoneStudioSettings,
|
||||
selectedShape: HalftoneGeometrySpec | undefined,
|
||||
componentName = 'HalftoneDashes',
|
||||
modelFilenameOverride?: string,
|
||||
initialPose?: HalftoneExportPose,
|
||||
previewDistance?: number,
|
||||
importedFile?: File,
|
||||
imageFilename?: string,
|
||||
background = 'transparent',
|
||||
options: ReactExportOptions = {},
|
||||
) {
|
||||
const isImageMode = settings.sourceMode === 'image';
|
||||
const exportSettings = normalizeReactExportSettings(options.exportSettings);
|
||||
const shape = createShapeDescriptor(
|
||||
selectedShape,
|
||||
settings,
|
||||
importedFile,
|
||||
modelFilenameOverride,
|
||||
options.importedFile,
|
||||
options.modelFilenameOverride,
|
||||
);
|
||||
const pose = normalizeExportPose(initialPose);
|
||||
const pose = normalizeExportPose(options.initialPose);
|
||||
const normalizedComponentName = normalizeExportComponentName(componentName);
|
||||
const defaultModelUrl =
|
||||
modelFilenameOverride ?? shape.filename ?? 'model.glb';
|
||||
const defaultImageUrl = imageFilename ?? 'image.png';
|
||||
if (isImageMode) {
|
||||
return `import { useEffect, useRef, type CSSProperties } from 'react';
|
||||
import * as THREE from 'three';
|
||||
|
||||
${serializeRuntimeSource(settings, shape, pose, previewDistance)}
|
||||
|
||||
${createImageMountScript()}
|
||||
|
||||
type ${normalizedComponentName}Props = {
|
||||
imageUrl?: string;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
export default function ${normalizedComponentName}({
|
||||
imageUrl = ${JSON.stringify(`./${defaultImageUrl}`)},
|
||||
style,
|
||||
}: ${normalizedComponentName}Props) {
|
||||
const mountReference = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = mountReference.current;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unmount = mountHalftoneCanvas({
|
||||
container,
|
||||
imageUrl,
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
void Promise.resolve(unmount).then((dispose) => dispose?.());
|
||||
};
|
||||
}, [imageUrl]);
|
||||
|
||||
return (
|
||||
const background = options.background ?? 'transparent';
|
||||
const assetUrl =
|
||||
exportSettings.includePublicAssetUrl && options.assetUrl
|
||||
? options.assetUrl
|
||||
: null;
|
||||
const defaultModelFilename =
|
||||
options.modelFilenameOverride ?? shape.filename ?? 'model.glb';
|
||||
const defaultImageFilename = options.imageFilename ?? 'image.png';
|
||||
const defaultModelUrl = assetUrl ?? `./${defaultModelFilename}`;
|
||||
const defaultImageUrl = assetUrl ?? `./${defaultImageFilename}`;
|
||||
const importBlock = getReactImportBlock(exportSettings, isImageMode, shape);
|
||||
const headerComment = exportSettings.includeRegistryComment
|
||||
? getTwentyReactHeaderComment(
|
||||
normalizedComponentName,
|
||||
toIllustrationRegistryKey(normalizedComponentName),
|
||||
isImageMode || shape.kind === 'imported'
|
||||
? (assetUrl ?? undefined)
|
||||
: undefined,
|
||||
)
|
||||
: '';
|
||||
const mountScript = isImageMode
|
||||
? createImageMountScript()
|
||||
: createMountScript();
|
||||
const serializedRuntime = serializeRuntimeSource(
|
||||
settings,
|
||||
shape,
|
||||
pose,
|
||||
options.previewDistance,
|
||||
);
|
||||
const directiveLines = [
|
||||
exportSettings.includeTsNoCheck ? '// @ts-nocheck' : null,
|
||||
exportSettings.includeUseClientDirective ? "'use client';" : null,
|
||||
]
|
||||
.filter((line): line is string => line !== null)
|
||||
.join('\n');
|
||||
const mountStyleBlock = exportSettings.includeStyledMount
|
||||
? `const StyledVisualMount = styled.div\`
|
||||
background: ${background};
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
\`;
|
||||
`
|
||||
: '';
|
||||
const assetPropName = isImageMode
|
||||
? 'imageUrl'
|
||||
: shape.kind === 'imported'
|
||||
? 'modelUrl'
|
||||
: null;
|
||||
const assetPropDefaultValue =
|
||||
assetPropName === 'imageUrl'
|
||||
? defaultImageUrl
|
||||
: assetPropName === 'modelUrl'
|
||||
? defaultModelUrl
|
||||
: null;
|
||||
const propsTypeBlock = assetPropName
|
||||
? `type ${normalizedComponentName}Props = {\n ${assetPropName}?: string;\n style?: CSSProperties;\n};`
|
||||
: `type ${normalizedComponentName}Props = {\n style?: CSSProperties;\n};`;
|
||||
const propsSignature = assetPropName
|
||||
? `{\n ${assetPropName} = ${JSON.stringify(assetPropDefaultValue)},\n style,\n}: ${normalizedComponentName}Props`
|
||||
: `{\n style,\n}: ${normalizedComponentName}Props`;
|
||||
const mountOptionsBlock = assetPropName
|
||||
? `const unmount = mountHalftoneCanvas({\n container,\n ${assetPropName},\n onError: (error) => {\n console.error(error);\n },\n });`
|
||||
: `const unmount = mountHalftoneCanvas({\n container,\n onError: (error) => {\n console.error(error);\n },\n });`;
|
||||
const effectDependencies = assetPropName ? `[${assetPropName}]` : '[]';
|
||||
const returnBlock = exportSettings.includeStyledMount
|
||||
? `return <StyledVisualMount aria-hidden ref={mountReference} style={style} />;`
|
||||
: `return (
|
||||
<div
|
||||
ref={mountReference}
|
||||
style={{
|
||||
@@ -2983,30 +3226,9 @@ export default function ${normalizedComponentName}({
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
return `import { useEffect, useRef, type CSSProperties } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
|
||||
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
|
||||
${serializeRuntimeSource(settings, shape, pose, previewDistance)}
|
||||
|
||||
${createMountScript()}
|
||||
|
||||
type ${normalizedComponentName}Props = {
|
||||
modelUrl?: string;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
export default function ${normalizedComponentName}({
|
||||
modelUrl = ${JSON.stringify(`./${defaultModelUrl}`)},
|
||||
style,
|
||||
}: ${normalizedComponentName}Props) {
|
||||
);`;
|
||||
const componentFunctionBlock = exportSettings.includeNamedAndDefaultExport
|
||||
? `export function ${normalizedComponentName}(${propsSignature}) {
|
||||
const mountReference = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -3016,31 +3238,46 @@ export default function ${normalizedComponentName}({
|
||||
return;
|
||||
}
|
||||
|
||||
const unmount = mountHalftoneCanvas({
|
||||
container,
|
||||
modelUrl,
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
${mountOptionsBlock}
|
||||
|
||||
return () => {
|
||||
void Promise.resolve(unmount).then((dispose) => dispose?.());
|
||||
};
|
||||
}, [modelUrl]);
|
||||
}, ${effectDependencies});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={mountReference}
|
||||
style={{
|
||||
background: ${JSON.stringify(background)},
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
${returnBlock}
|
||||
}
|
||||
|
||||
export default ${normalizedComponentName};`
|
||||
: `export default function ${normalizedComponentName}(${propsSignature}) {
|
||||
const mountReference = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = mountReference.current;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
${mountOptionsBlock}
|
||||
|
||||
return () => {
|
||||
void Promise.resolve(unmount).then((dispose) => dispose?.());
|
||||
};
|
||||
}, ${effectDependencies});
|
||||
|
||||
${returnBlock}
|
||||
}`;
|
||||
|
||||
return `${directiveLines ? `${directiveLines}\n\n` : ''}${importBlock}
|
||||
|
||||
${headerComment}${serializedRuntime}
|
||||
|
||||
${mountScript}
|
||||
|
||||
${mountStyleBlock ? `${mountStyleBlock}\n` : ''}${propsTypeBlock}
|
||||
|
||||
${componentFunctionBlock}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -3048,39 +3285,30 @@ export async function generateStandaloneHtml(
|
||||
settings: HalftoneStudioSettings,
|
||||
selectedShape: HalftoneGeometrySpec | undefined,
|
||||
componentName = 'HalftoneDashes',
|
||||
modelFilenameOverride?: string,
|
||||
initialPose?: HalftoneExportPose,
|
||||
previewDistance?: number,
|
||||
importedFile?: File,
|
||||
imageFilename?: string,
|
||||
background = 'transparent',
|
||||
options: ReactExportOptions = {},
|
||||
) {
|
||||
const isImageMode = settings.sourceMode === 'image';
|
||||
const shape = createShapeDescriptor(
|
||||
selectedShape,
|
||||
settings,
|
||||
importedFile,
|
||||
modelFilenameOverride,
|
||||
options.importedFile,
|
||||
options.modelFilenameOverride,
|
||||
);
|
||||
const pose = normalizeExportPose(initialPose);
|
||||
const pose = normalizeExportPose(options.initialPose);
|
||||
const normalizedComponentName = normalizeExportComponentName(componentName);
|
||||
const defaultImageUrl = imageFilename ?? 'image.png';
|
||||
const defaultImageUrl = options.imageFilename ?? 'image.png';
|
||||
const embeddedImportedModelUrl =
|
||||
!isImageMode && shape.kind === 'imported' && importedFile
|
||||
? await fileToDataUrl(importedFile, shape.loader)
|
||||
!isImageMode && shape.kind === 'imported' && options.importedFile
|
||||
? await fileToDataUrl(options.importedFile, shape.loader)
|
||||
: null;
|
||||
const defaultModelUrl =
|
||||
embeddedImportedModelUrl ??
|
||||
modelFilenameOverride ??
|
||||
options.modelFilenameOverride ??
|
||||
shape.filename ??
|
||||
'model.glb';
|
||||
const background = options.background ?? 'transparent';
|
||||
|
||||
const threeImports = isImageMode
|
||||
? `import * as THREE from 'three';`
|
||||
: `import * as THREE from 'three';
|
||||
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
|
||||
import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
|
||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';`;
|
||||
const threeImports = getStandaloneThreeImports(isImageMode, shape);
|
||||
|
||||
const mountScript = isImageMode
|
||||
? createImageMountScript()
|
||||
@@ -3189,7 +3417,7 @@ export async function generateStandaloneHtml(
|
||||
<script type="module">
|
||||
${threeImports}
|
||||
|
||||
${serializeRuntimeSource(settings, shape, pose, previewDistance)}
|
||||
${serializeRuntimeSource(settings, shape, pose, options.previewDistance)}
|
||||
|
||||
${mountScript}
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ export default async function RootLayout({
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${cssVariables} ${hostGrotesk.variable} ${aleo.variable} ${azeretMono.variable} ${vt323.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<ContactCalModalRoot>
|
||||
<StyledMain>{children}</StyledMain>
|
||||
|
||||
+7
-6
@@ -24,12 +24,13 @@ export function BecomePartnerButton({
|
||||
const { openPartnerApplicationModal } = usePartnerApplicationModal();
|
||||
|
||||
return (
|
||||
<StyledTrigger type="button" onClick={openPartnerApplicationModal}>
|
||||
<BaseButton
|
||||
color={color}
|
||||
label="Become a partner"
|
||||
variant={variant}
|
||||
/>
|
||||
<StyledTrigger
|
||||
data-color={color}
|
||||
data-variant={variant}
|
||||
type="button"
|
||||
onClick={openPartnerApplicationModal}
|
||||
>
|
||||
<BaseButton color={color} label="Become a partner" variant={variant} />
|
||||
</StyledTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ import type { EngagementBandDataType } from '@/sections/EngagementBand/types';
|
||||
|
||||
export const ENGAGEMENT_BAND_DATA: EngagementBandDataType = {
|
||||
heading: {
|
||||
text: 'Want a tailor-made plan?',
|
||||
text: 'Need help with customization?',
|
||||
fontFamily: 'serif',
|
||||
},
|
||||
body: {
|
||||
text: 'Help customers implement, customize, and succeed with Twenty. Combine sales and services to grow your business.',
|
||||
text: 'Find the right partner to implement, customize, and tailor Twenty to your team.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { HeroBaseDataType } from '@/sections/Hero/types';
|
||||
|
||||
export const HERO_DATA: HeroBaseDataType = {
|
||||
heading: [
|
||||
{ text: 'A pricing that scales ', fontFamily: 'serif' },
|
||||
{ text: 'with your need', fontFamily: 'sans' },
|
||||
],
|
||||
body: {
|
||||
text: "We're building the #1 open-source CRM, but we can't do it alone. Join our partner ecosystem and grow with us.",
|
||||
},
|
||||
const PRICING_HERO_SUBTAGLINE = {
|
||||
text: 'Start your free trial today without credit card.',
|
||||
};
|
||||
|
||||
export const HERO_DATA = {
|
||||
heading: [
|
||||
{ text: 'Simple', fontFamily: 'serif' },
|
||||
{ text: ' Pricing', fontFamily: 'sans' },
|
||||
],
|
||||
body: PRICING_HERO_SUBTAGLINE,
|
||||
} satisfies HeroBaseDataType;
|
||||
|
||||
@@ -4,37 +4,36 @@ const ORGANIZATION_HEADING = {
|
||||
text: 'Organization',
|
||||
};
|
||||
|
||||
const PRO_FEATURES_TITLE = { text: 'Key Features' };
|
||||
const ORGANIZATION_FEATURES_TITLE = { text: 'Everything in Pro +' };
|
||||
|
||||
const PRO_BULLETS_DEFAULT = [
|
||||
{ text: 'Full customisation' },
|
||||
{ text: 'AI Agents with custom skills' },
|
||||
{ text: '1K automation credits' },
|
||||
{ text: 'Standard support' },
|
||||
{ text: 'Add-on AI (1-month trial)' },
|
||||
];
|
||||
|
||||
const PRO_BULLETS_SELF_HOST_MONTHLY = [
|
||||
{ text: 'Full customisation' },
|
||||
{ text: 'AI Agents with custom skills' },
|
||||
{ text: 'Standard support' },
|
||||
{ text: 'Add-on AI (1-month trial)' },
|
||||
{ text: 'Community support' },
|
||||
];
|
||||
|
||||
const PRO_BULLETS_SELF_HOST_YEARLY = [
|
||||
{ text: 'Full customisation' },
|
||||
{ text: 'AI Agents with custom skills' },
|
||||
{ text: 'Standard support' },
|
||||
{ text: 'Add-on AI (1-month trial)' },
|
||||
{ text: 'Community support' },
|
||||
];
|
||||
|
||||
const ORGANIZATION_BULLETS = [
|
||||
const ORGANIZATION_BULLETS_DEFAULT = [
|
||||
{ text: 'Everything in Pro' },
|
||||
{ text: 'Roles & Permissions' },
|
||||
{ text: 'SAML/OIDC SSO' },
|
||||
{ text: '2K automation credits' },
|
||||
{ text: 'Priority support' },
|
||||
{ text: 'Add-on AI full time' },
|
||||
];
|
||||
|
||||
const ORGANIZATION_BULLETS_SELF_HOST = [
|
||||
{ text: 'Everything in Pro' },
|
||||
{ text: 'Roles & Permissions' },
|
||||
{ text: 'SAML/OIDC SSO' },
|
||||
{ text: 'Priority support' },
|
||||
];
|
||||
|
||||
import type { PlansDataType } from '@/sections/Plans/types';
|
||||
@@ -44,40 +43,42 @@ export const PLANS_DATA = {
|
||||
cells: {
|
||||
cloud: {
|
||||
monthly: {
|
||||
featureBullets: ORGANIZATION_BULLETS,
|
||||
featureBullets: ORGANIZATION_BULLETS_DEFAULT,
|
||||
price: {
|
||||
body: { text: '/month, paid monthly' },
|
||||
heading: { fontFamily: 'sans', text: '$25 USD' },
|
||||
body: { text: '/user/month' },
|
||||
heading: { fontFamily: 'sans', text: '$25' },
|
||||
},
|
||||
},
|
||||
yearly: {
|
||||
featureBullets: ORGANIZATION_BULLETS,
|
||||
featureBullets: ORGANIZATION_BULLETS_DEFAULT,
|
||||
price: {
|
||||
body: { text: '/month, paid yearly' },
|
||||
heading: { fontFamily: 'sans', text: '$19 USD' },
|
||||
body: { text: '/user/month' },
|
||||
heading: { fontFamily: 'sans', text: '$19' },
|
||||
},
|
||||
},
|
||||
},
|
||||
selfHost: {
|
||||
monthly: {
|
||||
featureBullets: ORGANIZATION_BULLETS,
|
||||
featureBullets: ORGANIZATION_BULLETS_SELF_HOST,
|
||||
price: {
|
||||
body: { text: '/month, paid monthly' },
|
||||
heading: { fontFamily: 'sans', text: '$25 USD' },
|
||||
body: { text: '/user/month' },
|
||||
heading: { fontFamily: 'sans', text: '$25' },
|
||||
},
|
||||
},
|
||||
yearly: {
|
||||
featureBullets: ORGANIZATION_BULLETS,
|
||||
featureBullets: ORGANIZATION_BULLETS_SELF_HOST,
|
||||
price: {
|
||||
body: { text: '/month, paid yearly' },
|
||||
heading: { fontFamily: 'sans', text: '$19 USD' },
|
||||
body: { text: '/user/month' },
|
||||
heading: { fontFamily: 'sans', text: '$19' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
featuresTitle: ORGANIZATION_FEATURES_TITLE,
|
||||
heading: ORGANIZATION_HEADING,
|
||||
illustration: 'planOrganization',
|
||||
icon: {
|
||||
alt: 'Organization plan icon',
|
||||
src: '/images/pricing/plans/organization-icon.png',
|
||||
},
|
||||
},
|
||||
pro: {
|
||||
cells: {
|
||||
@@ -85,15 +86,15 @@ export const PLANS_DATA = {
|
||||
monthly: {
|
||||
featureBullets: PRO_BULLETS_DEFAULT,
|
||||
price: {
|
||||
body: { text: '/month, paid monthly' },
|
||||
heading: { fontFamily: 'sans', text: '$12 USD' },
|
||||
body: { text: '/user/month' },
|
||||
heading: { fontFamily: 'sans', text: '$12' },
|
||||
},
|
||||
},
|
||||
yearly: {
|
||||
featureBullets: PRO_BULLETS_DEFAULT,
|
||||
price: {
|
||||
body: { text: '/month, paid yearly' },
|
||||
heading: { fontFamily: 'sans', text: '$9 USD' },
|
||||
body: { text: '/user/month' },
|
||||
heading: { fontFamily: 'sans', text: '$9' },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -101,21 +102,24 @@ export const PLANS_DATA = {
|
||||
monthly: {
|
||||
featureBullets: PRO_BULLETS_SELF_HOST_MONTHLY,
|
||||
price: {
|
||||
body: { text: '/month, paid monthly' },
|
||||
heading: { fontFamily: 'sans', text: '$0 USD' },
|
||||
body: { text: '/user/month' },
|
||||
heading: { fontFamily: 'sans', text: '$0' },
|
||||
},
|
||||
},
|
||||
yearly: {
|
||||
featureBullets: PRO_BULLETS_SELF_HOST_YEARLY,
|
||||
price: {
|
||||
body: { text: '/month, paid yearly' },
|
||||
heading: { fontFamily: 'sans', text: '$0 USD' },
|
||||
body: { text: '/user/month' },
|
||||
heading: { fontFamily: 'sans', text: '$0' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
featuresTitle: PRO_FEATURES_TITLE,
|
||||
heading: PRO_HEADING,
|
||||
illustration: 'planPro',
|
||||
icon: {
|
||||
alt: 'Pro plan icon',
|
||||
src: '/images/pricing/plans/pro-icon.png',
|
||||
width: 60,
|
||||
},
|
||||
},
|
||||
} satisfies PlansDataType;
|
||||
|
||||
@@ -148,7 +148,7 @@ export const SALESFORCE_DATA: SalesforceDataType = {
|
||||
rightLabel: 'Out of stock',
|
||||
tooltip: {
|
||||
title: 'Out of stock',
|
||||
body: "Your data prefers someone else's servers.",
|
||||
body: 'Self-hosting, now for rent!',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -219,7 +219,7 @@ export const SALESFORCE_DATA: SalesforceDataType = {
|
||||
},
|
||||
],
|
||||
basePriceAmount: 100,
|
||||
promoTag: 'Best for\nSalesforce',
|
||||
promoTag: '1‑800‑YES‑SOFTWARE',
|
||||
featureSectionHeading: 'Add-ons',
|
||||
productIconAlt: 'Retro help document icon',
|
||||
productIconSrc: '/images/pricing/salesforce/help-icon.webp',
|
||||
|
||||
@@ -18,8 +18,21 @@ import { Plans } from '@/sections/Plans/components';
|
||||
import { PlanTable } from '@/sections/PlanTable/components';
|
||||
import { Salesforce } from '@/sections/Salesforce/components';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
const PricingPlansContainer = styled.div`
|
||||
display: grid;
|
||||
margin: 0 auto;
|
||||
row-gap: ${theme.spacing(8)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const PricingBannerContainer = styled.div`
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Pricing — Twenty',
|
||||
description:
|
||||
@@ -50,28 +63,35 @@ export default async function PricingPage() {
|
||||
</Hero.Root>
|
||||
|
||||
<Plans.Root backgroundColor={theme.colors.secondary.background[5]}>
|
||||
<Plans.Content />
|
||||
<PricingPlansContainer>
|
||||
<Plans.Content />
|
||||
</PricingPlansContainer>
|
||||
</Plans.Root>
|
||||
|
||||
<EngagementBand.Root
|
||||
backgroundColor={theme.colors.secondary.background[5]}
|
||||
>
|
||||
<EngagementBand.Strip
|
||||
fillColor={theme.colors.primary.background[100]}
|
||||
variant="primary"
|
||||
>
|
||||
<EngagementBand.Copy>
|
||||
<EngagementBand.Heading segments={ENGAGEMENT_BAND_DATA.heading} />
|
||||
<EngagementBand.Body body={ENGAGEMENT_BAND_DATA.body} />
|
||||
</EngagementBand.Copy>
|
||||
<EngagementBand.Actions>
|
||||
<TalkToUsButton
|
||||
color="secondary"
|
||||
label="Contact us"
|
||||
variant="contained"
|
||||
/>
|
||||
</EngagementBand.Actions>
|
||||
</EngagementBand.Strip>
|
||||
<PricingBannerContainer>
|
||||
<EngagementBand.Strip
|
||||
desktopCopyMaxWidth="60%"
|
||||
fillColor={theme.colors.primary.background[100]}
|
||||
variant="primary"
|
||||
>
|
||||
<EngagementBand.Copy>
|
||||
<EngagementBand.Heading segments={ENGAGEMENT_BAND_DATA.heading} />
|
||||
<EngagementBand.Body body={ENGAGEMENT_BAND_DATA.body} />
|
||||
</EngagementBand.Copy>
|
||||
<EngagementBand.Actions>
|
||||
<LinkButton
|
||||
color="secondary"
|
||||
href="https://app.twenty.com/welcome"
|
||||
label="Find a partner"
|
||||
type="anchor"
|
||||
variant="outlined"
|
||||
/>
|
||||
</EngagementBand.Actions>
|
||||
</EngagementBand.Strip>
|
||||
</PricingBannerContainer>
|
||||
</EngagementBand.Root>
|
||||
|
||||
<PlanTable.Root backgroundColor={theme.colors.secondary.background[100]}>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
type VisibleWhenTabActiveProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function getIsTabActive() {
|
||||
if (typeof document === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return document.visibilityState !== 'hidden';
|
||||
}
|
||||
|
||||
export function VisibleWhenTabActive({
|
||||
children,
|
||||
}: VisibleWhenTabActiveProps) {
|
||||
const [isTabActive, setIsTabActive] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const syncTabVisibility = () => {
|
||||
setIsTabActive(getIsTabActive());
|
||||
};
|
||||
|
||||
syncTabVisibility();
|
||||
document.addEventListener('visibilitychange', syncTabVisibility);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', syncTabVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isTabActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import { styled } from '@linaria/react';
|
||||
import { ButtonShape } from './ButtonShape';
|
||||
|
||||
export const buttonBaseStyles = `
|
||||
--button-label-color: ${theme.colors.primary.text[100]};
|
||||
--button-label-hover-color: ${theme.colors.secondary.text[100]};
|
||||
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -15,11 +18,39 @@ export const buttonBaseStyles = `
|
||||
height: ${theme.spacing(10)};
|
||||
justify-content: center;
|
||||
letter-spacing: 0;
|
||||
overflow: hidden;
|
||||
padding: 0 ${theme.spacing(5)};
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
|
||||
&[data-variant='contained'][data-color='secondary'] {
|
||||
--button-label-color: ${theme.colors.secondary.text[100]};
|
||||
--button-label-hover-color: ${theme.colors.secondary.text[100]};
|
||||
}
|
||||
|
||||
&[data-variant='outlined'][data-color='secondary'] {
|
||||
--button-label-color: ${theme.colors.primary.text[100]};
|
||||
--button-label-hover-color: ${theme.colors.primary.text[100]};
|
||||
}
|
||||
|
||||
&[data-variant='outlined'][data-color='primary'] {
|
||||
--button-label-color: ${theme.colors.secondary.text[100]};
|
||||
--button-label-hover-color: ${theme.colors.primary.text[100]};
|
||||
}
|
||||
|
||||
&:is(:hover, :focus-visible) {
|
||||
--button-label-color: var(--button-label-hover-color);
|
||||
}
|
||||
|
||||
&:is(:hover, :focus-visible) [data-slot='button-hover-fill'] {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
&[data-variant='outlined'] [data-slot='button-base-shape'] {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid ${theme.colors.highlight[100]};
|
||||
outline-offset: 1px;
|
||||
@@ -27,16 +58,10 @@ export const buttonBaseStyles = `
|
||||
`;
|
||||
|
||||
const Label = styled.span`
|
||||
color: var(--button-label-color);
|
||||
position: relative;
|
||||
transition: color 220ms ease;
|
||||
z-index: 1;
|
||||
|
||||
&[data-color='primary'] {
|
||||
color: ${theme.colors.primary.text[100]};
|
||||
}
|
||||
|
||||
&[data-color='secondary'] {
|
||||
color: ${theme.colors.secondary.text[100]};
|
||||
}
|
||||
`;
|
||||
|
||||
export type BaseButtonProps = {
|
||||
@@ -45,31 +70,51 @@ export type BaseButtonProps = {
|
||||
variant: 'contained' | 'outlined';
|
||||
};
|
||||
|
||||
const secondaryContainedHoverFillColor = theme.colors.secondary.background.hover;
|
||||
const secondaryOutlinedHoverFillColor = theme.colors.primary.text[100];
|
||||
const secondaryOutlinedHoverFillOpacity = 0.05;
|
||||
|
||||
const HoverFill = styled.span`
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
transform: translateX(calc(-100% - ${theme.spacing(4)}));
|
||||
transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
z-index: 0;
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export function BaseButton({ color, label, variant }: BaseButtonProps) {
|
||||
let fillColor: string;
|
||||
let hoverFillColor: string;
|
||||
let hoverFillOpacity = 1;
|
||||
let strokeColor: string;
|
||||
let labelColor: 'primary' | 'secondary';
|
||||
|
||||
switch (`${variant}.${color}`) {
|
||||
case 'contained.primary':
|
||||
fillColor = theme.colors.primary.background[100];
|
||||
hoverFillColor = theme.colors.primary.background.hover;
|
||||
strokeColor = 'none';
|
||||
labelColor = 'primary';
|
||||
break;
|
||||
case 'contained.secondary':
|
||||
fillColor = theme.colors.secondary.background[100];
|
||||
hoverFillColor = secondaryContainedHoverFillColor;
|
||||
strokeColor = 'none';
|
||||
labelColor = 'secondary';
|
||||
break;
|
||||
case 'outlined.primary':
|
||||
fillColor = 'none';
|
||||
hoverFillColor = theme.colors.primary.background[100];
|
||||
strokeColor = theme.colors.primary.background[100];
|
||||
labelColor = 'secondary';
|
||||
break;
|
||||
case 'outlined.secondary':
|
||||
fillColor = 'none';
|
||||
hoverFillColor = secondaryOutlinedHoverFillColor;
|
||||
hoverFillOpacity = secondaryOutlinedHoverFillOpacity;
|
||||
strokeColor = theme.colors.secondary.background[100];
|
||||
labelColor = 'primary';
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unhandled button appearance: ${variant} ${color}`);
|
||||
@@ -77,8 +122,15 @@ export function BaseButton({ color, label, variant }: BaseButtonProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ButtonShape fillColor={fillColor} strokeColor={strokeColor} />
|
||||
<Label data-color={labelColor}>{label}</Label>
|
||||
<ButtonShape
|
||||
dataSlot="button-base-shape"
|
||||
fillColor={fillColor}
|
||||
strokeColor={strokeColor}
|
||||
/>
|
||||
<HoverFill data-slot="button-hover-fill" style={{ opacity: hoverFillOpacity }}>
|
||||
<ButtonShape fillColor={hoverFillColor} strokeColor="none" />
|
||||
</HoverFill>
|
||||
<Label data-slot="button-label">{label}</Label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { styled } from "@linaria/react";
|
||||
|
||||
interface ButtonShapeProps {
|
||||
dataSlot?: string;
|
||||
fillColor: string;
|
||||
strokeColor: string;
|
||||
}
|
||||
@@ -38,14 +39,18 @@ const RightCap = styled.svg`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export function ButtonShape({ fillColor, strokeColor }: ButtonShapeProps) {
|
||||
export function ButtonShape({
|
||||
dataSlot,
|
||||
fillColor,
|
||||
strokeColor,
|
||||
}: ButtonShapeProps) {
|
||||
const isOutline = fillColor === "none";
|
||||
|
||||
return (
|
||||
<ShapeContainer>
|
||||
<ShapeContainer data-slot={dataSlot}>
|
||||
<LeftCap width="4" height="40" viewBox="0 0 4 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{isOutline ? (
|
||||
<path d={LEFT_OUTLINE} fill={fillColor} stroke={strokeColor} strokeWidth="1" />
|
||||
<path d={LEFT_OUTLINE} fill={fillColor} stroke={strokeColor} strokeWidth="1" strokeLinejoin="round" strokeLinecap="round" />
|
||||
) : (
|
||||
<path d={LEFT_FILL} fill={fillColor} stroke={strokeColor} />
|
||||
)}
|
||||
|
||||
@@ -32,6 +32,8 @@ export function LinkButton({
|
||||
if (type === 'anchor') {
|
||||
return (
|
||||
<StyledButtonAnchor
|
||||
data-color={color}
|
||||
data-variant={variant}
|
||||
href={href}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
@@ -42,7 +44,7 @@ export function LinkButton({
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledButtonLink href={href}>
|
||||
<StyledButtonLink data-color={color} data-variant={variant} href={href}>
|
||||
{inner}
|
||||
</StyledButtonLink>
|
||||
);
|
||||
|
||||
@@ -20,7 +20,12 @@ export function SubmitButton({
|
||||
variant,
|
||||
}: SubmitButtonProps) {
|
||||
return (
|
||||
<StyledSubmitButton type="submit" onClick={onClick}>
|
||||
<StyledSubmitButton
|
||||
data-color={color}
|
||||
data-variant={variant}
|
||||
type="submit"
|
||||
onClick={onClick}
|
||||
>
|
||||
<BaseButton color={color} label={label} variant={variant} />
|
||||
</StyledSubmitButton>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
type CheckIconProps = { size: number; color: string };
|
||||
type CheckIconProps = { size: number; color: string; strokeWidth?: number };
|
||||
|
||||
export function CheckIcon({ size, color }: CheckIconProps) {
|
||||
export function CheckIcon({
|
||||
size,
|
||||
color,
|
||||
strokeWidth = 2,
|
||||
}: CheckIconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
@@ -13,12 +17,12 @@ export function CheckIcon({ size, color }: CheckIconProps) {
|
||||
<path
|
||||
d="m11 6-3.75 4L5 8"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
<path
|
||||
d="M8 14a6 6 0 1 0-4.243-1.757"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -1,355 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } 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/pricing/Price/organization.glb';
|
||||
|
||||
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,
|
||||
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;
|
||||
};
|
||||
|
||||
function applyScanlineMaterials(
|
||||
modelRoot: THREE.Object3D,
|
||||
lightDirection: THREE.Vector3,
|
||||
) {
|
||||
modelRoot.traverse((sceneObject) => {
|
||||
if (!(sceneObject instanceof THREE.Mesh)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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.planOrganizationMeshRest = rest;
|
||||
});
|
||||
}
|
||||
|
||||
export function Organization() {
|
||||
const mountReference = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = mountReference.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let animationFrameId = 0;
|
||||
|
||||
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 readSize = () => {
|
||||
const nextWidth = container.clientWidth;
|
||||
const nextHeight = container.clientHeight;
|
||||
return {
|
||||
width: Math.max(nextWidth, 1),
|
||||
height: Math.max(nextHeight, 1),
|
||||
};
|
||||
};
|
||||
|
||||
const { width, height } = readSize();
|
||||
|
||||
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 = 'default';
|
||||
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) => {
|
||||
if (cancelled) {
|
||||
disposeObjectSubtree(gltf.scene);
|
||||
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.35 / 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 influence = pointer.inside ? 1 : 0.22;
|
||||
targetRotation.y = pointer.x * 0.52 * influence;
|
||||
targetRotation.x = pointer.y * 0.42 * influence;
|
||||
|
||||
pivot.rotation.y = THREE.MathUtils.damp(
|
||||
pivot.rotation.y,
|
||||
targetRotation.y,
|
||||
8.2,
|
||||
delta,
|
||||
);
|
||||
pivot.rotation.x = THREE.MathUtils.damp(
|
||||
pivot.rotation.x,
|
||||
targetRotation.x,
|
||||
8.2,
|
||||
delta,
|
||||
);
|
||||
|
||||
const hoverLift = pointer.inside ? 1 : 0;
|
||||
pivot.scale.setScalar(
|
||||
THREE.MathUtils.damp(pivot.scale.x, 1 + hoverLift * 0.06, 7, delta),
|
||||
);
|
||||
|
||||
const mx = pointer.x * (pointer.inside ? 1 : 0.22);
|
||||
const my = pointer.y * (pointer.inside ? 1 : 0.22);
|
||||
|
||||
modelRoot.traverse((sceneObject) => {
|
||||
if (!(sceneObject instanceof THREE.Mesh)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rest = sceneObject.userData.planOrganizationMeshRest as
|
||||
| MeshRestPose
|
||||
| undefined;
|
||||
if (!rest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const phase = rest.wobblePhase;
|
||||
const wobble = pointer.inside ? 0.55 : 0.22;
|
||||
sceneObject.position.x =
|
||||
rest.position.x + mx * 0.12 * Math.sin(phase * 1.8);
|
||||
sceneObject.position.z =
|
||||
rest.position.z + my * 0.1 * Math.cos(phase * 1.4);
|
||||
sceneObject.position.y =
|
||||
rest.position.y + (mx + my) * 0.03 * Math.sin(phase * 2.5);
|
||||
|
||||
const twist = (mx * 0.18 + my * 0.14) * wobble * Math.sin(phase);
|
||||
sceneObject.quaternion.copy(rest.quaternion);
|
||||
sceneObject.rotateY(twist);
|
||||
});
|
||||
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
|
||||
renderFrame();
|
||||
},
|
||||
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 syncCanvasToContainer = () => {
|
||||
if (!mountReference.current || cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextWidth = mountReference.current.clientWidth;
|
||||
const nextHeight = mountReference.current.clientHeight;
|
||||
if (nextWidth < 1 || nextHeight < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
camera.aspect = nextWidth / nextHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(nextWidth, nextHeight);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
syncCanvasToContainer();
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
const handleWindowResize = () => {
|
||||
syncCanvasToContainer();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener('resize', handleWindowResize);
|
||||
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);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
ref={mountReference}
|
||||
style={{
|
||||
display: 'block',
|
||||
height: '100%',
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } 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/pricing/Price/pro.glb';
|
||||
|
||||
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,
|
||||
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;
|
||||
};
|
||||
|
||||
function applyScanlineMaterials(
|
||||
modelRoot: THREE.Object3D,
|
||||
lightDirection: THREE.Vector3,
|
||||
) {
|
||||
modelRoot.traverse((sceneObject) => {
|
||||
if (!(sceneObject instanceof THREE.Mesh)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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.planProMeshRest = rest;
|
||||
});
|
||||
}
|
||||
|
||||
export function Pro() {
|
||||
const mountReference = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = mountReference.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let animationFrameId = 0;
|
||||
|
||||
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 readSize = () => {
|
||||
const nextWidth = container.clientWidth;
|
||||
const nextHeight = container.clientHeight;
|
||||
return {
|
||||
width: Math.max(nextWidth, 1),
|
||||
height: Math.max(nextHeight, 1),
|
||||
};
|
||||
};
|
||||
|
||||
const { width, height } = readSize();
|
||||
|
||||
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 = 'default';
|
||||
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) => {
|
||||
if (cancelled) {
|
||||
disposeObjectSubtree(gltf.scene);
|
||||
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.35 / 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 influence = pointer.inside ? 1 : 0.22;
|
||||
targetRotation.y = pointer.x * 0.52 * influence;
|
||||
targetRotation.x = pointer.y * 0.42 * influence;
|
||||
|
||||
pivot.rotation.y = THREE.MathUtils.damp(
|
||||
pivot.rotation.y,
|
||||
targetRotation.y,
|
||||
8.2,
|
||||
delta,
|
||||
);
|
||||
pivot.rotation.x = THREE.MathUtils.damp(
|
||||
pivot.rotation.x,
|
||||
targetRotation.x,
|
||||
8.2,
|
||||
delta,
|
||||
);
|
||||
|
||||
const hoverLift = pointer.inside ? 1 : 0;
|
||||
pivot.scale.setScalar(
|
||||
THREE.MathUtils.damp(pivot.scale.x, 1 + hoverLift * 0.06, 7, delta),
|
||||
);
|
||||
|
||||
const mx = pointer.x * (pointer.inside ? 1 : 0.22);
|
||||
const my = pointer.y * (pointer.inside ? 1 : 0.22);
|
||||
|
||||
modelRoot.traverse((sceneObject) => {
|
||||
if (!(sceneObject instanceof THREE.Mesh)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rest = sceneObject.userData.planProMeshRest as
|
||||
| MeshRestPose
|
||||
| undefined;
|
||||
if (!rest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const phase = rest.wobblePhase;
|
||||
const wobble = pointer.inside ? 0.55 : 0.22;
|
||||
sceneObject.position.x =
|
||||
rest.position.x + mx * 0.12 * Math.sin(phase * 1.8);
|
||||
sceneObject.position.z =
|
||||
rest.position.z + my * 0.1 * Math.cos(phase * 1.4);
|
||||
sceneObject.position.y =
|
||||
rest.position.y + (mx + my) * 0.03 * Math.sin(phase * 2.5);
|
||||
|
||||
const twist = (mx * 0.18 + my * 0.14) * wobble * Math.sin(phase);
|
||||
sceneObject.quaternion.copy(rest.quaternion);
|
||||
sceneObject.rotateY(twist);
|
||||
});
|
||||
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
|
||||
renderFrame();
|
||||
},
|
||||
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 syncCanvasToContainer = () => {
|
||||
if (!mountReference.current || cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextWidth = mountReference.current.clientWidth;
|
||||
const nextHeight = mountReference.current.clientHeight;
|
||||
if (nextWidth < 1 || nextHeight < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
camera.aspect = nextWidth / nextHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(nextWidth, nextHeight);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
syncCanvasToContainer();
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
const handleWindowResize = () => {
|
||||
syncCanvasToContainer();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener('resize', handleWindowResize);
|
||||
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);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
ref={mountReference}
|
||||
style={{
|
||||
display: 'block',
|
||||
height: '100%',
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import { useEffect, useRef } 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/three-cards/connect.glb';
|
||||
|
||||
const halftoneVertexShader = /* glsl */ `
|
||||
varying vec3 vWorldNormal;
|
||||
|
||||
void main() {
|
||||
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
|
||||
vWorldNormal = normalize(mat3(modelMatrix) * normal);
|
||||
gl_Position = projectionMatrix * viewMatrix * worldPosition;
|
||||
}
|
||||
`;
|
||||
|
||||
const halftoneFragmentShader = /* glsl */ `
|
||||
uniform vec3 uColor;
|
||||
uniform vec3 uLightDir;
|
||||
uniform vec2 uResolution;
|
||||
uniform float uNumRows;
|
||||
|
||||
varying vec3 vWorldNormal;
|
||||
|
||||
void main() {
|
||||
vec3 normal = normalize(vWorldNormal);
|
||||
vec3 lightDir = normalize(uLightDir);
|
||||
float ndotl = max(dot(normal, lightDir), 0.0);
|
||||
float lum = mix(0.35, 1.0, ndotl);
|
||||
|
||||
float rowH = uResolution.y / uNumRows;
|
||||
float rowFrac = gl_FragCoord.y / rowH - floor(gl_FragCoord.y / rowH);
|
||||
float dy = abs(rowFrac - 0.5);
|
||||
|
||||
float cellW = rowH * 2.2;
|
||||
float cellFrac = (gl_FragCoord.x - floor(gl_FragCoord.x / cellW) * cellW) / cellW;
|
||||
|
||||
float fill = pow(lum, 0.45) * 0.95;
|
||||
|
||||
float dynamicBarHalf = mix(0.15, 0.34, smoothstep(0.05, 0.8, lum));
|
||||
|
||||
float dx2 = abs(cellFrac - 0.5);
|
||||
float halfFill = fill * 0.5;
|
||||
float bodyHalfW = max(halfFill - dynamicBarHalf * (rowH / cellW), 0.0);
|
||||
float capR = dynamicBarHalf * rowH;
|
||||
|
||||
float inDash = 0.0;
|
||||
if (dx2 <= bodyHalfW) {
|
||||
float edgeDist = dynamicBarHalf - dy;
|
||||
inDash = smoothstep(-0.03, 0.03, edgeDist);
|
||||
} else {
|
||||
float cdx = (dx2 - bodyHalfW) * cellW;
|
||||
float cdy = dy * rowH;
|
||||
float d = sqrt(cdx * cdx + cdy * cdy);
|
||||
inDash = 1.0 - smoothstep(capR - 1.5, capR + 1.5, d);
|
||||
}
|
||||
|
||||
if (inDash < 0.01) {
|
||||
discard;
|
||||
}
|
||||
|
||||
gl_FragColor = vec4(uColor, inDash);
|
||||
}
|
||||
`;
|
||||
|
||||
function createHalftoneDashMaterial(
|
||||
lightDirection: THREE.Vector3,
|
||||
resolution: THREE.Vector2,
|
||||
) {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uColor: { value: new THREE.Color('#1e5bff') },
|
||||
uLightDir: { value: lightDirection.clone() },
|
||||
uResolution: { value: resolution.clone() },
|
||||
uNumRows: { value: 65 },
|
||||
},
|
||||
vertexShader: halftoneVertexShader,
|
||||
fragmentShader: halftoneFragmentShader,
|
||||
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;
|
||||
};
|
||||
|
||||
function applyHalftoneDashMaterials(
|
||||
modelRoot: THREE.Object3D,
|
||||
lightDirection: THREE.Vector3,
|
||||
resolution: THREE.Vector2,
|
||||
) {
|
||||
modelRoot.traverse((sceneObject) => {
|
||||
if (!(sceneObject instanceof THREE.Mesh)) {
|
||||
return;
|
||||
}
|
||||
|
||||
sceneObject.material = createHalftoneDashMaterial(
|
||||
lightDirection,
|
||||
resolution,
|
||||
);
|
||||
|
||||
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.partnerCommunityMeshRest = rest;
|
||||
});
|
||||
}
|
||||
|
||||
const StyledVisualMount = styled.div`
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export function Connect() {
|
||||
const mountReference = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = mountReference.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let animationFrameId = 0;
|
||||
|
||||
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: false });
|
||||
renderer.setPixelRatio(1);
|
||||
renderer.setSize(width, height);
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
const canvas = renderer.domElement;
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.touchAction = 'none';
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.cursor = 'pointer';
|
||||
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) => {
|
||||
if (cancelled) {
|
||||
disposeObjectSubtree(gltf.scene);
|
||||
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.75 / maxAxis;
|
||||
|
||||
modelRoot.position.sub(center);
|
||||
modelRoot.scale.setScalar(scale);
|
||||
|
||||
const canvasResolution = new THREE.Vector2(
|
||||
renderer.domElement.width,
|
||||
renderer.domElement.height,
|
||||
);
|
||||
applyHalftoneDashMaterials(
|
||||
modelRoot,
|
||||
lightDirectionWorld,
|
||||
canvasResolution,
|
||||
);
|
||||
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.partnerCommunityMeshRest 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();
|
||||
},
|
||||
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;
|
||||
if (nextWidth < 1 || nextHeight < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
camera.aspect = nextWidth / nextHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(nextWidth, nextHeight);
|
||||
|
||||
const rw = renderer.domElement.width;
|
||||
const rh = renderer.domElement.height;
|
||||
pivot.traverse((sceneObject) => {
|
||||
if (
|
||||
sceneObject instanceof THREE.Mesh &&
|
||||
sceneObject.material instanceof THREE.ShaderMaterial &&
|
||||
sceneObject.material.uniforms.uResolution
|
||||
) {
|
||||
sceneObject.material.uniforms.uResolution.value.set(rw, rh);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <StyledVisualMount aria-hidden ref={mountReference} />;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,366 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import { useEffect, useRef } 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/three-cards/grow.glb';
|
||||
|
||||
const halftoneVertexShader = /* glsl */ `
|
||||
varying vec3 vWorldNormal;
|
||||
|
||||
void main() {
|
||||
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
|
||||
vWorldNormal = normalize(mat3(modelMatrix) * normal);
|
||||
gl_Position = projectionMatrix * viewMatrix * worldPosition;
|
||||
}
|
||||
`;
|
||||
|
||||
const halftoneFragmentShader = /* glsl */ `
|
||||
uniform vec3 uColor;
|
||||
uniform vec3 uLightDir;
|
||||
uniform vec2 uResolution;
|
||||
uniform float uNumRows;
|
||||
|
||||
varying vec3 vWorldNormal;
|
||||
|
||||
void main() {
|
||||
vec3 normal = normalize(vWorldNormal);
|
||||
vec3 lightDir = normalize(uLightDir);
|
||||
float ndotl = max(dot(normal, lightDir), 0.0);
|
||||
float lum = mix(0.35, 1.0, ndotl);
|
||||
|
||||
float rowH = uResolution.y / uNumRows;
|
||||
float rowFrac = gl_FragCoord.y / rowH - floor(gl_FragCoord.y / rowH);
|
||||
float dy = abs(rowFrac - 0.5);
|
||||
|
||||
float cellW = rowH * 2.2;
|
||||
float cellFrac = (gl_FragCoord.x - floor(gl_FragCoord.x / cellW) * cellW) / cellW;
|
||||
|
||||
float fill = pow(lum, 0.45) * 0.95;
|
||||
|
||||
float dynamicBarHalf = mix(0.15, 0.34, smoothstep(0.05, 0.8, lum));
|
||||
|
||||
float dx2 = abs(cellFrac - 0.5);
|
||||
float halfFill = fill * 0.5;
|
||||
float bodyHalfW = max(halfFill - dynamicBarHalf * (rowH / cellW), 0.0);
|
||||
float capR = dynamicBarHalf * rowH;
|
||||
|
||||
float inDash = 0.0;
|
||||
if (dx2 <= bodyHalfW) {
|
||||
float edgeDist = dynamicBarHalf - dy;
|
||||
inDash = smoothstep(-0.03, 0.03, edgeDist);
|
||||
} else {
|
||||
float cdx = (dx2 - bodyHalfW) * cellW;
|
||||
float cdy = dy * rowH;
|
||||
float d = sqrt(cdx * cdx + cdy * cdy);
|
||||
inDash = 1.0 - smoothstep(capR - 1.5, capR + 1.5, d);
|
||||
}
|
||||
|
||||
if (inDash < 0.01) {
|
||||
discard;
|
||||
}
|
||||
|
||||
gl_FragColor = vec4(uColor, inDash);
|
||||
}
|
||||
`;
|
||||
|
||||
function createHalftoneDashMaterial(
|
||||
lightDirection: THREE.Vector3,
|
||||
resolution: THREE.Vector2,
|
||||
) {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uColor: { value: new THREE.Color('#1e5bff') },
|
||||
uLightDir: { value: lightDirection.clone() },
|
||||
uResolution: { value: resolution.clone() },
|
||||
uNumRows: { value: 65 },
|
||||
},
|
||||
vertexShader: halftoneVertexShader,
|
||||
fragmentShader: halftoneFragmentShader,
|
||||
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;
|
||||
};
|
||||
|
||||
function applyHalftoneDashMaterials(
|
||||
modelRoot: THREE.Object3D,
|
||||
lightDirection: THREE.Vector3,
|
||||
resolution: THREE.Vector2,
|
||||
) {
|
||||
modelRoot.traverse((sceneObject) => {
|
||||
if (!(sceneObject instanceof THREE.Mesh)) {
|
||||
return;
|
||||
}
|
||||
|
||||
sceneObject.material = createHalftoneDashMaterial(
|
||||
lightDirection,
|
||||
resolution,
|
||||
);
|
||||
|
||||
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.partnerSolutionsMeshRest = rest;
|
||||
});
|
||||
}
|
||||
|
||||
const StyledVisualMount = styled.div`
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export function Grow() {
|
||||
const mountReference = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = mountReference.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let animationFrameId = 0;
|
||||
|
||||
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: false });
|
||||
renderer.setPixelRatio(1);
|
||||
renderer.setSize(width, height);
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
const canvas = renderer.domElement;
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.touchAction = 'none';
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.cursor = 'pointer';
|
||||
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) => {
|
||||
if (cancelled) {
|
||||
disposeObjectSubtree(gltf.scene);
|
||||
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.75 / maxAxis;
|
||||
|
||||
modelRoot.position.sub(center);
|
||||
modelRoot.scale.setScalar(scale);
|
||||
|
||||
const canvasResolution = new THREE.Vector2(
|
||||
renderer.domElement.width,
|
||||
renderer.domElement.height,
|
||||
);
|
||||
applyHalftoneDashMaterials(
|
||||
modelRoot,
|
||||
lightDirectionWorld,
|
||||
canvasResolution,
|
||||
);
|
||||
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.partnerSolutionsMeshRest 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();
|
||||
},
|
||||
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;
|
||||
if (nextWidth < 1 || nextHeight < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
camera.aspect = nextWidth / nextHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(nextWidth, nextHeight);
|
||||
|
||||
const rw = renderer.domElement.width;
|
||||
const rh = renderer.domElement.height;
|
||||
pivot.traverse((sceneObject) => {
|
||||
if (
|
||||
sceneObject instanceof THREE.Mesh &&
|
||||
sceneObject.material instanceof THREE.ShaderMaterial &&
|
||||
sceneObject.material.uniforms.uResolution
|
||||
) {
|
||||
sceneObject.material.uniforms.uResolution.value.set(rw, rh);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <StyledVisualMount aria-hidden ref={mountReference} />;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1,366 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import { useEffect, useRef } 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/three-cards/programming.glb';
|
||||
|
||||
const halftoneVertexShader = /* glsl */ `
|
||||
varying vec3 vWorldNormal;
|
||||
|
||||
void main() {
|
||||
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
|
||||
vWorldNormal = normalize(mat3(modelMatrix) * normal);
|
||||
gl_Position = projectionMatrix * viewMatrix * worldPosition;
|
||||
}
|
||||
`;
|
||||
|
||||
const halftoneFragmentShader = /* glsl */ `
|
||||
uniform vec3 uColor;
|
||||
uniform vec3 uLightDir;
|
||||
uniform vec2 uResolution;
|
||||
uniform float uNumRows;
|
||||
|
||||
varying vec3 vWorldNormal;
|
||||
|
||||
void main() {
|
||||
vec3 normal = normalize(vWorldNormal);
|
||||
vec3 lightDir = normalize(uLightDir);
|
||||
float ndotl = max(dot(normal, lightDir), 0.0);
|
||||
float lum = mix(0.35, 1.0, ndotl);
|
||||
|
||||
float rowH = uResolution.y / uNumRows;
|
||||
float rowFrac = gl_FragCoord.y / rowH - floor(gl_FragCoord.y / rowH);
|
||||
float dy = abs(rowFrac - 0.5);
|
||||
|
||||
float cellW = rowH * 2.2;
|
||||
float cellFrac = (gl_FragCoord.x - floor(gl_FragCoord.x / cellW) * cellW) / cellW;
|
||||
|
||||
float fill = pow(lum, 0.45) * 0.95;
|
||||
|
||||
float dynamicBarHalf = mix(0.15, 0.34, smoothstep(0.05, 0.8, lum));
|
||||
|
||||
float dx2 = abs(cellFrac - 0.5);
|
||||
float halfFill = fill * 0.5;
|
||||
float bodyHalfW = max(halfFill - dynamicBarHalf * (rowH / cellW), 0.0);
|
||||
float capR = dynamicBarHalf * rowH;
|
||||
|
||||
float inDash = 0.0;
|
||||
if (dx2 <= bodyHalfW) {
|
||||
float edgeDist = dynamicBarHalf - dy;
|
||||
inDash = smoothstep(-0.03, 0.03, edgeDist);
|
||||
} else {
|
||||
float cdx = (dx2 - bodyHalfW) * cellW;
|
||||
float cdy = dy * rowH;
|
||||
float d = sqrt(cdx * cdx + cdy * cdy);
|
||||
inDash = 1.0 - smoothstep(capR - 1.5, capR + 1.5, d);
|
||||
}
|
||||
|
||||
if (inDash < 0.01) {
|
||||
discard;
|
||||
}
|
||||
|
||||
gl_FragColor = vec4(uColor, inDash);
|
||||
}
|
||||
`;
|
||||
|
||||
function createHalftoneDashMaterial(
|
||||
lightDirection: THREE.Vector3,
|
||||
resolution: THREE.Vector2,
|
||||
) {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uColor: { value: new THREE.Color('#1e5bff') },
|
||||
uLightDir: { value: lightDirection.clone() },
|
||||
uResolution: { value: resolution.clone() },
|
||||
uNumRows: { value: 65 },
|
||||
},
|
||||
vertexShader: halftoneVertexShader,
|
||||
fragmentShader: halftoneFragmentShader,
|
||||
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;
|
||||
};
|
||||
|
||||
function applyHalftoneDashMaterials(
|
||||
modelRoot: THREE.Object3D,
|
||||
lightDirection: THREE.Vector3,
|
||||
resolution: THREE.Vector2,
|
||||
) {
|
||||
modelRoot.traverse((sceneObject) => {
|
||||
if (!(sceneObject instanceof THREE.Mesh)) {
|
||||
return;
|
||||
}
|
||||
|
||||
sceneObject.material = createHalftoneDashMaterial(
|
||||
lightDirection,
|
||||
resolution,
|
||||
);
|
||||
|
||||
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.partnerTechnologyMeshRest = rest;
|
||||
});
|
||||
}
|
||||
|
||||
const StyledVisualMount = styled.div`
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export function Programming() {
|
||||
const mountReference = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = mountReference.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let animationFrameId = 0;
|
||||
|
||||
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: false });
|
||||
renderer.setPixelRatio(1);
|
||||
renderer.setSize(width, height);
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
const canvas = renderer.domElement;
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.touchAction = 'none';
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.cursor = 'pointer';
|
||||
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) => {
|
||||
if (cancelled) {
|
||||
disposeObjectSubtree(gltf.scene);
|
||||
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.75 / maxAxis;
|
||||
|
||||
modelRoot.position.sub(center);
|
||||
modelRoot.scale.setScalar(scale);
|
||||
|
||||
const canvasResolution = new THREE.Vector2(
|
||||
renderer.domElement.width,
|
||||
renderer.domElement.height,
|
||||
);
|
||||
applyHalftoneDashMaterials(
|
||||
modelRoot,
|
||||
lightDirectionWorld,
|
||||
canvasResolution,
|
||||
);
|
||||
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.partnerTechnologyMeshRest 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();
|
||||
},
|
||||
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;
|
||||
if (nextWidth < 1 || nextHeight < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
camera.aspect = nextWidth / nextHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(nextWidth, nextHeight);
|
||||
|
||||
const rw = renderer.domElement.width;
|
||||
const rh = renderer.domElement.height;
|
||||
pivot.traverse((sceneObject) => {
|
||||
if (
|
||||
sceneObject instanceof THREE.Mesh &&
|
||||
sceneObject.material instanceof THREE.ShaderMaterial &&
|
||||
sceneObject.material.uniforms.uResolution
|
||||
) {
|
||||
sceneObject.material.uniforms.uResolution.value.set(rw, rh);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <StyledVisualMount aria-hidden ref={mountReference} />;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { VisibleWhenTabActive } from '@/components/VisibleWhenTabActive';
|
||||
import {
|
||||
ILLUSTRATIONS,
|
||||
type IllustrationId,
|
||||
@@ -12,5 +13,9 @@ type IllustrationMountProps = {
|
||||
export function IllustrationMount({ illustration }: IllustrationMountProps) {
|
||||
const IllustrationComponent = ILLUSTRATIONS[illustration];
|
||||
|
||||
return <IllustrationComponent />;
|
||||
return (
|
||||
<VisibleWhenTabActive>
|
||||
<IllustrationComponent />
|
||||
</VisibleWhenTabActive>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,30 +8,49 @@ import { Spaceship } from './Helped/Spaceship';
|
||||
import { Target } from './Helped/Target';
|
||||
import { Product } from './Hero/Product';
|
||||
import { WhyTwenty } from './Hero/WhyTwenty';
|
||||
import { Organization } from './Plans/Organization';
|
||||
import { Pro } from './Plans/Pro';
|
||||
import { Quotes } from './Quote/Quotes';
|
||||
import { Hourglass } from './Testimonials/Hourglass';
|
||||
import { Partner } from './Testimonials/Partner';
|
||||
import { Connect } from './ThreeCards/Connect';
|
||||
import { Diamond } from './ThreeCards/Diamond';
|
||||
import { Eye } from './ThreeCards/Eye';
|
||||
import { Flash } from './ThreeCards/Flash';
|
||||
import { Grow } from './ThreeCards/Grow';
|
||||
import { Lock } from './ThreeCards/Lock';
|
||||
import { Programming } from './ThreeCards/Programming';
|
||||
import { PartnerThreeCard } from './ThreeCards/PartnerThreeCard';
|
||||
import { SingleScreen } from './ThreeCards/SingleScreen';
|
||||
import { Speed } from './ThreeCards/Speed';
|
||||
import { Logo as WhyTwentyStepperLogo } from './WhyTwentyStepper/Logo';
|
||||
|
||||
const DiamondIllustration = () => (
|
||||
<PartnerThreeCard modelUrl="/illustrations/home/three-cards/diamond.glb" />
|
||||
);
|
||||
|
||||
const FlashIllustration = () => (
|
||||
<PartnerThreeCard modelUrl="/illustrations/home/three-cards/flash.glb" />
|
||||
);
|
||||
|
||||
const LockIllustration = () => (
|
||||
<PartnerThreeCard modelUrl="/illustrations/home/three-cards/lock.glb" />
|
||||
);
|
||||
|
||||
const ConnectIllustration = () => (
|
||||
<PartnerThreeCard modelUrl="/illustrations/partner/three-cards/connect.glb" />
|
||||
);
|
||||
|
||||
const GrowIllustration = () => (
|
||||
<PartnerThreeCard modelUrl="/illustrations/partner/three-cards/grow.glb" />
|
||||
);
|
||||
|
||||
const ProgrammingIllustration = () => (
|
||||
<PartnerThreeCard
|
||||
modelUrl="/illustrations/partner/three-cards/programming.glb"
|
||||
/>
|
||||
);
|
||||
|
||||
export const THREE_CARDS_ILLUSTRATIONS = {
|
||||
diamond: Diamond,
|
||||
diamond: DiamondIllustration,
|
||||
eye: Eye,
|
||||
flash: Flash,
|
||||
lock: Lock,
|
||||
connect: Connect,
|
||||
grow: Grow,
|
||||
programming: Programming,
|
||||
flash: FlashIllustration,
|
||||
lock: LockIllustration,
|
||||
connect: ConnectIllustration,
|
||||
grow: GrowIllustration,
|
||||
programming: ProgrammingIllustration,
|
||||
singleScreen: SingleScreen,
|
||||
speed: Speed,
|
||||
} as const satisfies Record<string, ComponentType>;
|
||||
@@ -48,8 +67,6 @@ export const ILLUSTRATIONS = {
|
||||
heroWhyTwenty: WhyTwenty,
|
||||
testimonialsPartner: Partner,
|
||||
testimonialsHourglass: Hourglass,
|
||||
planPro: Pro,
|
||||
planOrganization: Organization,
|
||||
money: Money,
|
||||
spaceship: Spaceship,
|
||||
target: Target,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { theme } from '@/theme';
|
||||
import { css } from '@linaria/core';
|
||||
import { styled } from '@linaria/react';
|
||||
import NextImage from 'next/image';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
const ENGAGEMENT_BAND_OVERLAY_SRC =
|
||||
'/images/pricing/engagement-band/overlay.webp';
|
||||
@@ -46,6 +46,14 @@ const StyledStrip = styled.div`
|
||||
row-gap: 0;
|
||||
}
|
||||
|
||||
&[data-has-custom-copy-max-width='true'] {
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
grid-template-columns:
|
||||
fit-content(var(--engagement-band-copy-max-width))
|
||||
minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-variant='primary'] {
|
||||
color: ${theme.colors.primary.text[100]};
|
||||
}
|
||||
@@ -57,13 +65,29 @@ const StyledStrip = styled.div`
|
||||
|
||||
type StripProps = {
|
||||
children: ReactNode;
|
||||
desktopCopyMaxWidth?: string;
|
||||
fillColor: string;
|
||||
variant: 'primary' | 'secondary';
|
||||
};
|
||||
|
||||
export function Strip({ children, fillColor, variant }: StripProps) {
|
||||
export function Strip({
|
||||
children,
|
||||
desktopCopyMaxWidth,
|
||||
fillColor,
|
||||
variant,
|
||||
}: StripProps) {
|
||||
const style = desktopCopyMaxWidth
|
||||
? ({
|
||||
'--engagement-band-copy-max-width': desktopCopyMaxWidth,
|
||||
} as CSSProperties)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<StyledStrip data-variant={variant}>
|
||||
<StyledStrip
|
||||
data-has-custom-copy-max-width={Boolean(desktopCopyMaxWidth)}
|
||||
data-variant={variant}
|
||||
style={style}
|
||||
>
|
||||
<EngagementBandShape fillColor={fillColor} />
|
||||
<OverlayLayer aria-hidden>
|
||||
<NextImage
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
Body as BaseBody,
|
||||
type BodyProps,
|
||||
} from '@/design-system/components/Body/Body';
|
||||
import { Pages } from '@/enums/pages';
|
||||
import type { Pages } from '@/enums/pages';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
@@ -12,36 +12,36 @@ const BodyContainer = styled.div`
|
||||
max-width: 360px;
|
||||
width: 100%;
|
||||
|
||||
&[data-page=${Pages.WhyTwenty}] {
|
||||
&[data-page='whyTwenty'] {
|
||||
color: ${theme.colors.secondary.text[60]};
|
||||
}
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
&[data-page=${Pages.Home}] {
|
||||
&[data-page='home'] {
|
||||
max-width: 591px;
|
||||
}
|
||||
|
||||
&[data-page=${Pages.Partner}] {
|
||||
&[data-page='partner'] {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
&[data-page=${Pages.Pricing}] {
|
||||
&[data-page='pricing'] {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
&[data-page=${Pages.Product}] {
|
||||
&[data-page='product'] {
|
||||
max-width: 591px;
|
||||
}
|
||||
|
||||
&[data-page=${Pages.WhyTwenty}] {
|
||||
&[data-page='whyTwenty'] {
|
||||
max-width: 443px;
|
||||
}
|
||||
|
||||
&[data-page=${Pages.ReleaseNotes}] {
|
||||
&[data-page='releaseNotes'] {
|
||||
max-width: 591px;
|
||||
}
|
||||
|
||||
&[data-page=${Pages.CaseStudies}] {
|
||||
&[data-page='caseStudies'] {
|
||||
max-width: 550px;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -16,8 +16,8 @@ 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_POWER_SHIFT = 0.9;
|
||||
const HALFTONE_HOVER_WIDTH_SHIFT = -0.2;
|
||||
const HALFTONE_HOVER_LIGHT_INTENSITY = 0;
|
||||
const HALFTONE_HOVER_LIGHT_RADIUS = 0.2;
|
||||
|
||||
|
||||
+4
-1
@@ -1,3 +1,4 @@
|
||||
import { VisibleWhenTabActive } from '@/components/VisibleWhenTabActive';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
@@ -22,7 +23,9 @@ const StyledContainer = styled.div`
|
||||
export function PartnerVisual() {
|
||||
return (
|
||||
<StyledContainer>
|
||||
<PartnerHalftoneOverlay imageUrl={HERO_IMAGE_URL} />
|
||||
<VisibleWhenTabActive>
|
||||
<PartnerHalftoneOverlay imageUrl={HERO_IMAGE_URL} />
|
||||
</VisibleWhenTabActive>
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { WhyTwenty as WhyTwentyGlb } from '@/illustrations/Hero/WhyTwenty';
|
||||
import { IllustrationMount } from '@/illustrations';
|
||||
import { theme } from '@/theme';
|
||||
import { css } from '@linaria/core';
|
||||
import { styled } from '@linaria/react';
|
||||
@@ -38,7 +38,7 @@ export function WhyTwentyVisual() {
|
||||
src="/images/why-twenty/hero/background.webp"
|
||||
/>
|
||||
</BackgroundLayer>
|
||||
<WhyTwentyGlb />
|
||||
<IllustrationMount illustration="heroWhyTwenty" />
|
||||
</VisualContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { Product as ProductVisual } from '@/illustrations/Hero/Product';
|
||||
import { IllustrationMount } from '@/illustrations';
|
||||
import { PartnerVisual } from '@/sections/Hero/components/PartnerVisual/PartnerVisual';
|
||||
import { WhyTwentyVisual } from '@/sections/Hero/components/WhyTwentyVisual/WhyTwentyVisual';
|
||||
import { createElement } from 'react';
|
||||
import { Body } from './Body/Body';
|
||||
import { Cta } from './Cta/Cta';
|
||||
import { Heading } from './Heading/Heading';
|
||||
import { HomeVisual } from './HomeVisual/HomeVisual';
|
||||
import { Root } from './Root/Root';
|
||||
|
||||
function ProductVisual() {
|
||||
return createElement(IllustrationMount, { illustration: 'heroProduct' });
|
||||
}
|
||||
|
||||
export const Hero = {
|
||||
Root,
|
||||
Heading,
|
||||
|
||||
+100
-11
@@ -3,13 +3,31 @@
|
||||
import type { PlansBillingPeriod } from '@/sections/Plans/types';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
const ToggleTrack = styled.div`
|
||||
align-items: center;
|
||||
background-color: ${theme.colors.primary.border[10]};
|
||||
border-radius: ${theme.radius(20)};
|
||||
display: flex;
|
||||
padding: ${theme.spacing(1)};
|
||||
display: inline-flex;
|
||||
overflow: hidden;
|
||||
padding: 2px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const ToggleHighlight = styled.div`
|
||||
background-color: ${theme.colors.primary.background[100]};
|
||||
border-radius: ${theme.radius(8)};
|
||||
bottom: 2px;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
transition:
|
||||
transform 0.24s cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
width 0.24s cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
opacity 0.18s ease;
|
||||
will-change: transform, width;
|
||||
`;
|
||||
|
||||
const ToggleOption = styled.button`
|
||||
@@ -23,12 +41,14 @@ const ToggleOption = styled.button`
|
||||
font-size: ${theme.font.size(3)};
|
||||
font-weight: ${theme.font.weight.medium};
|
||||
column-gap: ${theme.spacing(2)};
|
||||
height: ${theme.spacing(6)};
|
||||
height: 28px;
|
||||
justify-content: center;
|
||||
line-height: ${theme.lineHeight(4)};
|
||||
padding-left: ${theme.spacing(3)};
|
||||
text-transform: uppercase;
|
||||
position: relative;
|
||||
transition: color 0.18s ease;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
|
||||
&[data-period='monthly'] {
|
||||
padding-right: ${theme.spacing(3)};
|
||||
@@ -39,7 +59,6 @@ const ToggleOption = styled.button`
|
||||
}
|
||||
|
||||
&[data-active='true'] {
|
||||
background-color: ${theme.colors.primary.background[100]};
|
||||
color: ${theme.colors.primary.text[100]};
|
||||
}
|
||||
|
||||
@@ -59,14 +78,14 @@ const DiscountBadge = styled.span`
|
||||
color: ${theme.colors.secondary.text[100]};
|
||||
display: inline-flex;
|
||||
font-family: ${theme.font.family.sans};
|
||||
font-size: ${theme.font.size(3)};
|
||||
font-weight: ${theme.font.weight.regular};
|
||||
height: ${theme.spacing(5)};
|
||||
font-size: ${theme.font.size(2.5)};
|
||||
font-weight: ${theme.font.weight.medium};
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: ${theme.lineHeight(3.5)};
|
||||
padding-left: ${theme.spacing(1)};
|
||||
padding-right: ${theme.spacing(1)};
|
||||
padding-left: ${theme.spacing(1.5)};
|
||||
padding-right: ${theme.spacing(1.5)};
|
||||
`;
|
||||
|
||||
type BillingToggleProps = {
|
||||
@@ -74,17 +93,86 @@ type BillingToggleProps = {
|
||||
onBillingChange: (billing: PlansBillingPeriod) => void;
|
||||
};
|
||||
|
||||
type ToggleHighlightState = {
|
||||
ready: boolean;
|
||||
width: number;
|
||||
x: number;
|
||||
};
|
||||
|
||||
export function BillingToggle({
|
||||
billing,
|
||||
onBillingChange,
|
||||
}: BillingToggleProps) {
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const monthlyButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const yearlyButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [highlight, setHighlight] = useState<ToggleHighlightState>({
|
||||
ready: false,
|
||||
width: 0,
|
||||
x: 0,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const track = trackRef.current;
|
||||
const monthlyButton = monthlyButtonRef.current;
|
||||
const yearlyButton = yearlyButtonRef.current;
|
||||
|
||||
if (!track || !monthlyButton || !yearlyButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
const syncHighlight = () => {
|
||||
const activeButton =
|
||||
billing === 'monthly' ? monthlyButton : yearlyButton;
|
||||
const trackRect = track.getBoundingClientRect();
|
||||
const buttonRect = activeButton.getBoundingClientRect();
|
||||
const nextHighlight = {
|
||||
ready: true,
|
||||
width: buttonRect.width,
|
||||
x: buttonRect.left - trackRect.left,
|
||||
};
|
||||
|
||||
setHighlight((currentHighlight) => {
|
||||
if (
|
||||
currentHighlight.ready === nextHighlight.ready &&
|
||||
currentHighlight.width === nextHighlight.width &&
|
||||
currentHighlight.x === nextHighlight.x
|
||||
) {
|
||||
return currentHighlight;
|
||||
}
|
||||
|
||||
return nextHighlight;
|
||||
});
|
||||
};
|
||||
|
||||
syncHighlight();
|
||||
|
||||
const resizeObserver = new ResizeObserver(syncHighlight);
|
||||
resizeObserver.observe(track);
|
||||
resizeObserver.observe(monthlyButton);
|
||||
resizeObserver.observe(yearlyButton);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [billing]);
|
||||
|
||||
return (
|
||||
<ToggleTrack role="radiogroup" aria-label="Billing period">
|
||||
<ToggleTrack ref={trackRef} role="radiogroup" aria-label="Billing period">
|
||||
<ToggleHighlight
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
opacity: highlight.ready ? 1 : 0,
|
||||
transform: `translateX(${highlight.x}px)`,
|
||||
width: `${highlight.width}px`,
|
||||
}}
|
||||
/>
|
||||
<ToggleOption
|
||||
aria-checked={billing === 'monthly'}
|
||||
data-active={billing === 'monthly'}
|
||||
data-period="monthly"
|
||||
onClick={() => onBillingChange('monthly')}
|
||||
ref={monthlyButtonRef}
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
@@ -95,6 +183,7 @@ export function BillingToggle({
|
||||
data-active={billing === 'yearly'}
|
||||
data-period="yearly"
|
||||
onClick={() => onBillingChange('yearly')}
|
||||
ref={yearlyButtonRef}
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import NextImage from 'next/image';
|
||||
|
||||
import { Body, Heading, LinkButton } from '@/design-system/components';
|
||||
import { CheckIcon } from '@/icons/informative/Check';
|
||||
import { IllustrationMount } from '@/illustrations';
|
||||
import type { PlanCardType } from '@/sections/Plans/types';
|
||||
import { theme } from '@/theme';
|
||||
import { css } from '@linaria/core';
|
||||
|
||||
const FIXED_ROWS = 4;
|
||||
import { useEffect, useRef, useState, type CSSProperties } from 'react';
|
||||
|
||||
const StyledCard = styled.div`
|
||||
background-color: ${theme.colors.primary.background[100]};
|
||||
@@ -15,7 +16,6 @@ const StyledCard = styled.div`
|
||||
border-radius: ${theme.radius(2)};
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: subgrid;
|
||||
overflow: hidden;
|
||||
padding-bottom: ${theme.spacing(4)};
|
||||
padding-left: ${theme.spacing(4)};
|
||||
@@ -27,14 +27,15 @@ const StyledCard = styled.div`
|
||||
`;
|
||||
|
||||
const CardHeader = styled.div`
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
gap: ${theme.spacing(3)};
|
||||
justify-content: space-between;
|
||||
overflow: visible;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
align-items: flex-start;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
gap: ${theme.spacing(4)};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -48,6 +49,10 @@ const CardHeaderInfo = styled.div`
|
||||
`;
|
||||
|
||||
const cardPlanTitleClassName = css`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&[data-size='xs'] {
|
||||
line-height: ${theme.lineHeight(5)};
|
||||
}
|
||||
@@ -61,29 +66,27 @@ const cardPlanTitleClassName = css`
|
||||
|
||||
const priceBodyClassName = css`
|
||||
color: ${theme.colors.primary.text[60]};
|
||||
display: block;
|
||||
min-width: 0;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
|
||||
const PriceLine = styled.div`
|
||||
align-items: baseline;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: ${theme.spacing(1)};
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const CardIllustrationEmbed = styled.div`
|
||||
background-color: ${theme.colors.primary.background[100]};
|
||||
border: none;
|
||||
border-radius: ${theme.radius(2)};
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
height: 80px;
|
||||
overflow: hidden;
|
||||
width: 197px;
|
||||
min-width: 0;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
transform: translateX(${theme.spacing(4)});
|
||||
flex-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -93,6 +96,32 @@ const CardRule = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const CardIcon = styled.div`
|
||||
--card-icon-width: 80px;
|
||||
background-color: ${theme.colors.primary.background[100]};
|
||||
border: none;
|
||||
border-radius: ${theme.radius(2)};
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
height: 80px;
|
||||
margin-left: auto;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: var(--card-icon-width);
|
||||
|
||||
img {
|
||||
object-fit: contain;
|
||||
object-position: center right;
|
||||
}
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
height: 80px;
|
||||
width: var(--card-icon-width);
|
||||
}
|
||||
`;
|
||||
|
||||
const CtaWrapper = styled.div`
|
||||
width: 100%;
|
||||
|
||||
@@ -102,23 +131,95 @@ const CtaWrapper = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const FEATURES_SWITCH_ANIMATION_MS = 110;
|
||||
const FEATURE_ITEM_STAGGER_MS = 8;
|
||||
const FEATURE_ITEM_EXPANDED_HEIGHT = theme.spacing(8);
|
||||
const FEATURE_ITEM_SPACING = theme.spacing(4);
|
||||
|
||||
const FeaturesViewport = styled.div`
|
||||
height: var(--features-height, auto);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: height ${FEATURES_SWITCH_ANIMATION_MS}ms
|
||||
cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const FeaturesList = styled.ul`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: subgrid;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding-bottom: 0;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
padding-top: 0;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const FeatureCheck = styled.span`
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
height: 16px;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
`;
|
||||
|
||||
const FeatureItem = styled.li`
|
||||
@keyframes pricingFeatureItemEnter {
|
||||
from {
|
||||
margin-top: 0;
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
margin-top: var(--feature-spacing, 0px);
|
||||
max-height: var(--feature-item-height, ${FEATURE_ITEM_EXPANDED_HEIGHT});
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pricingFeatureItemExit {
|
||||
from {
|
||||
margin-top: var(--feature-spacing, 0px);
|
||||
max-height: var(--feature-item-height, ${FEATURE_ITEM_EXPANDED_HEIGHT});
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
margin-top: 0;
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
align-items: center;
|
||||
column-gap: ${theme.spacing(2)};
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
margin-top: var(--feature-spacing, 0px);
|
||||
max-height: var(--feature-item-height, ${FEATURE_ITEM_EXPANDED_HEIGHT});
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
&[data-state='entering'] {
|
||||
animation: pricingFeatureItemEnter ${FEATURES_SWITCH_ANIMATION_MS}ms
|
||||
cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
animation-delay: calc(var(--feature-index) * ${FEATURE_ITEM_STAGGER_MS}ms);
|
||||
}
|
||||
|
||||
&[data-state='exiting'] {
|
||||
animation: pricingFeatureItemExit ${FEATURES_SWITCH_ANIMATION_MS}ms
|
||||
cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
animation-delay: calc(var(--feature-index) * ${FEATURE_ITEM_STAGGER_MS}ms);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
`;
|
||||
|
||||
type CardProps = {
|
||||
@@ -127,11 +228,191 @@ type CardProps = {
|
||||
maxBullets: number;
|
||||
};
|
||||
|
||||
const PRICE_ROLL_DURATION_MS = 500;
|
||||
const PRICE_NUMBER_FORMATTER = new Intl.NumberFormat('en-US');
|
||||
const PRICE_HEADING_NUMBER_REGEX = /^(.*?)(\d[\d,]*)(.*)$/;
|
||||
const FEATURE_LIST_ROW_MIN_HEIGHT = theme.spacing(5.5);
|
||||
|
||||
const useAnimatedNumber = (target: number) => {
|
||||
const [display, setDisplay] = useState(target);
|
||||
const previousValueRef = useRef(target);
|
||||
|
||||
useEffect(() => {
|
||||
const from = previousValueRef.current;
|
||||
previousValueRef.current = target;
|
||||
|
||||
if (from === target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
let animationFrameId = 0;
|
||||
|
||||
const tick = (now: number) => {
|
||||
const progress = Math.min((now - start) / PRICE_ROLL_DURATION_MS, 1);
|
||||
const eased = 1 - (1 - progress) ** 3;
|
||||
setDisplay(Math.round(from + (target - from) * eased));
|
||||
|
||||
if (progress < 1) {
|
||||
animationFrameId = requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
|
||||
animationFrameId = requestAnimationFrame(tick);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [target]);
|
||||
|
||||
return display;
|
||||
};
|
||||
|
||||
function getHeadingSegments(heading: PlanCardType['price']['heading']) {
|
||||
return Array.isArray(heading) ? heading : [heading];
|
||||
}
|
||||
|
||||
function getPriceHeadingNumericValue(heading: PlanCardType['price']['heading']) {
|
||||
const segments = getHeadingSegments(heading);
|
||||
|
||||
for (const segment of segments) {
|
||||
const match = segment.text.match(PRICE_HEADING_NUMBER_REGEX);
|
||||
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return Number(match[2].replaceAll(',', ''));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getAnimatedPriceHeading(
|
||||
heading: PlanCardType['price']['heading'],
|
||||
animatedValue: number,
|
||||
) {
|
||||
const originalIsArray = Array.isArray(heading);
|
||||
let replaced = false;
|
||||
|
||||
const nextSegments = getHeadingSegments(heading).map((segment) => {
|
||||
if (replaced) {
|
||||
return segment;
|
||||
}
|
||||
|
||||
const match = segment.text.match(PRICE_HEADING_NUMBER_REGEX);
|
||||
|
||||
if (!match) {
|
||||
return segment;
|
||||
}
|
||||
|
||||
replaced = true;
|
||||
|
||||
return {
|
||||
...segment,
|
||||
text: `${match[1]}${PRICE_NUMBER_FORMATTER.format(animatedValue)}${match[3]}`,
|
||||
};
|
||||
});
|
||||
|
||||
return originalIsArray ? nextSegments : nextSegments[0];
|
||||
}
|
||||
|
||||
function getBulletsKey(bullets: PlanCardType['features']['bullets']) {
|
||||
return bullets.map((bullet) => bullet.text).join('||');
|
||||
}
|
||||
|
||||
function getFeaturesMinHeight(maxBullets: number) {
|
||||
if (maxBullets <= 0) {
|
||||
return '0px';
|
||||
}
|
||||
|
||||
return `calc((${FEATURE_LIST_ROW_MIN_HEIGHT} * ${maxBullets}) + (${theme.spacing(
|
||||
4,
|
||||
)} * ${maxBullets - 1}))`;
|
||||
}
|
||||
|
||||
export function Card({ card, highlighted = false, maxBullets }: CardProps) {
|
||||
const totalRows = FIXED_ROWS + maxBullets;
|
||||
const iconWidth = card.icon.width ?? 80;
|
||||
const iconStyle = {
|
||||
'--card-icon-width': `${iconWidth}px`,
|
||||
} as CSSProperties;
|
||||
const targetPriceValue = getPriceHeadingNumericValue(card.price.heading);
|
||||
const animatedPriceValue = useAnimatedNumber(targetPriceValue ?? 0);
|
||||
const animatedPriceHeading =
|
||||
targetPriceValue === null
|
||||
? card.price.heading
|
||||
: getAnimatedPriceHeading(card.price.heading, animatedPriceValue);
|
||||
const [visibleBullets, setVisibleBullets] = useState(card.features.bullets);
|
||||
const [queuedBullets, setQueuedBullets] = useState<
|
||||
PlanCardType['features']['bullets'] | null
|
||||
>(null);
|
||||
const [comparisonBullets, setComparisonBullets] = useState<
|
||||
PlanCardType['features']['bullets'] | null
|
||||
>(null);
|
||||
const [featuresPhase, setFeaturesPhase] = useState<
|
||||
'stable' | 'exiting' | 'entering'
|
||||
>('stable');
|
||||
|
||||
useEffect(() => {
|
||||
const nextBullets = card.features.bullets;
|
||||
const nextBulletsKey = getBulletsKey(nextBullets);
|
||||
|
||||
if (
|
||||
nextBulletsKey === getBulletsKey(visibleBullets) ||
|
||||
nextBulletsKey === getBulletsKey(queuedBullets ?? [])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setQueuedBullets(nextBullets);
|
||||
|
||||
if (featuresPhase === 'stable') {
|
||||
setFeaturesPhase('exiting');
|
||||
}
|
||||
}, [card.features.bullets, featuresPhase, queuedBullets, visibleBullets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (featuresPhase !== 'exiting' || !queuedBullets) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setComparisonBullets(visibleBullets);
|
||||
setVisibleBullets(queuedBullets);
|
||||
setQueuedBullets(null);
|
||||
setFeaturesPhase('entering');
|
||||
}, FEATURES_SWITCH_ANIMATION_MS +
|
||||
FEATURE_ITEM_STAGGER_MS * visibleBullets.length);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [featuresPhase, queuedBullets, visibleBullets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (featuresPhase !== 'entering') {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setComparisonBullets(null);
|
||||
setFeaturesPhase('stable');
|
||||
}, FEATURES_SWITCH_ANIMATION_MS +
|
||||
FEATURE_ITEM_STAGGER_MS * visibleBullets.length);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [featuresPhase, visibleBullets]);
|
||||
|
||||
const comparisonBulletTexts = new Set(
|
||||
(featuresPhase === 'exiting' ? queuedBullets : comparisonBullets)?.map(
|
||||
(bullet) => bullet.text,
|
||||
) ?? [],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledCard style={{ gridRow: `span ${totalRows}` }}>
|
||||
<StyledCard>
|
||||
<CardHeader>
|
||||
<CardHeaderInfo>
|
||||
<Heading
|
||||
@@ -144,7 +425,7 @@ export function Card({ card, highlighted = false, maxBullets }: CardProps) {
|
||||
<PriceLine>
|
||||
<Heading
|
||||
as="h4"
|
||||
segments={card.price.heading}
|
||||
segments={animatedPriceHeading}
|
||||
size="sm"
|
||||
weight="regular"
|
||||
/>
|
||||
@@ -156,11 +437,56 @@ export function Card({ card, highlighted = false, maxBullets }: CardProps) {
|
||||
/>
|
||||
</PriceLine>
|
||||
</CardHeaderInfo>
|
||||
<CardIllustrationEmbed>
|
||||
<IllustrationMount illustration={card.illustration} />
|
||||
</CardIllustrationEmbed>
|
||||
<CardIcon style={iconStyle}>
|
||||
<NextImage
|
||||
alt={card.icon.alt}
|
||||
fill
|
||||
sizes={`${iconWidth}px`}
|
||||
src={card.icon.src}
|
||||
/>
|
||||
</CardIcon>
|
||||
</CardHeader>
|
||||
|
||||
<CardRule />
|
||||
|
||||
<FeaturesViewport
|
||||
style={{
|
||||
'--features-height': getFeaturesMinHeight(maxBullets),
|
||||
} as CSSProperties}
|
||||
>
|
||||
<FeaturesList data-state={featuresPhase}>
|
||||
{visibleBullets.map((bullet, index) => (
|
||||
<FeatureItem
|
||||
data-state={
|
||||
featuresPhase === 'stable'
|
||||
? 'stable'
|
||||
: comparisonBulletTexts.has(bullet.text)
|
||||
? 'stable'
|
||||
: featuresPhase
|
||||
}
|
||||
key={bullet.text}
|
||||
style={
|
||||
{
|
||||
'--feature-index': index,
|
||||
'--feature-item-height': FEATURE_ITEM_EXPANDED_HEIGHT,
|
||||
'--feature-spacing':
|
||||
index > 0 ? FEATURE_ITEM_SPACING : '0px',
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<FeatureCheck>
|
||||
<CheckIcon
|
||||
color={theme.colors.highlight[100]}
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</FeatureCheck>
|
||||
<Body as="span" body={bullet} size="sm" />
|
||||
</FeatureItem>
|
||||
))}
|
||||
</FeaturesList>
|
||||
</FeaturesViewport>
|
||||
|
||||
<CtaWrapper>
|
||||
<LinkButton
|
||||
color="secondary"
|
||||
@@ -170,19 +496,6 @@ export function Card({ card, highlighted = false, maxBullets }: CardProps) {
|
||||
variant={highlighted ? 'contained' : 'outlined'}
|
||||
/>
|
||||
</CtaWrapper>
|
||||
|
||||
<CardRule />
|
||||
|
||||
<Body body={card.features.title} size="md" />
|
||||
|
||||
<FeaturesList style={{ gridRow: `span ${maxBullets}` }}>
|
||||
{card.features.bullets.map((bullet, index) => (
|
||||
<FeatureItem key={index}>
|
||||
<CheckIcon color={theme.colors.highlight[100]} size={16} />
|
||||
<Body as="span" body={bullet} size="sm" />
|
||||
</FeatureItem>
|
||||
))}
|
||||
</FeaturesList>
|
||||
</StyledCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import type { PlanCardType } from '@/sections/Plans/types';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import { Card } from '../Card/Card';
|
||||
|
||||
const CardsGrid = styled.div`
|
||||
column-gap: ${theme.spacing(4)};
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: ${theme.spacing(4)};
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
column-gap: ${theme.spacing(6)};
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
+5
-7
@@ -20,9 +20,9 @@ const Checkbox = styled.span`
|
||||
border: 1px solid ${theme.colors.highlight[100]};
|
||||
border-radius: ${theme.radius(1)};
|
||||
display: grid;
|
||||
height: 20px;
|
||||
height: 16px;
|
||||
justify-items: center;
|
||||
width: 20px;
|
||||
width: 16px;
|
||||
|
||||
&[data-checked='true'] {
|
||||
background-color: ${theme.colors.highlight[100]};
|
||||
@@ -38,9 +38,9 @@ function CheckmarkIcon() {
|
||||
<svg
|
||||
aria-hidden
|
||||
fill="none"
|
||||
height="12"
|
||||
height="10"
|
||||
viewBox="0 0 12 12"
|
||||
width="12"
|
||||
width="10"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
@@ -84,9 +84,7 @@ export function SelfHostToggle({
|
||||
<ToggleRow>
|
||||
<HiddenInput
|
||||
checked={isSelfHost}
|
||||
onChange={() =>
|
||||
onHostingChange(isSelfHost ? 'cloud' : 'selfHost')
|
||||
}
|
||||
onChange={() => onHostingChange(isSelfHost ? 'cloud' : 'selfHost')}
|
||||
type="checkbox"
|
||||
/>
|
||||
<LabelText>Selfhosting</LabelText>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { type HeadingType } from '@/design-system/components/Heading/types/Heading';
|
||||
import type { IllustrationId } from '@/illustrations';
|
||||
import type { ImageType } from '@/design-system/components/Image/types/Image';
|
||||
import { type PlanFeaturesType } from '@/sections/Plans/types/PlanFeatures';
|
||||
import { type PlanPriceType } from '@/sections/Plans/types/PlanPrice';
|
||||
|
||||
export type PlanIconType = ImageType & {
|
||||
width?: number;
|
||||
};
|
||||
|
||||
export type PlanCardType = {
|
||||
heading: HeadingType;
|
||||
price: PlanPriceType;
|
||||
illustration: IllustrationId;
|
||||
icon: PlanIconType;
|
||||
features: PlanFeaturesType;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { BodyType } from '@/design-system/components/Body/types/Body';
|
||||
|
||||
export type PlanFeaturesType = {
|
||||
title: BodyType;
|
||||
bullets: BodyType[];
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BodyType } from '@/design-system/components/Body/types/Body';
|
||||
import type { HeadingType } from '@/design-system/components/Heading/types/Heading';
|
||||
import type { IllustrationId } from '@/illustrations';
|
||||
import type { PlanIconType } from './PlanCard';
|
||||
import type { PlanPriceType } from './PlanPrice';
|
||||
|
||||
// Same literals as UI toggles when reading cells.
|
||||
@@ -27,9 +27,8 @@ export type PlansTierCellsType = {
|
||||
// Shared across all eight combinations for this tier.
|
||||
export type PlansTierType = {
|
||||
cells: PlansTierCellsType;
|
||||
featuresTitle: BodyType;
|
||||
heading: HeadingType;
|
||||
illustration: IllustrationId;
|
||||
icon: PlanIconType;
|
||||
};
|
||||
|
||||
export type PlansDataType = {
|
||||
|
||||
@@ -17,11 +17,10 @@ export function getPlanCard(
|
||||
|
||||
return {
|
||||
heading: tier.heading,
|
||||
illustration: tier.illustration,
|
||||
icon: tier.icon,
|
||||
price: cell.price,
|
||||
features: {
|
||||
bullets: cell.featureBullets,
|
||||
title: tier.featuresTitle,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { VisibleWhenTabActive } from '@/components/VisibleWhenTabActive';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import Monolith from './monolith';
|
||||
@@ -53,7 +54,9 @@ export function Visual() {
|
||||
<StyledVisual>
|
||||
<StyledMasked>
|
||||
<StyledHalftoneLayer>
|
||||
<Monolith />
|
||||
<VisibleWhenTabActive>
|
||||
<Monolith />
|
||||
</VisibleWhenTabActive>
|
||||
</StyledHalftoneLayer>
|
||||
</StyledMasked>
|
||||
</StyledVisual>
|
||||
|
||||
+138
-131
@@ -114,13 +114,20 @@ const PromoTagBorder = styled.div`
|
||||
clip-path: ${STARBURST_CLIP};
|
||||
background: #005fb2;
|
||||
filter: drop-shadow(2px 3px 6px rgba(0, 40, 80, 0.45));
|
||||
height: 130px;
|
||||
display: inline-flex;
|
||||
min-width: 200px;
|
||||
padding: 4px;
|
||||
position: absolute;
|
||||
right: ${theme.spacing(25)};
|
||||
top: -16px;
|
||||
transform: rotate(-8deg);
|
||||
width: 200px;
|
||||
z-index: 30;
|
||||
|
||||
@media (max-width: ${theme.breakpoints.md - 1}px) {
|
||||
min-width: 168px;
|
||||
right: ${theme.spacing(8)};
|
||||
top: -8px;
|
||||
}
|
||||
`;
|
||||
|
||||
const PromoTagInner = styled.div`
|
||||
@@ -132,17 +139,20 @@ const PromoTagInner = styled.div`
|
||||
font-family: ${theme.font.family.retro};
|
||||
font-size: ${theme.font.size(4.5)};
|
||||
font-weight: bold;
|
||||
height: calc(100% - 8px);
|
||||
justify-content: center;
|
||||
left: 4px;
|
||||
letter-spacing: 1px;
|
||||
line-height: 1.3;
|
||||
position: absolute;
|
||||
line-height: 1.15;
|
||||
min-height: 104px;
|
||||
padding: ${theme.spacing(7)} ${theme.spacing(10)};
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
top: 4px;
|
||||
white-space: pre-line;
|
||||
width: calc(100% - 8px);
|
||||
|
||||
@media (max-width: ${theme.breakpoints.md - 1}px) {
|
||||
font-size: ${theme.font.size(4)};
|
||||
min-height: 88px;
|
||||
padding: ${theme.spacing(6)} ${theme.spacing(7)};
|
||||
}
|
||||
`;
|
||||
|
||||
const Panel = styled.div`
|
||||
@@ -654,131 +664,128 @@ export function PricingWindow({
|
||||
<Panel>
|
||||
<WindowChrome aria-hidden="true" />
|
||||
<PricingHeader>
|
||||
<TitleBar>
|
||||
<TitleBarText>{pricing.windowTitle}</TitleBarText>
|
||||
<TitleBarActions>
|
||||
<TitleBarActionButton
|
||||
aria-label="Help"
|
||||
onClick={() => undefined}
|
||||
type="button"
|
||||
>
|
||||
?
|
||||
</TitleBarActionButton>
|
||||
<TitleBarActionButton
|
||||
aria-label="Close pricing window"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
</TitleBarActionButton>
|
||||
</TitleBarActions>
|
||||
</TitleBar>
|
||||
<SummaryPad>
|
||||
<SummaryInner>
|
||||
<ProductBlock>
|
||||
<ProductHeader>
|
||||
<ProductCopy>
|
||||
<ProductTitle>{pricing.productTitle}</ProductTitle>
|
||||
<PriceRow>
|
||||
{perSeatPriceAmount > pricing.basePriceAmount ? (
|
||||
<BasePriceAmount>
|
||||
{formatPriceAmount(pricing.basePriceAmount)}
|
||||
</BasePriceAmount>
|
||||
<TitleBar>
|
||||
<TitleBarText>{pricing.windowTitle}</TitleBarText>
|
||||
<TitleBarActions>
|
||||
<TitleBarActionButton
|
||||
aria-label="Help"
|
||||
onClick={() => undefined}
|
||||
type="button"
|
||||
>
|
||||
?
|
||||
</TitleBarActionButton>
|
||||
<TitleBarActionButton
|
||||
aria-label="Close pricing window"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
</TitleBarActionButton>
|
||||
</TitleBarActions>
|
||||
</TitleBar>
|
||||
<SummaryPad>
|
||||
<SummaryInner>
|
||||
<ProductBlock>
|
||||
<ProductHeader>
|
||||
<ProductCopy>
|
||||
<ProductTitle>{pricing.productTitle}</ProductTitle>
|
||||
<PriceRow>
|
||||
{perSeatPriceAmount > pricing.basePriceAmount ? (
|
||||
<BasePriceAmount>
|
||||
{formatPriceAmount(pricing.basePriceAmount)}
|
||||
</BasePriceAmount>
|
||||
) : null}
|
||||
<PriceAmount>
|
||||
{formatPriceAmount(animatedPerSeat)}
|
||||
</PriceAmount>
|
||||
<PriceSuffix>{pricing.priceSuffix}</PriceSuffix>
|
||||
</PriceRow>
|
||||
{fixedPriceAmount > 0 ? (
|
||||
<TotalPriceRow>
|
||||
<TotalPriceAmount>
|
||||
{formatPriceAmount(animatedTotal)}
|
||||
</TotalPriceAmount>
|
||||
<TotalPriceLabel>
|
||||
{pricing.totalPriceLabel}
|
||||
</TotalPriceLabel>
|
||||
</TotalPriceRow>
|
||||
) : null}
|
||||
<PriceAmount>
|
||||
{formatPriceAmount(animatedPerSeat)}
|
||||
</PriceAmount>
|
||||
<PriceSuffix>{pricing.priceSuffix}</PriceSuffix>
|
||||
</PriceRow>
|
||||
{fixedPriceAmount > 0 ? (
|
||||
<TotalPriceRow>
|
||||
<TotalPriceAmount>
|
||||
{formatPriceAmount(animatedTotal)}
|
||||
</TotalPriceAmount>
|
||||
<TotalPriceLabel>
|
||||
{pricing.totalPriceLabel}
|
||||
</TotalPriceLabel>
|
||||
</TotalPriceRow>
|
||||
) : null}
|
||||
</ProductCopy>
|
||||
<ProductIcon
|
||||
alt={pricing.productIconAlt}
|
||||
src={pricing.productIconSrc}
|
||||
/>
|
||||
</ProductHeader>
|
||||
</ProductBlock>
|
||||
<Separator aria-hidden="true" />
|
||||
</SummaryInner>
|
||||
</SummaryPad>
|
||||
</PricingHeader>
|
||||
<ContentPad>
|
||||
<Inner>
|
||||
<SectionHeader>
|
||||
<SectionLabel>{pricing.featureSectionHeading}</SectionLabel>
|
||||
<SelectAllButton
|
||||
onClick={onSelectAll}
|
||||
type="button"
|
||||
>
|
||||
Select all
|
||||
</SelectAllButton>
|
||||
</SectionHeader>
|
||||
{pricing.addons.map((addon) => {
|
||||
const checked = checkedIds.has(addon.id);
|
||||
return (
|
||||
<AddonRow key={addon.id}>
|
||||
<CheckboxLabel
|
||||
disabled={addon.disabled}
|
||||
ref={(node) => {
|
||||
addonAnchorRefs.current[addon.id] = node;
|
||||
}}
|
||||
>
|
||||
<HiddenCheckbox
|
||||
checked={checked}
|
||||
disabled={addon.disabled}
|
||||
onChange={() =>
|
||||
onAddonToggle(
|
||||
addon,
|
||||
addonAnchorRefs.current[
|
||||
addon.id
|
||||
]?.getBoundingClientRect() ?? null,
|
||||
)
|
||||
}
|
||||
type="checkbox"
|
||||
</ProductCopy>
|
||||
<ProductIcon
|
||||
alt={pricing.productIconAlt}
|
||||
src={pricing.productIconSrc}
|
||||
/>
|
||||
<CheckboxFace checked={checked} aria-hidden="true">
|
||||
{checked ? <CheckGlyph>✓</CheckGlyph> : null}
|
||||
</CheckboxFace>
|
||||
<AddonLabelText>{addon.label}</AddonLabelText>
|
||||
</CheckboxLabel>
|
||||
<AddonRightText>
|
||||
{addon.rightLabelParts
|
||||
? renderRightLabelParts(addon.rightLabelParts)
|
||||
: renderRightLabel(addon.rightLabel)}
|
||||
</AddonRightText>
|
||||
{addon.tooltip ? (
|
||||
<Tooltip>
|
||||
<TooltipTitleBar>{addon.tooltip.title}</TooltipTitleBar>
|
||||
<TooltipBody>{addon.tooltip.body}</TooltipBody>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</AddonRow>
|
||||
);
|
||||
})}
|
||||
<FooterCtaSection>
|
||||
<Separator aria-hidden="true" />
|
||||
{pricing.secondaryCtaNote ? (
|
||||
<FooterNote>{pricing.secondaryCtaNote}</FooterNote>
|
||||
) : null}
|
||||
<FakeButton
|
||||
href={pricing.secondaryCtaHref}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{pricing.secondaryCtaLabel}
|
||||
</FakeButton>
|
||||
</FooterCtaSection>
|
||||
</Inner>
|
||||
</ContentPad>
|
||||
</ProductHeader>
|
||||
</ProductBlock>
|
||||
<Separator aria-hidden="true" />
|
||||
</SummaryInner>
|
||||
</SummaryPad>
|
||||
</PricingHeader>
|
||||
<ContentPad>
|
||||
<Inner>
|
||||
<SectionHeader>
|
||||
<SectionLabel>{pricing.featureSectionHeading}</SectionLabel>
|
||||
<SelectAllButton onClick={onSelectAll} type="button">
|
||||
Select all
|
||||
</SelectAllButton>
|
||||
</SectionHeader>
|
||||
{pricing.addons.map((addon) => {
|
||||
const checked = checkedIds.has(addon.id);
|
||||
return (
|
||||
<AddonRow key={addon.id}>
|
||||
<CheckboxLabel
|
||||
disabled={addon.disabled}
|
||||
ref={(node) => {
|
||||
addonAnchorRefs.current[addon.id] = node;
|
||||
}}
|
||||
>
|
||||
<HiddenCheckbox
|
||||
checked={checked}
|
||||
disabled={addon.disabled}
|
||||
onChange={() =>
|
||||
onAddonToggle(
|
||||
addon,
|
||||
addonAnchorRefs.current[
|
||||
addon.id
|
||||
]?.getBoundingClientRect() ?? null,
|
||||
)
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
<CheckboxFace checked={checked} aria-hidden="true">
|
||||
{checked ? <CheckGlyph>✓</CheckGlyph> : null}
|
||||
</CheckboxFace>
|
||||
<AddonLabelText>{addon.label}</AddonLabelText>
|
||||
</CheckboxLabel>
|
||||
<AddonRightText>
|
||||
{addon.rightLabelParts
|
||||
? renderRightLabelParts(addon.rightLabelParts)
|
||||
: renderRightLabel(addon.rightLabel)}
|
||||
</AddonRightText>
|
||||
{addon.tooltip ? (
|
||||
<Tooltip>
|
||||
<TooltipTitleBar>{addon.tooltip.title}</TooltipTitleBar>
|
||||
<TooltipBody>{addon.tooltip.body}</TooltipBody>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</AddonRow>
|
||||
);
|
||||
})}
|
||||
<FooterCtaSection>
|
||||
<Separator aria-hidden="true" />
|
||||
{pricing.secondaryCtaNote ? (
|
||||
<FooterNote>{pricing.secondaryCtaNote}</FooterNote>
|
||||
) : null}
|
||||
<FakeButton
|
||||
href={pricing.secondaryCtaHref}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{pricing.secondaryCtaLabel}
|
||||
</FakeButton>
|
||||
</FooterCtaSection>
|
||||
</Inner>
|
||||
</ContentPad>
|
||||
</Panel>
|
||||
</PanelWrapper>
|
||||
);
|
||||
|
||||
+10
-6
@@ -162,6 +162,8 @@ export function Carousel({ children, eyebrow, testimonials }: CarouselProps) {
|
||||
const hasPrevious = index > 0;
|
||||
const hasNext = index < total - 1;
|
||||
const current = testimonials[index];
|
||||
const authorSecondaryLine =
|
||||
current.author.designation ?? current.author.handle ?? null;
|
||||
|
||||
const goToPrevious = () => {
|
||||
if (hasPrevious) setIndex(index - 1);
|
||||
@@ -246,12 +248,14 @@ export function Carousel({ children, eyebrow, testimonials }: CarouselProps) {
|
||||
size="sm"
|
||||
weight="medium"
|
||||
/>
|
||||
<Body
|
||||
as="span"
|
||||
body={current.author.designation}
|
||||
size="xs"
|
||||
weight="light"
|
||||
/>
|
||||
{authorSecondaryLine ? (
|
||||
<Body
|
||||
as="span"
|
||||
body={authorSecondaryLine}
|
||||
size="xs"
|
||||
weight="light"
|
||||
/>
|
||||
) : null}
|
||||
</AuthorBlock>
|
||||
</FooterRow>
|
||||
</RightColumn>
|
||||
|
||||
+2
-5
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Body, Heading, IconButton } from '@/design-system/components';
|
||||
import { ArrowRightIcon } from '@/icons';
|
||||
import { THREE_CARDS_ILLUSTRATIONS } from '@/illustrations';
|
||||
import { IllustrationMount } from '@/illustrations';
|
||||
import type { ThreeCardsIllustrationCardType } from '@/sections/ThreeCards/types';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
@@ -79,9 +79,6 @@ export function IllustrationCard({
|
||||
illustrationCard,
|
||||
variant = 'shaped',
|
||||
}: IllustrationCardProps) {
|
||||
const ThreeCardsIllustration =
|
||||
THREE_CARDS_ILLUSTRATIONS[illustrationCard.illustration];
|
||||
|
||||
return (
|
||||
<IllustrationCardContainer>
|
||||
{variant === 'shaped' && (
|
||||
@@ -98,7 +95,7 @@ export function IllustrationCard({
|
||||
/>
|
||||
<CardRule />
|
||||
<CardEmbed>
|
||||
<ThreeCardsIllustration />
|
||||
<IllustrationMount illustration={illustrationCard.illustration} />
|
||||
</CardEmbed>
|
||||
<CardRule />
|
||||
<CardBodyCell>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export const colors = {
|
||||
primary: {
|
||||
background: { 100: 'var(--color-white-100)' },
|
||||
background: {
|
||||
100: 'var(--color-white-100)',
|
||||
hover: 'var(--color-black-100)',
|
||||
},
|
||||
text: {
|
||||
100: 'var(--color-black-100)',
|
||||
80: 'var(--color-black-80)',
|
||||
@@ -23,6 +26,7 @@ export const colors = {
|
||||
secondary: {
|
||||
background: {
|
||||
100: 'var(--color-black-100)',
|
||||
hover: 'var(--color-black-hover)',
|
||||
80: 'var(--color-black-80)',
|
||||
5: 'var(--color-black-5)',
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ export const cssVariables = css`
|
||||
--color-white-10: #FFFFFF1A;
|
||||
|
||||
--color-black-100: #1C1C1C;
|
||||
--color-black-hover: #333333;
|
||||
--color-black-80: #1C1C1CCC;
|
||||
--color-black-60: #1C1C1C99;
|
||||
--color-black-40: #1C1C1C66;
|
||||
|
||||
Reference in New Issue
Block a user