[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>
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 459 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
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";
|
||||