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:
committed by
GitHub
parent
83917f0dca
commit
0e0fb246e6
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
+404
-330
File diff suppressed because it is too large
Load Diff
+51
@@ -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>
|
||||
);
|
||||
}
|
||||
+25
@@ -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>
|
||||
);
|
||||
}
|
||||
+150
@@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
+44
-2
@@ -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>
|
||||
|
||||
+94
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user