Refactor the website hero into an interactive multi-view illustration (#19429)

## Summary
- replace the home hero visual with an interactive multi-view experience
for table, kanban, workflow, and dashboard states
- add the supporting hero data model, page normalizers, loaders, chips,
and sales dashboard assets
- update related website illustration components and ignore local Claude
worktrees in `.gitignore`

## Testing
- Not run (not requested)
This commit is contained in:
Thomas des Francs
2026-04-08 15:00:41 +02:00
committed by GitHub
parent 83917f0dca
commit 0e0fb246e6
15 changed files with 4469 additions and 866 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
import { styled } from '@linaria/react';
import { motion } from 'framer-motion';
import { VISUAL_TOKENS } from './homeVisualTokens';
const DEFAULT_LOADER_COLOR = `var(--tw-button-color, ${VISUAL_TOKENS.font.color.tertiary})`;
const StyledLoaderContainer = styled.div`
align-items: center;
border: 1px solid ${DEFAULT_LOADER_COLOR};
border-radius: ${VISUAL_TOKENS.border.radius.pill};
box-sizing: border-box;
display: flex;
gap: ${VISUAL_TOKENS.spacing[2]};
height: 12px;
justify-content: center;
overflow: hidden;
width: 24px;
`;
const StyledLoader = styled(motion.div)`
background-color: ${DEFAULT_LOADER_COLOR};
border-radius: ${VISUAL_TOKENS.border.radius.pill};
height: 8px;
width: 8px;
`;
type HomeVisualLoaderProps = {
color?: string;
};
export function HomeVisualLoader({ color }: HomeVisualLoaderProps) {
return (
<StyledLoaderContainer
style={color ? { borderColor: color } : undefined}
>
<StyledLoader
animate={{
x: [-16, 0, 16],
width: [8, 12, 8],
height: [8, 2, 8],
}}
style={color ? { backgroundColor: color } : undefined}
transition={{
duration: 0.8,
times: [0, 0.15, 0.3],
repeat: Infinity,
}}
/>
</StyledLoaderContainer>
);
}
@@ -0,0 +1,638 @@
'use client';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import {
IconBuildingSkyscraper,
IconCalendarEvent,
IconCheck,
IconCurrencyDollar,
IconId,
IconPlus,
IconStar,
IconUser,
IconUserCircle,
} from '@tabler/icons-react';
import { useState } from 'react';
import type {
HeroCellEntity,
HeroCellPerson,
HeroKanbanCardType,
HeroKanbanLaneType,
HeroKanbanPageDefinition,
} from '../../types/HeroHomeData';
import { Chip } from './homeVisualChip';
import { VISUAL_TOKENS } from './homeVisualTokens';
const APP_FONT = VISUAL_TOKENS.font.family;
const TABLER_STROKE = 1.6;
const LANE_WIDTH = 206.4;
const COLORS = {
accentBorder: VISUAL_TOKENS.border.color.blue,
accentSurfaceSoft: VISUAL_TOKENS.background.transparent.blue,
background: VISUAL_TOKENS.background.primary,
backgroundSecondary: VISUAL_TOKENS.background.secondary,
border: VISUAL_TOKENS.border.color.medium,
borderLight: VISUAL_TOKENS.border.color.light,
borderStrong: VISUAL_TOKENS.border.color.strong,
shadow: VISUAL_TOKENS.boxShadow.light,
text: VISUAL_TOKENS.font.color.primary,
textLight: VISUAL_TOKENS.font.color.light,
textSecondary: VISUAL_TOKENS.font.color.secondary,
textTertiary: VISUAL_TOKENS.font.color.tertiary,
};
const PERSON_TONES: Record<string, { background: string; color: string }> = {
amber: { background: '#f6e6d7', color: '#7a4f2a' },
blue: { background: '#dbeafe', color: '#1d4ed8' },
gray: { background: '#e5e7eb', color: '#4b5563' },
green: { background: '#dcfce7', color: '#15803d' },
pink: { background: '#ffe4e6', color: '#be123c' },
purple: { background: '#ede9fe', color: '#6d28d9' },
red: { background: '#fee2e2', color: '#b91c1c' },
teal: { background: '#ccfbf1', color: '#0f766e' },
};
const LANE_TONES: Record<string, { background: string; color: string }> = {
blue: { background: '#def4ff', color: '#007bb8' },
gray: { background: '#f3f1ef', color: '#666666' },
green: { background: '#dcf7ed', color: '#1a7f50' },
pink: { background: '#fce5f3', color: '#d6409f' },
purple: { background: '#ede9fe', color: '#8e4ec6' },
};
const failedAvatarUrls = new Set<string>();
const failedFaviconUrls = new Set<string>();
const BoardShell = styled.div`
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow: auto;
scrollbar-width: none;
width: 100%;
&::-webkit-scrollbar {
display: none;
}
`;
const BoardCanvas = styled.div<{ $laneCount: number }>`
box-sizing: border-box;
display: grid;
grid-template-columns: repeat(
${({ $laneCount }) => $laneCount},
minmax(${LANE_WIDTH}px, 1fr)
);
min-height: 100%;
min-width: ${({ $laneCount }) =>
`max(100%, ${$laneCount * LANE_WIDTH + 16}px)`};
padding: 0 8px;
width: 100%;
`;
const Lane = styled.div<{ $last?: boolean }>`
border-right: ${({ $last }) =>
$last ? 'none' : `1px solid ${COLORS.borderLight}`};
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
`;
const LaneHeader = styled.div`
align-items: center;
display: flex;
gap: 4px;
min-height: 40px;
padding: 8px;
`;
const LaneTag = styled.span<{ $background: string; $color: string }>`
align-items: center;
background: ${({ $background }) => $background};
border-radius: 4px;
color: ${({ $color }) => $color};
display: inline-flex;
font-family: ${APP_FONT};
font-size: 13px;
font-weight: ${theme.font.weight.medium};
height: 20px;
line-height: 1.4;
padding: 0 8px;
white-space: nowrap;
`;
const LaneCount = styled.span`
color: ${COLORS.textTertiary};
font-family: ${APP_FONT};
font-size: 13px;
font-weight: ${theme.font.weight.regular};
line-height: 1.4;
white-space: nowrap;
`;
const LaneBody = styled.div`
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 8px;
min-height: 0;
padding: 0 8px 8px;
`;
const Card = styled.div`
background: ${COLORS.backgroundSecondary};
border: 1px solid ${COLORS.border};
border-radius: 4px;
box-shadow: ${COLORS.shadow};
display: flex;
flex-direction: column;
overflow: hidden;
`;
const CardHeader = styled.div`
align-items: center;
display: flex;
gap: 8px;
justify-content: space-between;
padding: 8px 8px 4px;
`;
const CardTitle = styled.span`
color: ${COLORS.text};
font-family: ${APP_FONT};
font-size: 13px;
font-weight: ${theme.font.weight.medium};
line-height: 1.4;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const CheckboxContainer = styled.div`
align-items: center;
display: flex;
flex: 0 0 24px;
height: 24px;
justify-content: center;
width: 24px;
`;
const CheckboxBox = styled.div<{ $checked?: boolean }>`
align-items: center;
background: ${({ $checked }) =>
$checked ? COLORS.accentSurfaceSoft : 'transparent'};
border: 1px solid
${({ $checked }) => ($checked ? COLORS.accentBorder : COLORS.borderStrong)};
border-radius: 3px;
color: ${COLORS.textSecondary};
display: flex;
height: 14px;
justify-content: center;
width: 14px;
`;
const CardFields = styled.div`
display: flex;
flex-direction: column;
gap: 2px;
padding: 0 8px 4px 10px;
`;
const FieldRow = styled.div`
align-items: center;
display: flex;
gap: 4px;
min-height: 24px;
width: 100%;
`;
const FieldIcon = styled.div`
align-items: center;
color: ${COLORS.textTertiary};
display: flex;
flex: 0 0 16px;
height: 16px;
justify-content: center;
width: 16px;
`;
const FieldValueWrap = styled.div`
align-items: center;
display: flex;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
`;
const FieldText = styled.span`
color: ${COLORS.text};
font-family: ${APP_FONT};
font-size: 13px;
font-weight: ${theme.font.weight.regular};
line-height: 1.4;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StarsRow = styled.div`
align-items: center;
display: inline-flex;
gap: 2px;
padding: 0 4px;
`;
const StarGlyph = styled.span<{ $filled: boolean }>`
color: ${({ $filled }) => ($filled ? COLORS.textSecondary : '#d6d6d6')};
font-family: ${APP_FONT};
font-size: 13px;
line-height: 1;
`;
const AddCardButton = styled.div`
align-items: center;
color: ${COLORS.textTertiary};
display: inline-flex;
font-family: ${APP_FONT};
font-size: 13px;
font-weight: ${theme.font.weight.regular};
gap: 4px;
height: 24px;
line-height: 1.4;
padding: 0 4px;
white-space: nowrap;
`;
const PersonAvatarCircle = styled.div<{
$background: string;
$color: string;
$square?: boolean;
}>`
align-items: center;
background: ${({ $background }) => $background};
border-radius: ${({ $square }) => ($square ? '4px' : '999px')};
color: ${({ $color }) => $color};
display: flex;
flex: 0 0 auto;
font-family: ${APP_FONT};
font-size: 10px;
font-weight: ${theme.font.weight.medium};
height: 14px;
justify-content: center;
overflow: hidden;
width: 14px;
`;
const AvatarImage = styled.img`
display: block;
height: 100%;
object-fit: cover;
width: 100%;
`;
const FaviconImage = styled.img`
display: block;
height: 100%;
object-fit: contain;
width: 100%;
`;
function getInitials(value: string) {
return value
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase();
}
function sanitizeURL(link: string | null | undefined) {
return link
? link.replace(/(https?:\/\/)|(www\.)/g, '').replace(/\/$/, '')
: '';
}
function getLogoUrlFromDomainName(domainName?: string): string | undefined {
const sanitizedDomain = sanitizeURL(domainName);
return sanitizedDomain
? `https://twenty-icons.com/${sanitizedDomain}`
: undefined;
}
function FaviconLogo({
src,
domain,
label,
size = 14,
}: {
domain?: string;
label?: string;
size?: number;
src?: string;
}) {
const faviconUrl = src ?? getLogoUrlFromDomainName(domain);
const [localFailedUrl, setLocalFailedUrl] = useState<string | null>(null);
const showFavicon =
faviconUrl !== undefined &&
!failedFaviconUrls.has(faviconUrl) &&
localFailedUrl !== faviconUrl;
const baseStyle = {
alignItems: 'center',
borderRadius: '4px',
display: 'flex',
flex: '0 0 auto',
fontFamily: APP_FONT,
fontSize: size <= 14 ? '8px' : '9px',
fontWeight: 600,
height: `${size}px`,
justifyContent: 'center',
lineHeight: 1,
overflow: 'hidden',
width: `${size}px`,
} as const;
if (showFavicon) {
return (
<div style={baseStyle}>
<FaviconImage
alt={label ? `${label} logo` : ''}
src={faviconUrl}
onError={() => {
failedFaviconUrls.add(faviconUrl);
setLocalFailedUrl(faviconUrl);
}}
/>
</div>
);
}
const initials = label ? getInitials(label) : '?';
return (
<div style={{ ...baseStyle, background: '#ebebeb', color: '#666666' }}>
{initials.slice(0, 1)}
</div>
);
}
function PersonAvatarContent({ token }: { token: HeroCellPerson }) {
const [localFailedUrl, setLocalFailedUrl] = useState<string | null>(null);
const showAvatar =
token.avatarUrl !== undefined &&
!failedAvatarUrls.has(token.avatarUrl) &&
localFailedUrl !== token.avatarUrl;
if (showAvatar) {
return (
<AvatarImage
alt=""
src={token.avatarUrl}
onError={() => {
if (token.avatarUrl) {
failedAvatarUrls.add(token.avatarUrl);
setLocalFailedUrl(token.avatarUrl);
}
}}
/>
);
}
return token.shortLabel ?? getInitials(token.name);
}
function EntityChip({ entity }: { entity: HeroCellEntity }) {
return (
<Chip
clickable={false}
label={entity.name}
leftComponent={<FaviconLogo domain={entity.domain} label={entity.name} />}
maxWidth={152}
/>
);
}
function PersonChip({ person }: { person: HeroCellPerson }) {
const tone = PERSON_TONES[person.tone ?? 'gray'] ?? PERSON_TONES.gray;
const square =
person.kind === 'api' ||
person.kind === 'system' ||
person.kind === 'workflow';
return (
<Chip
clickable={false}
label={person.name}
leftComponent={
<PersonAvatarCircle
$background={tone.background}
$color={tone.color}
$square={square}
>
<PersonAvatarContent token={person} />
</PersonAvatarCircle>
}
maxWidth={152}
/>
);
}
function RatingValue({ rating }: { rating: number }) {
return (
<StarsRow>
{Array.from({ length: 5 }, (_, index) => (
<StarGlyph key={index} $filled={index < rating}>
{index < rating ? '★' : '★'}
</StarGlyph>
))}
</StarsRow>
);
}
function Checkbox({ checked = false }: { checked?: boolean }) {
return (
<CheckboxContainer>
<CheckboxBox $checked={checked}>
{checked ? (
<IconCheck
aria-hidden
color={COLORS.textSecondary}
size={10}
stroke={TABLER_STROKE}
/>
) : null}
</CheckboxBox>
</CheckboxContainer>
);
}
function KanbanCard({ card }: { card: HeroKanbanCardType }) {
return (
<Card>
<CardHeader>
<CardTitle>{card.title}</CardTitle>
<Checkbox checked={card.checked} />
</CardHeader>
<CardFields>
<FieldRow>
<FieldIcon>
<IconCurrencyDollar
aria-hidden
color={COLORS.textTertiary}
size={16}
stroke={TABLER_STROKE}
/>
</FieldIcon>
<FieldValueWrap>
<FieldText>{card.amount}</FieldText>
</FieldValueWrap>
</FieldRow>
<FieldRow>
<FieldIcon>
<IconBuildingSkyscraper
aria-hidden
color={COLORS.textTertiary}
size={16}
stroke={TABLER_STROKE}
/>
</FieldIcon>
<FieldValueWrap>
<EntityChip entity={card.company} />
</FieldValueWrap>
</FieldRow>
<FieldRow>
<FieldIcon>
<IconUserCircle
aria-hidden
color={COLORS.textTertiary}
size={16}
stroke={TABLER_STROKE}
/>
</FieldIcon>
<FieldValueWrap>
<PersonChip person={card.accountOwner} />
</FieldValueWrap>
</FieldRow>
<FieldRow>
<FieldIcon>
<IconStar
aria-hidden
color={COLORS.textTertiary}
size={16}
stroke={TABLER_STROKE}
/>
</FieldIcon>
<FieldValueWrap>
<RatingValue rating={card.rating} />
</FieldValueWrap>
</FieldRow>
<FieldRow>
<FieldIcon>
<IconCalendarEvent
aria-hidden
color={COLORS.textTertiary}
size={16}
stroke={TABLER_STROKE}
/>
</FieldIcon>
<FieldValueWrap>
<FieldText>{card.date}</FieldText>
</FieldValueWrap>
</FieldRow>
<FieldRow>
<FieldIcon>
<IconUser
aria-hidden
color={COLORS.textTertiary}
size={16}
stroke={TABLER_STROKE}
/>
</FieldIcon>
<FieldValueWrap>
<PersonChip person={card.mainContact} />
</FieldValueWrap>
</FieldRow>
<FieldRow>
<FieldIcon>
<IconId
aria-hidden
color={COLORS.textTertiary}
size={16}
stroke={TABLER_STROKE}
/>
</FieldIcon>
<FieldValueWrap>
<FieldText>{card.recordId}</FieldText>
</FieldValueWrap>
</FieldRow>
</CardFields>
</Card>
);
}
function KanbanLane({
lane,
isLast,
}: {
isLast: boolean;
lane: HeroKanbanLaneType;
}) {
const tone = LANE_TONES[lane.tone] ?? LANE_TONES.gray;
return (
<Lane $last={isLast}>
<LaneHeader>
<LaneTag $background={tone.background} $color={tone.color}>
{lane.label}
</LaneTag>
<LaneCount>{lane.cards.length}</LaneCount>
</LaneHeader>
<LaneBody>
{lane.cards.map((card) => (
<KanbanCard key={card.id} card={card} />
))}
<AddCardButton aria-hidden="true">
<IconPlus
aria-hidden
color={COLORS.textLight}
size={12}
stroke={TABLER_STROKE}
/>
New
</AddCardButton>
</LaneBody>
</Lane>
);
}
export function KanbanPage({ page }: { page: HeroKanbanPageDefinition }) {
return (
<BoardShell aria-label={`Interactive preview of the ${page.header.title} board`}>
<BoardCanvas $laneCount={page.lanes.length}>
{page.lanes.map((lane, index) => (
<KanbanLane
key={lane.id}
isLast={index === page.lanes.length - 1}
lane={lane}
/>
))}
</BoardCanvas>
</BoardShell>
);
}
@@ -0,0 +1,25 @@
import { styled } from '@linaria/react';
import { HomeVisualLoader } from './HomeVisualLoader';
const StyledPagePreviewLoader = styled.div`
align-items: center;
display: flex;
height: 100%;
justify-content: center;
min-height: 100%;
min-width: 0;
padding: 24px;
width: 100%;
`;
type PagePreviewLoaderProps = {
ariaLabel: string;
};
export function PagePreviewLoader({ ariaLabel }: PagePreviewLoaderProps) {
return (
<StyledPagePreviewLoader aria-label={ariaLabel}>
<HomeVisualLoader />
</StyledPagePreviewLoader>
);
}
@@ -0,0 +1,150 @@
import type {
HeroDashboardChartImageType,
HeroDashboardPageDefinition,
} from '@/sections/Hero/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { VISUAL_TOKENS } from './homeVisualTokens';
const APP_FONT = VISUAL_TOKENS.font.family;
const CARD_BACKGROUND = VISUAL_TOKENS.background.secondary;
const CARD_BORDER = VISUAL_TOKENS.border.color.light;
const DashboardGrid = styled.div`
display: grid;
gap: 8px;
grid-template-areas:
'metrics'
'visits'
'revenue'
'distribution';
grid-template-columns: minmax(0, 1fr);
min-width: 0;
padding: 8px;
@media (min-width: ${theme.breakpoints.md}px) {
grid-template-areas:
'metrics visits visits'
'revenue revenue distribution';
grid-template-columns: minmax(168px, 252px) minmax(0, 1fr) minmax(
168px,
252px
);
}
`;
const MetricStack = styled.div`
align-self: stretch;
display: grid;
gap: 8px;
grid-area: metrics;
min-width: 0;
@media (min-width: ${theme.breakpoints.md}px) {
grid-template-rows: repeat(3, minmax(0, 1fr));
}
`;
const MetricCard = styled.div`
background: ${CARD_BACKGROUND};
border: 1px solid ${CARD_BORDER};
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 16px;
justify-content: space-between;
min-height: 92px;
padding: 12px;
@media (min-width: ${theme.breakpoints.md}px) {
min-height: 0;
}
`;
const MetricTitle = styled.span`
color: ${VISUAL_TOKENS.font.color.primary};
font-family: ${APP_FONT};
font-size: 13px;
font-weight: ${theme.font.weight.medium};
line-height: 1.4;
`;
const MetricValue = styled.span`
color: ${VISUAL_TOKENS.font.color.primary};
font-family: ${APP_FONT};
font-size: 24px;
font-weight: 600;
line-height: 1.2;
`;
const VisitsChart = styled.div`
grid-area: visits;
min-width: 0;
`;
const RevenueChart = styled.div`
grid-area: revenue;
min-width: 0;
`;
const DistributionChart = styled.div`
grid-area: distribution;
min-width: 0;
`;
const ChartImage = styled.img`
display: block;
height: auto;
max-width: 100%;
width: 100%;
`;
function DashboardChart({
chart,
className,
}: {
chart: HeroDashboardChartImageType;
className?: string;
}) {
return (
<ChartImage
alt={chart.alt}
className={className}
height={chart.height}
loading="eager"
src={chart.src}
width={chart.width}
/>
);
}
export function SalesDashboardPage({
page,
}: {
page: HeroDashboardPageDefinition;
}) {
return (
<DashboardGrid>
<MetricStack>
{page.dashboard.metrics.map((metric) => (
<MetricCard key={metric.id}>
<MetricTitle>{metric.title}</MetricTitle>
<MetricValue>{metric.value}</MetricValue>
</MetricCard>
))}
</MetricStack>
<VisitsChart>
<DashboardChart chart={page.dashboard.visitsChart} />
</VisitsChart>
<RevenueChart>
<DashboardChart chart={page.dashboard.revenueChart} />
</RevenueChart>
<DistributionChart>
<DashboardChart chart={page.dashboard.distributionChart} />
</DistributionChart>
</DashboardGrid>
);
}
@@ -0,0 +1,495 @@
'use client';
import type { HeroWorkflowPageDefinition } from '@/sections/Hero/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import {
IconCode,
IconFilter,
IconPlug,
IconPlus,
IconRepeat,
IconSearch,
IconSitemap,
} from '@tabler/icons-react';
import { VISUAL_TOKENS } from './homeVisualTokens';
const APP_FONT = VISUAL_TOKENS.font.family;
const TABLER_STROKE = 1.6;
const CANVAS_WIDTH = 1480;
const CANVAS_HEIGHT = 1260;
const NODE_HEIGHT = 48;
const CANVAS_TOP_OFFSET = 16;
type WorkflowNodeDefinition = {
Icon: typeof IconPlug;
id: string;
iconColor: string;
label: 'Trigger' | 'Action';
title: string;
width: number;
x: number;
y: number;
};
type WorkflowBranchLabel = {
text: string;
x: number;
y: number;
};
type WorkflowEdgeDefinition = {
from: string;
to: string;
type: 'branch' | 'curve' | 'vertical';
};
const workflowNodes: WorkflowNodeDefinition[] = [
{
id: 'trigger',
x: 370,
y: 80,
width: 238,
label: 'Trigger',
title: 'Record is created or updated',
Icon: IconPlug,
iconColor: '#4A67F6',
},
{
id: 'is-personal-email',
x: 620,
y: 210,
width: 220,
label: 'Action',
title: 'Is this a personal email?',
Icon: IconCode,
iconColor: '#FF6B5F',
},
{
id: 'if-business-email',
x: 640,
y: 340,
width: 180,
label: 'Action',
title: 'If business email',
Icon: IconFilter,
iconColor: VISUAL_TOKENS.font.color.secondary,
},
{
id: 'extract-domain',
x: 620,
y: 470,
width: 220,
label: 'Action',
title: 'Extract domain from email',
Icon: IconCode,
iconColor: '#FF6B5F',
},
{
id: 'search-company',
x: 640,
y: 600,
width: 180,
label: 'Action',
title: 'Search Company',
Icon: IconSearch,
iconColor: VISUAL_TOKENS.font.color.secondary,
},
{
id: 'find-exact-match',
x: 610,
y: 730,
width: 240,
label: 'Action',
title: 'Find exact company match',
Icon: IconCode,
iconColor: '#FF6B5F',
},
{
id: 'company-already-exists',
x: 600,
y: 860,
width: 260,
label: 'Action',
title: 'If a company already exists',
Icon: IconSitemap,
iconColor: VISUAL_TOKENS.font.color.secondary,
},
{
id: 'attach-existing-company',
x: 370,
y: 990,
width: 240,
label: 'Action',
title: 'Attach person to existing company',
Icon: IconRepeat,
iconColor: VISUAL_TOKENS.font.color.secondary,
},
{
id: 'create-company',
x: 840,
y: 990,
width: 220,
label: 'Action',
title: 'Create a new company',
Icon: IconPlus,
iconColor: VISUAL_TOKENS.font.color.secondary,
},
{
id: 'attach-created-company',
x: 850,
y: 1120,
width: 240,
label: 'Action',
title: 'Attach person to this company',
Icon: IconRepeat,
iconColor: VISUAL_TOKENS.font.color.secondary,
},
];
const workflowEdges: WorkflowEdgeDefinition[] = [
{
from: 'trigger',
to: 'is-personal-email',
type: 'curve',
},
{
from: 'is-personal-email',
to: 'if-business-email',
type: 'vertical',
},
{
from: 'if-business-email',
to: 'extract-domain',
type: 'vertical',
},
{
from: 'extract-domain',
to: 'search-company',
type: 'vertical',
},
{
from: 'search-company',
to: 'find-exact-match',
type: 'vertical',
},
{
from: 'find-exact-match',
to: 'company-already-exists',
type: 'vertical',
},
{
from: 'company-already-exists',
to: 'attach-existing-company',
type: 'branch',
},
{
from: 'company-already-exists',
to: 'create-company',
type: 'branch',
},
{
from: 'create-company',
to: 'attach-created-company',
type: 'vertical',
},
];
const workflowBranchLabels: WorkflowBranchLabel[] = [
{ x: 566, y: 944, text: 'if' },
{ x: 820, y: 944, text: 'else' },
];
const PageShell = styled.div`
background: #ffffff;
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 100%;
min-width: 100%;
`;
const CanvasViewportShell = styled.div`
background: #ffffff;
display: flex;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
position: relative;
`;
const CanvasViewport = styled.div`
display: flex;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow: auto;
`;
const Canvas = styled.div`
background-color: #ffffff;
background-image: radial-gradient(circle, #ebebeb 1px, transparent 1.2px);
background-position: 10px 10px;
background-size: 20px 20px;
box-sizing: border-box;
height: ${CANVAS_HEIGHT + CANVAS_TOP_OFFSET}px;
min-height: 100%;
min-width: 100%;
overflow: hidden;
position: relative;
width: ${CANVAS_WIDTH}px;
`;
const CanvasContent = styled.div`
height: ${CANVAS_HEIGHT}px;
left: calc((100% - ${CANVAS_WIDTH}px) / 2);
position: absolute;
top: ${CANVAS_TOP_OFFSET}px;
width: ${CANVAS_WIDTH}px;
`;
const CanvasOverlay = styled.svg`
inset: 0;
overflow: visible;
pointer-events: none;
position: absolute;
`;
const ActiveBadge = styled.div`
left: 8px;
position: absolute;
pointer-events: none;
top: 8px;
z-index: 3;
`;
const ActiveBadgeLabel = styled.span`
align-items: center;
background: #dff3e6;
border-radius: 4px;
color: #228b52;
display: inline-flex;
font-family: ${APP_FONT};
font-size: 13px;
font-weight: ${theme.font.weight.regular};
height: 20px;
line-height: 1.4;
padding: 0 8px;
`;
const Node = styled.div`
align-items: center;
background: ${VISUAL_TOKENS.background.secondary};
border: 1px solid ${VISUAL_TOKENS.border.color.strong};
border-radius: 8px;
box-sizing: border-box;
display: flex;
gap: 8px;
height: ${NODE_HEIGHT}px;
left: 0;
padding: 8px;
position: absolute;
top: 0;
z-index: 1;
`;
const NodeIconContainer = styled.div`
align-items: center;
background: ${VISUAL_TOKENS.background.transparent.light};
border-radius: 4px;
display: flex;
flex: 0 0 auto;
height: 32px;
justify-content: center;
width: 32px;
`;
const NodeContent = styled.div`
align-items: stretch;
align-self: stretch;
display: flex;
flex: 1 1 auto;
flex-direction: column;
justify-content: space-between;
max-width: 184px;
min-width: 0;
padding-bottom: 2px;
`;
const NodeLabel = styled.div`
color: ${VISUAL_TOKENS.font.color.tertiary};
font-family: ${APP_FONT};
font-size: 11px;
font-weight: 600;
line-height: 1;
`;
const NodeTitle = styled.div`
color: ${VISUAL_TOKENS.font.color.primary};
font-family: ${APP_FONT};
font-size: 13px;
font-weight: ${theme.font.weight.medium};
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const BranchLabel = styled.div`
align-items: center;
background: ${VISUAL_TOKENS.background.secondary};
border: 1px solid ${VISUAL_TOKENS.border.color.medium};
border-radius: 6px;
color: ${VISUAL_TOKENS.font.color.tertiary};
display: inline-flex;
font-family: ${APP_FONT};
font-size: 11px;
font-weight: ${theme.font.weight.medium};
height: 22px;
justify-content: center;
min-width: 24px;
padding: 0 6px;
position: absolute;
z-index: 2;
`;
function getNodeById(nodeId: string) {
const node = workflowNodes.find((workflowNode) => workflowNode.id === nodeId);
if (!node) {
throw new Error(`Unknown workflow node: ${nodeId}`);
}
return node;
}
function getNodeTopCenter(node: WorkflowNodeDefinition) {
return {
x: node.x + node.width / 2,
y: node.y,
};
}
function getNodeBottomCenter(node: WorkflowNodeDefinition) {
return {
x: node.x + node.width / 2,
y: node.y + NODE_HEIGHT + 1,
};
}
function getWorkflowEdgePath(edge: WorkflowEdgeDefinition) {
const fromNode = getNodeById(edge.from);
const toNode = getNodeById(edge.to);
const start = getNodeBottomCenter(fromNode);
const end = getNodeTopCenter(toNode);
if (edge.type === 'vertical') {
return `M${start.x} ${start.y} L${end.x} ${end.y}`;
}
if (edge.type === 'curve') {
const controlStartY = start.y + 28;
const controlEndY = end.y - 28;
return `M${start.x} ${start.y} C${start.x} ${controlStartY} ${end.x} ${controlEndY} ${end.x} ${end.y}`;
}
const controlStartY = start.y + 28;
const controlEndY = end.y - 28;
return `M${start.x} ${start.y} C${start.x} ${controlStartY} ${end.x} ${controlEndY} ${end.x} ${end.y}`;
}
function WorkflowNode({
x,
y,
width,
label,
title,
Icon,
iconColor,
}: WorkflowNodeDefinition) {
return (
<Node style={{ left: x, top: y, width }}>
<NodeIconContainer>
<Icon aria-hidden color={iconColor} size={20} stroke={TABLER_STROKE} />
</NodeIconContainer>
<NodeContent>
<NodeLabel>{label}</NodeLabel>
<NodeTitle>{title}</NodeTitle>
</NodeContent>
</Node>
);
}
export function WorkflowPage({ page }: { page: HeroWorkflowPageDefinition }) {
return (
<PageShell>
<CanvasViewportShell>
<ActiveBadge>
<ActiveBadgeLabel>Active</ActiveBadgeLabel>
</ActiveBadge>
<CanvasViewport
aria-label={`Interactive preview of the ${page.header.title.toLowerCase()} workflow`}
>
<Canvas>
<CanvasContent>
<CanvasOverlay
aria-hidden
viewBox={`0 0 ${CANVAS_WIDTH} ${CANVAS_HEIGHT}`}
>
<defs>
<marker
id="hero-workflow-arrow"
markerHeight="6"
markerWidth="6"
orient="auto"
refX="5"
refY="3"
>
<path
d="M0.75 0.75 L5 3 L0.75 5.25"
fill="none"
stroke="#d8d2cb"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.1"
/>
</marker>
</defs>
{workflowEdges.map((edge) => (
<path
key={`${edge.from}-${edge.to}`}
d={getWorkflowEdgePath(edge)}
fill="none"
markerEnd="url(#hero-workflow-arrow)"
stroke="#d8d2cb"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1"
/>
))}
</CanvasOverlay>
{workflowNodes.map((node) => (
<WorkflowNode
key={`${node.title}-${node.x}-${node.y}`}
{...node}
/>
))}
{workflowBranchLabels.map((label) => (
<BranchLabel
key={`${label.text}-${label.x}-${label.y}`}
style={{ left: label.x, top: label.y }}
>
{label.text}
</BranchLabel>
))}
</CanvasContent>
</Canvas>
</CanvasViewport>
</CanvasViewportShell>
</PageShell>
);
}
@@ -1,5 +1,10 @@
import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
import {
type KeyboardEvent,
type MouseEvent,
type PointerEvent,
type ReactNode,
} from 'react';
import { VISUAL_TOKENS } from './homeVisualTokens';
@@ -18,6 +23,7 @@ export type ChipProps = {
leftComponent?: ReactNode | null;
className?: string;
maxWidth?: number;
onClick?: () => void;
variant?: ChipVariant;
};
@@ -126,15 +132,51 @@ export const Chip = ({
leftComponent = null,
className,
maxWidth,
onClick,
variant = ChipVariant.Regular,
}: ChipProps) => {
const isInteractive = clickable || onClick !== undefined;
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (!isInteractive) {
return;
}
event.stopPropagation();
};
const handleClick = (event: MouseEvent<HTMLDivElement>) => {
if (!isInteractive) {
return;
}
event.stopPropagation();
onClick?.();
};
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (!onClick) {
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onClick();
}
};
return (
<StyledContainer
clickable={clickable}
clickable={isInteractive}
isBold={isBold}
variant={variant}
className={className}
maxWidth={maxWidth}
onClick={handleClick}
onKeyDown={handleKeyDown}
onPointerDown={handlePointerDown}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
>
{leftComponent}
<StyledLabel>{label}</StyledLabel>
@@ -0,0 +1,94 @@
import type {
HeroKanbanPageDefinition,
HeroPageDefinition,
HeroSidebarItem,
HeroTablePageDefinition,
HeroWorkflowPageDefinition,
} from '../../types/HeroHomeData';
export type HeroPageDefaults = {
defaultActions: string[];
defaultTableWidth?: number;
};
function normalizeTablePage(
page: HeroTablePageDefinition,
defaults: HeroPageDefaults,
): HeroTablePageDefinition {
return {
...page,
header: {
...page.header,
actions: page.header.actions ?? defaults.defaultActions,
count: page.header.count ?? page.rows.length,
showListIcon: page.header.showListIcon ?? true,
},
width: page.width ?? defaults.defaultTableWidth,
};
}
function normalizeKanbanPage(
page: HeroKanbanPageDefinition,
defaults: HeroPageDefaults,
): HeroKanbanPageDefinition {
return {
...page,
header: {
...page.header,
actions: page.header.actions ?? defaults.defaultActions,
count:
page.header.count ??
page.lanes.reduce((sum, lane) => sum + lane.cards.length, 0),
showListIcon: page.header.showListIcon ?? true,
},
};
}
function normalizeExplicitPage(
page: HeroPageDefinition,
defaults: HeroPageDefaults,
): HeroPageDefinition {
if (page.type === 'table') {
return normalizeTablePage(page, defaults);
}
if (page.type === 'kanban') {
return normalizeKanbanPage(page, defaults);
}
if (page.type === 'workflow') {
return normalizeWorkflowPage(page);
}
return {
...page,
header: {
...page.header,
showListIcon: page.header.showListIcon ?? false,
},
};
}
function normalizeWorkflowPage(
page: HeroWorkflowPageDefinition,
): HeroWorkflowPageDefinition {
return {
...page,
header: {
...page.header,
actions: [],
showListIcon: false,
},
};
}
export function normalizeHeroPage(
item: HeroSidebarItem,
defaults: HeroPageDefaults,
): HeroPageDefinition | null {
if (!item.page) {
return null;
}
return normalizeExplicitPage(item.page, defaults);
}
@@ -4,6 +4,7 @@ import type { HeroBaseDataType } from '@/sections/Hero/types/HeroBaseData';
export type HeroCellText = {
type: 'text';
targetLabel?: string;
value: string;
shortLabel?: string;
tone?: string;
@@ -58,6 +59,96 @@ export type HeroRowDef = {
cells: Record<string, HeroCellValue>;
};
export type HeroDashboardMetricType = {
id: string;
title: string;
value: string;
};
export type HeroDashboardChartImageType = {
alt: string;
height: number;
src: string;
width: number;
};
export type HeroDashboardDataType = {
distributionChart: HeroDashboardChartImageType;
metrics: HeroDashboardMetricType[];
revenueChart: HeroDashboardChartImageType;
visitsChart: HeroDashboardChartImageType;
};
export type HeroNavbarActionType = {
desktopOnly?: boolean;
icon: string;
label?: string;
labelTone?: 'primary' | 'secondary' | 'tertiary';
trailingLabel?: string;
variant?: 'button' | 'icon';
};
export type HeroPageHeaderType = {
actions?: string[];
count?: number;
navbarActions?: HeroNavbarActionType[];
showListIcon?: boolean;
title: string;
};
export type HeroTablePageDefinition = {
columns: HeroColumnDef[];
header: HeroPageHeaderType;
rows: HeroRowDef[];
type: 'table';
width?: number;
};
export type HeroDashboardPageDefinition = {
dashboard: HeroDashboardDataType;
header: HeroPageHeaderType;
type: 'dashboard';
};
export type HeroWorkflowPageDefinition = {
header: HeroPageHeaderType;
type: 'workflow';
};
export type HeroKanbanCardType = {
accountOwner: HeroCellPerson;
amount: string;
checked?: boolean;
company: HeroCellEntity;
date: string;
id: string;
mainContact: HeroCellPerson;
rating: number;
recordId: string;
title: string;
};
export type HeroKanbanLaneType = {
cards: HeroKanbanCardType[];
id: string;
label: string;
tone: string;
};
export type HeroKanbanPageDefinition = {
header: HeroPageHeaderType;
lanes: HeroKanbanLaneType[];
type: 'kanban';
};
export type HeroPageDefinition =
| HeroDashboardPageDefinition
| HeroKanbanPageDefinition
| HeroTablePageDefinition
| HeroWorkflowPageDefinition;
export type HeroPageType = HeroPageDefinition['type'];
// -- Sidebar icon --
export type HeroSidebarIcon =
@@ -70,6 +161,7 @@ export type HeroSidebarIcon =
overlay?: 'link';
}
| {
color?: string;
kind: 'avatar';
label: string;
tone: string;
@@ -83,14 +175,11 @@ export type HeroSidebarItem = {
label: string;
href?: string;
icon: HeroSidebarIcon;
page?: HeroPageDefinition;
meta?: string;
active?: boolean;
showChevron?: boolean;
children?: HeroSidebarItem[];
columns?: HeroColumnDef[];
rows?: HeroRowDef[];
viewLabel?: string;
viewCount?: number;
};
export type HeroSidebarFolder = {
@@ -1,10 +1,21 @@
export type { HeroBaseDataType } from './HeroBaseData';
export type {
HeroDashboardChartImageType,
HeroDashboardDataType,
HeroDashboardMetricType,
HeroDashboardPageDefinition,
HeroKanbanCardType,
HeroKanbanLaneType,
HeroKanbanPageDefinition,
HeroWorkflowPageDefinition,
HeroCellBoolean,
HeroCellEntity,
HeroCellLink,
HeroCellNumber,
HeroCellPerson,
HeroPageDefinition,
HeroNavbarActionType,
HeroPageHeaderType,
HeroCellRelation,
HeroCellTag,
HeroCellText,
@@ -12,10 +23,12 @@ export type {
HeroColumnDef,
HeroHomeDataType,
HeroRowDef,
HeroPageType,
HeroSidebarEntry,
HeroSidebarFolder,
HeroSidebarIcon,
HeroSidebarItem,
HeroTablePageDefinition,
HeroVisualType,
} from './HeroHomeData';
export type { HeroIllustrationDataType } from './HeroIllustrationData';