[Website] Replace feature card screenshots with interactive visuals (#20442)

Replace static screenshot images with lightweight interactive
mini-components for all 7 feature cards (Dashboard, Tasks, Emails,
Contacts, Pipeline, Files, Data Import). Add scroll-triggered entrance
animations, shared WindowChrome component, and dark-themed visual
tokens. Remove unused screenshot assets.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Abdullah.
2026-05-11 21:13:46 +05:00
committed by GitHub
parent e7032d0638
commit b1f7a2c544
27 changed files with 4201 additions and 283 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

@@ -1,81 +1,108 @@
import { msg } from '@lingui/core/macro';
import type { ImageType } from '@/design-system/components/Image';
import type { FeatureTileType } from '@/sections/Feature';
export const FEATURE_MASK: ImageType = {
src: '/images/product/feature/mask.webp',
alt: '',
};
import type { FeatureTileType } from '@/sections/Feature';
import { ContactsVisual } from '@/sections/Feature/visuals/ContactsVisual';
import { DashboardVisual } from '@/sections/Feature/visuals/DashboardVisual';
import { EmailsVisual } from '@/sections/Feature/visuals/EmailsVisual';
import { FilesVisual } from '@/sections/Feature/visuals/FilesVisual';
import { ImportVisual } from '@/sections/Feature/visuals/ImportVisual';
import { PipelineVisual } from '@/sections/Feature/visuals/PipelineVisual';
import { TasksVisual } from '@/sections/Feature/visuals/TasksVisual';
export const FEATURE_TILES: FeatureTileType[] = [
{
icon: 'check',
image: { src: '/images/product/feature/dashboards.webp', alt: '' },
heading: msg`Reports & Dashboards`,
category: msg`Reports & Dashboards`,
heading: msg`Metrics you can actually trust.`,
description: msg`Build custom dashboards from live CRM data. Aggregate anything — deals, accounts, activity — into charts your team actually reads.`,
visual: DashboardVisual,
bullets: [
msg`Build custom dashboards`,
msg`Aggregate, bar, line, pie, and gauge widgets`,
msg`Filtered metrics from live CRM data`,
{
icon: 'check',
text: msg`Aggregate, bar, line, pie, and gauge widgets`,
},
{ icon: 'search', text: msg`Filtered metrics from any object` },
{ icon: 'check', text: msg`Real-time data` },
],
},
{
icon: 'check',
image: { src: '/images/product/feature/tasks.webp', alt: '' },
heading: msg`Tasks & Activities`,
category: msg`Tasks & Activities`,
heading: msg`Context lives with the record.`,
description: msg`Create tasks, assign owners, and attach rich notes directly from any record. No tab-switching, no lost context.`,
visual: TasksVisual,
bullets: [
msg`Create tasks from records`,
msg`Assign owners and due dates`,
msg`Rich notes attached to records`,
{ icon: 'check', text: msg`Create tasks from records` },
{ icon: 'users', text: msg`Assign owners and due dates` },
{ icon: 'edit', text: msg`Rich notes attached to records` },
],
},
{
icon: 'check',
image: { src: '/images/product/feature/emails.webp', alt: '' },
heading: msg`Email & Calendar`,
category: msg`Email & Calendar`,
heading: msg`Every thread, on the right record.`,
description: msg`Connect Google or Microsoft accounts and see emails and events linked to CRM records automatically.`,
visual: EmailsVisual,
bullets: [
msg`Connect Google or Microsoft accounts`,
msg`Emails and events linked to CRM records`,
msg`Full communication history in one place`,
{ icon: 'check', text: msg`Connect Google or Microsoft accounts` },
{
icon: 'search',
text: msg`Emails and events linked to CRM records`,
},
{ icon: 'book', text: msg`Full communication history in one place` },
],
},
{
icon: 'check',
image: { src: '/images/product/feature/contacts.webp', alt: '' },
heading: msg`Contacts & Companies`,
category: msg`Contacts & Companies`,
heading: msg`Your network, fully mapped.`,
description: msg`Custom fields, relationships, and a unified timeline for every contact and company in your workspace.`,
visual: ContactsVisual,
bullets: [
msg`Custom fields and relationships`,
msg`Unified timeline (emails, events, tasks, notes, files)`,
msg`Email/calendar activity on each record`,
{ icon: 'edit', text: msg`Custom fields and relationships` },
{
icon: 'book',
text: msg`Unified timeline (emails, events, tasks, notes, files)`,
},
{
icon: 'search',
text: msg`Email/calendar activity on each record`,
},
],
},
{
icon: 'check',
image: { src: '/images/product/feature/pipeline.webp', alt: '' },
heading: msg`Pipeline Management`,
category: msg`Pipeline Management`,
heading: msg`Deals that move themselves.`,
description: msg`Custom deal stages, drag-and-drop boards, and real-time tracking so your pipeline reflects reality.`,
visual: PipelineVisual,
bullets: [
msg`Custom deal stages for your process`,
msg`Drag-and-drop deals between stages`,
msg`Track amount and close date`,
{ icon: 'edit', text: msg`Custom deal stages for your process` },
{ icon: 'check', text: msg`Drag-and-drop deals between stages` },
{ icon: 'tag', text: msg`Track amount and close date` },
],
},
{
icon: 'check',
image: { src: '/images/product/feature/files.webp', alt: '' },
heading: msg`Files`,
category: msg`Files`,
heading: msg`Attachments without the chaos.`,
description: msg`Upload, rename, preview, and manage files directly on records. Everything stays where it belongs.`,
visual: FilesVisual,
bullets: [
msg`Multi-file upload on records`,
msg`Rename, download, and delete attachments`,
msg`In-app preview for supported file types (when enabled)`,
{ icon: 'check', text: msg`Multi-file upload on records` },
{ icon: 'edit', text: msg`Rename, download, and delete attachments` },
{
icon: 'eye',
text: msg`In-app preview for supported file types (when enabled)`,
},
],
},
{
icon: 'check',
image: { src: '/images/product/feature/data.webp', alt: '' },
heading: msg`Data import`,
category: msg`Data import`,
heading: msg`From CSV to CRM in minutes.`,
description: msg`Import your data with field mapping, including relations. Export anytime — your data is always yours.`,
visual: ImportVisual,
bullets: [
msg`CSV import flow`,
msg`Column-to-field mapping (including relations)`,
msg`CSV export anytime`,
{ icon: 'check', text: msg`CSV import flow` },
{
icon: 'code',
text: msg`Column-to-field mapping (including relations)`,
},
{ icon: 'check', text: msg`CSV export anytime` },
],
},
];
@@ -4,10 +4,7 @@ import { Faq, FAQ_QUESTIONS } from '@/sections/Faq';
import { TRUSTED_BY_LOGOS, TrustedBy } from '@/sections/TrustedBy';
import { TalkToUsButton } from '@/sections/ContactCal';
import { APP_PREVIEW_DATA } from '@/app/[locale]/(home)/app-preview.data';
import {
FEATURE_MASK,
FEATURE_TILES,
} from '@/app/[locale]/product/feature.data';
import { FEATURE_TILES } from '@/app/[locale]/product/feature.data';
import { TABS } from '@/app/[locale]/product/tabs.data';
import { ILLUSTRATION_CARDS } from '@/app/[locale]/product/three-cards.data';
import {
@@ -189,7 +186,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
</HeadingPart>
</Heading>
</Feature.Intro>
<Feature.Tiles mask={FEATURE_MASK} tiles={FEATURE_TILES} />
<Feature.Tiles tiles={FEATURE_TILES} />
</Feature.Root>
<ThreeCards.Root scheme="light">
@@ -1,19 +1,25 @@
import { Container } from '@/design-system/components';
import type { ImageType } from '@/design-system/components/Image';
import type { MessageDescriptor } from '@lingui/core';
import { type Page, Pages } from '@/lib/pages';
import { theme, type Scheme } from '@/theme';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
import type { ComponentType, ReactNode } from 'react';
import { FeatureScrollEntrance } from './FeatureScrollEntrance';
import { TileContent } from './TileContent';
import { TileVisual } from './TileVisual';
export type FeatureTileType = {
bullets: MessageDescriptor[];
heading: MessageDescriptor;
export type FeatureBullet = {
icon: string;
image: ImageType;
text: MessageDescriptor;
};
export type FeatureTileType = {
bullets: FeatureBullet[];
category: MessageDescriptor;
description: MessageDescriptor;
heading: MessageDescriptor;
visual: ComponentType<{ active: boolean }>;
};
// --- Root ---
@@ -109,68 +115,139 @@ function Intro({ align, children, page }: IntroProps) {
// --- Tiles ---
const TilesGrid = styled.div`
display: grid;
gap: ${theme.spacing(4)};
grid-template-columns: 1fr;
const BORDER = `1px solid ${theme.colors.primary.border[20]}`;
@media (min-width: ${theme.breakpoints.md}px) {
gap: ${theme.spacing(4)};
grid-template-columns: repeat(12, minmax(0, 1fr));
}
`;
const TileCell = styled.div`
min-width: 0;
@media (min-width: ${theme.breakpoints.md}px) {
&[data-index='0'] {
grid-column: span 12;
}
&[data-index='1'],
&[data-index='2'],
&[data-index='5'],
&[data-index='6'] {
grid-column: span 6;
}
&[data-index='3'] {
grid-column: span 4;
}
&[data-index='4'] {
grid-column: span 8;
}
}
`;
const TileRoot = styled.article`
background-color: ${theme.colors.primary.background[100]};
border: 1px solid ${theme.colors.primary.border[20]};
const Grid = styled.div`
border: ${BORDER};
border-radius: ${theme.radius(2)};
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto auto;
height: 100%;
min-width: 0;
overflow: hidden;
@media (min-width: ${theme.breakpoints.md}px) {
grid-template-columns: repeat(2, 1fr);
}
`;
const SpotlightCell = styled.div`
border-bottom: ${BORDER};
display: flex;
flex-direction: column;
min-width: 0;
@media (min-width: ${theme.breakpoints.md}px) {
grid-column: span 2;
}
`;
const GridCell = styled.div`
border-bottom: ${BORDER};
display: flex;
flex-direction: column;
min-width: 0;
@media (min-width: ${theme.breakpoints.md}px) {
&:nth-child(even) {
border-right: ${BORDER};
}
&:nth-last-child(-n + 2) {
border-bottom: none;
}
}
`;
const SpotlightInner = styled.div`
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
flex-direction: row;
min-height: 420px;
}
`;
const SpotlightContent = styled.div`
@media (min-width: ${theme.breakpoints.md}px) {
flex: 1;
min-width: 0;
}
`;
const SpotlightVisual = styled.div`
min-height: 260px;
@media (min-width: ${theme.breakpoints.md}px) {
flex: 1.1;
min-width: 0;
}
`;
const CardInner = styled.div`
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
`;
const CardVisualFrame = styled.div`
height: 300px;
margin: ${theme.spacing(4)} ${theme.spacing(4)} 0;
overflow: hidden;
position: relative;
@media (min-width: ${theme.breakpoints.md}px) {
height: 340px;
margin: ${theme.spacing(5)} ${theme.spacing(5)} 0;
}
`;
type TilesProps = {
mask: ImageType;
tiles: FeatureTileType[];
};
function Tiles({ mask, tiles }: TilesProps) {
function Tiles({ tiles }: TilesProps) {
const totalCount = tiles.length;
return (
<TilesGrid>
{tiles.map((tile, index) => (
<TileCell data-index={index} key={index}>
<TileRoot>
<TileVisual image={tile.image} index={index} mask={mask} />
<TileContent tile={tile} />
</TileRoot>
</TileCell>
))}
</TilesGrid>
<Grid>
{tiles.map((tile, index) => {
const isSpotlight = index === 0;
const counter = `${String(index + 1).padStart(2, '0')} / ${String(totalCount).padStart(2, '0')}`;
if (isSpotlight) {
return (
<SpotlightCell key={index}>
<FeatureScrollEntrance index={index}>
<SpotlightInner>
<SpotlightContent>
<TileContent counter={counter} spotlight tile={tile} />
</SpotlightContent>
<SpotlightVisual>
<TileVisual visual={tile.visual} />
</SpotlightVisual>
</SpotlightInner>
</FeatureScrollEntrance>
</SpotlightCell>
);
}
return (
<GridCell key={index}>
<FeatureScrollEntrance index={index}>
<CardInner>
<CardVisualFrame>
<TileVisual visual={tile.visual} />
</CardVisualFrame>
<TileContent counter={counter} spotlight={false} tile={tile} />
</CardInner>
</FeatureScrollEntrance>
</GridCell>
);
})}
</Grid>
);
}
@@ -0,0 +1,114 @@
'use client';
import { css } from '@linaria/core';
import { useEffect, useRef, useState } from 'react';
const STAGGER_DELAY_MS = 120;
const VIEWPORT_THRESHOLD = 0.15;
const entranceBaseClassName = css`
transition:
opacity 0.7s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.7s cubic-bezier(0.22, 1, 0.36, 1);
@media (prefers-reduced-motion: reduce) {
opacity: 1 !important;
transform: none !important;
transition: none !important;
}
`;
const hiddenDropClassName = css`
opacity: 0;
transform: translateY(60px) rotate(2deg);
`;
const hiddenFromLeftClassName = css`
opacity: 0;
transform: translateX(-40px) translateY(30px) rotate(-3deg);
`;
const hiddenFromRightClassName = css`
opacity: 0;
transform: translateX(40px) translateY(30px) rotate(3deg);
`;
const visibleClassName = css`
opacity: 1;
transform: translateX(0) translateY(0) rotate(0deg);
`;
function getHiddenClassName(index: number): string {
if (index === 0) return hiddenDropClassName;
const gridIndex = index - 1;
const isLeft = gridIndex % 2 === 0;
return isLeft ? hiddenFromLeftClassName : hiddenFromRightClassName;
}
type FeatureScrollEntranceProps = {
children: React.ReactNode;
className?: string;
index: number;
};
export function FeatureScrollEntrance({
children,
className = '',
index,
}: FeatureScrollEntranceProps) {
const ref = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const prefersReduced = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches;
if (prefersReduced) {
setIsVisible(true);
return;
}
let staggerTimeout: ReturnType<typeof setTimeout> | null = null;
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry.isIntersecting) {
staggerTimeout = setTimeout(
() => setIsVisible(true),
index * STAGGER_DELAY_MS,
);
} else {
if (staggerTimeout) {
clearTimeout(staggerTimeout);
staggerTimeout = null;
}
setIsVisible(false);
}
},
{ threshold: VIEWPORT_THRESHOLD },
);
observer.observe(element);
return () => {
observer.disconnect();
if (staggerTimeout) clearTimeout(staggerTimeout);
};
}, [index]);
const stateClass = isVisible ? visibleClassName : getHiddenClassName(index);
return (
<div
className={`${entranceBaseClassName} ${stateClass} ${className}`}
ref={ref}
style={{ height: '100%', width: '100%' }}
>
{children}
</div>
);
}
@@ -0,0 +1,36 @@
'use client';
import { useEffect, useRef, useState, type ComponentType } from 'react';
type InteractiveVisualMountProps = {
visual: ComponentType<{ active: boolean }>;
};
export function InteractiveVisualMount({
visual: Visual,
}: InteractiveVisualMountProps) {
const ref = useRef<HTMLDivElement>(null);
const [active, setActive] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new IntersectionObserver(
(entries) => {
setActive(entries[0].isIntersecting);
},
{ threshold: 0.3 },
);
observer.observe(element);
return () => observer.disconnect();
}, []);
return (
<div ref={ref} style={{ height: '100%', width: '100%' }}>
<Visual active={active} />
</div>
);
}
@@ -1,3 +1,4 @@
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import { Body, Heading, HeadingPart } from '@/design-system/components';
@@ -7,83 +8,162 @@ import { theme } from '@/theme';
import type { FeatureTileType } from './Feature';
const DIVIDER_COLOR = theme.colors.primary.border[10];
const ICON_STROKE_WIDTH = 1.5;
const Content = styled.div`
display: grid;
grid-template-columns: 1fr;
padding: ${theme.spacing(4)};
row-gap: ${theme.spacing(4)};
`;
const TitleRow = styled.div`
align-items: center;
column-gap: ${theme.spacing(2)};
display: grid;
grid-template-columns: auto minmax(0, 1fr);
`;
const IconSlot = styled.div`
align-items: center;
background-color: ${theme.colors.highlight[100]};
border-radius: ${theme.radius(1)};
display: flex;
height: 24px;
justify-content: center;
width: 24px;
`;
flex-direction: column;
gap: ${theme.spacing(3)};
padding: ${theme.spacing(4)} ${theme.spacing(5)};
const HeadingWrap = styled.div`
min-width: 0;
`;
@media (min-width: ${theme.breakpoints.md}px) {
padding: ${theme.spacing(5)} ${theme.spacing(6)};
}
const Bullets = styled.ul`
display: grid;
grid-template-columns: 1fr;
list-style-type: disc;
margin: 0;
padding: 0;
padding-left: ${theme.spacing(5)};
row-gap: ${theme.spacing(2)};
`;
&[data-spotlight='true'] {
flex: 1;
gap: ${theme.spacing(4)};
const BulletItem = styled.li`
margin: 0;
&::marker {
color: ${theme.colors.primary.text[40]};
@media (min-width: ${theme.breakpoints.md}px) {
justify-content: center;
padding: ${theme.spacing(8)} ${theme.spacing(7)};
}
}
`;
const Header = styled.div`
align-items: center;
display: flex;
gap: ${theme.spacing(2.5)};
`;
const CategoryDot = styled.span`
background-color: ${theme.colors.highlight[100]};
border-radius: 2px;
flex-shrink: 0;
height: 8px;
width: 8px;
`;
const CategoryLabel = styled.span`
color: ${theme.colors.primary.text[60]};
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(3)};
letter-spacing: 0.06em;
line-height: ${theme.lineHeight(4)};
text-transform: uppercase;
`;
const Counter = styled.span`
color: ${theme.colors.primary.text[20]};
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(3)};
letter-spacing: 0.04em;
margin-left: auto;
white-space: nowrap;
`;
const descriptionClassName = css`
color: ${theme.colors.primary.text[60]};
`;
const CardRule = styled.div`
border-top: 1px dotted ${DIVIDER_COLOR};
flex-shrink: 0;
height: 0;
width: 100%;
`;
const BulletList = styled.div`
display: flex;
flex-direction: column;
width: 100%;
`;
const BulletItem = styled.div`
align-items: center;
column-gap: ${theme.spacing(2.5)};
display: flex;
padding: ${theme.spacing(2)} 0;
&:not(:first-child) {
border-top: 1px solid ${DIVIDER_COLOR};
}
`;
const BulletIconSlot = styled.span`
color: ${theme.colors.highlight[100]};
display: inline-flex;
flex-shrink: 0;
`;
const bulletTextClassName = css`
display: block;
overflow-wrap: anywhere;
color: ${theme.colors.primary.text[80]};
`;
type TileContentProps = {
counter: string;
spotlight: boolean;
tile: FeatureTileType;
};
export function TileContent({ tile }: TileContentProps) {
export function TileContent({ counter, spotlight, tile }: TileContentProps) {
const i18n = getServerI18n();
const Icon = INFORMATIVE_ICONS[tile.icon];
return (
<Content>
<TitleRow>
<IconSlot>
{Icon ? (
<Icon color={theme.colors.primary.background[100]} size={16} />
) : null}
</IconSlot>
<HeadingWrap>
<Heading as="h3" size="xs" weight="medium">
<HeadingPart fontFamily="sans">{i18n._(tile.heading)}</HeadingPart>
</Heading>
</HeadingWrap>
</TitleRow>
<Bullets>
{tile.bullets.map((bullet, bulletIndex) => (
<BulletItem key={bulletIndex}>
<Body as="span" size="sm" weight="regular">
{i18n._(bullet)}
</Body>
</BulletItem>
))}
</Bullets>
<Content data-spotlight={spotlight}>
<Header>
<CategoryDot aria-hidden />
<CategoryLabel>{i18n._(tile.category)}</CategoryLabel>
<Counter>{counter}</Counter>
</Header>
<Heading as="h3" size={spotlight ? 'sm' : 'xs'} weight="medium">
<HeadingPart fontFamily="sans">{i18n._(tile.heading)}</HeadingPart>
</Heading>
{spotlight ? (
<Body
as="p"
className={descriptionClassName}
size="sm"
weight="regular"
>
{i18n._(tile.description)}
</Body>
) : null}
<CardRule />
<BulletList>
{tile.bullets.map((bullet, bulletIndex) => {
const Icon = INFORMATIVE_ICONS[bullet.icon];
return (
<BulletItem key={bulletIndex}>
<BulletIconSlot aria-hidden>
{Icon ? (
<Icon
color="currentColor"
size={16}
strokeWidth={ICON_STROKE_WIDTH}
/>
) : null}
</BulletIconSlot>
<Body
as="span"
className={bulletTextClassName}
size="sm"
weight="regular"
>
{i18n._(bullet.text)}
</Body>
</BulletItem>
);
})}
</BulletList>
</Content>
);
}
@@ -1,123 +1,21 @@
import { styled } from '@linaria/react';
import NextImage from 'next/image';
import type { ComponentType } from 'react';
import type { ImageType } from '@/design-system/components/Image';
import { theme } from '@/theme';
import { InteractiveVisualMount } from './InteractiveVisualMount';
const VisualArea = styled.div`
background-color: ${theme.colors.primary.border[10]};
height: 200px;
overflow: hidden;
position: relative;
const VisualContainer = styled.div`
height: 100%;
width: 100%;
&[data-index='0'] {
height: 220px;
}
@media (min-width: ${theme.breakpoints.md}px) {
height: 240px;
&[data-index='0'] {
height: 320px;
}
}
`;
const ImageInset = styled.div`
bottom: 0;
left: ${theme.spacing(4)};
position: absolute;
right: ${theme.spacing(4)};
top: ${theme.spacing(4)};
z-index: 1;
@media (min-width: ${theme.breakpoints.md}px) {
left: ${theme.spacing(8)};
right: ${theme.spacing(8)};
top: ${theme.spacing(8)};
&[data-index='0'] {
left: 10%;
right: 10%;
top: ${theme.spacing(6)};
}
&[data-index='3'] {
left: 0;
right: 0;
}
}
img {
object-fit: contain;
object-position: bottom center;
}
&[data-index='3'] img {
object-position: bottom left;
}
`;
const MaskOverlay = styled.div`
inset: 0;
pointer-events: none;
position: absolute;
z-index: 2;
img {
height: 100%;
object-fit: cover;
width: 100%;
}
&[data-index='0'] img {
object-position: center 20%;
}
&[data-index='1'] img {
object-position: left 40%;
}
&[data-index='2'] img {
object-position: right 40%;
}
&[data-index='3'] img {
object-position: left 60%;
}
&[data-index='4'] img {
object-position: right 60%;
}
&[data-index='5'] img {
object-position: left 80%;
}
&[data-index='6'] img {
object-position: right 80%;
}
`;
type TileVisualProps = {
image: ImageType;
index: number;
mask: ImageType;
visual: ComponentType<{ active: boolean }>;
};
export function TileVisual({ image, index, mask }: TileVisualProps) {
export function TileVisual({ visual }: TileVisualProps) {
return (
<VisualArea data-index={index}>
<ImageInset data-index={index}>
<NextImage
alt={image.alt}
fill
sizes="(min-width: 921px) 50vw, 100vw"
src={image.src}
/>
</ImageInset>
<MaskOverlay aria-hidden data-index={index}>
<NextImage
alt={mask.alt}
fill
sizes="(min-width: 921px) 50vw, 100vw"
src={mask.src}
/>
</MaskOverlay>
</VisualArea>
<VisualContainer>
<InteractiveVisualMount visual={visual} />
</VisualContainer>
);
}
@@ -0,0 +1,128 @@
'use client';
import { styled } from '@linaria/react';
import { useState } from 'react';
import { ACCENT } from './dashboard-visual.data';
import {
BG_PANEL,
BORDER_COLOR,
TEXT_MUTED,
TEXT_SECONDARY,
} from './visual-tokens';
const Panel = styled.div`
background-color: ${BG_PANEL};
border: 1px solid ${BORDER_COLOR};
border-radius: 10px;
display: flex;
flex: 1;
flex-direction: column;
gap: 14px;
min-width: 0;
padding: 18px 20px;
`;
const PanelTitle = styled.span`
color: ${TEXT_SECONDARY};
font-size: 12px;
font-weight: 500;
letter-spacing: 0.02em;
`;
const BarsArea = styled.div`
align-items: flex-end;
display: flex;
flex: 1;
gap: 5px;
min-height: 0;
padding-top: 20px;
`;
const BarColumn = styled.div`
align-items: center;
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
height: 100%;
justify-content: flex-end;
position: relative;
`;
const Bar = styled.div`
border-radius: 3px 3px 0 0;
cursor: pointer;
min-height: 2px;
transition:
height 0.8s cubic-bezier(0.22, 1, 0.36, 1),
filter 0.15s ease;
width: 80%;
`;
const BarValue = styled.span`
color: ${TEXT_SECONDARY};
font-size: 9px;
font-variant-numeric: tabular-nums;
font-weight: 500;
position: absolute;
top: 0;
transform: translateY(calc(-100% - 4px));
white-space: nowrap;
`;
const BarLabel = styled.span`
color: ${TEXT_MUTED};
font-size: 9px;
font-weight: 400;
letter-spacing: 0.02em;
`;
type BarDatum = {
label: string;
value: number;
};
type BarChartProps = {
active: boolean;
data: BarDatum[];
};
export function BarChart({ active, data }: BarChartProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const rawMax = Math.max(...data.map((datum) => datum.value));
const maxValue = rawMax * 1.3;
return (
<Panel>
<PanelTitle>Monthly volume</PanelTitle>
<BarsArea>
{data.map((datum, index) => {
const heightPercent = (datum.value / maxValue) * 100;
const isHovered = hoveredIndex === index;
return (
<BarColumn key={index}>
{active ? (
<BarValue style={{ opacity: isHovered ? 1 : 0.7 }}>
{datum.value}
</BarValue>
) : null}
<Bar
onPointerEnter={() => setHoveredIndex(index)}
onPointerLeave={() => setHoveredIndex(null)}
style={{
backgroundColor: ACCENT,
filter: isHovered ? 'brightness(1.3)' : 'none',
height: active ? `${heightPercent}%` : '0%',
transitionDelay: active ? `${index * 50}ms` : '0ms',
}}
/>
<BarLabel>{datum.label}</BarLabel>
</BarColumn>
);
})}
</BarsArea>
</Panel>
);
}
@@ -0,0 +1,701 @@
'use client';
import { useHorizontalDragScroll } from '@/lib/dom/use-horizontal-drag-scroll';
import { styled } from '@linaria/react';
import { useState } from 'react';
import {
CARD_ACCENT,
CARD_BG,
CARD_BORDER,
CARD_FONT,
CARD_TEXT,
CARD_TEXT_SECONDARY,
CARD_TEXT_TERTIARY,
} from './visual-tokens';
const PERSON_TONES: Record<string, { background: string; color: string }> = {
gray: { background: 'rgba(255,255,255,0.1)', color: 'rgba(255,255,255,0.7)' },
purple: { background: 'rgba(168,85,247,0.15)', color: '#c084fc' },
teal: { background: 'rgba(20,184,166,0.15)', color: '#5eead4' },
pink: { background: 'rgba(236,72,153,0.15)', color: '#f472b6' },
blue: { background: 'rgba(59,130,246,0.15)', color: '#93c5fd' },
amber: { background: 'rgba(245,158,11,0.15)', color: '#fbbf24' },
};
const Root = styled.div`
background: ${CARD_BG};
display: flex;
flex-direction: column;
font-family: ${CARD_FONT};
height: 100%;
overflow: hidden;
width: 100%;
`;
const ViewHeader = styled.div`
align-items: center;
border-bottom: 1px solid ${CARD_BORDER};
display: flex;
gap: 4px;
height: 34px;
padding: 0 12px;
`;
const ViewTitle = styled.span`
color: ${CARD_TEXT};
font-size: 13px;
font-weight: 500;
`;
const ViewCount = styled.span`
color: ${CARD_TEXT_TERTIARY};
font-size: 13px;
`;
const TableShell = styled.div`
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
`;
const GripRail = styled.div`
background: ${CARD_BG};
display: grid;
flex: 0 0 12px;
grid-auto-rows: 32px;
width: 12px;
`;
const GripCell = styled.div`
border-bottom: 1px solid ${CARD_BORDER};
`;
const TableViewport = styled.div<{ $dragging: boolean }>`
cursor: ${({ $dragging }) => ($dragging ? 'grabbing' : 'grab')};
flex: 1;
min-height: 0;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
`;
const TableCanvas = styled.div<{ $width: number }>`
display: flex;
flex-direction: column;
height: 100%;
min-width: ${({ $width }) => `${$width}px`};
width: ${({ $width }) => `${$width}px`};
`;
const HeaderRow = styled.div`
animation: headerIn 260ms ease-out both;
display: flex;
@keyframes headerIn {
from {
opacity: 0;
transform: translateY(-2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
const DataRow = styled.div<{ $index: number }>`
animation: rowIn 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: ${({ $index }) => `${120 + $index * 70}ms`};
display: flex;
@keyframes rowIn {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
const Cell = styled.div<{
$header?: boolean;
$hovered?: boolean;
$width: number;
}>`
align-items: center;
background: ${({ $header, $hovered }) =>
$header ? CARD_BG : $hovered ? '#25252f' : CARD_BG};
border-bottom: 1px solid ${CARD_BORDER};
border-right: 1px solid ${CARD_BORDER};
box-sizing: border-box;
display: flex;
flex: 0 0 ${({ $width }) => `${$width}px`};
height: 32px;
min-width: ${({ $width }) => `${$width}px`};
overflow: hidden;
padding: 0 8px;
`;
const HeaderContent = styled.div`
align-items: center;
display: flex;
gap: 4px;
height: 100%;
min-width: 0;
width: 100%;
`;
const HeaderLabel = styled.span`
color: ${CARD_TEXT_TERTIARY};
font-size: 13px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const HeaderIcon = styled.span`
align-items: center;
color: ${CARD_TEXT_TERTIARY};
display: inline-flex;
flex-shrink: 0;
height: 16px;
width: 16px;
`;
const EdgePlus = styled.span`
color: ${CARD_TEXT_TERTIARY};
display: inline-flex;
margin-left: auto;
`;
const ChipEl = styled.div<{ $highlighted?: boolean }>`
align-items: center;
background: ${({ $highlighted }) =>
$highlighted ? 'rgba(255,255,255,0.04)' : 'transparent'};
border-radius: 4px;
display: inline-flex;
gap: 4px;
height: 20px;
max-width: 100%;
min-width: 0;
overflow: hidden;
padding: 0 4px;
`;
const ChipLabel = styled.span<{ $bold?: boolean }>`
color: ${CARD_TEXT};
font-family: ${CARD_FONT};
font-size: 13px;
font-weight: ${({ $bold }) => ($bold ? 500 : 400)};
max-width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const LinkChip = styled.div`
align-items: center;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
display: inline-flex;
gap: 4px;
height: 20px;
max-width: 100%;
min-width: 0;
overflow: hidden;
padding: 0 8px;
`;
const LinkLabel = styled.span`
color: ${CARD_TEXT_SECONDARY};
font-family: ${CARD_FONT};
font-size: 13px;
max-width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const FaviconFrame = styled.div`
align-items: center;
border-radius: 4px;
display: flex;
flex: 0 0 14px;
height: 14px;
justify-content: center;
overflow: hidden;
width: 14px;
`;
const FaviconImg = styled.img`
display: block;
height: 100%;
object-fit: contain;
width: 100%;
`;
const FaviconFallback = styled(FaviconFrame)`
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.6);
font-size: 8px;
font-weight: 600;
`;
const AvatarEl = styled.div<{ $bg: string; $color: string }>`
align-items: center;
background: ${({ $bg }) => $bg};
border-radius: 999px;
color: ${({ $color }) => $color};
display: flex;
flex: 0 0 14px;
font-family: ${CARD_FONT};
font-size: 9px;
font-weight: 500;
height: 14px;
justify-content: center;
line-height: 1;
overflow: hidden;
width: 14px;
`;
const TagChip = styled.span`
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
color: ${CARD_TEXT_SECONDARY};
display: inline-flex;
font-family: ${CARD_FONT};
font-size: 13px;
height: 20px;
align-items: center;
max-width: 100%;
min-width: 0;
overflow: hidden;
padding: 0 8px;
text-overflow: ellipsis;
white-space: nowrap;
`;
const BooleanRow = styled.span`
align-items: center;
display: inline-flex;
gap: 4px;
`;
const BooleanText = styled.span`
color: ${CARD_TEXT_SECONDARY};
font-size: 13px;
`;
const CheckboxWrap = styled.div`
align-items: center;
display: flex;
flex-shrink: 0;
height: 24px;
justify-content: center;
width: 24px;
`;
const CheckboxBox = styled.div<{ $checked?: boolean }>`
align-items: center;
border: 1px solid
${({ $checked }) =>
$checked ? 'rgba(62,99,221,0.5)' : 'rgba(255,255,255,0.15)'};
border-radius: 3px;
cursor: pointer;
display: flex;
height: 14px;
justify-content: center;
transition:
background 0.1s,
border-color 0.1s;
width: 14px;
background: ${({ $checked }) =>
$checked ? 'rgba(62,99,221,0.12)' : 'transparent'};
&:hover {
border-color: rgba(62, 99, 221, 0.5);
}
`;
const COLUMNS = [
{ id: 'company', label: 'Companies', width: 180, isFirst: true },
{ id: 'url', label: 'Url', width: 140 },
{ id: 'createdBy', label: 'Created By', width: 150 },
{ id: 'address', label: 'Address', width: 140 },
{ id: 'accountOwner', label: 'Account Owner', width: 150 },
{ id: 'icp', label: 'ICP', width: 80 },
{ id: 'arr', label: 'ARR', width: 120 },
{ id: 'industry', label: 'Industry', width: 140 },
];
const COMPANIES = [
{
name: 'Anthropic',
domain: 'anthropic.com',
createdBy: { name: 'Dario Amodei', tone: 'gray' },
address: '18 Rue De Navarin',
accountOwner: { name: 'Dario Amodei', tone: 'gray' },
icp: true,
arr: '$500,000',
industry: 'AI Research',
},
{
name: 'Linkedin',
domain: 'linkedin.com',
createdBy: { name: 'Reid Hoffman', tone: 'purple' },
address: '1226 Moises Causeway',
accountOwner: { name: 'Ryan Roslansky', tone: 'teal' },
icp: false,
arr: '$1,000,000',
industry: 'Professional Networking',
},
{
name: 'Slack',
domain: 'slack.com',
createdBy: { name: 'Stewart Butterfield', tone: 'teal' },
address: '1316 Dameon Mountain',
accountOwner: { name: 'Stewart Butterfield', tone: 'teal' },
icp: true,
arr: '$2,300,000',
industry: 'Collaboration Software',
},
{
name: 'Notion',
domain: 'notion.com',
createdBy: { name: 'API - Key name', tone: 'gray' },
address: '1162 Sammy Creek',
accountOwner: { name: 'Ivan Zhao', tone: 'gray' },
icp: false,
arr: '$750,000',
industry: 'Productivity Software',
},
{
name: 'Figma',
domain: 'figma.com',
createdBy: { name: 'Workflow name', tone: 'gray' },
address: '110 Oswald Junction',
accountOwner: { name: 'Dylan Field', tone: 'purple' },
icp: true,
arr: '$3,500,000',
industry: 'Design Tools',
},
{
name: 'Github',
domain: 'github.com',
createdBy: { name: 'Chris Wanstrath', tone: 'gray' },
address: '3891 Ranchview Drive',
accountOwner: { name: 'Thomas Dohmke', tone: 'gray' },
icp: true,
arr: '$900,000',
industry: 'Developer Platform',
},
{
name: 'Airbnb',
domain: 'airbnb.com',
createdBy: { name: 'Brian Chesky', tone: 'pink' },
address: '888 Brannan Street',
accountOwner: { name: 'Brian Chesky', tone: 'pink' },
icp: false,
arr: '$1,800,000',
industry: 'Travel & Hospitality',
},
{
name: 'Stripe',
domain: 'stripe.com',
createdBy: { name: 'Patrick Collison', tone: 'blue' },
address: '354 Oyster Point Blvd',
accountOwner: { name: 'Patrick Collison', tone: 'blue' },
icp: true,
arr: '$4,200,000',
industry: 'Fintech',
},
{
name: 'Vercel',
domain: 'vercel.com',
createdBy: { name: 'Guillermo Rauch', tone: 'amber' },
address: '340 S Lemon Ave',
accountOwner: { name: 'Guillermo Rauch', tone: 'amber' },
icp: true,
arr: '$620,000',
industry: 'Developer Platform',
},
];
const HEADER_ICON_PATHS: Record<string, string> = {
company: 'M3 21h18M5 21V7l7-4 7 4v14M9 21v-6h6v6',
url: 'M10 14a3.5 3.5 0 0 0 5 0l4-4a3.5 3.5 0 0 0-5-5l-.5.5M14 10a3.5 3.5 0 0 0-5 0l-4 4a3.5 3.5 0 0 0 5 5l.5-.5',
createdBy:
'M12 12m-9 0a9 9 0 1 0 18 0 9 9 0 1 0 -18 0M12 10m-3 0a3 3 0 1 0 6 0 3 3 0 1 0 -6 0M6.168 18.849A4 4 0 0 1 10 16h4a4 4 0 0 1 3.834 2.855',
address:
'M9 11a3 3 0 1 0 6 0a3 3 0 0 0 -6 0M17.657 16.657l-4.243 4.243a2 2 0 0 1 -2.827 0l-4.244 -4.243a8 8 0 1 1 11.314 0z',
accountOwner:
'M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2',
icp: 'M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0M12 12m-5 0a5 5 0 1 0 10 0a5 5 0 1 0 -10 0M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0',
arr: 'M17 8V5L12 3 7 5v3M3 10a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V10zM12 13v3',
industry:
'M4 21V11l4-4 4 4v10M12 21V7l4-4 4 4v14M8 14v.01M8 17v.01M16 11v.01M16 14v.01M16 17v.01',
};
function Favicon({ domain, name }: { domain: string; name: string }) {
const [failed, setFailed] = useState(false);
const url = `https://twenty-icons.com/${domain}`;
if (failed) {
return <FaviconFallback>{name[0]}</FaviconFallback>;
}
return (
<FaviconFrame>
<FaviconImg
alt={`${name} logo`}
src={url}
onError={() => setFailed(true)}
/>
</FaviconFrame>
);
}
function Avatar({ name, tone }: { name: string; tone: string }) {
const resolved = PERSON_TONES[tone] ?? PERSON_TONES.gray;
const initials = name
.split(' ')
.map((w) => w[0])
.join('')
.slice(0, 2)
.toUpperCase();
return (
<AvatarEl $bg={resolved.background} $color={resolved.color}>
{initials}
</AvatarEl>
);
}
type ContactsVisualProps = {
active: boolean;
};
export function ContactsVisual({ active: _active }: ContactsVisualProps) {
const [checked, setChecked] = useState<Record<number, boolean>>({});
const [hoveredRow, setHoveredRow] = useState<number | null>(null);
const {
dragging,
onPointerCancel,
onPointerDown,
onPointerLeave,
onPointerMove,
onPointerUp,
viewportRef,
} = useHorizontalDragScroll<HTMLDivElement>();
const toggleCheck = (index: number) => {
setChecked((prev) => ({ ...prev, [index]: !prev[index] }));
};
const totalWidth = COLUMNS.reduce((sum, col) => sum + col.width, 0);
const renderCell = (company: (typeof COMPANIES)[0], colId: string) => {
switch (colId) {
case 'company':
return (
<ChipEl $highlighted>
<Favicon domain={company.domain} name={company.name} />
<ChipLabel $bold>{company.name}</ChipLabel>
</ChipEl>
);
case 'url':
return (
<LinkChip>
<LinkLabel>{company.domain}</LinkLabel>
</LinkChip>
);
case 'createdBy':
return (
<ChipEl>
<Avatar
name={company.createdBy.name}
tone={company.createdBy.tone}
/>
<ChipLabel>{company.createdBy.name}</ChipLabel>
</ChipEl>
);
case 'address':
return <ChipLabel>{company.address}</ChipLabel>;
case 'accountOwner':
return (
<ChipEl>
<Avatar
name={company.accountOwner.name}
tone={company.accountOwner.tone}
/>
<ChipLabel>{company.accountOwner.name}</ChipLabel>
</ChipEl>
);
case 'icp':
return (
<BooleanRow>
<svg
fill="none"
height="11"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
style={{ color: CARD_TEXT_SECONDARY }}
viewBox="0 0 24 24"
width="11"
>
{company.icp ? (
<path d="M5 12l5 5L20 7" />
) : (
<path d="M18 6L6 18M6 6l12 12" />
)}
</svg>
<BooleanText>{company.icp ? 'True' : 'False'}</BooleanText>
</BooleanRow>
);
case 'arr':
return <ChipLabel>{company.arr}</ChipLabel>;
case 'industry':
return <TagChip>{company.industry}</TagChip>;
default:
return null;
}
};
return (
<Root>
<ViewHeader>
<ViewTitle>All Companies</ViewTitle>
<ViewCount>· 9</ViewCount>
</ViewHeader>
<TableShell>
<GripRail>
{Array.from({ length: 11 }).map((_, i) => (
<GripCell key={i} />
))}
</GripRail>
<TableViewport
ref={viewportRef}
$dragging={dragging}
onPointerCancel={onPointerCancel}
onPointerDown={onPointerDown}
onPointerLeave={onPointerLeave}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<TableCanvas $width={totalWidth}>
<HeaderRow>
{COLUMNS.map((col) => (
<Cell key={col.id} $header $width={col.width}>
<HeaderContent>
{col.isFirst && (
<CheckboxWrap>
<CheckboxBox />
</CheckboxWrap>
)}
<HeaderIcon>
<svg
fill="none"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width="16"
>
<path d={HEADER_ICON_PATHS[col.id]} />
</svg>
</HeaderIcon>
<HeaderLabel>{col.label}</HeaderLabel>
{col.isFirst && (
<EdgePlus>
<svg
fill="none"
height="12"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.6"
viewBox="0 0 16 16"
width="12"
>
<path d="M8 3v10M3 8h10" />
</svg>
</EdgePlus>
)}
</HeaderContent>
</Cell>
))}
</HeaderRow>
{COMPANIES.map((company, index) => {
const hovered = hoveredRow === index;
return (
<DataRow
key={company.domain}
$index={index}
onMouseEnter={() => setHoveredRow(index)}
onMouseLeave={() =>
setHoveredRow((c) => (c === index ? null : c))
}
>
{COLUMNS.map((col) => (
<Cell key={col.id} $hovered={hovered} $width={col.width}>
{col.isFirst ? (
<HeaderContent>
<CheckboxWrap
onClick={() => toggleCheck(index)}
onPointerDown={(e) => e.stopPropagation()}
>
<CheckboxBox $checked={!!checked[index]}>
{checked[index] && (
<svg
fill="none"
height="9"
viewBox="0 0 12 12"
width="9"
>
<path
d="M3 6.5L5 8.5L9 4"
stroke={CARD_ACCENT}
strokeLinecap="round"
strokeWidth="1.5"
/>
</svg>
)}
</CheckboxBox>
</CheckboxWrap>
{renderCell(company, col.id)}
</HeaderContent>
) : (
renderCell(company, col.id)
)}
</Cell>
))}
</DataRow>
);
})}
</TableCanvas>
</TableViewport>
</TableShell>
</Root>
);
}
@@ -0,0 +1,41 @@
'use client';
import { styled } from '@linaria/react';
import { BarChart } from './BarChart';
import { BAR_DATA, DONUT_VALUE } from './dashboard-visual.data';
import { DonutChart } from './DonutChart';
import { WindowChrome } from './WindowChrome';
const Body = styled.div`
display: grid;
flex: 1;
gap: 12px;
grid-template-columns: 200px 1fr;
min-height: 0;
padding: 14px;
@media (max-width: 600px) {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
`;
type DashboardVisualProps = {
active: boolean;
};
export function DashboardVisual({ active }: DashboardVisualProps) {
return (
<WindowChrome
breadcrumb="Dashboard"
breadcrumbBold="Sales performance"
title="Apple"
>
<Body>
<DonutChart active={active} value={DONUT_VALUE} />
<BarChart active={active} data={BAR_DATA} />
</Body>
</WindowChrome>
);
}
@@ -0,0 +1,138 @@
'use client';
import { styled } from '@linaria/react';
import { useState } from 'react';
import { ACCENT, ACCENT_DIM } from './dashboard-visual.data';
import {
BG_PANEL,
BORDER_COLOR,
TEXT_MUTED,
TEXT_PRIMARY,
TEXT_SECONDARY,
} from './visual-tokens';
const RADIUS = 46;
const STROKE_WIDTH = 14;
const SIZE = 130;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
const Panel = styled.div`
background-color: ${BG_PANEL};
border: 1px solid ${BORDER_COLOR};
border-radius: 10px;
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px 16px;
`;
const PanelTitle = styled.span`
color: ${TEXT_SECONDARY};
font-size: 12px;
font-weight: 500;
letter-spacing: 0.02em;
`;
const ChartArea = styled.div`
align-items: center;
display: flex;
flex: 1;
justify-content: center;
position: relative;
`;
const CenterLabel = styled.div`
align-items: center;
display: flex;
flex-direction: column;
left: 50%;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
`;
const CenterValue = styled.span`
color: ${TEXT_PRIMARY};
font-size: 26px;
font-variant-numeric: tabular-nums;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1;
`;
const Legend = styled.div`
align-items: center;
display: flex;
gap: 6px;
`;
const LegendDot = styled.span`
background-color: ${ACCENT};
border-radius: 50%;
height: 7px;
width: 7px;
`;
const LegendLabel = styled.span`
color: ${TEXT_MUTED};
font-size: 10px;
letter-spacing: 0.02em;
`;
type DonutChartProps = {
active: boolean;
value: number;
};
export function DonutChart({ active, value }: DonutChartProps) {
const [hovered, setHovered] = useState(false);
const filled = (value / 100) * CIRCUMFERENCE;
const gap = CIRCUMFERENCE - filled;
return (
<Panel>
<PanelTitle>Conversion rate</PanelTitle>
<ChartArea>
<svg height={SIZE} viewBox={`0 0 ${SIZE} ${SIZE}`} width={SIZE}>
<circle
cx={SIZE / 2}
cy={SIZE / 2}
fill="none"
r={RADIUS}
stroke={ACCENT_DIM}
strokeWidth={STROKE_WIDTH}
/>
<circle
cx={SIZE / 2}
cy={SIZE / 2}
fill="none"
onPointerEnter={() => setHovered(true)}
onPointerLeave={() => setHovered(false)}
r={RADIUS}
stroke={ACCENT}
strokeDasharray={`${filled} ${gap}`}
strokeDashoffset={active ? CIRCUMFERENCE * 0.25 : CIRCUMFERENCE}
strokeLinecap="round"
strokeWidth={STROKE_WIDTH}
style={{
cursor: 'pointer',
filter: hovered ? 'brightness(1.2)' : 'none',
transform: 'rotate(-90deg)',
transformOrigin: 'center',
transition:
'stroke-dashoffset 1.2s cubic-bezier(0.22, 1, 0.36, 1), filter 0.2s ease',
}}
/>
</svg>
<CenterLabel>
<CenterValue>{active ? `${value}%` : '0%'}</CenterValue>
</CenterLabel>
</ChartArea>
<Legend>
<LegendDot />
<LegendLabel>Conversion rate</LegendLabel>
</Legend>
</Panel>
);
}
@@ -0,0 +1,534 @@
'use client';
import { styled } from '@linaria/react';
import { useState } from 'react';
const Root = styled.div`
background-color: #191920;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
width: 100%;
`;
const TabBar = styled.div`
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
gap: 0;
padding: 0 12px;
`;
const Tab = styled.span`
align-items: center;
color: rgba(255, 255, 255, 0.4);
display: flex;
font-size: 10.5px;
font-weight: 500;
gap: 4px;
padding: 9px 10px;
white-space: nowrap;
&[data-active='true'] {
border-bottom: 1.5px solid rgba(255, 255, 255, 0.85);
color: rgba(255, 255, 255, 0.85);
}
`;
const TabIcon = styled.span`
display: inline-flex;
flex-shrink: 0;
height: 11px;
width: 11px;
`;
const InboxHeader = styled.div`
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
justify-content: space-between;
padding: 8px 14px;
`;
const InboxLeft = styled.div`
align-items: baseline;
display: flex;
gap: 5px;
`;
const InboxTitle = styled.span`
color: rgba(255, 255, 255, 0.9);
font-size: 11.5px;
font-weight: 600;
`;
const InboxCount = styled.span`
color: rgba(255, 255, 255, 0.4);
font-size: 11px;
`;
const ComposeButton = styled.button`
background: none;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 3px;
color: rgba(255, 255, 255, 0.65);
cursor: pointer;
font-size: 10px;
font-weight: 500;
padding: 3px 8px;
transition: border-color 0.12s ease;
&:hover {
border-color: rgba(255, 255, 255, 0.3);
}
`;
const EmailList = styled.div`
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
`;
const EmailRow = styled.div`
align-items: center;
cursor: pointer;
display: flex;
gap: 8px;
padding: 8px 14px;
transition: background-color 0.12s ease;
&:hover {
background-color: rgba(255, 255, 255, 0.03);
}
&[data-selected='true'] {
background-color: rgba(255, 255, 255, 0.04);
}
`;
const AvatarSingle = styled.div`
align-items: center;
border-radius: 50%;
color: #fff;
display: flex;
flex-shrink: 0;
font-size: 8px;
font-weight: 600;
height: 18px;
justify-content: center;
width: 18px;
`;
const AvatarStack = styled.div`
display: flex;
flex-shrink: 0;
width: 28px;
`;
const StackedAvatar = styled.div`
align-items: center;
border: 1.5px solid #191920;
border-radius: 50%;
color: #fff;
display: flex;
font-size: 6px;
font-weight: 700;
height: 16px;
justify-content: center;
margin-left: -5px;
width: 16px;
&:first-child {
margin-left: 0;
}
`;
const EmailBody = styled.div`
align-items: center;
display: flex;
flex: 1;
gap: 6px;
min-width: 0;
overflow: hidden;
`;
const SenderName = styled.span`
color: rgba(255, 255, 255, 0.85);
flex-shrink: 0;
font-size: 11px;
font-weight: 500;
`;
const SenderCount = styled.span`
color: rgba(255, 255, 255, 0.35);
flex-shrink: 0;
font-size: 10px;
`;
const PreviewIcon = styled.span`
display: inline-flex;
flex-shrink: 0;
height: 10px;
width: 10px;
`;
const SubjectBold = styled.span`
color: rgba(255, 255, 255, 0.8);
flex-shrink: 0;
font-size: 11px;
font-weight: 600;
`;
const PreviewText = styled.span`
color: rgba(255, 255, 255, 0.35);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const EmailMeta = styled.div`
align-items: center;
color: rgba(255, 255, 255, 0.3);
display: flex;
flex-shrink: 0;
font-size: 10px;
gap: 4px;
padding-left: 8px;
white-space: nowrap;
`;
const MetaIcon = styled.span`
display: inline-flex;
flex-shrink: 0;
height: 10px;
opacity: 0.5;
width: 10px;
`;
const TAB_PATHS = [
{
label: 'Timeline',
d: 'M8 2a6 6 0 1 0 0 12A6 6 0 0 0 8 2zM8 4.5V8l2.5 1.5',
},
{
label: 'Tasks',
d: 'M5 3h6a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zM6 8l1.5 1.5L10 7',
},
{ label: 'Notes', d: 'M4 4h8M4 7h8M4 10h5' },
{
label: 'Files',
d: 'M3 8.5a3 3 0 0 1 3-3h4.5a2 2 0 0 1 0 4H6.5a1 1 0 0 1 0-2H11',
},
{
label: 'Emails',
d: 'M2 5.5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-5zM2 5.5l6 4 6-4',
},
{
label: 'Calendar',
d: 'M5 3h6a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zM3 7h10M6 2v2M10 2v2',
},
];
const DetailHeader = styled.div`
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
gap: 8px;
padding: 8px 14px;
`;
const BackButton = styled.button`
background: none;
border: none;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
display: inline-flex;
flex-shrink: 0;
padding: 2px;
&:hover {
color: rgba(255, 255, 255, 0.8);
}
`;
const DetailSubject = styled.span`
color: rgba(255, 255, 255, 0.9);
font-size: 11.5px;
font-weight: 600;
`;
const DetailBody = styled.div`
display: flex;
flex: 1;
flex-direction: column;
gap: 10px;
padding: 12px 14px;
`;
const DetailSenderRow = styled.div`
align-items: center;
display: flex;
gap: 8px;
`;
const DetailSenderName = styled.span`
color: rgba(255, 255, 255, 0.85);
font-size: 11px;
font-weight: 500;
`;
const DetailTime = styled.span`
color: rgba(255, 255, 255, 0.3);
font-size: 10px;
margin-left: auto;
`;
const DetailContent = styled.p`
color: rgba(255, 255, 255, 0.5);
font-size: 11px;
line-height: 1.6;
margin: 0;
`;
const EMAILS_DATA = [
{
sender: 'Alexandre',
subject: 'NDA Document - Private',
time: '1:30pm',
color: '#6366f1',
thread: [
{
sender: 'Alexandre',
color: '#6366f1',
time: '1:30pm',
body: 'Hi, please find the NDA attached for your review. This is confidential and not shared with external parties yet. Let me know once signed so I can countersign.',
},
],
},
{
sender: 'Félix',
subject: 'Partnerships - Q4 Strategy',
time: '4 nov 2023',
color: '#f59e0b',
thread: [
{
sender: 'Félix',
color: '#f59e0b',
time: '2 nov',
body: "Hey team, I've been in touch with Notion and Figma about potential integrations. Both are open to co-marketing in Q4.",
},
{
sender: 'Marie',
color: '#8b5cf6',
time: '3 nov',
body: 'Great news! I can draft the partnership brief by Friday. Should we loop in product for the Notion integration?',
},
{
sender: 'Félix',
color: '#f59e0b',
time: '4 nov',
body: "Yes, let's set up a call next Tuesday. I'll send calendar invites. Attaching the proposal deck for review.",
},
],
},
];
const ThreadMessage = styled.div`
border-left: 2px solid rgba(255, 255, 255, 0.06);
padding-left: 10px;
`;
function EmailDetail({
emailIndex,
onBack,
}: {
emailIndex: number;
onBack: () => void;
}) {
const email = EMAILS_DATA[emailIndex];
return (
<>
<DetailHeader>
<BackButton aria-label="Back to inbox" onClick={onBack}>
<svg
fill="none"
height="12"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
viewBox="0 0 12 12"
width="12"
>
<path d="M7.5 2.5L4 6l3.5 3.5" />
</svg>
</BackButton>
<DetailSubject>{email.subject}</DetailSubject>
</DetailHeader>
<DetailBody>
{email.thread.map((msg, i) => (
<ThreadMessage key={i}>
<DetailSenderRow>
<AvatarSingle style={{ backgroundColor: msg.color }}>
{msg.sender[0]}
</AvatarSingle>
<DetailSenderName>{msg.sender}</DetailSenderName>
<DetailTime>{msg.time}</DetailTime>
</DetailSenderRow>
<DetailContent>{msg.body}</DetailContent>
</ThreadMessage>
))}
</DetailBody>
</>
);
}
type EmailsVisualProps = {
active: boolean;
};
export function EmailsVisual({ active: _active }: EmailsVisualProps) {
const [openEmail, setOpenEmail] = useState<number | null>(null);
return (
<Root>
<TabBar>
{TAB_PATHS.map((tab, index) => (
<Tab data-active={index === 4} key={tab.label}>
<TabIcon>
<svg
fill="none"
height="11"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
viewBox="0 0 16 16"
width="11"
>
<path d={tab.d} />
</svg>
</TabIcon>
{tab.label}
</Tab>
))}
</TabBar>
{openEmail === null ? (
<>
<InboxHeader>
<InboxLeft>
<InboxTitle>Inbox</InboxTitle>
<InboxCount>2</InboxCount>
</InboxLeft>
<ComposeButton>+ Compose</ComposeButton>
</InboxHeader>
<EmailList>
<EmailRow
data-selected={false}
onClick={() => setOpenEmail(0)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setOpenEmail(0);
}
}}
role="button"
tabIndex={0}
>
<AvatarSingle style={{ backgroundColor: '#6366f1' }}>
A
</AvatarSingle>
<EmailBody>
<SenderName>Alexandre..</SenderName>
<SenderCount>2</SenderCount>
<PreviewIcon>
<svg
fill="none"
height="10"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
viewBox="0 0 12 12"
width="10"
>
<rect height="5" rx="1" width="6" x="3" y="5.5" />
<path d="M4.5 5.5V4a1.5 1.5 0 0 1 3 0v1.5" />
</svg>
</PreviewIcon>
<PreviewText>Not shared</PreviewText>
</EmailBody>
<EmailMeta>1:30pm</EmailMeta>
</EmailRow>
<EmailRow
data-selected={false}
onClick={() => setOpenEmail(1)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setOpenEmail(1);
}
}}
role="button"
tabIndex={0}
>
<AvatarStack>
<StackedAvatar style={{ backgroundColor: '#f59e0b' }}>
F
</StackedAvatar>
<StackedAvatar style={{ backgroundColor: '#8b5cf6' }}>
M
</StackedAvatar>
<StackedAvatar style={{ backgroundColor: '#06b6d4' }}>
K
</StackedAvatar>
</AvatarStack>
<EmailBody>
<SenderName>Félix..</SenderName>
<SenderCount>4</SenderCount>
<SubjectBold>Partnerships</SubjectBold>
<PreviewText>
Hey team, I've been in touch with Notion and...
</PreviewText>
</EmailBody>
<EmailMeta>
<MetaIcon>
<svg
fill="none"
height="10"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
viewBox="0 0 12 12"
width="10"
>
<path d="M2.5 7a3 3 0 0 1 3-3H9a1.5 1.5 0 0 1 0 3H5.5a.75.75 0 0 1 0-1.5H9" />
</svg>
</MetaIcon>
<MetaIcon>
<svg
fill="none"
height="10"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
viewBox="0 0 12 12"
width="10"
>
<rect height="7" rx="1.5" width="8" x="2" y="3" />
<path d="M2 5.5h8M4.5 2v2M7.5 2v2" />
</svg>
</MetaIcon>
4 nov 2023
</EmailMeta>
</EmailRow>
</EmailList>
</>
) : (
<EmailDetail onBack={() => setOpenEmail(null)} emailIndex={openEmail} />
)}
</Root>
);
}
@@ -0,0 +1,347 @@
'use client';
import { styled } from '@linaria/react';
import {
type PointerEvent as ReactPointerEvent,
useRef,
useState,
} from 'react';
import {
CARD_BG,
CARD_FONT,
CARD_TEXT,
CARD_TEXT_SECONDARY,
CARD_TEXT_TERTIARY,
} from './visual-tokens';
const Root = styled.div`
background: ${CARD_BG};
border: 1.5px dashed rgba(255, 255, 255, 0.12);
border-radius: 0;
display: flex;
flex-direction: column;
font-family: ${CARD_FONT};
height: 100%;
overflow: hidden;
width: 100%;
`;
const Header = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
padding: 12px 14px;
`;
const HeaderLeft = styled.div`
align-items: center;
display: flex;
gap: 6px;
`;
const HeaderLabel = styled.span`
color: ${CARD_TEXT};
font-size: 12px;
font-weight: 500;
`;
const HeaderCount = styled.span`
color: ${CARD_TEXT_TERTIARY};
font-size: 12px;
`;
const AddButton = styled.span`
color: ${CARD_TEXT_SECONDARY};
font-size: 11px;
font-weight: 500;
`;
const FileList = styled.div`
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
min-height: 0;
padding: 0 10px;
`;
const FileRow = styled.div<{ $dragging?: boolean }>`
align-items: center;
border-radius: 6px;
cursor: grab;
display: flex;
gap: 10px;
opacity: ${({ $dragging }) => ($dragging ? 0.4 : 1)};
padding: 6px 8px;
touch-action: none;
transition: background-color 0.1s ease;
user-select: none;
&:hover {
background-color: rgba(255, 255, 255, 0.03);
}
&:active {
cursor: grabbing;
}
`;
const FileIcon = styled.div<{ $bg: string; $color: string }>`
align-items: center;
background: ${({ $bg }) => $bg};
border-radius: 6px;
color: ${({ $color }) => $color};
display: flex;
flex-shrink: 0;
font-size: 8px;
font-weight: 700;
height: 28px;
justify-content: center;
letter-spacing: 0.02em;
width: 28px;
`;
const FileName = styled.span`
color: ${CARD_TEXT};
flex: 1;
font-size: 12px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const UploadArea = styled.div`
align-items: center;
display: flex;
flex-direction: column;
gap: 4px;
justify-content: center;
margin: auto 14px 14px;
padding: 20px;
&[data-over='true'] {
background-color: rgba(59, 130, 246, 0.05);
}
`;
const UploadIcon = styled.div`
color: ${CARD_TEXT_TERTIARY};
margin-bottom: 4px;
`;
const UploadTitle = styled.span`
color: ${CARD_TEXT_SECONDARY};
font-size: 11px;
font-weight: 500;
`;
const UploadHint = styled.span`
color: ${CARD_TEXT_TERTIARY};
font-size: 10px;
`;
const FloatingFile = styled.div`
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
left: 0;
pointer-events: none;
position: absolute;
top: 0;
z-index: 100;
`;
const FloatingFileInner = styled.div`
align-items: center;
background: #2a2a34;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
display: flex;
gap: 10px;
padding: 6px 12px;
`;
type FileData = {
bg: string;
color: string;
ext: string;
name: string;
};
const FILES: FileData[] = [
{
name: 'meeting_record.mp3',
ext: 'MP3',
color: '#f97316',
bg: 'rgba(249,115,22,0.15)',
},
{
name: 'invoice2023_04_02(2).pdf',
ext: 'PDF',
color: '#ef4444',
bg: 'rgba(239,68,68,0.15)',
},
{
name: 'mapping01.xls',
ext: 'XLS',
color: '#22c55e',
bg: 'rgba(34,197,94,0.15)',
},
];
type FilesVisualProps = {
active: boolean;
};
export function FilesVisual({ active: _active }: FilesVisualProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [fileOrder] = useState([0, 1, 2]);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const rootRef = useRef<HTMLDivElement>(null);
const activePointerRef = useRef<number | null>(null);
const dragStateRef = useRef<{
fileIndex: number;
originX: number;
originY: number;
pointerX: number;
pointerY: number;
} | null>(null);
const handlePointerDown = (
event: ReactPointerEvent<HTMLDivElement>,
fileIndex: number,
) => {
event.preventDefault();
const rootRect = rootRef.current?.getBoundingClientRect();
const rowRect = event.currentTarget.getBoundingClientRect();
if (!rootRect || dragStateRef.current) return;
dragStateRef.current = {
fileIndex,
originX: rowRect.left - rootRect.left,
originY: rowRect.top - rootRect.top,
pointerX: event.clientX,
pointerY: event.clientY,
};
setDraggedIndex(fileIndex);
setDragOffset({
x: rowRect.left - rootRect.left,
y: rowRect.top - rootRect.top,
});
activePointerRef.current = event.pointerId;
rootRef.current?.setPointerCapture(event.pointerId);
};
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerId !== activePointerRef.current) return;
event.preventDefault();
const state = dragStateRef.current;
if (!state) return;
setDragOffset({
x: state.originX + event.clientX - state.pointerX,
y: state.originY + event.clientY - state.pointerY,
});
};
const handlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerId !== activePointerRef.current) return;
event.preventDefault();
dragStateRef.current = null;
setDraggedIndex(null);
activePointerRef.current = null;
if (rootRef.current?.hasPointerCapture(event.pointerId)) {
rootRef.current.releasePointerCapture(event.pointerId);
}
};
const handlePointerCancel = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerId !== activePointerRef.current) return;
dragStateRef.current = null;
setDraggedIndex(null);
activePointerRef.current = null;
if (rootRef.current?.hasPointerCapture(event.pointerId)) {
rootRef.current.releasePointerCapture(event.pointerId);
}
};
return (
<Root
ref={rootRef}
onPointerCancel={handlePointerCancel}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
<Header>
<HeaderLeft>
<HeaderLabel>All</HeaderLabel>
<HeaderCount>{FILES.length}</HeaderCount>
</HeaderLeft>
<AddButton>+ Add file</AddButton>
</Header>
<FileList>
{fileOrder.map((fileIdx) => {
const file = FILES[fileIdx];
return (
<FileRow
key={fileIdx}
$dragging={draggedIndex === fileIdx}
onPointerDown={(e) => handlePointerDown(e, fileIdx)}
>
<FileIcon $bg={file.bg} $color={file.color}>
{file.ext}
</FileIcon>
<FileName>{file.name}</FileName>
</FileRow>
);
})}
</FileList>
<UploadArea
data-over={isDragOver}
onDragEnter={() => setIsDragOver(true)}
onDragLeave={() => setIsDragOver(false)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => setIsDragOver(false)}
>
<UploadIcon>
<svg
fill="none"
height="20"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width="20"
>
<path d="M12 15V3m0 0l-4 4m4-4l4 4" />
<path d="M2 17l.621 2.485A2 2 0 0 0 4.561 21h14.878a2 2 0 0 0 1.94-1.515L22 17" />
</svg>
</UploadIcon>
<UploadTitle>Upload a file</UploadTitle>
<UploadHint>Drag and Drop Here</UploadHint>
</UploadArea>
{draggedIndex !== null && (
<FloatingFile
style={{
transform: `translate3d(${dragOffset.x}px, ${dragOffset.y}px, 0)`,
}}
>
<FloatingFileInner>
<FileIcon
$bg={FILES[draggedIndex].bg}
$color={FILES[draggedIndex].color}
>
{FILES[draggedIndex].ext}
</FileIcon>
<FileName>{FILES[draggedIndex].name}</FileName>
</FloatingFileInner>
</FloatingFile>
)}
</Root>
);
}
@@ -0,0 +1,262 @@
'use client';
import { styled } from '@linaria/react';
import {
CARD_ACCENT,
CARD_BG,
CARD_BORDER,
CARD_FONT,
CARD_TEXT,
CARD_TEXT_SECONDARY,
CARD_TEXT_TERTIARY,
} from './visual-tokens';
const Root = styled.div`
background: ${CARD_BG};
display: flex;
flex-direction: column;
font-family: ${CARD_FONT};
height: 100%;
overflow: hidden;
padding: 16px;
width: 100%;
`;
const PageTitle = styled.h4`
color: ${CARD_TEXT};
font-size: 14px;
font-weight: 600;
margin: 0 0 6px;
`;
const PageDescription = styled.p`
color: ${CARD_TEXT_TERTIARY};
font-size: 11px;
line-height: 1.5;
margin: 0 0 16px;
`;
const TableFrame = styled.div`
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
`;
const TableHeaderRow = styled.div`
display: flex;
padding: 8px 12px;
`;
const NameHeader = styled.span`
color: ${CARD_TEXT_TERTIARY};
flex: 1;
font-size: 11px;
font-weight: 500;
`;
const RightsHeader = styled.span`
color: ${CARD_TEXT_TERTIARY};
font-size: 11px;
font-weight: 500;
text-align: right;
width: 130px;
`;
const SectionRow = styled.div`
align-items: center;
background: rgba(255, 255, 255, 0.02);
border-bottom: 1px solid ${CARD_BORDER};
border-top: 1px solid ${CARD_BORDER};
display: flex;
gap: 6px;
padding: 6px 12px;
`;
const SectionLabel = styled.span`
color: ${CARD_TEXT_SECONDARY};
font-size: 10px;
font-weight: 500;
`;
const SectionChevron = styled.span`
color: ${CARD_TEXT_TERTIARY};
display: inline-flex;
margin-left: auto;
`;
const TableBody = styled.div`
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow-y: auto;
`;
const Row = styled.div`
align-items: center;
border-bottom: 1px solid ${CARD_BORDER};
display: flex;
padding: 8px 12px;
transition: background-color 0.1s ease;
&:hover {
background-color: rgba(255, 255, 255, 0.02);
}
&:last-child {
border-bottom: none;
}
`;
const RoleIconEl = styled.div`
align-items: center;
color: ${CARD_TEXT_TERTIARY};
display: flex;
flex-shrink: 0;
height: 18px;
justify-content: center;
margin-right: 8px;
width: 18px;
`;
const RoleName = styled.span`
color: ${CARD_TEXT};
flex: 1;
font-size: 12px;
font-weight: 500;
`;
const RightsGroup = styled.div`
align-items: center;
display: flex;
gap: 4px;
width: 130px;
justify-content: flex-end;
`;
const RightIcon = styled.div`
align-items: center;
border: 1px solid ${CARD_ACCENT};
border-radius: 4px;
color: ${CARD_ACCENT};
display: flex;
height: 18px;
justify-content: center;
width: 18px;
`;
const RowChevron = styled.span`
color: ${CARD_TEXT_TERTIARY};
display: inline-flex;
margin-left: 8px;
`;
const ROLES = [
{ name: 'CMO', icons: 4 },
{ name: 'Product Marketing Manager', icons: 4 },
{ name: 'Digital Marketing Manager', icons: 4 },
{ name: 'Content Marketing Manager', icons: 4 },
{ name: 'CEO', icons: 4 },
];
const ICON_PATHS = [
'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8zM12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z',
'M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z',
'M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2',
'M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v13',
];
type ImportVisualProps = {
active: boolean;
};
export function ImportVisual({ active: _active }: ImportVisualProps) {
return (
<Root>
<PageTitle>Permissions</PageTitle>
<PageDescription>
Customise the fields available in the company views and their display
order in the company detail view.
</PageDescription>
<TableFrame>
<TableHeaderRow>
<NameHeader>Name</NameHeader>
<RightsHeader>Rights</RightsHeader>
</TableHeaderRow>
<SectionRow>
<SectionLabel>Have Access</SectionLabel>
<SectionChevron>
<svg
fill="none"
height="10"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
viewBox="0 0 10 10"
width="10"
>
<path d="M3 4l2 2 2-2" />
</svg>
</SectionChevron>
</SectionRow>
<TableBody>
{ROLES.map((role, index) => (
<Row key={index}>
<RoleIconEl>
<svg
fill="none"
height="14"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width="14"
>
<path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0-8 0M6 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2" />
</svg>
</RoleIconEl>
<RoleName>{role.name}</RoleName>
<RightsGroup>
{ICON_PATHS.map((path, iconIdx) => (
<RightIcon key={iconIdx}>
<svg
fill="none"
height="10"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
viewBox="0 0 24 24"
width="10"
>
<path d={path} />
</svg>
</RightIcon>
))}
</RightsGroup>
<RowChevron>
<svg
fill="none"
height="12"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
viewBox="0 0 10 10"
width="12"
>
<path d="M4 2l3 3-3 3" />
</svg>
</RowChevron>
</Row>
))}
</TableBody>
</TableFrame>
</Root>
);
}
@@ -0,0 +1,966 @@
'use client';
import {
SHARED_COMPANY_LOGO_URLS,
SHARED_PEOPLE_AVATAR_URLS,
} from '@/content/site/asset-paths';
import { styled } from '@linaria/react';
import {
type PointerEvent as ReactPointerEvent,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import {
CARD_BG,
CARD_BORDER,
CARD_FONT,
CARD_TEXT,
CARD_TEXT_TERTIARY,
} from './visual-tokens';
const TONE_MAP: Record<string, { bg: string; color: string }> = {
gray: { bg: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.6)' },
purple: { bg: 'rgba(168,85,247,0.12)', color: '#c084fc' },
blue: { bg: 'rgba(59,130,246,0.12)', color: '#93c5fd' },
pink: { bg: 'rgba(236,72,153,0.12)', color: '#f472b6' },
amber: { bg: 'rgba(245,158,11,0.12)', color: '#fbbf24' },
red: { bg: 'rgba(239,68,68,0.12)', color: '#fca5a5' },
teal: { bg: 'rgba(20,184,166,0.12)', color: '#5eead4' },
yellow: { bg: 'rgba(234,179,8,0.12)', color: '#fde047' },
};
const LANE_STYLES: Record<string, { bg: string; pill: string; text: string }> =
{
pink: {
bg: 'rgba(236,72,153,0.06)',
pill: 'rgba(236,72,153,0.12)',
text: '#f472b6',
},
purple: {
bg: 'rgba(168,85,247,0.06)',
pill: 'rgba(168,85,247,0.12)',
text: '#c084fc',
},
};
const CARD_DROP_MS = 300;
const CARD_DROP_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
type CardId = 'airbnb' | 'figma' | 'github' | 'notion';
type LaneIndex = 0 | 1;
type LaneCards = [CardId[], CardId[]];
type PersonData = {
avatarUrl?: string;
initials: string;
name: string;
tone: string;
};
type CompanyData = {
initials: string;
logoSrc?: string;
name: string;
tone: string;
};
type DealData = {
amount: string;
company: CompanyData;
contact: PersonData;
date: string;
header: CompanyData;
id: CardId;
owner: PersonData;
rating: number;
recordId: string;
};
const CARDS: Record<CardId, DealData> = {
github: {
id: 'github',
header: {
initials: 'G',
logoSrc: SHARED_COMPANY_LOGO_URLS.github,
name: 'Github',
tone: 'gray',
},
amount: '$6,562.04',
company: {
initials: 'G',
logoSrc: SHARED_COMPANY_LOGO_URLS.github,
name: 'Github',
tone: 'gray',
},
owner: {
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.eddyCue,
initials: 'E',
name: 'Eddy Cue',
tone: 'amber',
},
rating: 2,
date: 'Jun 16, 2023',
contact: {
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.chrisWanstrath,
initials: 'C',
name: 'Chris Wanstrath',
tone: 'gray',
},
recordId: 'OPP-1',
},
figma: {
id: 'figma',
header: {
initials: 'F',
logoSrc: SHARED_COMPANY_LOGO_URLS.figma,
name: 'Figma',
tone: 'purple',
},
amount: '$6,562.04',
company: {
initials: 'F',
logoSrc: SHARED_COMPANY_LOGO_URLS.figma,
name: 'Figma',
tone: 'purple',
},
owner: {
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.jeffWilliams,
initials: 'J',
name: 'Jeff Williams',
tone: 'blue',
},
rating: 2,
date: 'Jun 21, 2023',
contact: {
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.dylanField,
initials: 'D',
name: 'Dylan Field',
tone: 'pink',
},
recordId: 'OPP-2',
},
airbnb: {
id: 'airbnb',
header: {
initials: 'A',
logoSrc: SHARED_COMPANY_LOGO_URLS.airbnb,
name: 'Airbnb',
tone: 'pink',
},
amount: '$2,433.89',
company: {
initials: 'A',
logoSrc: SHARED_COMPANY_LOGO_URLS.airbnb,
name: 'Airbnb',
tone: 'pink',
},
owner: {
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.katherineAdams,
initials: 'K',
name: 'Katherine Adams',
tone: 'red',
},
rating: 3,
date: 'Jun 6, 2023',
contact: { initials: 'I', name: 'Ivan Zhao', tone: 'gray' },
recordId: 'OPP-8',
},
notion: {
id: 'notion',
header: {
initials: 'N',
logoSrc: SHARED_COMPANY_LOGO_URLS.notion,
name: 'Notion',
tone: 'gray',
},
amount: '$2,650',
company: {
initials: 'N',
logoSrc: SHARED_COMPANY_LOGO_URLS.notion,
name: 'Notion',
tone: 'gray',
},
owner: { initials: 'A', name: 'Airbnb', tone: 'yellow' },
rating: 2,
date: 'Jun 6, 2023',
contact: { initials: 'I', name: 'Ivan Zhao', tone: 'gray' },
recordId: 'OPP-6',
},
};
const INITIAL_LANES: LaneCards = [
['github', 'figma'],
['airbnb', 'notion'],
];
const LANES_META = [
{ key: 'pink', label: 'Identified' },
{ key: 'purple', label: 'Qualified' },
];
const Root = styled.div`
background: ${CARD_BG};
display: flex;
flex-direction: column;
font-family: ${CARD_FONT};
height: 100%;
overflow: hidden;
position: relative;
width: 100%;
`;
const BoardHeader = styled.div`
align-items: center;
border-bottom: 1px solid ${CARD_BORDER};
display: flex;
gap: 4px;
height: 34px;
padding: 0 14px;
`;
const BoardTitle = styled.span`
color: ${CARD_TEXT};
font-size: 13px;
font-weight: 500;
`;
const BoardCount = styled.span`
color: ${CARD_TEXT_TERTIARY};
font-size: 13px;
`;
const ColumnsHeaderGrid = styled.div`
border-bottom: 1px solid ${CARD_BORDER};
display: grid;
grid-template-columns: 1fr 1fr;
`;
const LaneHeaderEl = styled.div`
align-items: center;
display: flex;
gap: 6px;
padding: 8px 12px;
`;
const LanePill = styled.span<{ $bg: string; $color: string }>`
background: ${({ $bg }) => $bg};
border-radius: 4px;
color: ${({ $color }) => $color};
font-size: 12px;
font-weight: 500;
height: 22px;
display: inline-flex;
align-items: center;
padding: 0 8px;
`;
const LaneCount = styled.span`
color: ${CARD_TEXT_TERTIARY};
font-size: 12px;
`;
const ColumnsGrid = styled.div`
display: grid;
flex: 1;
grid-template-columns: 1fr 1fr;
min-height: 0;
overflow: hidden;
`;
const LaneBody = styled.div`
border-right: 1px solid ${CARD_BORDER};
display: flex;
flex-direction: column;
gap: 6px;
min-height: 40px;
overflow-y: auto;
padding: 8px;
&:last-child {
border-right: none;
}
`;
const CardEl = styled.div<{ $dragging?: boolean }>`
background: #26262f;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
cursor: grab;
display: flex;
flex-direction: column;
opacity: ${({ $dragging }) => ($dragging ? 0 : 1)};
overflow: hidden;
touch-action: none;
user-select: none;
&:active {
cursor: grabbing;
}
`;
const CardHeader = styled.div`
align-items: center;
display: flex;
padding: 8px 8px 4px;
`;
const CardFields = styled.div`
display: flex;
flex-direction: column;
padding: 0 8px 8px;
`;
const FieldRow = styled.div`
align-items: center;
display: flex;
gap: 6px;
min-height: 22px;
`;
const FieldIcon = styled.div`
align-items: center;
color: ${CARD_TEXT_TERTIARY};
display: flex;
flex: 0 0 14px;
height: 14px;
justify-content: center;
width: 14px;
`;
const FieldValue = styled.div`
align-items: center;
display: flex;
flex: 1;
min-width: 0;
`;
const ValueText = styled.span`
color: ${CARD_TEXT};
font-size: 11.5px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const TokenChip = styled.span<{ $soft?: boolean }>`
align-items: center;
background: ${({ $soft }) =>
$soft ? 'rgba(255,255,255,0.04)' : 'transparent'};
border-radius: 4px;
display: inline-flex;
gap: 4px;
height: 18px;
max-width: 100%;
overflow: hidden;
padding: ${({ $soft }) => ($soft ? '2px 4px' : '2px 4px 2px 0')};
`;
const TokenMark = styled.span<{
$bg: string;
$color: string;
$round?: boolean;
}>`
align-items: center;
background: ${({ $bg }) => $bg};
border-radius: ${({ $round }) => ($round ? '999px' : '3px')};
color: ${({ $color }) => $color};
display: inline-flex;
flex: 0 0 14px;
font-size: 8px;
font-weight: 600;
height: 14px;
justify-content: center;
overflow: hidden;
width: 14px;
`;
const TokenImg = styled.img`
display: block;
height: 100%;
object-fit: contain;
width: 100%;
`;
const TokenLabel = styled.span`
color: ${CARD_TEXT};
font-size: 11.5px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const HeaderTokenChip = styled.span`
align-items: center;
border-radius: 4px;
display: inline-flex;
gap: 4px;
height: 22px;
max-width: 100%;
overflow: hidden;
padding: 4px;
`;
const HeaderTokenLabel = styled(TokenLabel)`
font-weight: 500;
`;
const RatingRow = styled.div`
display: inline-flex;
gap: 1px;
`;
const AddCardRow = styled.div`
align-items: center;
color: ${CARD_TEXT_TERTIARY};
display: flex;
font-size: 11px;
gap: 4px;
padding: 4px 8px;
`;
const InteractionLayer = styled.div`
inset: 0;
position: absolute;
z-index: 50;
`;
const FloatingCardShell = styled.div<{ $dragging: boolean }>`
box-shadow: ${({ $dragging }) =>
$dragging ? '0 12px 40px rgba(0,0,0,0.5)' : 'none'};
left: 0;
pointer-events: none;
position: absolute;
top: 0;
transform-origin: center center;
transition: box-shadow 0.15s ease;
width: calc(50% - 20px);
z-index: 100;
`;
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function moveCard(
lanes: LaneCards,
cardId: CardId,
targetLane: LaneIndex,
targetIndex: number,
): LaneCards {
const next = lanes.map((l) => l.filter((c) => c !== cardId)) as LaneCards;
const bounded = clamp(targetIndex, 0, next[targetLane].length);
next[targetLane] = [
...next[targetLane].slice(0, bounded),
cardId,
...next[targetLane].slice(bounded),
];
return next;
}
function CompanyMark({ data }: { data: CompanyData }) {
const [failed, setFailed] = useState(false);
const tone = TONE_MAP[data.tone] ?? TONE_MAP.gray;
if (data.logoSrc && !failed) {
return (
<TokenMark $bg={tone.bg} $color={tone.color}>
<TokenImg alt="" src={data.logoSrc} onError={() => setFailed(true)} />
</TokenMark>
);
}
return (
<TokenMark $bg={tone.bg} $color={tone.color}>
{data.initials}
</TokenMark>
);
}
function PersonMark({ data }: { data: PersonData }) {
const [failed, setFailed] = useState(false);
const tone = TONE_MAP[data.tone] ?? TONE_MAP.gray;
if (data.avatarUrl && !failed) {
return (
<TokenMark $bg={tone.bg} $color={tone.color} $round>
<TokenImg alt="" src={data.avatarUrl} onError={() => setFailed(true)} />
</TokenMark>
);
}
return (
<TokenMark $bg={tone.bg} $color={tone.color} $round>
{data.initials}
</TokenMark>
);
}
function StarIcon({ filled }: { filled: boolean }) {
return (
<svg
fill={filled ? '#d4d4d4' : 'none'}
height="11"
stroke={filled ? '#d4d4d4' : 'rgba(255,255,255,0.2)'}
strokeWidth="1.5"
viewBox="0 0 24 24"
width="11"
>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.27 5.82 22 7 14.14l-5-4.87 6.91-1.01L12 2z" />
</svg>
);
}
function OpportunityCard({ data }: { data: DealData }) {
return (
<>
<CardHeader>
<HeaderTokenChip>
<CompanyMark data={data.header} />
<HeaderTokenLabel>{data.header.name}</HeaderTokenLabel>
</HeaderTokenChip>
</CardHeader>
<CardFields>
<FieldRow>
<FieldIcon>
<svg
fill="none"
height="14"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width="14"
>
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
</FieldIcon>
<FieldValue>
<ValueText>{data.amount}</ValueText>
</FieldValue>
</FieldRow>
<FieldRow>
<FieldIcon>
<svg
fill="none"
height="14"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width="14"
>
<path d="M3 21h18M5 21V7l7-4 7 4v14M9 21v-6h6v6" />
</svg>
</FieldIcon>
<FieldValue>
<TokenChip $soft>
<CompanyMark data={data.company} />
<TokenLabel>{data.company.name}</TokenLabel>
</TokenChip>
</FieldValue>
</FieldRow>
<FieldRow>
<FieldIcon>
<svg
fill="none"
height="14"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width="14"
>
<circle cx="12" cy="12" r="9" />
<circle cx="12" cy="10" r="3" />
<path d="M6.168 18.849A4 4 0 0 1 10 16h4a4 4 0 0 1 3.834 2.855" />
</svg>
</FieldIcon>
<FieldValue>
<TokenChip>
<PersonMark data={data.owner} />
<TokenLabel>{data.owner.name}</TokenLabel>
</TokenChip>
</FieldValue>
</FieldRow>
<FieldRow>
<FieldIcon>
<svg
fill="none"
height="14"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width="14"
>
<path d="M3 12h2l2-4 3 8 2-4h2" />
</svg>
</FieldIcon>
<FieldValue>
<RatingRow>
{Array.from({ length: 5 }, (_, i) => (
<StarIcon key={i} filled={i < data.rating} />
))}
</RatingRow>
</FieldValue>
</FieldRow>
<FieldRow>
<FieldIcon>
<svg
fill="none"
height="14"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width="14"
>
<rect height="16" rx="2" width="18" x="3" y="5" />
<path d="M16 3v4M8 3v4M3 9h18" />
</svg>
</FieldIcon>
<FieldValue>
<ValueText>{data.date}</ValueText>
</FieldValue>
</FieldRow>
<FieldRow>
<FieldIcon>
<svg
fill="none"
height="14"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width="14"
>
<path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0-8 0M6 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2" />
</svg>
</FieldIcon>
<FieldValue>
<TokenChip $soft>
<PersonMark data={data.contact} />
<TokenLabel>{data.contact.name}</TokenLabel>
</TokenChip>
</FieldValue>
</FieldRow>
<FieldRow>
<FieldIcon>
<svg
fill="none"
height="14"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width="14"
>
<rect height="14" rx="3" width="18" x="3" y="5" />
<path d="M7 9h10M7 13h6" />
</svg>
</FieldIcon>
<FieldValue>
<ValueText>{data.recordId}</ValueText>
</FieldValue>
</FieldRow>
</CardFields>
</>
);
}
type PipelineVisualProps = { active: boolean };
export function PipelineVisual({ active: _active }: PipelineVisualProps) {
const [lanes, setLanes] = useState<LaneCards>(INITIAL_LANES);
const [draggedCardId, setDraggedCardId] = useState<CardId | null>(null);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const interactionRef = useRef<HTMLDivElement>(null);
const laneBodyRefs = useRef<(HTMLDivElement | null)[]>([]);
const cardRefs = useRef<Partial<Record<CardId, HTMLDivElement | null>>>({});
const pendingRectsRef = useRef<Partial<Record<CardId, DOMRect>>>({});
const animationsRef = useRef<Partial<Record<CardId, Animation>>>({});
const activePointerRef = useRef<number | null>(null);
const dragStateRef = useRef<{
cardId: CardId;
lastX: number;
lastY: number;
maxX: number;
maxY: number;
minX: number;
minY: number;
originX: number;
originY: number;
pointerX: number;
pointerY: number;
} | null>(null);
useLayoutEffect(() => {
const prev = pendingRectsRef.current;
const ids = Object.keys(prev) as CardId[];
if (ids.length === 0) return;
pendingRectsRef.current = {};
for (const id of ids) {
const prevRect = prev[id];
const el = cardRefs.current[id];
const nextRect = el?.getBoundingClientRect();
if (!prevRect || !el || !nextRect) continue;
const dx = prevRect.left - nextRect.left;
const dy = prevRect.top - nextRect.top;
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) continue;
animationsRef.current[id]?.cancel();
animationsRef.current[id] = el.animate(
[
{ transform: `translate3d(${dx}px, ${dy}px, 0)` },
{ transform: 'translate3d(0, 0, 0)' },
],
{ duration: CARD_DROP_MS, easing: CARD_DROP_EASING },
);
}
}, [lanes]);
const captureRects = () => {
const rects: Partial<Record<CardId, DOMRect>> = {};
for (const lane of lanes) {
for (const id of lane) {
const el = cardRefs.current[id];
if (el) rects[id] = el.getBoundingClientRect();
}
}
pendingRectsRef.current = rects;
};
const getDropTarget = (
clientX: number,
clientY: number,
cardId: CardId,
): { cardIndex: number; laneIndex: LaneIndex } | null => {
const matchedLane = laneBodyRefs.current.findIndex((el) => {
const rect = el?.getBoundingClientRect();
return (
rect &&
clientX >= rect.left &&
clientX <= rect.right &&
clientY >= rect.top &&
clientY <= rect.bottom
);
});
if (matchedLane !== 0 && matchedLane !== 1) return null;
const laneIndex = matchedLane as LaneIndex;
const laneCardIds = lanes[laneIndex].filter((c) => c !== cardId);
let cardIndex = laneCardIds.length;
for (const [i, cid] of laneCardIds.entries()) {
const rect = cardRefs.current[cid]?.getBoundingClientRect();
if (rect && clientY < rect.top + rect.height / 2) {
cardIndex = i;
break;
}
}
return { cardIndex, laneIndex };
};
const handlePointerDown = (
event: ReactPointerEvent<HTMLDivElement>,
cardId: CardId,
) => {
event.preventDefault();
const layerRect = interactionRef.current?.getBoundingClientRect();
const cardRect = event.currentTarget.getBoundingClientRect();
if (!layerRect || dragStateRef.current) return;
const originX = cardRect.left - layerRect.left;
const originY = cardRect.top - layerRect.top;
dragStateRef.current = {
cardId,
lastX: event.clientX,
lastY: event.clientY,
maxX: layerRect.width - cardRect.width,
maxY: layerRect.height - cardRect.height,
minX: 0,
minY: 0,
originX,
originY,
pointerX: event.clientX,
pointerY: event.clientY,
};
setDraggedCardId(cardId);
setDragOffset({ x: originX, y: originY });
setIsDragging(true);
activePointerRef.current = event.pointerId;
interactionRef.current?.setPointerCapture(event.pointerId);
};
const finishDrag = (clientX: number, clientY: number) => {
const state = dragStateRef.current;
if (!state) return;
const dropTarget = getDropTarget(clientX, clientY, state.cardId);
if (dropTarget) {
captureRects();
setLanes((prev) =>
moveCard(
prev,
state.cardId,
dropTarget.laneIndex,
dropTarget.cardIndex,
),
);
}
dragStateRef.current = null;
setDraggedCardId(null);
setIsDragging(false);
activePointerRef.current = null;
};
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerId !== activePointerRef.current) return;
event.preventDefault();
const state = dragStateRef.current;
if (!state) return;
state.lastX = event.clientX;
state.lastY = event.clientY;
setDragOffset({
x: clamp(
state.originX + event.clientX - state.pointerX,
state.minX,
state.maxX,
),
y: clamp(
state.originY + event.clientY - state.pointerY,
state.minY,
state.maxY,
),
});
};
const handlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerId !== activePointerRef.current) return;
event.preventDefault();
finishDrag(event.clientX, event.clientY);
if (interactionRef.current?.hasPointerCapture(event.pointerId)) {
interactionRef.current.releasePointerCapture(event.pointerId);
}
};
const handlePointerCancel = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerId !== activePointerRef.current) return;
const state = dragStateRef.current;
finishDrag(state?.lastX ?? event.clientX, state?.lastY ?? event.clientY);
if (interactionRef.current?.hasPointerCapture(event.pointerId)) {
interactionRef.current.releasePointerCapture(event.pointerId);
}
};
const handleLostCapture = () => {
const state = dragStateRef.current;
if (state) finishDrag(state.lastX, state.lastY);
activePointerRef.current = null;
};
useEffect(() => {
const layer = interactionRef.current;
return () => {
if (activePointerRef.current !== null && layer) {
try {
layer.releasePointerCapture(activePointerRef.current);
} catch {}
}
};
}, []);
return (
<Root>
<BoardHeader>
<svg
fill="none"
height="14"
stroke={CARD_TEXT_TERTIARY}
strokeLinecap="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width="14"
>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
<BoardTitle>All opportunities</BoardTitle>
<BoardCount>· 4</BoardCount>
</BoardHeader>
<ColumnsHeaderGrid>
{LANES_META.map((meta) => {
const style = LANE_STYLES[meta.key];
return (
<LaneHeaderEl key={meta.key}>
<LanePill $bg={style.pill} $color={style.text}>
{meta.label}
</LanePill>
<LaneCount>{lanes[LANES_META.indexOf(meta)].length}</LaneCount>
</LaneHeaderEl>
);
})}
</ColumnsHeaderGrid>
<ColumnsGrid>
{lanes.map((laneCardIds, laneIndex) => (
<LaneBody
key={laneIndex}
ref={(el) => {
laneBodyRefs.current[laneIndex] = el;
}}
>
{laneCardIds.map((cardId) => (
<CardEl
key={cardId}
$dragging={draggedCardId === cardId}
ref={(el) => {
cardRefs.current[cardId] = el;
}}
onPointerDown={(e) => handlePointerDown(e, cardId)}
>
<OpportunityCard data={CARDS[cardId]} />
</CardEl>
))}
<AddCardRow>
<svg
fill="none"
height="12"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.6"
viewBox="0 0 16 16"
width="12"
>
<path d="M8 3v10M3 8h10" />
</svg>
New
</AddCardRow>
</LaneBody>
))}
</ColumnsGrid>
<InteractionLayer
ref={interactionRef}
style={{ pointerEvents: isDragging ? 'auto' : 'none' }}
onLostPointerCapture={handleLostCapture}
onPointerCancel={handlePointerCancel}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
{draggedCardId && (
<FloatingCardShell
$dragging={isDragging}
style={{
transform: `translate3d(${dragOffset.x}px, ${dragOffset.y}px, 0)`,
}}
>
<CardEl>
<OpportunityCard data={CARDS[draggedCardId]} />
</CardEl>
</FloatingCardShell>
)}
</InteractionLayer>
</Root>
);
}
@@ -0,0 +1,434 @@
'use client';
import { styled } from '@linaria/react';
import { useState } from 'react';
const Root = styled.div`
background-color: #191920;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
width: 100%;
`;
const TabBar = styled.div`
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
gap: 0;
padding: 0 16px;
`;
const Tab = styled.span`
align-items: center;
color: rgba(255, 255, 255, 0.45);
display: flex;
font-size: 12px;
font-weight: 500;
gap: 6px;
padding: 12px 14px;
position: relative;
white-space: nowrap;
&[data-active='true'] {
background-color: rgba(255, 255, 255, 0.06);
border-radius: 6px 6px 0 0;
color: rgba(255, 255, 255, 0.9);
}
svg {
height: 13px;
opacity: 0.7;
width: 13px;
}
&[data-active='true'] svg {
opacity: 1;
}
`;
const SectionHeader = styled.div`
color: rgba(255, 255, 255, 0.35);
font-size: 11px;
font-weight: 500;
padding: 14px 20px 8px;
`;
const ActivityList = styled.div`
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
`;
const ActivityRow = styled.div`
align-items: center;
display: flex;
gap: 12px;
padding: 10px 20px;
`;
const ActivityIcon = styled.div`
align-items: center;
color: rgba(255, 255, 255, 0.35);
display: flex;
flex-shrink: 0;
height: 18px;
justify-content: center;
width: 18px;
svg {
height: 14px;
width: 14px;
}
`;
const ActivityBody = styled.div`
align-items: center;
display: flex;
flex: 1;
flex-wrap: wrap;
gap: 4px;
min-width: 0;
`;
const ActorName = styled.span`
color: rgba(255, 255, 255, 0.9);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
`;
const ActionText = styled.span`
color: rgba(255, 255, 255, 0.45);
font-size: 12px;
white-space: nowrap;
`;
const TaskChip = styled.span`
align-items: center;
display: inline-flex;
gap: 5px;
white-space: nowrap;
`;
const TaskCircleEl = styled.span`
align-items: center;
border: 1.5px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
cursor: pointer;
display: inline-flex;
flex-shrink: 0;
height: 12px;
justify-content: center;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
width: 12px;
&:hover {
border-color: rgba(255, 255, 255, 0.5);
}
&[data-filled='true'] {
background-color: #3b82f6;
border-color: #3b82f6;
}
&[data-filled='true']:hover {
background-color: #2563eb;
border-color: #2563eb;
}
`;
const TaskName = styled.span`
color: rgba(255, 255, 255, 0.75);
font-size: 12px;
&[data-strikethrough='true'] {
text-decoration: line-through;
text-decoration-color: rgba(255, 255, 255, 0.4);
}
`;
const Timestamp = styled.span`
color: rgba(255, 255, 255, 0.3);
flex-shrink: 0;
font-size: 11px;
font-variant-numeric: tabular-nums;
margin-left: auto;
padding-left: 12px;
white-space: nowrap;
`;
function IconTimeline() {
return (
<svg
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
viewBox="0 0 16 16"
>
<circle cx="8" cy="8" r="6" />
<path d="M8 4.5V8l2.5 1.5" />
</svg>
);
}
function IconTasks() {
return (
<svg
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
viewBox="0 0 16 16"
>
<rect height="10" rx="2" width="10" x="3" y="3" />
<path d="M6 8l1.5 1.5L10 7" />
</svg>
);
}
function IconNotes() {
return (
<svg
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
viewBox="0 0 16 16"
>
<path d="M4 4h8M4 7h8M4 10h5" />
</svg>
);
}
function IconFiles() {
return (
<svg
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
viewBox="0 0 16 16"
>
<path d="M3 8.5a3 3 0 0 1 3-3h4.5a2 2 0 0 1 0 4H6.5a1 1 0 0 1 0-2H11" />
</svg>
);
}
function IconEmails() {
return (
<svg
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
viewBox="0 0 16 16"
>
<rect height="9" rx="2" width="12" x="2" y="3.5" />
<path d="M2 5.5l6 4 6-4" />
</svg>
);
}
function IconCalendar() {
return (
<svg
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
viewBox="0 0 16 16"
>
<rect height="10" rx="2" width="10" x="3" y="3" />
<path d="M3 7h10M6 2v2M10 2v2" />
</svg>
);
}
function IconComment() {
return (
<svg
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
viewBox="0 0 16 16"
>
<path d="M3 4a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H6l-3 2.5V4z" />
</svg>
);
}
function IconCheck() {
return (
<svg
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.8"
viewBox="0 0 16 16"
>
<path d="M4 8.5l3 3 5-6" />
</svg>
);
}
function IconPencil() {
return (
<svg
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
viewBox="0 0 16 16"
>
<path d="M10.5 3.5l2 2M4 10l6.5-6.5 2 2L6 12H4v-2z" />
</svg>
);
}
const TABS = [
{ label: 'Timeline', icon: IconTimeline },
{ label: 'Tasks', icon: IconTasks },
{ label: 'Notes', icon: IconNotes },
{ label: 'Files', icon: IconFiles },
{ label: 'Emails', icon: IconEmails },
{ label: 'Calendar', icon: IconCalendar },
];
const ACTIVITIES = [
{
icon: IconComment,
actor: 'Tim Cook',
action: 'commented',
task: 'Send NDA to Steve Anavi',
filled: false,
strikethrough: false,
time: '10 mins ago',
},
{
icon: IconComment,
actor: 'Tim Cook',
action: 'deleted',
task: 'Send NDA to Steve Anavi',
filled: true,
strikethrough: true,
time: '10 mins ago',
},
{
icon: IconCheck,
actor: 'Tim Cook',
action: 'completed',
task: 'Send NDA to Steve Anavi',
filled: true,
strikethrough: true,
time: '10 mins ago',
},
{
icon: IconComment,
actor: 'Tim Cook',
action: 'has been assigned to',
task: 'Call back Steve about Qonto pricing',
filled: false,
strikethrough: false,
time: '18:34',
},
{
icon: IconPencil,
actor: 'Tim Cook',
action: 'Edited',
task: 'Call back Steve about Qonto pricing',
filled: false,
strikethrough: false,
time: '18:34',
},
];
type TasksVisualProps = {
active: boolean;
};
export function TasksVisual({ active: _active }: TasksVisualProps) {
const [completed, setCompleted] = useState<Record<number, boolean>>({
1: true,
2: true,
});
const toggleComplete = (index: number) => {
setCompleted((prev) => ({ ...prev, [index]: !prev[index] }));
};
return (
<Root>
<TabBar>
{TABS.map((tab, index) => {
const Icon = tab.icon;
return (
<Tab data-active={index === 1} key={tab.label}>
<Icon />
{tab.label}
</Tab>
);
})}
</TabBar>
<SectionHeader>August</SectionHeader>
<ActivityList>
{ACTIVITIES.map((activity, index) => {
const Icon = activity.icon;
const isCompleted = !!completed[index];
return (
<ActivityRow key={index}>
<ActivityIcon>
<Icon />
</ActivityIcon>
<ActivityBody>
<ActorName>{activity.actor}</ActorName>
<ActionText>{activity.action}</ActionText>
<TaskChip>
<TaskCircleEl
aria-label={
isCompleted ? 'Mark incomplete' : 'Mark complete'
}
data-filled={isCompleted}
onClick={() => toggleComplete(index)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleComplete(index);
}
}}
role="button"
tabIndex={0}
>
{isCompleted && (
<svg
fill="none"
height="7"
stroke="white"
strokeLinecap="round"
strokeWidth="2"
viewBox="0 0 10 10"
width="7"
>
<path d="M2 5.5L4.2 7.5L8 3" />
</svg>
)}
</TaskCircleEl>
<TaskName data-strikethrough={isCompleted}>
{activity.task}
</TaskName>
</TaskChip>
</ActivityBody>
<Timestamp>{activity.time}</Timestamp>
</ActivityRow>
);
})}
</ActivityList>
</Root>
);
}
@@ -0,0 +1,105 @@
'use client';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
import {
BG_DARK,
BORDER_COLOR,
TEXT_PRIMARY,
TEXT_SECONDARY,
} from './visual-tokens';
const Window = styled.div`
background-color: ${BG_DARK};
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
width: 100%;
`;
const Titlebar = styled.div`
align-items: center;
border-bottom: 1px solid ${BORDER_COLOR};
display: flex;
flex-shrink: 0;
gap: 12px;
padding: 12px 16px;
`;
const TrafficLights = styled.div`
display: flex;
gap: 6px;
`;
const Dot = styled.span`
border-radius: 50%;
height: 9px;
width: 9px;
`;
const Title = styled.span`
color: ${TEXT_PRIMARY};
font-size: 12px;
font-weight: 600;
letter-spacing: 0.01em;
`;
const Breadcrumb = styled.span`
color: ${TEXT_SECONDARY};
font-size: 11px;
letter-spacing: 0.01em;
margin-left: auto;
`;
const Bold = styled.span`
color: ${TEXT_PRIMARY};
font-weight: 600;
`;
const Body = styled.div`
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
`;
type WindowChromeProps = {
breadcrumb?: string;
breadcrumbBold?: string;
children: ReactNode;
title: string;
};
export function WindowChrome({
breadcrumb,
breadcrumbBold,
children,
title,
}: WindowChromeProps) {
return (
<Window>
<Titlebar>
<TrafficLights>
<Dot style={{ backgroundColor: '#ff5f57' }} />
<Dot style={{ backgroundColor: '#ffbd2e' }} />
<Dot style={{ backgroundColor: '#28c840' }} />
</TrafficLights>
<Title>{title}</Title>
{breadcrumb ? (
<Breadcrumb>
{breadcrumb}
{breadcrumbBold ? (
<>
{' / '}
<Bold>{breadcrumbBold}</Bold>
</>
) : null}
</Breadcrumb>
) : null}
</Titlebar>
<Body>{children}</Body>
</Window>
);
}
@@ -0,0 +1,19 @@
export const ACCENT = '#16a34a';
export const ACCENT_DIM = 'rgba(22, 163, 74, 0.25)';
export const BAR_DATA = [
{ label: 'Jan', value: 38 },
{ label: 'Feb', value: 41 },
{ label: 'Mar', value: 40 },
{ label: 'Apr', value: 88 },
{ label: 'May', value: 44 },
{ label: 'Jun', value: 30 },
{ label: 'Jul', value: 39 },
{ label: 'Aug', value: 44 },
{ label: 'Sep', value: 30 },
{ label: 'Oct', value: 30 },
{ label: 'Nov', value: 41 },
{ label: 'Dec', value: 43 },
];
export const DONUT_VALUE = 70;
@@ -0,0 +1,14 @@
export const BG_DARK = '#1c1c28';
export const BG_PANEL = '#242434';
export const BORDER_COLOR = 'rgba(255, 255, 255, 0.06)';
export const TEXT_PRIMARY = '#f0f0f4';
export const TEXT_SECONDARY = '#8b8b9e';
export const TEXT_MUTED = '#55556a';
export const CARD_BG = '#1d1d25';
export const CARD_BORDER = 'rgba(255, 255, 255, 0.06)';
export const CARD_TEXT = 'rgba(255, 255, 255, 0.88)';
export const CARD_TEXT_SECONDARY = 'rgba(255, 255, 255, 0.55)';
export const CARD_TEXT_TERTIARY = 'rgba(255, 255, 255, 0.35)';
export const CARD_ACCENT = '#3e63dd';
export const CARD_FONT = "'Inter', sans-serif";