Frameless ProductStepper with twenty-front-faithful scenes (#22197)
## What Reworks the website's **ProductStepper** (the scroll-driven *Data model / Automation / Layout* section) to be frameless and to render all three scenes faithfully to twenty-front — real icons, labels, colors, structure, and connectors. ## Changes **Frameless + scaling** - Removed the shared white card/header; the three scenes now sit directly on the dark dot-grid stage. - Unified every scene on one `StageFit` primitive (fixed design box → scaled to fit), pixel-identical at full width and uniform on smaller screens. **Data model scene** - Real object schema (3 Standard + 2 Custom objects, real relation fields); replaced an invented "Investors" object with the real **Employment History** custom object (`IconBriefcase`, Company + Person relations) from the server seed. - Clean spanning-tree connections; removed the Standard/Custom badge; fixed card sizing + edge centering. **Workflow scene** - Real action labels + a logical flow: *Record is Created → Filter → Search Records → AI Agent → (Update Record · Send Email · Create Record)*. Dropped the iterator (a loop construct shown without a loop body). - Per-action icon colors matching twenty-front (trigger blue, flow green, record gray, send-email red, AI agent pink) on a gray tile. - Rebuilt the node to twenty-front's real anatomy and the connectors (source circle → `getBezierPath` → arrow marker) verbatim. **Layout scene** - Real workspace sidebar: real Tabler object icons, exact labels, the true default sidebar (6 objects + Workflows folder), `getIconTileColorShades` tile colors; dropped invented entries. - Record overview + Fields editor with correct field-type labels (Links, True/False, Date and Time); legibility + spacing tuning. **Icons** — replaced every hand-drawn approximation with real `@tabler/icons-react` / twenty-front object icons. **Misc** — smoother step-to-step transitions (translate + easing tokens). ## Testing Marketing visual; `lint` / `typecheck` / `build` green, and each scene reviewed visually against twenty-front. https://github.com/user-attachments/assets/997e1b95-55c0-401a-93a4-c70545577057
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
export function getElementScale(element: HTMLElement): number {
|
||||
const layoutWidth = element.offsetWidth;
|
||||
|
||||
if (layoutWidth === 0) {
|
||||
return 1;
|
||||
}
|
||||
return element.getBoundingClientRect().width / layoutWidth;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export {
|
||||
type ScrollProgressEffectProps,
|
||||
} from './ScrollProgressEffect';
|
||||
export { getReducedMotionSnapshot } from './reduced-motion-snapshot';
|
||||
export { getElementScale } from './get-element-scale';
|
||||
export { observeElementSize } from './observe-element-size';
|
||||
export { useScaleToFit } from './use-scale-to-fit';
|
||||
export { useMediaQuery } from './use-media-query';
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from 'react';
|
||||
|
||||
import { getElementScale } from './get-element-scale';
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
|
||||
export type PointerDragPositions = {
|
||||
@@ -44,6 +46,7 @@ export function usePointerDragPositions(
|
||||
id: string;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
scale: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
} | null>(null);
|
||||
@@ -79,6 +82,7 @@ export function usePointerDragPositions(
|
||||
startY: event.clientY,
|
||||
positionX: position.x,
|
||||
positionY: position.y,
|
||||
scale: getElementScale(event.currentTarget),
|
||||
};
|
||||
setDraggingId(id);
|
||||
};
|
||||
@@ -89,9 +93,9 @@ export function usePointerDragPositions(
|
||||
if (!dragStart || event.pointerId !== captureRef.current?.pointerId) {
|
||||
return;
|
||||
}
|
||||
const { id, startX, startY, positionX, positionY } = dragStart;
|
||||
const deltaX = event.clientX - startX;
|
||||
const deltaY = event.clientY - startY;
|
||||
const { id, startX, startY, positionX, positionY, scale } = dragStart;
|
||||
const deltaX = (event.clientX - startX) / scale;
|
||||
const deltaY = (event.clientY - startY) / scale;
|
||||
setPositions((previous) => ({
|
||||
...previous,
|
||||
[id]: { x: positionX + deltaX, y: positionY + deltaY },
|
||||
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { usePointerDragPositions } from '@/platform/motion';
|
||||
import { PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
|
||||
|
||||
import { STEPPER_SHELL_CHROME } from '../components/ProductStepperShell';
|
||||
import { DATA_MODEL_ICONS } from './components/DataModelIcons';
|
||||
import { DrawEdge } from './components/DrawEdge';
|
||||
import { DATA_MODEL_GRAPH } from './data/data-model-data';
|
||||
import { type EntityConnection } from './types/entity-connection';
|
||||
|
||||
const shell = PRODUCT_STEPPER_SCENE.shell;
|
||||
const entityTones = PRODUCT_STEPPER_SCENE.entityTones;
|
||||
|
||||
const { Canvas, Shell, StageFit, SvgLayer } = STEPPER_SHELL_CHROME;
|
||||
|
||||
const EntityCard = styled.div`
|
||||
backdrop-filter: blur(14px);
|
||||
background: ${shell.cardBackground};
|
||||
border: 1px solid ${shell.borderMedium};
|
||||
border-radius: 8px;
|
||||
box-shadow: ${PRODUCT_STEPPER_SCENE.cardShadow};
|
||||
cursor: grab;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
position: absolute;
|
||||
touch-action: none;
|
||||
transition: border-color 0.15s ease;
|
||||
width: 180px;
|
||||
z-index: 2;
|
||||
|
||||
&[data-hovered] {
|
||||
border-color: ${shell.borderStrong};
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
z-index: 10;
|
||||
}
|
||||
`;
|
||||
|
||||
const EntityHeader = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const InnerCard = styled.div`
|
||||
background: white;
|
||||
border: 1px solid ${shell.borderLight};
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
padding: 4px 0;
|
||||
`;
|
||||
|
||||
const EntityIcon = styled.span<{ $tint: string; $line: string }>`
|
||||
align-items: center;
|
||||
background: ${({ $tint }) => $tint};
|
||||
border: 1px solid ${({ $line }) => $line};
|
||||
border-radius: 3px;
|
||||
color: ${shell.text};
|
||||
display: flex;
|
||||
height: 14px;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
`;
|
||||
|
||||
const EntityLabel = styled.span`
|
||||
color: ${shell.text};
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const EntityMeta = styled.span`
|
||||
color: ${shell.textTertiary};
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
`;
|
||||
|
||||
const FieldRow = styled.div`
|
||||
align-items: center;
|
||||
color: ${shell.text};
|
||||
display: flex;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
gap: 4px;
|
||||
height: 22px;
|
||||
line-height: 1.4;
|
||||
padding: 0 6px;
|
||||
`;
|
||||
|
||||
const FieldIcon = styled.span`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 14px;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
`;
|
||||
|
||||
const ExpandHint = styled.div`
|
||||
align-items: center;
|
||||
color: ${shell.textTertiary};
|
||||
display: flex;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
gap: 4px;
|
||||
height: 22px;
|
||||
line-height: 1.4;
|
||||
padding: 0 6px;
|
||||
`;
|
||||
|
||||
const CARD_WIDTH = 180;
|
||||
const CARD_HEIGHT_ESTIMATE = 110;
|
||||
|
||||
type EntityPositions = Record<string, { x: number; y: number }>;
|
||||
|
||||
function getCardCenter(
|
||||
positions: EntityPositions,
|
||||
entityId: string,
|
||||
): { x: number; y: number } {
|
||||
const position = positions[entityId];
|
||||
|
||||
if (!position) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
return {
|
||||
x: position.x + CARD_WIDTH / 2,
|
||||
y: position.y + CARD_HEIGHT_ESTIMATE / 2,
|
||||
};
|
||||
}
|
||||
|
||||
export function DataModelVisual({ active }: { active: boolean }) {
|
||||
const {
|
||||
canvasHandlers,
|
||||
draggingId: dragging,
|
||||
handlePointerDown,
|
||||
positions,
|
||||
} = usePointerDragPositions(() =>
|
||||
Object.fromEntries(
|
||||
DATA_MODEL_GRAPH.entities.map((entity) => [
|
||||
entity.id,
|
||||
{ x: entity.x, y: entity.y },
|
||||
]),
|
||||
),
|
||||
);
|
||||
const [hoveredEntity, setHoveredEntity] = useState<string | null>(null);
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const isConnectionHighlighted = (connection: EntityConnection) =>
|
||||
hoveredEntity === connection.from || hoveredEntity === connection.to;
|
||||
|
||||
return (
|
||||
<Shell active={active}>
|
||||
<Canvas {...canvasHandlers}>
|
||||
<StageFit designHeight={530} designWidth={560}>
|
||||
<SvgLayer>
|
||||
{DATA_MODEL_GRAPH.connections.map((connection) => (
|
||||
<DrawEdge
|
||||
circleRadius={1.5}
|
||||
elbow="horizontal-first"
|
||||
from={getCardCenter(positions, connection.from)}
|
||||
highlighted={isConnectionHighlighted(connection)}
|
||||
key={`${connection.from}-${connection.to}`}
|
||||
to={getCardCenter(positions, connection.to)}
|
||||
/>
|
||||
))}
|
||||
</SvgLayer>
|
||||
|
||||
{DATA_MODEL_GRAPH.entities.map((entity) => {
|
||||
const position = positions[entity.id];
|
||||
const HeaderIcon = DATA_MODEL_ICONS.headers[entity.headerIcon];
|
||||
const tone = entityTones[entity.tone];
|
||||
|
||||
return (
|
||||
<EntityCard
|
||||
data-hovered={
|
||||
hoveredEntity === entity.id || dragging === entity.id
|
||||
? ''
|
||||
: undefined
|
||||
}
|
||||
key={entity.id}
|
||||
onPointerDown={(event) => handlePointerDown(entity.id, event)}
|
||||
onPointerEnter={() => setHoveredEntity(entity.id)}
|
||||
onPointerLeave={() => setHoveredEntity(null)}
|
||||
style={{
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
transition:
|
||||
dragging === entity.id ? 'none' : 'border-color 0.15s',
|
||||
}}
|
||||
>
|
||||
<EntityHeader>
|
||||
<EntityIcon $line={tone.border} $tint={tone.background}>
|
||||
<HeaderIcon />
|
||||
</EntityIcon>
|
||||
<EntityLabel>{i18n._(entity.label)}</EntityLabel>
|
||||
<EntityMeta>· {entity.meta}</EntityMeta>
|
||||
</EntityHeader>
|
||||
<InnerCard>
|
||||
{entity.fields.map((field) => {
|
||||
const Icon = DATA_MODEL_ICONS.fields[field.icon];
|
||||
return (
|
||||
<FieldRow key={field.id}>
|
||||
<FieldIcon>
|
||||
<Icon />
|
||||
</FieldIcon>
|
||||
{i18n._(field.label)}
|
||||
</FieldRow>
|
||||
);
|
||||
})}
|
||||
<ExpandHint>
|
||||
<FieldIcon>
|
||||
<DATA_MODEL_ICONS.ChevronExpand />
|
||||
</FieldIcon>
|
||||
{entity.expandCount} {i18n._(msg`fields`)}
|
||||
</ExpandHint>
|
||||
</InnerCard>
|
||||
</EntityCard>
|
||||
);
|
||||
})}
|
||||
</StageFit>
|
||||
</Canvas>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
+22
-16
@@ -1,17 +1,3 @@
|
||||
export type DataModelFieldIcon =
|
||||
| 'apps'
|
||||
| 'building'
|
||||
| 'tag'
|
||||
| 'target'
|
||||
| 'user';
|
||||
|
||||
export type DataModelHeaderIcon =
|
||||
| 'buildingSmall'
|
||||
| 'targetSmall'
|
||||
| 'userScreenSmall'
|
||||
| 'userSmall'
|
||||
| 'usersSmall';
|
||||
|
||||
function IconBuilding() {
|
||||
return (
|
||||
<svg
|
||||
@@ -112,6 +98,26 @@ function IconTarget() {
|
||||
);
|
||||
}
|
||||
|
||||
function IconBriefcaseSmall() {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
height={9}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
viewBox="0 0 24 24"
|
||||
width={9}
|
||||
>
|
||||
<path d="M3 7m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" />
|
||||
<path d="M8 7v-2a2 2 0 0 1 2 -2h4a2 2 0 0 1 2 2v2" />
|
||||
<path d="M12 12l0 .01" />
|
||||
<path d="M3 13a20 20 0 0 0 18 0" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconBuildingSmall() {
|
||||
return (
|
||||
<svg
|
||||
@@ -225,8 +231,7 @@ function IconChevronExpand() {
|
||||
viewBox="0 0 24 24"
|
||||
width={12}
|
||||
>
|
||||
<path d="M6 9l6 -6" />
|
||||
<path d="M6 9l-6 -6" />
|
||||
<path d="M6 9l6 6l6 -6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -241,6 +246,7 @@ export const DATA_MODEL_ICONS = {
|
||||
user: IconUser,
|
||||
},
|
||||
headers: {
|
||||
briefcaseSmall: IconBriefcaseSmall,
|
||||
buildingSmall: IconBuildingSmall,
|
||||
targetSmall: IconTargetSmall,
|
||||
userScreenSmall: IconUserScreenSmall,
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
import { type EntityConnection } from '../types/entity-connection';
|
||||
import { type EntityDefinition } from '../types/entity-definition';
|
||||
|
||||
export const DATA_MODEL_GRAPH: {
|
||||
connections: EntityConnection[];
|
||||
entities: EntityDefinition[];
|
||||
} = {
|
||||
entities: [
|
||||
{
|
||||
id: 'people',
|
||||
label: msg`People`,
|
||||
meta: '840',
|
||||
isCustom: false,
|
||||
headerIcon: 'userSmall',
|
||||
tone: 'green',
|
||||
fields: [
|
||||
{ id: 'company', icon: 'building', label: msg`Company` },
|
||||
{ id: 'opportunities', icon: 'target', label: msg`Opportunities` },
|
||||
],
|
||||
expandCount: 22,
|
||||
x: 40,
|
||||
y: 40,
|
||||
},
|
||||
{
|
||||
id: 'companies',
|
||||
label: msg`Companies`,
|
||||
meta: '120',
|
||||
isCustom: false,
|
||||
headerIcon: 'buildingSmall',
|
||||
tone: 'indigo',
|
||||
fields: [
|
||||
{ id: 'people', icon: 'user', label: msg`People` },
|
||||
{ id: 'opportunities', icon: 'target', label: msg`Opportunities` },
|
||||
],
|
||||
expandCount: 39,
|
||||
x: 330,
|
||||
y: 20,
|
||||
},
|
||||
{
|
||||
id: 'employment-history',
|
||||
label: msg`Employment History`,
|
||||
meta: '48',
|
||||
isCustom: true,
|
||||
headerIcon: 'briefcaseSmall',
|
||||
tone: 'purple',
|
||||
fields: [
|
||||
{ id: 'company', icon: 'building', label: msg`Company` },
|
||||
{ id: 'person', icon: 'user', label: msg`Person` },
|
||||
],
|
||||
expandCount: 8,
|
||||
x: 40,
|
||||
y: 310,
|
||||
},
|
||||
{
|
||||
id: 'opportunities',
|
||||
label: msg`Opportunities`,
|
||||
meta: '64',
|
||||
isCustom: false,
|
||||
headerIcon: 'targetSmall',
|
||||
tone: 'red',
|
||||
fields: [
|
||||
{ id: 'company', icon: 'building', label: msg`Company` },
|
||||
{ id: 'point-of-contact', icon: 'user', label: msg`Point of Contact` },
|
||||
],
|
||||
expandCount: 11,
|
||||
x: 380,
|
||||
y: 190,
|
||||
},
|
||||
{
|
||||
id: 'demos',
|
||||
label: msg`Demos`,
|
||||
meta: '45',
|
||||
isCustom: true,
|
||||
headerIcon: 'userScreenSmall',
|
||||
tone: 'indigo',
|
||||
fields: [
|
||||
{ id: 'company', icon: 'building', label: msg`Company` },
|
||||
{ id: 'opportunity', icon: 'target', label: msg`Opportunity` },
|
||||
],
|
||||
expandCount: 6,
|
||||
x: 280,
|
||||
y: 400,
|
||||
},
|
||||
],
|
||||
connections: [
|
||||
{ from: 'people', to: 'companies' },
|
||||
{ from: 'companies', to: 'opportunities' },
|
||||
{ from: 'opportunities', to: 'demos' },
|
||||
{ from: 'people', to: 'employment-history' },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { DataModelVisual } from './DataModelVisual';
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export type DataModelFieldIcon =
|
||||
| 'apps'
|
||||
| 'building'
|
||||
| 'tag'
|
||||
| 'target'
|
||||
| 'user';
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export type DataModelHeaderIcon =
|
||||
| 'briefcaseSmall'
|
||||
| 'buildingSmall'
|
||||
| 'targetSmall'
|
||||
| 'userScreenSmall'
|
||||
| 'userSmall'
|
||||
| 'usersSmall';
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type EntityConnection = {
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
|
||||
import { type DataModelFieldIcon } from './data-model-field-icon';
|
||||
import { type DataModelHeaderIcon } from './data-model-header-icon';
|
||||
import { type EntityTone } from './entity-tone';
|
||||
|
||||
export type EntityDefinition = {
|
||||
expandCount: number;
|
||||
fields: { icon: DataModelFieldIcon; id: string; label: MessageDescriptor }[];
|
||||
headerIcon: DataModelHeaderIcon;
|
||||
id: string;
|
||||
isCustom: boolean;
|
||||
label: MessageDescriptor;
|
||||
meta: string;
|
||||
tone: EntityTone;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import { type PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
|
||||
|
||||
export type EntityTone = keyof typeof PRODUCT_STEPPER_SCENE.entityTones;
|
||||
@@ -0,0 +1,354 @@
|
||||
'use client';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import {
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from 'react';
|
||||
|
||||
import { getElementScale } from '@/platform/motion';
|
||||
import { PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
|
||||
|
||||
import { STEPPER_SHELL_CHROME } from '../components/ProductStepperShell';
|
||||
import { LAYOUT_GLYPHS } from './components/LayoutIcons';
|
||||
import { LAYOUT_CHROME } from './components/layout-chrome';
|
||||
import { LAYOUT_EDITOR_CONTENT } from './data/layout-data';
|
||||
|
||||
const {
|
||||
ChevronLeft: ChevronLeftGlyph,
|
||||
Dots: DotsGlyph,
|
||||
Eye: EyeGlyph,
|
||||
Field: FieldGlyph,
|
||||
Grip: GripGlyph,
|
||||
List: ListGlyph,
|
||||
Nav: NavGlyph,
|
||||
NewSection: NewSectionGlyph,
|
||||
Plus: PlusGlyph,
|
||||
Spark: SparkGlyph,
|
||||
} = LAYOUT_GLYPHS;
|
||||
|
||||
const {
|
||||
ActionButton,
|
||||
ActionsBar,
|
||||
AddIconBox,
|
||||
AddSectionRow,
|
||||
AddText,
|
||||
DoneButton,
|
||||
EditableRow,
|
||||
EditableText,
|
||||
FieldDot,
|
||||
FieldIconBox,
|
||||
FieldLabels,
|
||||
FieldName,
|
||||
FieldRow,
|
||||
FieldType,
|
||||
NavBreadcrumb,
|
||||
NavChevron,
|
||||
NavIconBox,
|
||||
NavItem,
|
||||
NavPanel,
|
||||
NavSectionLabel,
|
||||
NavSubItem,
|
||||
NewFieldsColumn,
|
||||
NewFieldsDescription,
|
||||
NewFieldsRow,
|
||||
NewFieldsTitle,
|
||||
PanelActionButton,
|
||||
PanelBackButton,
|
||||
PanelFields,
|
||||
PanelHeader,
|
||||
PanelIconBox,
|
||||
PanelSubBar,
|
||||
PanelSubLabel,
|
||||
PanelTitleBold,
|
||||
PanelTitleGroup,
|
||||
PanelTitleSub,
|
||||
RightPanel,
|
||||
SectionName,
|
||||
SectionRow,
|
||||
WidgetChip,
|
||||
WidgetIcon,
|
||||
WidgetInner,
|
||||
WidgetLabel,
|
||||
WidgetPanel,
|
||||
WidgetRow,
|
||||
WidgetSectionLabel,
|
||||
WidgetValue,
|
||||
} = LAYOUT_CHROME;
|
||||
|
||||
const { Canvas, Shell, StageFit } = STEPPER_SHELL_CHROME;
|
||||
|
||||
const LAYOUT_DESIGN = { height: 496, width: 473 };
|
||||
|
||||
const ROW_STEP_PX = 22;
|
||||
|
||||
export function LayoutVisual({ active }: { active: boolean }) {
|
||||
const [fields, setFields] = useState(LAYOUT_EDITOR_CONTENT.fields);
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||
const dragStartY = useRef(0);
|
||||
const dragScale = useRef(1);
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const toggleVisibility = (fieldId: string) => {
|
||||
setFields((previous) =>
|
||||
previous.map((field) =>
|
||||
field.id === fieldId ? { ...field, visible: !field.visible } : field,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleDragStart = (
|
||||
fieldId: string,
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setDraggingId(fieldId);
|
||||
dragStartY.current = event.clientY;
|
||||
dragScale.current = getElementScale(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleDragMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!draggingId) {
|
||||
return;
|
||||
}
|
||||
const deltaY = (event.clientY - dragStartY.current) / dragScale.current;
|
||||
const steps = Math.round(deltaY / ROW_STEP_PX);
|
||||
|
||||
if (steps === 0) {
|
||||
return;
|
||||
}
|
||||
setFields((previous) => {
|
||||
const index = previous.findIndex((field) => field.id === draggingId);
|
||||
|
||||
if (index === -1) {
|
||||
return previous;
|
||||
}
|
||||
const target = Math.max(0, Math.min(previous.length - 1, index + steps));
|
||||
|
||||
if (target === index) {
|
||||
return previous;
|
||||
}
|
||||
const next = [...previous];
|
||||
const [moved] = next.splice(index, 1);
|
||||
next.splice(target, 0, moved);
|
||||
return next;
|
||||
});
|
||||
dragStartY.current = event.clientY;
|
||||
};
|
||||
|
||||
const handleDragEnd = () => setDraggingId(null);
|
||||
const handleLostCapture = () => {
|
||||
if (draggingId) {
|
||||
setDraggingId(null);
|
||||
}
|
||||
};
|
||||
const sections = [...new Set(fields.map((field) => field.section))];
|
||||
|
||||
return (
|
||||
<Shell active={active}>
|
||||
<Canvas>
|
||||
<StageFit
|
||||
baseScale={1.35}
|
||||
designHeight={LAYOUT_DESIGN.height}
|
||||
designWidth={LAYOUT_DESIGN.width}
|
||||
zoom={1.05}
|
||||
>
|
||||
<WidgetPanel>
|
||||
<WidgetInner>
|
||||
<WidgetSectionLabel>{i18n._(msg`General`)}</WidgetSectionLabel>
|
||||
<WidgetRow>
|
||||
<WidgetIcon>
|
||||
<FieldGlyph type="link" />
|
||||
</WidgetIcon>
|
||||
<WidgetLabel>{i18n._(msg`URL`)}</WidgetLabel>
|
||||
<WidgetChip>anthropic.com</WidgetChip>
|
||||
</WidgetRow>
|
||||
<WidgetRow>
|
||||
<WidgetIcon>
|
||||
<FieldGlyph type="user" />
|
||||
</WidgetIcon>
|
||||
<WidgetLabel>{i18n._(msg`Account Owner`)}</WidgetLabel>
|
||||
<WidgetValue>Félix Malfait</WidgetValue>
|
||||
</WidgetRow>
|
||||
<WidgetRow>
|
||||
<WidgetIcon>
|
||||
<FieldGlyph type="map" />
|
||||
</WidgetIcon>
|
||||
<WidgetLabel>{i18n._(msg`Address`)}</WidgetLabel>
|
||||
<WidgetValue>548 Market St, San Fr...</WidgetValue>
|
||||
</WidgetRow>
|
||||
<WidgetRow>
|
||||
<WidgetIcon>
|
||||
<FieldGlyph type="target" />
|
||||
</WidgetIcon>
|
||||
<WidgetLabel>{i18n._(msg`ICP`)}</WidgetLabel>
|
||||
<WidgetValue>✓ True</WidgetValue>
|
||||
</WidgetRow>
|
||||
</WidgetInner>
|
||||
</WidgetPanel>
|
||||
|
||||
<NavPanel>
|
||||
<NavSectionLabel>{i18n._(msg`Workspace`)}</NavSectionLabel>
|
||||
{LAYOUT_EDITOR_CONTENT.navItems.map((item) => (
|
||||
<div key={item.icon}>
|
||||
<NavItem data-active={item.isActive ? '' : undefined}>
|
||||
<NavIconBox
|
||||
$background={
|
||||
PRODUCT_STEPPER_SCENE.navTiles[item.color].background
|
||||
}
|
||||
$border={PRODUCT_STEPPER_SCENE.navTiles[item.color].border}
|
||||
>
|
||||
<NavGlyph
|
||||
color={PRODUCT_STEPPER_SCENE.navTiles[item.color].icon}
|
||||
type={item.icon}
|
||||
/>
|
||||
</NavIconBox>
|
||||
{i18n._(item.label)}
|
||||
{item.isFolder ? <NavChevron>▾</NavChevron> : null}
|
||||
</NavItem>
|
||||
{item.children?.map((child) => (
|
||||
<NavSubItem key={child.icon}>
|
||||
<NavBreadcrumb />
|
||||
<NavIconBox
|
||||
$background={
|
||||
PRODUCT_STEPPER_SCENE.navTiles[child.color].background
|
||||
}
|
||||
$border={
|
||||
PRODUCT_STEPPER_SCENE.navTiles[child.color].border
|
||||
}
|
||||
>
|
||||
<NavGlyph
|
||||
color={PRODUCT_STEPPER_SCENE.navTiles[child.color].icon}
|
||||
type={child.icon}
|
||||
/>
|
||||
</NavIconBox>
|
||||
{i18n._(child.label)}
|
||||
</NavSubItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</NavPanel>
|
||||
|
||||
<ActionsBar>
|
||||
<ActionButton>+ {i18n._(msg`New record`)}</ActionButton>
|
||||
<ActionButton>✧ {i18n._(msg`Enrich`)}</ActionButton>
|
||||
<ActionButton>✎ {i18n._(msg`Edit actions`)}</ActionButton>
|
||||
</ActionsBar>
|
||||
|
||||
<RightPanel
|
||||
onLostPointerCapture={handleLostCapture}
|
||||
onPointerCancel={handleDragEnd}
|
||||
onPointerMove={handleDragMove}
|
||||
onPointerUp={handleDragEnd}
|
||||
>
|
||||
<PanelHeader>
|
||||
<PanelBackButton>
|
||||
<ChevronLeftGlyph />
|
||||
</PanelBackButton>
|
||||
<PanelIconBox>
|
||||
<ListGlyph />
|
||||
</PanelIconBox>
|
||||
<PanelTitleGroup>
|
||||
<PanelTitleBold>{i18n._(msg`Fields`)}</PanelTitleBold>
|
||||
<PanelTitleSub>{i18n._(msg`Fields widget`)}</PanelTitleSub>
|
||||
</PanelTitleGroup>
|
||||
<PanelActionButton>
|
||||
<SparkGlyph />
|
||||
</PanelActionButton>
|
||||
</PanelHeader>
|
||||
|
||||
<PanelSubBar>
|
||||
<PanelBackButton>
|
||||
<ChevronLeftGlyph />
|
||||
</PanelBackButton>
|
||||
<PanelSubLabel>{i18n._(msg`Layout`)}</PanelSubLabel>
|
||||
</PanelSubBar>
|
||||
|
||||
<PanelFields>
|
||||
{sections.map((section, sectionNumber) => (
|
||||
<div key={section}>
|
||||
<SectionRow>
|
||||
<GripGlyph />
|
||||
<SectionName>
|
||||
{i18n._(LAYOUT_EDITOR_CONTENT.sectionLabels[section])}
|
||||
</SectionName>
|
||||
</SectionRow>
|
||||
|
||||
{sectionNumber === 0 ? (
|
||||
<EditableRow>
|
||||
<EditableText>{i18n._(msg`Industry`)}</EditableText>
|
||||
<DoneButton>{i18n._(msg`Done`)}</DoneButton>
|
||||
</EditableRow>
|
||||
) : null}
|
||||
|
||||
{fields
|
||||
.filter((field) => field.section === section)
|
||||
.map((field) => (
|
||||
<FieldRow
|
||||
data-dragging={draggingId === field.id ? '' : undefined}
|
||||
key={field.id}
|
||||
onPointerDown={(event) =>
|
||||
handleDragStart(field.id, event)
|
||||
}
|
||||
>
|
||||
<FieldIconBox>
|
||||
<FieldGlyph type={field.icon} />
|
||||
</FieldIconBox>
|
||||
<FieldLabels>
|
||||
<FieldName>{i18n._(field.label)}</FieldName>
|
||||
<FieldDot>·</FieldDot>
|
||||
<FieldType>{i18n._(field.type)}</FieldType>
|
||||
</FieldLabels>
|
||||
<PanelActionButton
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleVisibility(field.id);
|
||||
}}
|
||||
>
|
||||
<EyeGlyph visible={field.visible} />
|
||||
</PanelActionButton>
|
||||
<PanelActionButton>
|
||||
<DotsGlyph />
|
||||
</PanelActionButton>
|
||||
</FieldRow>
|
||||
))}
|
||||
|
||||
{sectionNumber > 0 && sectionNumber < sections.length - 1 ? (
|
||||
<AddSectionRow>
|
||||
<AddIconBox>
|
||||
<NewSectionGlyph />
|
||||
</AddIconBox>
|
||||
<AddText>{i18n._(msg`Add a Section`)}</AddText>
|
||||
</AddSectionRow>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<NewFieldsRow>
|
||||
<AddIconBox>
|
||||
<PlusGlyph />
|
||||
</AddIconBox>
|
||||
<NewFieldsColumn>
|
||||
<NewFieldsTitle>{i18n._(msg`New fields`)}</NewFieldsTitle>
|
||||
<NewFieldsDescription>
|
||||
{i18n._(msg`Default position/visibility for field…`)}
|
||||
</NewFieldsDescription>
|
||||
</NewFieldsColumn>
|
||||
</NewFieldsRow>
|
||||
|
||||
<AddSectionRow>
|
||||
<AddIconBox>
|
||||
<NewSectionGlyph />
|
||||
</AddIconBox>
|
||||
<AddText>{i18n._(msg`Add a Section`)}</AddText>
|
||||
</AddSectionRow>
|
||||
</PanelFields>
|
||||
</RightPanel>
|
||||
</StageFit>
|
||||
</Canvas>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
IconBuildingSkyscraper,
|
||||
IconCalendar,
|
||||
IconCheckbox,
|
||||
IconChevronLeft,
|
||||
IconCurrencyDollar,
|
||||
IconDotsVertical,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconGripVertical,
|
||||
IconHistoryToggle,
|
||||
IconLayoutDashboard,
|
||||
IconLink,
|
||||
IconList,
|
||||
IconMapPin,
|
||||
IconNotes,
|
||||
IconPlus,
|
||||
IconSettingsAutomation,
|
||||
IconSparkles,
|
||||
IconTargetArrow,
|
||||
IconUser,
|
||||
IconUserCircle,
|
||||
IconUsers,
|
||||
IconVersions,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import { PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
|
||||
|
||||
import { type LayoutFieldIconType } from '../types/layout-field-icon-type';
|
||||
import { type LayoutNavIconType } from '../types/layout-nav-icon-type';
|
||||
|
||||
const inks = PRODUCT_STEPPER_SCENE.layout;
|
||||
|
||||
const FIELD_ICONS: Record<LayoutFieldIconType, typeof IconLink> = {
|
||||
calendar: IconCalendar,
|
||||
link: IconLink,
|
||||
map: IconMapPin,
|
||||
money: IconCurrencyDollar,
|
||||
target: IconTargetArrow,
|
||||
user: IconUserCircle,
|
||||
users: IconUsers,
|
||||
};
|
||||
|
||||
const NAV_ICONS: Record<LayoutNavIconType, typeof IconLink> = {
|
||||
automation: IconSettingsAutomation,
|
||||
building: IconBuildingSkyscraper,
|
||||
checkbox: IconCheckbox,
|
||||
dashboard: IconLayoutDashboard,
|
||||
history: IconHistoryToggle,
|
||||
notes: IconNotes,
|
||||
target: IconTargetArrow,
|
||||
user: IconUser,
|
||||
versions: IconVersions,
|
||||
};
|
||||
|
||||
function FieldGlyph({ type }: { type: LayoutFieldIconType }) {
|
||||
const IconComponent = FIELD_ICONS[type];
|
||||
return <IconComponent color={inks.fieldInk} size={12} stroke={1.6} />;
|
||||
}
|
||||
|
||||
function NavGlyph({ color, type }: { color: string; type: LayoutNavIconType }) {
|
||||
const IconComponent = NAV_ICONS[type];
|
||||
return <IconComponent color={color} size={11} stroke={1.8} />;
|
||||
}
|
||||
|
||||
function EyeGlyph({ visible }: { visible: boolean }) {
|
||||
return visible ? (
|
||||
<IconEye color={inks.eyeInk} size={11} stroke={1.6} />
|
||||
) : (
|
||||
<IconEyeOff color={inks.eyeHiddenInk} size={11} stroke={1.6} />
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronLeftGlyph() {
|
||||
return <IconChevronLeft color={inks.eyeInk} size={12} stroke={1.6} />;
|
||||
}
|
||||
|
||||
function DotsGlyph() {
|
||||
return <IconDotsVertical color={inks.eyeInk} size={12} stroke={1.8} />;
|
||||
}
|
||||
|
||||
function GripGlyph() {
|
||||
return <IconGripVertical color={inks.eyeInk} size={12} stroke={1.8} />;
|
||||
}
|
||||
|
||||
function ListGlyph() {
|
||||
return <IconList color={inks.fieldInk} size={11} stroke={1.6} />;
|
||||
}
|
||||
|
||||
function SparkGlyph() {
|
||||
return <IconSparkles color={inks.eyeInk} size={11} stroke={1.6} />;
|
||||
}
|
||||
|
||||
function NewSectionGlyph() {
|
||||
return <IconPlus color={inks.fieldInk} size={11} stroke={1.6} />;
|
||||
}
|
||||
|
||||
function PlusGlyph() {
|
||||
return <IconPlus color={inks.fieldInk} size={11} stroke={1.6} />;
|
||||
}
|
||||
|
||||
export const LAYOUT_GLYPHS = {
|
||||
ChevronLeft: ChevronLeftGlyph,
|
||||
Dots: DotsGlyph,
|
||||
Eye: EyeGlyph,
|
||||
Field: FieldGlyph,
|
||||
Grip: GripGlyph,
|
||||
List: ListGlyph,
|
||||
Nav: NavGlyph,
|
||||
NewSection: NewSectionGlyph,
|
||||
Plus: PlusGlyph,
|
||||
Spark: SparkGlyph,
|
||||
};
|
||||
+14
-98
@@ -7,80 +7,17 @@ const layout = PRODUCT_STEPPER_SCENE.layout;
|
||||
|
||||
const PANEL_RADIUS = '3px';
|
||||
|
||||
const Canvas = styled.div`
|
||||
font-family: ${shell.font};
|
||||
height: 100%;
|
||||
opacity: 0.6;
|
||||
position: relative;
|
||||
transition: opacity 0.3s;
|
||||
width: 100%;
|
||||
|
||||
&[data-active] {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const MainCard = styled.div`
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
height: 84%;
|
||||
left: 16%;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
top: 8%;
|
||||
width: 72%;
|
||||
`;
|
||||
|
||||
const BlueHeader = styled.div`
|
||||
align-items: center;
|
||||
background: ${layout.accent};
|
||||
display: flex;
|
||||
height: 32px;
|
||||
justify-content: space-between;
|
||||
padding: 0 10px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const HeaderLeft = styled.span`
|
||||
color: white;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const HeaderCenter = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const HeaderTitle = styled.span`
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const HeaderSave = styled.span`
|
||||
align-items: center;
|
||||
border: 1px solid ${layout.saveBorder};
|
||||
border-radius: ${PANEL_RADIUS};
|
||||
color: white;
|
||||
display: flex;
|
||||
font-size: 8px;
|
||||
font-weight: 500;
|
||||
gap: 3px;
|
||||
padding: 2px 8px;
|
||||
`;
|
||||
|
||||
const WidgetPanel = styled.div`
|
||||
backdrop-filter: blur(5px);
|
||||
background: ${layout.glass};
|
||||
background: ${shell.cardBackground};
|
||||
border: 0.8px solid ${layout.accent};
|
||||
border-radius: ${PANEL_RADIUS};
|
||||
left: 50%;
|
||||
left: 48%;
|
||||
max-height: 36%;
|
||||
overflow: hidden;
|
||||
padding: 6px;
|
||||
position: absolute;
|
||||
top: 20%;
|
||||
top: 13%;
|
||||
width: 38%;
|
||||
`;
|
||||
|
||||
@@ -90,13 +27,6 @@ const WidgetInner = styled.div`
|
||||
gap: 5px;
|
||||
`;
|
||||
|
||||
const WidgetTitle = styled.div`
|
||||
color: ${shell.text};
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 3px;
|
||||
`;
|
||||
|
||||
const WidgetSectionLabel = styled.div`
|
||||
color: ${shell.textTertiary};
|
||||
font-size: 7px;
|
||||
@@ -153,15 +83,15 @@ const WidgetChip = styled.span`
|
||||
|
||||
const NavPanel = styled.div`
|
||||
backdrop-filter: blur(5px);
|
||||
background: ${layout.glass};
|
||||
background: ${shell.cardBackground};
|
||||
border: 0.8px solid ${layout.accent};
|
||||
border-radius: ${PANEL_RADIUS};
|
||||
left: 10%;
|
||||
left: 8%;
|
||||
max-height: 68%;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
position: absolute;
|
||||
top: 17%;
|
||||
top: 13%;
|
||||
width: 30%;
|
||||
`;
|
||||
|
||||
@@ -190,10 +120,10 @@ const NavItem = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const NavIconBox = styled.span<{ $tint: string }>`
|
||||
const NavIconBox = styled.span<{ $background: string; $border: string }>`
|
||||
align-items: center;
|
||||
background: ${({ $tint }) => $tint};
|
||||
border: 0.5px solid ${shell.borderStrong};
|
||||
background: ${({ $background }) => $background};
|
||||
border: 0.5px solid ${({ $border }) => $border};
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
@@ -222,11 +152,6 @@ const NavBreadcrumb = styled.span`
|
||||
width: 4px;
|
||||
`;
|
||||
|
||||
const NavSuffix = styled.span`
|
||||
color: ${shell.textTertiary};
|
||||
font-size: 6px;
|
||||
`;
|
||||
|
||||
const NavChevron = styled.span`
|
||||
color: ${shell.textTertiary};
|
||||
font-size: 6px;
|
||||
@@ -235,15 +160,15 @@ const NavChevron = styled.span`
|
||||
|
||||
const ActionsBar = styled.div`
|
||||
backdrop-filter: blur(5px);
|
||||
background: ${layout.glass};
|
||||
background: ${shell.cardBackground};
|
||||
border: 0.8px solid ${layout.accent};
|
||||
border-radius: ${PANEL_RADIUS};
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
left: 54%;
|
||||
left: 46%;
|
||||
padding: 5px 6px;
|
||||
position: absolute;
|
||||
top: 14%;
|
||||
top: 6%;
|
||||
z-index: 3;
|
||||
`;
|
||||
|
||||
@@ -258,7 +183,7 @@ const ActionButton = styled.span`
|
||||
|
||||
const RightPanel = styled.div`
|
||||
backdrop-filter: blur(5px);
|
||||
background: ${layout.glass};
|
||||
background: ${shell.cardBackground};
|
||||
border: 0.8px solid ${layout.panelAccent};
|
||||
border-radius: ${PANEL_RADIUS};
|
||||
bottom: 3%;
|
||||
@@ -267,7 +192,7 @@ const RightPanel = styled.div`
|
||||
left: 42%;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
top: 40%;
|
||||
top: 36%;
|
||||
width: 54%;
|
||||
z-index: 4;
|
||||
`;
|
||||
@@ -501,8 +426,6 @@ export const LAYOUT_CHROME = {
|
||||
AddIconBox,
|
||||
AddSectionRow,
|
||||
AddText,
|
||||
BlueHeader,
|
||||
Canvas,
|
||||
DoneButton,
|
||||
EditableRow,
|
||||
EditableText,
|
||||
@@ -512,11 +435,6 @@ export const LAYOUT_CHROME = {
|
||||
FieldName,
|
||||
FieldRow,
|
||||
FieldType,
|
||||
HeaderCenter,
|
||||
HeaderLeft,
|
||||
HeaderSave,
|
||||
HeaderTitle,
|
||||
MainCard,
|
||||
NavBreadcrumb,
|
||||
NavChevron,
|
||||
NavIconBox,
|
||||
@@ -524,7 +442,6 @@ export const LAYOUT_CHROME = {
|
||||
NavPanel,
|
||||
NavSectionLabel,
|
||||
NavSubItem,
|
||||
NavSuffix,
|
||||
NewFieldsColumn,
|
||||
NewFieldsDescription,
|
||||
NewFieldsRow,
|
||||
@@ -549,6 +466,5 @@ export const LAYOUT_CHROME = {
|
||||
WidgetPanel,
|
||||
WidgetRow,
|
||||
WidgetSectionLabel,
|
||||
WidgetTitle,
|
||||
WidgetValue,
|
||||
};
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
import { type LayoutFieldDefinition } from '../types/layout-field-definition';
|
||||
import { type LayoutNavItemDefinition } from '../types/layout-nav-item-definition';
|
||||
|
||||
export const LAYOUT_EDITOR_CONTENT: {
|
||||
fields: LayoutFieldDefinition[];
|
||||
navItems: LayoutNavItemDefinition[];
|
||||
sectionLabels: Record<string, MessageDescriptor>;
|
||||
} = {
|
||||
fields: [
|
||||
{
|
||||
id: 'url',
|
||||
icon: 'link',
|
||||
label: msg`URL`,
|
||||
type: msg`Links`,
|
||||
section: 'General',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'account-owner',
|
||||
icon: 'user',
|
||||
label: msg`Account Owner`,
|
||||
type: msg`Relation`,
|
||||
section: 'General',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'revenue',
|
||||
icon: 'money',
|
||||
label: msg`Revenue`,
|
||||
type: msg`Currency`,
|
||||
section: 'General',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'icp',
|
||||
icon: 'target',
|
||||
label: msg`ICP`,
|
||||
type: msg`True/False`,
|
||||
section: 'Additional',
|
||||
visible: false,
|
||||
},
|
||||
{
|
||||
id: 'employees',
|
||||
icon: 'users',
|
||||
label: msg`Employees`,
|
||||
type: msg`Number`,
|
||||
section: 'Other',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'address',
|
||||
icon: 'map',
|
||||
label: msg`Address`,
|
||||
type: msg`Address`,
|
||||
section: 'Other',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'creation-date',
|
||||
icon: 'calendar',
|
||||
label: msg`Creation date`,
|
||||
type: msg`Date and Time`,
|
||||
section: 'Other',
|
||||
visible: true,
|
||||
},
|
||||
],
|
||||
navItems: [
|
||||
{ icon: 'building', label: msg`Companies`, isActive: true, color: 'blue' },
|
||||
{ icon: 'user', label: msg`People`, isActive: false, color: 'blue' },
|
||||
{
|
||||
icon: 'target',
|
||||
label: msg`Opportunities`,
|
||||
isActive: false,
|
||||
color: 'red',
|
||||
},
|
||||
{
|
||||
icon: 'checkbox',
|
||||
label: msg`Tasks`,
|
||||
isActive: false,
|
||||
color: 'turquoise',
|
||||
},
|
||||
{ icon: 'notes', label: msg`Notes`, isActive: false, color: 'turquoise' },
|
||||
{
|
||||
icon: 'dashboard',
|
||||
label: msg`Dashboards`,
|
||||
isActive: false,
|
||||
color: 'gray',
|
||||
},
|
||||
{
|
||||
icon: 'automation',
|
||||
label: msg`Workflows`,
|
||||
isActive: false,
|
||||
color: 'orange',
|
||||
isFolder: true,
|
||||
children: [
|
||||
{ icon: 'automation', label: msg`Workflows`, color: 'gray' },
|
||||
{ icon: 'history', label: msg`Workflow Runs`, color: 'gray' },
|
||||
{ icon: 'versions', label: msg`Workflow Versions`, color: 'gray' },
|
||||
],
|
||||
},
|
||||
],
|
||||
sectionLabels: {
|
||||
Additional: msg`Additional`,
|
||||
General: msg`General`,
|
||||
Other: msg`Other`,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { LayoutVisual } from './LayoutVisual';
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
|
||||
import { type LayoutFieldIconType } from './layout-field-icon-type';
|
||||
|
||||
export type LayoutFieldDefinition = {
|
||||
icon: LayoutFieldIconType;
|
||||
id: string;
|
||||
label: MessageDescriptor;
|
||||
section: string;
|
||||
type: MessageDescriptor;
|
||||
visible: boolean;
|
||||
};
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
export type LayoutFieldIconType =
|
||||
| 'calendar'
|
||||
| 'link'
|
||||
| 'map'
|
||||
| 'money'
|
||||
| 'target'
|
||||
| 'user'
|
||||
| 'users';
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
export type LayoutNavIconType =
|
||||
| 'automation'
|
||||
| 'building'
|
||||
| 'checkbox'
|
||||
| 'dashboard'
|
||||
| 'history'
|
||||
| 'notes'
|
||||
| 'target'
|
||||
| 'user'
|
||||
| 'versions';
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
|
||||
import { type PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
|
||||
|
||||
import { type LayoutNavIconType } from './layout-nav-icon-type';
|
||||
|
||||
type NavColor = keyof typeof PRODUCT_STEPPER_SCENE.navTiles;
|
||||
|
||||
export type LayoutNavItemDefinition = {
|
||||
children?: {
|
||||
color: NavColor;
|
||||
icon: LayoutNavIconType;
|
||||
label: MessageDescriptor;
|
||||
}[];
|
||||
color: NavColor;
|
||||
icon: LayoutNavIconType;
|
||||
isActive: boolean;
|
||||
isFolder?: boolean;
|
||||
label: MessageDescriptor;
|
||||
};
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { usePointerDragPositions } from '@/platform/motion';
|
||||
import { PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
|
||||
|
||||
import { STEPPER_SHELL_CHROME } from '../components/ProductStepperShell';
|
||||
import { WORKFLOW_GLYPHS } from './components/WorkflowIcons';
|
||||
import { WORKFLOW_GRAPH } from './data/workflow-data';
|
||||
import { useWorkflowAnimation } from './utils/use-workflow-animation';
|
||||
|
||||
const shell = PRODUCT_STEPPER_SCENE.shell;
|
||||
const workflow = PRODUCT_STEPPER_SCENE.workflow;
|
||||
|
||||
const { Canvas, Shell, StageFit, SvgLayer } = STEPPER_SHELL_CHROME;
|
||||
|
||||
const NODE_WIDTH = 170;
|
||||
const NODE_HEIGHT = 48;
|
||||
|
||||
const ARROW_GAP = 5;
|
||||
|
||||
const ARROW_MARKER_PATH =
|
||||
'M1.7915 1.38672H8.18311C8.57541 1.38705 8.81458 1.81852 8.60693 2.15137L5.41064 7.26465C5.21481 7.57798 4.75882 7.57798 4.56299 7.26465L1.3667 2.15137C1.15906 1.81841 1.39896 1.38672 1.7915 1.38672Z';
|
||||
|
||||
const NodeCard = styled.div<{ $accent: string; $active: boolean }>`
|
||||
align-items: center;
|
||||
background: ${shell.cardBackground};
|
||||
border: 1px solid
|
||||
${({ $accent, $active }) => ($active ? $accent : shell.borderStrong)};
|
||||
border-radius: 8px;
|
||||
box-shadow: ${PRODUCT_STEPPER_SCENE.nodeShadow};
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
position: absolute;
|
||||
width: ${NODE_WIDTH}px;
|
||||
z-index: 2;
|
||||
`;
|
||||
|
||||
const NodeIconBox = styled.div<{ $ink: string }>`
|
||||
align-items: center;
|
||||
background: ${shell.tint};
|
||||
border-radius: 4px;
|
||||
color: ${({ $ink }) => $ink};
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
height: 30px;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
`;
|
||||
|
||||
const NodeRight = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const NodeLabel = styled.span`
|
||||
color: ${shell.textTertiary};
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const NodeName = styled.div`
|
||||
color: ${shell.text};
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
function getEdgePath(
|
||||
from: { x: number; y: number },
|
||||
to: { x: number; y: number },
|
||||
): string {
|
||||
const sourceX = from.x + NODE_WIDTH / 2;
|
||||
const sourceY = from.y + NODE_HEIGHT;
|
||||
const targetX = to.x + NODE_WIDTH / 2;
|
||||
const targetY = to.y - ARROW_GAP;
|
||||
const midY = (sourceY + targetY) / 2;
|
||||
return `M${sourceX},${sourceY} C${sourceX},${midY} ${targetX},${midY} ${targetX},${targetY}`;
|
||||
}
|
||||
|
||||
export function WorkflowVisual({ active }: { active: boolean }) {
|
||||
const {
|
||||
canvasHandlers,
|
||||
draggingId: dragging,
|
||||
handlePointerDown,
|
||||
positions,
|
||||
} = usePointerDragPositions(() =>
|
||||
Object.fromEntries(
|
||||
WORKFLOW_GRAPH.nodes.map((node) => [node.id, { x: node.x, y: node.y }]),
|
||||
),
|
||||
);
|
||||
|
||||
const activeNodes = useWorkflowAnimation(active);
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const sourceNodeIds = [
|
||||
...new Set(WORKFLOW_GRAPH.edges.map((edge) => edge.from)),
|
||||
];
|
||||
|
||||
return (
|
||||
<Shell active={active}>
|
||||
<Canvas {...canvasHandlers}>
|
||||
<StageFit baseScale={1.05} designHeight={560} designWidth={540}>
|
||||
<SvgLayer>
|
||||
<defs>
|
||||
<marker
|
||||
id="workflowArrow"
|
||||
markerHeight={8}
|
||||
markerWidth={10}
|
||||
refX={5}
|
||||
refY={4}
|
||||
>
|
||||
<path
|
||||
d={ARROW_MARKER_PATH}
|
||||
fill={shell.cardBackground}
|
||||
stroke={shell.borderStrong}
|
||||
/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{WORKFLOW_GRAPH.edges.map((edge) => {
|
||||
const from = positions[edge.from];
|
||||
const to = positions[edge.to];
|
||||
|
||||
if (!from || !to) {
|
||||
return null;
|
||||
}
|
||||
const isActive =
|
||||
activeNodes.has(edge.from) && activeNodes.has(edge.to);
|
||||
|
||||
return (
|
||||
<path
|
||||
d={getEdgePath(from, to)}
|
||||
fill="none"
|
||||
key={`${edge.from}-${edge.to}`}
|
||||
markerEnd="url(#workflowArrow)"
|
||||
stroke={isActive ? shell.borderStrong : shell.borderMedium}
|
||||
strokeWidth={1}
|
||||
style={{ transition: 'stroke 0.3s' }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SvgLayer>
|
||||
|
||||
{WORKFLOW_GRAPH.nodes.map((node) => {
|
||||
const position = positions[node.id];
|
||||
const Icon = WORKFLOW_GLYPHS.nodes[node.icon];
|
||||
const accent = workflow.accents[node.accent];
|
||||
|
||||
return (
|
||||
<NodeCard
|
||||
$accent={accent}
|
||||
$active={activeNodes.has(node.id)}
|
||||
key={node.id}
|
||||
onPointerDown={(event) => handlePointerDown(node.id, event)}
|
||||
style={{
|
||||
cursor: dragging === node.id ? 'grabbing' : 'grab',
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
transition:
|
||||
dragging === node.id ? 'none' : 'border-color 0.3s ease',
|
||||
}}
|
||||
>
|
||||
<NodeIconBox $ink={accent}>
|
||||
<Icon size={16} stroke={1.8} />
|
||||
</NodeIconBox>
|
||||
<NodeRight>
|
||||
<NodeLabel>{i18n._(node.type)}</NodeLabel>
|
||||
<NodeName>{i18n._(node.label)}</NodeName>
|
||||
</NodeRight>
|
||||
</NodeCard>
|
||||
);
|
||||
})}
|
||||
|
||||
<SvgLayer style={{ zIndex: 3 }}>
|
||||
{sourceNodeIds.map((id) => {
|
||||
const position = positions[id];
|
||||
|
||||
if (!position) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<circle
|
||||
cx={position.x + NODE_WIDTH / 2}
|
||||
cy={position.y + NODE_HEIGHT}
|
||||
fill={shell.cardBackground}
|
||||
key={`handle-${id}`}
|
||||
r={3.5}
|
||||
stroke={shell.borderStrong}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SvgLayer>
|
||||
</StageFit>
|
||||
</Canvas>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
IconBrain,
|
||||
IconCheck,
|
||||
IconFilter,
|
||||
IconPlaylistAdd,
|
||||
IconPlus,
|
||||
IconReload,
|
||||
IconSearch,
|
||||
IconSend,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import { type WorkflowIconName } from '../types/workflow-icon-name';
|
||||
|
||||
const NODE_ICONS: Record<WorkflowIconName, typeof IconPlus> = {
|
||||
brain: IconBrain,
|
||||
filter: IconFilter,
|
||||
playlistAdd: IconPlaylistAdd,
|
||||
plus: IconPlus,
|
||||
reload: IconReload,
|
||||
search: IconSearch,
|
||||
send: IconSend,
|
||||
};
|
||||
|
||||
function WorkflowCheckGlyph({ color }: { color: string }) {
|
||||
return <IconCheck color={color} size={8} stroke={2.5} />;
|
||||
}
|
||||
|
||||
export const WORKFLOW_GLYPHS = {
|
||||
Check: WorkflowCheckGlyph,
|
||||
nodes: NODE_ICONS,
|
||||
};
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
import { type WorkflowEdgeDefinition } from '../types/workflow-edge-definition';
|
||||
import { type WorkflowNodeDefinition } from '../types/workflow-node-definition';
|
||||
|
||||
const TRUNK_X = 185;
|
||||
const LEFT_X = 10;
|
||||
const RIGHT_X = 360;
|
||||
|
||||
export const WORKFLOW_GRAPH: {
|
||||
animationSequence: string[];
|
||||
edges: WorkflowEdgeDefinition[];
|
||||
nodes: WorkflowNodeDefinition[];
|
||||
stepIntervalMs: number;
|
||||
} = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'trigger',
|
||||
type: msg`Trigger`,
|
||||
label: msg`Record is Created`,
|
||||
icon: 'playlistAdd',
|
||||
accent: 'blue',
|
||||
x: TRUNK_X,
|
||||
y: 16,
|
||||
},
|
||||
{
|
||||
id: 'filter',
|
||||
type: msg`Action`,
|
||||
label: msg`Filter`,
|
||||
icon: 'filter',
|
||||
accent: 'green',
|
||||
x: TRUNK_X,
|
||||
y: 136,
|
||||
},
|
||||
{
|
||||
id: 'search',
|
||||
type: msg`Action`,
|
||||
label: msg`Search Records`,
|
||||
icon: 'search',
|
||||
accent: 'gray',
|
||||
x: TRUNK_X,
|
||||
y: 256,
|
||||
},
|
||||
{
|
||||
id: 'ai',
|
||||
type: msg`Action`,
|
||||
label: msg`AI Agent`,
|
||||
icon: 'brain',
|
||||
accent: 'pink',
|
||||
x: TRUNK_X,
|
||||
y: 376,
|
||||
},
|
||||
{
|
||||
id: 'update',
|
||||
type: msg`Action`,
|
||||
label: msg`Update Record`,
|
||||
icon: 'reload',
|
||||
accent: 'gray',
|
||||
x: LEFT_X,
|
||||
y: 496,
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
type: msg`Action`,
|
||||
label: msg`Send Email`,
|
||||
icon: 'send',
|
||||
accent: 'red',
|
||||
x: TRUNK_X,
|
||||
y: 496,
|
||||
},
|
||||
{
|
||||
id: 'create',
|
||||
type: msg`Action`,
|
||||
label: msg`Create Record`,
|
||||
icon: 'plus',
|
||||
accent: 'gray',
|
||||
x: RIGHT_X,
|
||||
y: 496,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ from: 'trigger', to: 'filter' },
|
||||
{ from: 'filter', to: 'search' },
|
||||
{ from: 'search', to: 'ai' },
|
||||
{ from: 'ai', to: 'update' },
|
||||
{ from: 'ai', to: 'email' },
|
||||
{ from: 'ai', to: 'create' },
|
||||
],
|
||||
animationSequence: [
|
||||
'trigger',
|
||||
'filter',
|
||||
'search',
|
||||
'ai',
|
||||
'update',
|
||||
'email',
|
||||
'create',
|
||||
],
|
||||
stepIntervalMs: 800,
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { WorkflowVisual } from './WorkflowVisual';
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type WorkflowEdgeDefinition = {
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
export type WorkflowIconName =
|
||||
| 'brain'
|
||||
| 'filter'
|
||||
| 'playlistAdd'
|
||||
| 'plus'
|
||||
| 'reload'
|
||||
| 'search'
|
||||
| 'send';
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
|
||||
import { type WorkflowIconName } from './workflow-icon-name';
|
||||
|
||||
export type WorkflowNodeDefinition = {
|
||||
accent: 'blue' | 'gray' | 'green' | 'pink' | 'red';
|
||||
icon: WorkflowIconName;
|
||||
id: string;
|
||||
label: MessageDescriptor;
|
||||
type: MessageDescriptor;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
@@ -1,283 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { usePointerDragPositions } from '@/platform/motion';
|
||||
import { PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
|
||||
|
||||
import {
|
||||
DATA_MODEL_GRAPH,
|
||||
type EntityConnection,
|
||||
} from '../data/data-model-data';
|
||||
import { DATA_MODEL_ICONS } from './DataModelIcons';
|
||||
import { DrawEdge } from './DrawEdge';
|
||||
import { STEPPER_SHELL_CHROME } from './ProductStepperShell';
|
||||
|
||||
const shell = PRODUCT_STEPPER_SCENE.shell;
|
||||
const badges = PRODUCT_STEPPER_SCENE.badges;
|
||||
const entityTones = PRODUCT_STEPPER_SCENE.entityTones;
|
||||
|
||||
const { Canvas, Shell, SvgLayer } = STEPPER_SHELL_CHROME;
|
||||
|
||||
const EntityCard = styled.div`
|
||||
backdrop-filter: blur(14px);
|
||||
background: ${shell.cardBackground};
|
||||
border: 1px solid ${shell.borderMedium};
|
||||
border-radius: 8px;
|
||||
box-shadow: ${PRODUCT_STEPPER_SCENE.cardShadow};
|
||||
cursor: grab;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 130px;
|
||||
padding: 6px;
|
||||
position: absolute;
|
||||
touch-action: none;
|
||||
transition: border-color 0.15s ease;
|
||||
z-index: 2;
|
||||
|
||||
&[data-hovered] {
|
||||
border-color: ${shell.borderStrong};
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
z-index: 10;
|
||||
}
|
||||
`;
|
||||
|
||||
const EntityHeader = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const InnerCard = styled.div`
|
||||
background: white;
|
||||
border: 1px solid ${shell.borderLight};
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
padding: 4px 0;
|
||||
`;
|
||||
|
||||
const EntityIcon = styled.span<{ $tint: string; $line: string }>`
|
||||
align-items: center;
|
||||
background: ${({ $tint }) => $tint};
|
||||
border: 1px solid ${({ $line }) => $line};
|
||||
border-radius: 3px;
|
||||
color: ${shell.text};
|
||||
display: flex;
|
||||
height: 14px;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
`;
|
||||
|
||||
const EntityLabel = styled.span`
|
||||
color: ${shell.text};
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const EntityMeta = styled.span`
|
||||
color: ${shell.textTertiary};
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
`;
|
||||
|
||||
const MetaBadge = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
`;
|
||||
|
||||
const MetaBadgeIcon = styled.span<{
|
||||
$ink: string;
|
||||
$line: string;
|
||||
$tint: string;
|
||||
}>`
|
||||
align-items: center;
|
||||
background: ${({ $tint }) => $tint};
|
||||
border: 1px solid ${({ $line }) => $line};
|
||||
border-radius: 2px;
|
||||
color: ${({ $ink }) => $ink};
|
||||
display: flex;
|
||||
font-size: 8px;
|
||||
font-weight: 500;
|
||||
height: 14px;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
`;
|
||||
|
||||
const MetaBadgeText = styled.span`
|
||||
color: ${shell.textMuted};
|
||||
font-size: 9px;
|
||||
font-weight: 400;
|
||||
`;
|
||||
|
||||
const FieldRow = styled.div`
|
||||
align-items: center;
|
||||
color: ${shell.text};
|
||||
display: flex;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
gap: 4px;
|
||||
height: 22px;
|
||||
line-height: 1.4;
|
||||
padding: 0 6px;
|
||||
`;
|
||||
|
||||
const FieldIcon = styled.span`
|
||||
align-items: center;
|
||||
color: ${shell.textMuted};
|
||||
display: flex;
|
||||
height: 14px;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
`;
|
||||
|
||||
const ExpandHint = styled.div`
|
||||
align-items: center;
|
||||
color: ${shell.textTertiary};
|
||||
display: flex;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
gap: 4px;
|
||||
height: 22px;
|
||||
line-height: 1.4;
|
||||
padding: 0 6px;
|
||||
`;
|
||||
|
||||
const CARD_WIDTH = 140;
|
||||
const CARD_HEIGHT_ESTIMATE = 110;
|
||||
|
||||
type EntityPositions = Record<string, { x: number; y: number }>;
|
||||
|
||||
function getCardCenter(
|
||||
positions: EntityPositions,
|
||||
entityId: string,
|
||||
): { x: number; y: number } {
|
||||
const position = positions[entityId];
|
||||
|
||||
if (!position) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
return {
|
||||
x: position.x + CARD_WIDTH / 2,
|
||||
y: position.y + CARD_HEIGHT_ESTIMATE / 2,
|
||||
};
|
||||
}
|
||||
|
||||
export function DataModelVisual({ active }: { active: boolean }) {
|
||||
const {
|
||||
canvasHandlers,
|
||||
draggingId: dragging,
|
||||
handlePointerDown,
|
||||
positions,
|
||||
} = usePointerDragPositions(() =>
|
||||
Object.fromEntries(
|
||||
DATA_MODEL_GRAPH.entities.map((entity) => [
|
||||
entity.id,
|
||||
{ x: entity.x, y: entity.y },
|
||||
]),
|
||||
),
|
||||
);
|
||||
const [hoveredEntity, setHoveredEntity] = useState<string | null>(null);
|
||||
|
||||
const isConnectionHighlighted = (connection: EntityConnection) =>
|
||||
hoveredEntity === connection.from || hoveredEntity === connection.to;
|
||||
|
||||
return (
|
||||
<Shell active={active} title="Data model">
|
||||
<Canvas {...canvasHandlers}>
|
||||
<SvgLayer>
|
||||
{DATA_MODEL_GRAPH.connections.map((connection) => (
|
||||
<DrawEdge
|
||||
circleRadius={1.5}
|
||||
elbow="horizontal-first"
|
||||
from={getCardCenter(positions, connection.from)}
|
||||
highlighted={isConnectionHighlighted(connection)}
|
||||
key={`${connection.from}-${connection.to}`}
|
||||
to={getCardCenter(positions, connection.to)}
|
||||
/>
|
||||
))}
|
||||
</SvgLayer>
|
||||
|
||||
{DATA_MODEL_GRAPH.entities.map((entity) => {
|
||||
const position = positions[entity.id];
|
||||
const HeaderIcon = DATA_MODEL_ICONS.headers[entity.headerIcon];
|
||||
const tone = entityTones[entity.tone];
|
||||
const badge = entity.isCustom ? badges.custom : badges.standard;
|
||||
|
||||
return (
|
||||
<EntityCard
|
||||
data-hovered={
|
||||
hoveredEntity === entity.id || dragging === entity.id
|
||||
? ''
|
||||
: undefined
|
||||
}
|
||||
key={entity.id}
|
||||
onPointerDown={(event) => handlePointerDown(entity.id, event)}
|
||||
onPointerEnter={() => setHoveredEntity(entity.id)}
|
||||
onPointerLeave={() => setHoveredEntity(null)}
|
||||
style={{
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
transition:
|
||||
dragging === entity.id ? 'none' : 'border-color 0.15s',
|
||||
}}
|
||||
>
|
||||
<EntityHeader>
|
||||
<EntityIcon $line={tone.border} $tint={tone.background}>
|
||||
<HeaderIcon />
|
||||
</EntityIcon>
|
||||
<EntityLabel>{entity.label}</EntityLabel>
|
||||
<EntityMeta>· {entity.meta}</EntityMeta>
|
||||
<MetaBadge>
|
||||
<MetaBadgeIcon
|
||||
$ink={badge.text}
|
||||
$line={badge.border}
|
||||
$tint={badge.background}
|
||||
>
|
||||
L
|
||||
</MetaBadgeIcon>
|
||||
<MetaBadgeText>
|
||||
{entity.isCustom ? 'Custom' : 'Standard'}
|
||||
</MetaBadgeText>
|
||||
</MetaBadge>
|
||||
</EntityHeader>
|
||||
<InnerCard>
|
||||
{entity.fields.map((field) => {
|
||||
const Icon = DATA_MODEL_ICONS.fields[field.icon];
|
||||
return (
|
||||
<FieldRow key={field.label}>
|
||||
<FieldIcon>
|
||||
<Icon />
|
||||
</FieldIcon>
|
||||
{field.label}
|
||||
</FieldRow>
|
||||
);
|
||||
})}
|
||||
<ExpandHint>
|
||||
<DATA_MODEL_ICONS.ChevronExpand /> {entity.expandCount} fields
|
||||
</ExpandHint>
|
||||
</InnerCard>
|
||||
</EntityCard>
|
||||
);
|
||||
})}
|
||||
</Canvas>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -1,466 +0,0 @@
|
||||
import { PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
|
||||
|
||||
const inks = PRODUCT_STEPPER_SCENE.layout;
|
||||
|
||||
export type LayoutFieldIconType =
|
||||
| 'calendar'
|
||||
| 'link'
|
||||
| 'map'
|
||||
| 'money'
|
||||
| 'target'
|
||||
| 'user'
|
||||
| 'users';
|
||||
|
||||
export type LayoutNavIconType =
|
||||
| 'ai'
|
||||
| 'automation'
|
||||
| 'building'
|
||||
| 'checkbox'
|
||||
| 'letterS'
|
||||
| 'notes'
|
||||
| 'play'
|
||||
| 'stripeS'
|
||||
| 'target'
|
||||
| 'user'
|
||||
| 'versions';
|
||||
|
||||
function FieldGlyph({ type }: { type: LayoutFieldIconType }) {
|
||||
const ink = inks.fieldInk;
|
||||
const size = 10;
|
||||
switch (type) {
|
||||
case 'link':
|
||||
return (
|
||||
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
|
||||
<path
|
||||
d="M7 9a3.5 3.5 0 005 0l2-2a3.536 3.536 0 00-5-5L8 3m1 4a3.5 3.5 0 00-5 0L2 9a3.536 3.536 0 005 5l1-1"
|
||||
stroke={ink}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case 'user':
|
||||
return (
|
||||
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
|
||||
<circle cx="8" cy="5.5" r="2.5" stroke={ink} strokeWidth={1.2} />
|
||||
<path
|
||||
d="M3.5 13.5c0-2.5 2-4 4.5-4s4.5 1.5 4.5 4"
|
||||
stroke={ink}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case 'money':
|
||||
return (
|
||||
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
|
||||
<path
|
||||
d="M8 2v12M5 4.5h4.5a1.5 1.5 0 010 3H5.5a1.5 1.5 0 000 3H11"
|
||||
stroke={ink}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case 'target':
|
||||
return (
|
||||
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
|
||||
<circle cx="8" cy="8" r="6" stroke={ink} strokeWidth={1.2} />
|
||||
<circle cx="8" cy="8" r="3" stroke={ink} strokeWidth={1.2} />
|
||||
<circle cx="8" cy="8" fill={ink} r="1" />
|
||||
</svg>
|
||||
);
|
||||
case 'users':
|
||||
return (
|
||||
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
|
||||
<circle cx="6" cy="5" r="2" stroke={ink} strokeWidth={1.2} />
|
||||
<path
|
||||
d="M2 13c0-2 1.5-3.5 4-3.5s4 1.5 4 3.5"
|
||||
stroke={ink}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
<circle cx="11" cy="5.5" r="1.5" stroke={ink} strokeWidth={1.2} />
|
||||
<path
|
||||
d="M12 9.5c1.5.5 2.5 1.5 2.5 3"
|
||||
stroke={ink}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case 'map':
|
||||
return (
|
||||
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
|
||||
<path
|
||||
d="M3 4l3.5-1.5 3 1.5L13 2.5v9.5l-3.5 1.5-3-1.5L3 13.5V4z"
|
||||
stroke={ink}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
<path d="M6.5 2.5v9M9.5 4v9" stroke={ink} strokeWidth={1.2} />
|
||||
</svg>
|
||||
);
|
||||
case 'calendar':
|
||||
return (
|
||||
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
|
||||
<rect
|
||||
height="10"
|
||||
rx="1.5"
|
||||
stroke={ink}
|
||||
strokeWidth={1.2}
|
||||
width="10"
|
||||
x="3"
|
||||
y="3.5"
|
||||
/>
|
||||
<path d="M3 7h10M6 2v2M10 2v2" stroke={ink} strokeWidth={1.2} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function NavGlyph({ type }: { type: LayoutNavIconType }) {
|
||||
const ink = inks.navInk;
|
||||
const size = 7;
|
||||
switch (type) {
|
||||
case 'building':
|
||||
return (
|
||||
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
|
||||
<path
|
||||
d="M5 14V3l6 2v9M5 6H3v8h12V7h-4"
|
||||
stroke={ink}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.4}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case 'user':
|
||||
return (
|
||||
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
|
||||
<circle cx="8" cy="5" r="2.5" stroke={ink} strokeWidth={1.4} />
|
||||
<path
|
||||
d="M3.5 14c0-2.5 2-4 4.5-4s4.5 1.5 4.5 4"
|
||||
stroke={ink}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={1.4}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case 'target':
|
||||
return (
|
||||
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
|
||||
<circle cx="8" cy="8" r="5" stroke={ink} strokeWidth={1.4} />
|
||||
<circle cx="8" cy="8" r="2" stroke={ink} strokeWidth={1.4} />
|
||||
<path d="M8 3v2M13 8h-2" stroke={ink} strokeWidth={1.4} />
|
||||
</svg>
|
||||
);
|
||||
case 'checkbox':
|
||||
return (
|
||||
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
|
||||
<rect
|
||||
height="10"
|
||||
rx="2"
|
||||
stroke={ink}
|
||||
strokeWidth={1.4}
|
||||
width="10"
|
||||
x="3"
|
||||
y="3"
|
||||
/>
|
||||
<path
|
||||
d="M6 8l1.5 1.5L10 6"
|
||||
stroke={ink}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.4}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case 'notes':
|
||||
return (
|
||||
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
|
||||
<rect
|
||||
height="10"
|
||||
rx="1.5"
|
||||
stroke={ink}
|
||||
strokeWidth={1.4}
|
||||
width="8"
|
||||
x="4"
|
||||
y="3"
|
||||
/>
|
||||
<path
|
||||
d="M6.5 6h3M6.5 8.5h3"
|
||||
stroke={ink}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={1.4}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case 'automation':
|
||||
return (
|
||||
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
|
||||
<circle cx="8" cy="8" r="5" stroke={ink} strokeWidth={1.4} />
|
||||
<path
|
||||
d="M8 5v3l2 1"
|
||||
stroke={ink}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.4}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case 'play':
|
||||
return (
|
||||
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
|
||||
<path
|
||||
d="M5 3.5l8 4.5-8 4.5V3.5z"
|
||||
stroke={ink}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.4}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case 'versions':
|
||||
return (
|
||||
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
|
||||
<rect
|
||||
height="8"
|
||||
rx="1"
|
||||
stroke={ink}
|
||||
strokeWidth={1.4}
|
||||
width="6"
|
||||
x="5"
|
||||
y="4"
|
||||
/>
|
||||
<path d="M3 6v6a1 1 0 001 1h6" stroke={ink} strokeWidth={1.4} />
|
||||
</svg>
|
||||
);
|
||||
case 'ai':
|
||||
return (
|
||||
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
|
||||
<path
|
||||
d="M8 2l1.5 4L14 8l-4.5 2L8 14l-1.5-4L2 8l4.5-2L8 2z"
|
||||
stroke={ink}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case 'letterS':
|
||||
return (
|
||||
<svg height={size} viewBox="0 0 16 16" width={size}>
|
||||
<text
|
||||
fill={inks.letterSInk}
|
||||
fontFamily="Inter"
|
||||
fontSize="9"
|
||||
fontWeight="600"
|
||||
textAnchor="middle"
|
||||
x="8"
|
||||
y="12"
|
||||
>
|
||||
S
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
case 'stripeS':
|
||||
return (
|
||||
<svg height={size} viewBox="0 0 16 16" width={size}>
|
||||
<text
|
||||
fill={inks.stripeSInk}
|
||||
fontFamily="Inter"
|
||||
fontSize="9"
|
||||
fontWeight="600"
|
||||
textAnchor="middle"
|
||||
x="8"
|
||||
y="12"
|
||||
>
|
||||
S
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function EyeGlyph({ visible }: { visible: boolean }) {
|
||||
if (visible) {
|
||||
return (
|
||||
<svg fill="none" height={10} viewBox="0 0 16 16" width={10}>
|
||||
<path
|
||||
d="M2 8s2.5-4 6-4 6 4 6 4-2.5 4-6 4-6-4-6-4z"
|
||||
stroke={inks.eyeInk}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
<circle cx="8" cy="8" r="1.5" stroke={inks.eyeInk} strokeWidth={1.2} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg fill="none" height={10} viewBox="0 0 16 16" width={10}>
|
||||
<path
|
||||
d="M2 8s2.5-4 6-4 6 4 6 4-2.5 4-6 4-6-4-6-4z"
|
||||
stroke={inks.eyeHiddenInk}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
<path
|
||||
d="M3 3l10 10"
|
||||
stroke={inks.eyeHiddenInk}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronLeftGlyph() {
|
||||
return (
|
||||
<svg fill="none" height={10} viewBox="0 0 16 16" width={10}>
|
||||
<path
|
||||
d="M10 4l-4 4 4 4"
|
||||
stroke={inks.eyeInk}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DotsGlyph() {
|
||||
return (
|
||||
<svg fill="none" height={10} viewBox="0 0 4 12" width={5}>
|
||||
<circle cx="2" cy="2" fill={inks.eyeInk} r="0.8" />
|
||||
<circle cx="2" cy="6" fill={inks.eyeInk} r="0.8" />
|
||||
<circle cx="2" cy="10" fill={inks.eyeInk} r="0.8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DotsWhiteGlyph() {
|
||||
return (
|
||||
<svg fill="none" height={12} viewBox="0 0 4 12" width={5}>
|
||||
<circle cx="2" cy="2" fill="white" r="1" />
|
||||
<circle cx="2" cy="6" fill="white" r="1" />
|
||||
<circle cx="2" cy="10" fill="white" r="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GripGlyph() {
|
||||
return (
|
||||
<svg fill="none" height={10} viewBox="0 0 8 12" width={7}>
|
||||
<circle cx="3" cy="2" fill={inks.eyeInk} r="0.8" />
|
||||
<circle cx="5" cy="2" fill={inks.eyeInk} r="0.8" />
|
||||
<circle cx="3" cy="6" fill={inks.eyeInk} r="0.8" />
|
||||
<circle cx="5" cy="6" fill={inks.eyeInk} r="0.8" />
|
||||
<circle cx="3" cy="10" fill={inks.eyeInk} r="0.8" />
|
||||
<circle cx="5" cy="10" fill={inks.eyeInk} r="0.8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PaintGlyph() {
|
||||
return (
|
||||
<svg fill="none" height={12} viewBox="0 0 16 16" width={12}>
|
||||
<rect
|
||||
height="6"
|
||||
rx="1"
|
||||
stroke="white"
|
||||
strokeWidth={1.2}
|
||||
width="8"
|
||||
x="4"
|
||||
y="3"
|
||||
/>
|
||||
<path
|
||||
d="M6 9v3a1 1 0 001 1h0a1 1 0 001-1V9"
|
||||
stroke="white"
|
||||
strokeLinecap="round"
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckGlyph() {
|
||||
return (
|
||||
<svg fill="none" height={8} viewBox="0 0 16 16" width={8}>
|
||||
<path
|
||||
d="M3 8l4 4 6-8"
|
||||
stroke="white"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ListGlyph() {
|
||||
return (
|
||||
<svg fill="none" height={10} viewBox="0 0 16 16" width={10}>
|
||||
<path
|
||||
d="M3 4h4M3 8h4M3 12h4M9 4h4M9 8h4M9 12h4"
|
||||
stroke={inks.fieldInk}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SparkGlyph() {
|
||||
return (
|
||||
<svg fill="none" height={10} viewBox="0 0 16 16" width={10}>
|
||||
<path
|
||||
d="M8 2l1.5 4.5L14 8l-4.5 1.5L8 14l-1.5-4.5L2 8l4.5-1.5L8 2z"
|
||||
stroke={inks.eyeInk}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function NewSectionGlyph() {
|
||||
return (
|
||||
<svg fill="none" height={10} viewBox="0 0 16 16" width={10}>
|
||||
<path
|
||||
d="M3 4h10M3 8h5M3 12h10M12 8v4M10 10h4"
|
||||
stroke={inks.fieldInk}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PlusGlyph() {
|
||||
return (
|
||||
<svg fill="none" height={10} viewBox="0 0 16 16" width={10}>
|
||||
<path
|
||||
d="M8 3v10M3 8h10"
|
||||
stroke={inks.fieldInk}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const LAYOUT_GLYPHS = {
|
||||
Check: CheckGlyph,
|
||||
ChevronLeft: ChevronLeftGlyph,
|
||||
Dots: DotsGlyph,
|
||||
DotsWhite: DotsWhiteGlyph,
|
||||
Eye: EyeGlyph,
|
||||
Field: FieldGlyph,
|
||||
Grip: GripGlyph,
|
||||
List: ListGlyph,
|
||||
Nav: NavGlyph,
|
||||
NewSection: NewSectionGlyph,
|
||||
Paint: PaintGlyph,
|
||||
Plus: PlusGlyph,
|
||||
Spark: SparkGlyph,
|
||||
};
|
||||
@@ -1,347 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from 'react';
|
||||
|
||||
import { LAYOUT_CHROME } from '../data/layout-chrome';
|
||||
import { LAYOUT_EDITOR_CONTENT } from '../data/layout-data';
|
||||
import { LAYOUT_GLYPHS } from './LayoutIcons';
|
||||
import { PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
|
||||
|
||||
const {
|
||||
Check: CheckGlyph,
|
||||
ChevronLeft: ChevronLeftGlyph,
|
||||
Dots: DotsGlyph,
|
||||
DotsWhite: DotsWhiteGlyph,
|
||||
Eye: EyeGlyph,
|
||||
Field: FieldGlyph,
|
||||
Grip: GripGlyph,
|
||||
List: ListGlyph,
|
||||
Nav: NavGlyph,
|
||||
NewSection: NewSectionGlyph,
|
||||
Paint: PaintGlyph,
|
||||
Plus: PlusGlyph,
|
||||
Spark: SparkGlyph,
|
||||
} = LAYOUT_GLYPHS;
|
||||
|
||||
const {
|
||||
ActionButton,
|
||||
ActionsBar,
|
||||
AddIconBox,
|
||||
AddSectionRow,
|
||||
AddText,
|
||||
BlueHeader,
|
||||
Canvas,
|
||||
DoneButton,
|
||||
EditableRow,
|
||||
EditableText,
|
||||
FieldDot,
|
||||
FieldIconBox,
|
||||
FieldLabels,
|
||||
FieldName,
|
||||
FieldRow,
|
||||
FieldType,
|
||||
HeaderCenter,
|
||||
HeaderLeft,
|
||||
HeaderSave,
|
||||
HeaderTitle,
|
||||
MainCard,
|
||||
NavBreadcrumb,
|
||||
NavChevron,
|
||||
NavIconBox,
|
||||
NavItem,
|
||||
NavPanel,
|
||||
NavSectionLabel,
|
||||
NavSubItem,
|
||||
NavSuffix,
|
||||
NewFieldsColumn,
|
||||
NewFieldsDescription,
|
||||
NewFieldsRow,
|
||||
NewFieldsTitle,
|
||||
PanelActionButton,
|
||||
PanelBackButton,
|
||||
PanelFields,
|
||||
PanelHeader,
|
||||
PanelIconBox,
|
||||
PanelSubBar,
|
||||
PanelSubLabel,
|
||||
PanelTitleBold,
|
||||
PanelTitleGroup,
|
||||
PanelTitleSub,
|
||||
RightPanel,
|
||||
SectionName,
|
||||
SectionRow,
|
||||
WidgetChip,
|
||||
WidgetIcon,
|
||||
WidgetInner,
|
||||
WidgetLabel,
|
||||
WidgetPanel,
|
||||
WidgetRow,
|
||||
WidgetSectionLabel,
|
||||
WidgetTitle,
|
||||
WidgetValue,
|
||||
} = LAYOUT_CHROME;
|
||||
|
||||
const ROW_STEP_PX = 22;
|
||||
|
||||
export function LayoutVisual({ active }: { active: boolean }) {
|
||||
const [fields, setFields] = useState(LAYOUT_EDITOR_CONTENT.fields);
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||
const dragStartY = useRef(0);
|
||||
|
||||
const toggleVisibility = (fieldId: string) => {
|
||||
setFields((previous) =>
|
||||
previous.map((field) =>
|
||||
field.id === fieldId ? { ...field, visible: !field.visible } : field,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleDragStart = (
|
||||
fieldId: string,
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setDraggingId(fieldId);
|
||||
dragStartY.current = event.clientY;
|
||||
};
|
||||
|
||||
const handleDragMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!draggingId) {
|
||||
return;
|
||||
}
|
||||
const deltaY = event.clientY - dragStartY.current;
|
||||
const steps = Math.round(deltaY / ROW_STEP_PX);
|
||||
|
||||
if (steps === 0) {
|
||||
return;
|
||||
}
|
||||
setFields((previous) => {
|
||||
const index = previous.findIndex((field) => field.id === draggingId);
|
||||
|
||||
if (index === -1) {
|
||||
return previous;
|
||||
}
|
||||
const target = Math.max(0, Math.min(previous.length - 1, index + steps));
|
||||
|
||||
if (target === index) {
|
||||
return previous;
|
||||
}
|
||||
const next = [...previous];
|
||||
const [moved] = next.splice(index, 1);
|
||||
next.splice(target, 0, moved);
|
||||
return next;
|
||||
});
|
||||
dragStartY.current = event.clientY;
|
||||
};
|
||||
|
||||
const handleDragEnd = () => setDraggingId(null);
|
||||
const handleLostCapture = () => {
|
||||
if (draggingId) {
|
||||
setDraggingId(null);
|
||||
}
|
||||
};
|
||||
const sections = [...new Set(fields.map((field) => field.section))];
|
||||
|
||||
return (
|
||||
<Canvas data-active={active ? '' : undefined}>
|
||||
<MainCard>
|
||||
<BlueHeader>
|
||||
<HeaderLeft>
|
||||
<DotsWhiteGlyph />
|
||||
</HeaderLeft>
|
||||
<HeaderCenter>
|
||||
<PaintGlyph />
|
||||
<HeaderTitle>Layout edition</HeaderTitle>
|
||||
</HeaderCenter>
|
||||
<HeaderSave>
|
||||
<CheckGlyph /> Save
|
||||
</HeaderSave>
|
||||
</BlueHeader>
|
||||
</MainCard>
|
||||
|
||||
<WidgetPanel>
|
||||
<WidgetInner>
|
||||
<WidgetTitle>Widget name</WidgetTitle>
|
||||
<WidgetSectionLabel>General</WidgetSectionLabel>
|
||||
<WidgetRow>
|
||||
<WidgetIcon>
|
||||
<FieldGlyph type="link" />
|
||||
</WidgetIcon>
|
||||
<WidgetLabel>URL</WidgetLabel>
|
||||
<WidgetChip>qonto.com</WidgetChip>
|
||||
</WidgetRow>
|
||||
<WidgetRow>
|
||||
<WidgetIcon>
|
||||
<FieldGlyph type="user" />
|
||||
</WidgetIcon>
|
||||
<WidgetLabel>Account O...</WidgetLabel>
|
||||
<WidgetValue>Phil Schiller</WidgetValue>
|
||||
</WidgetRow>
|
||||
<WidgetRow>
|
||||
<WidgetIcon>
|
||||
<FieldGlyph type="map" />
|
||||
</WidgetIcon>
|
||||
<WidgetLabel>Address</WidgetLabel>
|
||||
<WidgetValue>18 Rue De Navarin, 750...</WidgetValue>
|
||||
</WidgetRow>
|
||||
<WidgetRow>
|
||||
<WidgetIcon>
|
||||
<FieldGlyph type="target" />
|
||||
</WidgetIcon>
|
||||
<WidgetLabel>ICP</WidgetLabel>
|
||||
<WidgetValue>✓ True</WidgetValue>
|
||||
</WidgetRow>
|
||||
</WidgetInner>
|
||||
</WidgetPanel>
|
||||
|
||||
<NavPanel>
|
||||
<NavSectionLabel>Workspace</NavSectionLabel>
|
||||
{LAYOUT_EDITOR_CONTENT.navItems.map((item) => (
|
||||
<div key={item.label}>
|
||||
<NavItem data-active={item.isActive ? '' : undefined}>
|
||||
<NavIconBox
|
||||
$tint={PRODUCT_STEPPER_SCENE.navTints[item.background]}
|
||||
>
|
||||
<NavGlyph type={item.icon} />
|
||||
</NavIconBox>
|
||||
{item.label}
|
||||
{item.suffix ? <NavSuffix>· {item.suffix}</NavSuffix> : null}
|
||||
{item.isFolder ? <NavChevron>▾</NavChevron> : null}
|
||||
</NavItem>
|
||||
{item.children?.map((child) => (
|
||||
<NavSubItem key={child.label}>
|
||||
<NavBreadcrumb />
|
||||
<NavIconBox
|
||||
$tint={PRODUCT_STEPPER_SCENE.navTints[child.background]}
|
||||
>
|
||||
<NavGlyph type={child.icon} />
|
||||
</NavIconBox>
|
||||
{child.label}
|
||||
</NavSubItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</NavPanel>
|
||||
|
||||
<ActionsBar>
|
||||
<ActionButton>+ New record</ActionButton>
|
||||
<ActionButton>✧ Enrich</ActionButton>
|
||||
<ActionButton>✎ Edit actions</ActionButton>
|
||||
</ActionsBar>
|
||||
|
||||
<RightPanel
|
||||
onLostPointerCapture={handleLostCapture}
|
||||
onPointerCancel={handleDragEnd}
|
||||
onPointerMove={handleDragMove}
|
||||
onPointerUp={handleDragEnd}
|
||||
>
|
||||
<PanelHeader>
|
||||
<PanelBackButton>
|
||||
<ChevronLeftGlyph />
|
||||
</PanelBackButton>
|
||||
<PanelIconBox>
|
||||
<ListGlyph />
|
||||
</PanelIconBox>
|
||||
<PanelTitleGroup>
|
||||
<PanelTitleBold>Fields</PanelTitleBold>
|
||||
<PanelTitleSub>Fields widget</PanelTitleSub>
|
||||
</PanelTitleGroup>
|
||||
<PanelActionButton>
|
||||
<SparkGlyph />
|
||||
</PanelActionButton>
|
||||
</PanelHeader>
|
||||
|
||||
<PanelSubBar>
|
||||
<PanelBackButton>
|
||||
<ChevronLeftGlyph />
|
||||
</PanelBackButton>
|
||||
<PanelSubLabel>Layout</PanelSubLabel>
|
||||
</PanelSubBar>
|
||||
|
||||
<PanelFields>
|
||||
{sections.map((section, sectionNumber) => (
|
||||
<div key={section}>
|
||||
<SectionRow>
|
||||
<GripGlyph />
|
||||
<SectionName>{section}</SectionName>
|
||||
<PanelActionButton>
|
||||
<DotsGlyph />
|
||||
</PanelActionButton>
|
||||
</SectionRow>
|
||||
|
||||
{sectionNumber === 0 ? (
|
||||
<EditableRow>
|
||||
<EditableText>Industry</EditableText>
|
||||
<DoneButton>Done</DoneButton>
|
||||
</EditableRow>
|
||||
) : null}
|
||||
|
||||
{fields
|
||||
.filter((field) => field.section === section)
|
||||
.map((field) => (
|
||||
<FieldRow
|
||||
data-dragging={draggingId === field.id ? '' : undefined}
|
||||
key={field.id}
|
||||
onPointerDown={(event) => handleDragStart(field.id, event)}
|
||||
>
|
||||
<FieldIconBox>
|
||||
<FieldGlyph type={field.icon} />
|
||||
</FieldIconBox>
|
||||
<FieldLabels>
|
||||
<FieldName>{field.label}</FieldName>
|
||||
<FieldDot>·</FieldDot>
|
||||
<FieldType>{field.type}</FieldType>
|
||||
</FieldLabels>
|
||||
<PanelActionButton
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleVisibility(field.id);
|
||||
}}
|
||||
>
|
||||
<EyeGlyph visible={field.visible} />
|
||||
</PanelActionButton>
|
||||
<PanelActionButton>
|
||||
<DotsGlyph />
|
||||
</PanelActionButton>
|
||||
</FieldRow>
|
||||
))}
|
||||
|
||||
{sectionNumber > 0 && sectionNumber < sections.length - 1 ? (
|
||||
<AddSectionRow>
|
||||
<AddIconBox>
|
||||
<NewSectionGlyph />
|
||||
</AddIconBox>
|
||||
<AddText>Add a Section</AddText>
|
||||
</AddSectionRow>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<NewFieldsRow>
|
||||
<AddIconBox>
|
||||
<PlusGlyph />
|
||||
</AddIconBox>
|
||||
<NewFieldsColumn>
|
||||
<NewFieldsTitle>New fields</NewFieldsTitle>
|
||||
<NewFieldsDescription>
|
||||
Default position/visibility for field…
|
||||
</NewFieldsDescription>
|
||||
</NewFieldsColumn>
|
||||
</NewFieldsRow>
|
||||
|
||||
<AddSectionRow>
|
||||
<AddIconBox>
|
||||
<NewSectionGlyph />
|
||||
</AddIconBox>
|
||||
<AddText>Add a Section</AddText>
|
||||
</AddSectionRow>
|
||||
</PanelFields>
|
||||
</RightPanel>
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
+2
-4
@@ -16,10 +16,8 @@ import {
|
||||
StepperSwipeDeck,
|
||||
} from '@/ui';
|
||||
|
||||
import {
|
||||
PRODUCT_STEPPER_STEPS,
|
||||
type ProductStepperStep,
|
||||
} from '../data/product-stepper-data';
|
||||
import { PRODUCT_STEPPER_STEPS } from '../data/product-stepper-data';
|
||||
import { type ProductStepperStep } from '../types/product-stepper-step';
|
||||
|
||||
const ContentRoot = styled.div`
|
||||
display: grid;
|
||||
|
||||
+49
-148
@@ -1,101 +1,49 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { type ReactNode } from 'react';
|
||||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import { useRef, type ReactNode } from 'react';
|
||||
|
||||
import { useScaleToFit } from '@/platform/motion';
|
||||
import { PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
|
||||
|
||||
const shell = PRODUCT_STEPPER_SCENE.shell;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
background: ${shell.background};
|
||||
border-radius: 2px;
|
||||
box-shadow: ${shell.shadowSm};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: ${shell.font};
|
||||
height: 92%;
|
||||
margin-left: auto;
|
||||
margin-top: auto;
|
||||
height: 100%;
|
||||
opacity: 0.7;
|
||||
overflow: hidden;
|
||||
transition: opacity 0.3s;
|
||||
width: 88%;
|
||||
width: 100%;
|
||||
|
||||
&[data-active] {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
`;
|
||||
|
||||
const HeaderLogo = styled.span`
|
||||
align-items: center;
|
||||
background: ${shell.headerBackground};
|
||||
border: 1px solid ${shell.headerBorder};
|
||||
border-radius: 3px;
|
||||
color: ${shell.text};
|
||||
display: flex;
|
||||
height: 14px;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
`;
|
||||
|
||||
const HeaderTitle = styled.span`
|
||||
color: ${shell.text};
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
padding: 0 2px;
|
||||
`;
|
||||
|
||||
const HeaderActions = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
`;
|
||||
|
||||
const HeaderButton = styled.span`
|
||||
align-items: center;
|
||||
border: 1px solid ${shell.borderMedium};
|
||||
border-radius: 4px;
|
||||
color: ${shell.textMuted};
|
||||
display: flex;
|
||||
height: 22px;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
`;
|
||||
|
||||
const HeaderCommandButton = styled.span`
|
||||
align-items: center;
|
||||
border: 1px solid ${shell.borderSubtle};
|
||||
border-radius: 4px;
|
||||
color: ${shell.textTertiary};
|
||||
display: flex;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
gap: 4px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
`;
|
||||
|
||||
const Canvas = styled.div`
|
||||
background: white;
|
||||
border: 1px solid ${shell.borderMedium};
|
||||
border-radius: 8px;
|
||||
flex: 1;
|
||||
margin: 0 10px 10px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const StageFitContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StageBox = styled.div`
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const SvgLayer = styled.svg`
|
||||
height: 100%;
|
||||
left: 0;
|
||||
@@ -105,92 +53,45 @@ const SvgLayer = styled.svg`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
function Shell({
|
||||
active,
|
||||
function Shell({ active, children }: { active: boolean; children: ReactNode }) {
|
||||
return <Wrapper data-active={active ? '' : undefined}>{children}</Wrapper>;
|
||||
}
|
||||
|
||||
function StageFit({
|
||||
baseScale = 1,
|
||||
children,
|
||||
title,
|
||||
designHeight,
|
||||
designWidth,
|
||||
zoom = 1,
|
||||
}: {
|
||||
active: boolean;
|
||||
baseScale?: number;
|
||||
children: ReactNode;
|
||||
title: string;
|
||||
designHeight: number;
|
||||
designWidth: number;
|
||||
zoom?: number;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const fit = useScaleToFit(containerRef, designWidth, designHeight, 1);
|
||||
const scale = Math.min(baseScale, zoom * fit || baseScale);
|
||||
|
||||
return (
|
||||
<Wrapper data-active={active ? '' : undefined}>
|
||||
<Header>
|
||||
<HeaderLogo>
|
||||
<svg
|
||||
fill="none"
|
||||
height="9"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="9"
|
||||
>
|
||||
<path d="M10 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
|
||||
<path d="M19.5 13a2 2 0 1 0 0 4a2 2 0 0 0 0 -4" />
|
||||
<path d="M4.5 13a2 2 0 1 0 0 4a2 2 0 0 0 0 -4" />
|
||||
<path d="M12 7v4" />
|
||||
<path d="M6.5 13l5.5 -2l5.5 2" />
|
||||
</svg>
|
||||
</HeaderLogo>
|
||||
<HeaderTitle>{title}</HeaderTitle>
|
||||
<HeaderActions>
|
||||
<HeaderButton>
|
||||
<svg
|
||||
fill="none"
|
||||
height="14"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
width="14"
|
||||
>
|
||||
<path d="M6 15l6 -6l6 6" />
|
||||
</svg>
|
||||
</HeaderButton>
|
||||
<HeaderButton>
|
||||
<svg
|
||||
fill="none"
|
||||
height="14"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
width="14"
|
||||
>
|
||||
<path d="M6 9l6 6l6 -6" />
|
||||
</svg>
|
||||
</HeaderButton>
|
||||
<HeaderCommandButton>
|
||||
<svg
|
||||
fill="none"
|
||||
height="12"
|
||||
stroke={shell.textMuted}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
width="12"
|
||||
>
|
||||
<path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M12 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M12 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
</svg>
|
||||
⌘K
|
||||
</HeaderCommandButton>
|
||||
</HeaderActions>
|
||||
</Header>
|
||||
{children}
|
||||
</Wrapper>
|
||||
<StageFitContainer ref={containerRef}>
|
||||
<StageBox
|
||||
style={{
|
||||
height: designHeight,
|
||||
transform: `scale(${scale})`,
|
||||
width: designWidth,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</StageBox>
|
||||
</StageFitContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export const STEPPER_SHELL_CHROME = {
|
||||
Canvas,
|
||||
Shell,
|
||||
StageFit,
|
||||
SvgLayer,
|
||||
};
|
||||
|
||||
+11
-9
@@ -3,16 +3,14 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { type ComponentType } from 'react';
|
||||
|
||||
import { mediaUp } from '@/tokens';
|
||||
import { EASING, mediaUp } from '@/tokens';
|
||||
|
||||
import { DataModelVisual } from './DataModelVisual';
|
||||
import { LayoutVisual } from './LayoutVisual';
|
||||
import {
|
||||
PRODUCT_STEPPER_STEPS,
|
||||
type ProductStepperVisualKey,
|
||||
} from '../data/product-stepper-data';
|
||||
import { DataModelVisual } from '../DataModelVisual';
|
||||
import { LayoutVisual } from '../LayoutVisual';
|
||||
import { WorkflowVisual } from '../WorkflowVisual';
|
||||
import { PRODUCT_STEPPER_STEPS } from '../data/product-stepper-data';
|
||||
import { type ProductStepperVisualKey } from '../types/product-stepper-visual-key';
|
||||
import { ProductStepperVisualFrame } from './ProductStepperVisualFrame';
|
||||
import { WorkflowVisual } from './WorkflowVisual';
|
||||
|
||||
const VISUALS: Record<
|
||||
ProductStepperVisualKey,
|
||||
@@ -54,11 +52,15 @@ const Slide = styled.div`
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
transition: opacity 0.4s ease;
|
||||
transform: translateY(18px) scale(0.985);
|
||||
transition:
|
||||
opacity 0.55s ${EASING.gentle},
|
||||
transform 0.55s ${EASING.standard};
|
||||
|
||||
&[data-active] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
|
||||
|
||||
export type WorkflowIconName =
|
||||
| 'playlistAdd'
|
||||
| 'plus'
|
||||
| 'reload'
|
||||
| 'repeat'
|
||||
| 'search'
|
||||
| 'send';
|
||||
|
||||
function IconPlaylistAdd() {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
height={16}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
viewBox="0 0 24 24"
|
||||
width={16}
|
||||
>
|
||||
<path d="M19 8h-14" />
|
||||
<path d="M5 12h9" />
|
||||
<path d="M11 16h-6" />
|
||||
<path d="M15 16h6" />
|
||||
<path d="M18 13v6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconSearch() {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
height={16}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
viewBox="0 0 24 24"
|
||||
width={16}
|
||||
>
|
||||
<path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0" />
|
||||
<path d="M21 21l-6 -6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconRepeat() {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
height={16}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
viewBox="0 0 24 24"
|
||||
width={16}
|
||||
>
|
||||
<path d="M4 12v-3a3 3 0 0 1 3 -3h13m-3 -3l3 3l-3 3" />
|
||||
<path d="M20 12v3a3 3 0 0 1 -3 3h-13m3 3l-3 -3l3 -3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconSend() {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
height={16}
|
||||
stroke={PRODUCT_STEPPER_SCENE.workflow.amber}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
viewBox="0 0 24 24"
|
||||
width={16}
|
||||
>
|
||||
<path d="M10 14l11 -11" />
|
||||
<path d="M21 3l-6.5 18a.55 .55 0 0 1 -1 0l-3.5 -7l-7 -3.5a.55 .55 0 0 1 0 -1l18 -6.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconReload() {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
height={16}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
viewBox="0 0 24 24"
|
||||
width={16}
|
||||
>
|
||||
<path d="M19.933 13.041a8 8 0 1 1 -9.925 -8.788c3.899 -1.002 7.935 1.007 9.425 4.747" />
|
||||
<path d="M20 4v5h-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconPlus() {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
height={16}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
viewBox="0 0 24 24"
|
||||
width={16}
|
||||
>
|
||||
<path d="M12 5v14" />
|
||||
<path d="M5 12h14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowCheckGlyph({ color }: { color: string }) {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
height="8"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2.5"
|
||||
viewBox="0 0 24 24"
|
||||
width="8"
|
||||
>
|
||||
<path d="M5 12l5 5l10 -10" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const WORKFLOW_GLYPHS = {
|
||||
Check: WorkflowCheckGlyph,
|
||||
nodes: {
|
||||
playlistAdd: IconPlaylistAdd,
|
||||
plus: IconPlus,
|
||||
reload: IconReload,
|
||||
repeat: IconRepeat,
|
||||
search: IconSearch,
|
||||
send: IconSend,
|
||||
},
|
||||
};
|
||||
@@ -1,273 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { usePointerDragPositions } from '@/platform/motion';
|
||||
import { PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
|
||||
|
||||
import { DrawEdge } from './DrawEdge';
|
||||
import { STEPPER_SHELL_CHROME } from './ProductStepperShell';
|
||||
import { useWorkflowAnimation } from '../utils/use-workflow-animation';
|
||||
import { WORKFLOW_GRAPH } from '../data/workflow-data';
|
||||
import { WORKFLOW_GLYPHS } from './WorkflowIcons';
|
||||
|
||||
const shell = PRODUCT_STEPPER_SCENE.shell;
|
||||
const workflow = PRODUCT_STEPPER_SCENE.workflow;
|
||||
|
||||
const { Canvas, Shell, SvgLayer } = STEPPER_SHELL_CHROME;
|
||||
|
||||
const NODE_WIDTH = WORKFLOW_GRAPH.nodeWidthPx;
|
||||
const NODE_HEIGHT = WORKFLOW_GRAPH.nodeHeightPx;
|
||||
|
||||
const LABEL_TONES = {
|
||||
amber: workflow.amber,
|
||||
gray: workflow.gray,
|
||||
green: workflow.green,
|
||||
};
|
||||
|
||||
const NodeCard = styled.div`
|
||||
align-items: center;
|
||||
background: ${shell.cardBackground};
|
||||
border: 1px solid ${shell.borderStrong};
|
||||
border-radius: 8px;
|
||||
box-shadow: ${PRODUCT_STEPPER_SCENE.nodeShadow};
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
min-width: ${NODE_WIDTH}px;
|
||||
padding: 8px;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
`;
|
||||
|
||||
const NodeIconBox = styled.div`
|
||||
align-items: center;
|
||||
background: ${shell.tint};
|
||||
border-radius: 4px;
|
||||
color: ${shell.textMuted};
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
height: 30px;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
`;
|
||||
|
||||
const NodeRight = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const NodeLabelRow = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
height: 13px;
|
||||
`;
|
||||
|
||||
const NodeLabel = styled.span<{ $ink: string }>`
|
||||
color: ${({ $ink }) => $ink};
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const NodeCheck = styled.span<{ $tint: string }>`
|
||||
align-items: center;
|
||||
background: ${({ $tint }) => $tint};
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
height: 12px;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
width: 12px;
|
||||
|
||||
&[data-visible] {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const NodeName = styled.div`
|
||||
color: ${shell.text};
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&[data-dimmed] {
|
||||
color: ${shell.textTertiary};
|
||||
}
|
||||
`;
|
||||
|
||||
const IterationLabel = styled.span`
|
||||
background: ${shell.cardBackground};
|
||||
border: 1px solid ${shell.borderStrong};
|
||||
border-radius: 4px;
|
||||
color: ${shell.textTertiary};
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
padding: 2px 4px;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
z-index: 3;
|
||||
`;
|
||||
|
||||
type NodePositions = Record<string, { x: number; y: number }>;
|
||||
|
||||
function getNodeCenter(position: { x: number; y: number }): {
|
||||
x: number;
|
||||
y: number;
|
||||
} {
|
||||
return { x: position.x + NODE_WIDTH / 2, y: position.y + NODE_HEIGHT / 2 };
|
||||
}
|
||||
|
||||
function IteratorLoopPath({ positions }: { positions: NodePositions }) {
|
||||
const iteratorPosition = positions.iterator;
|
||||
const emailPosition = positions.email;
|
||||
|
||||
if (!iteratorPosition || !emailPosition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startX = iteratorPosition.x + NODE_WIDTH;
|
||||
const startY = iteratorPosition.y + NODE_HEIGHT / 2;
|
||||
const horizontalEnd = startX + 117;
|
||||
const cornerRadius = 8;
|
||||
const verticalEnd = emailPosition.y;
|
||||
const emailCenterX = emailPosition.x + NODE_WIDTH / 2;
|
||||
const midY = verticalEnd - 28;
|
||||
const loopColor = shell.borderStrong;
|
||||
const arrowHalf = 3;
|
||||
|
||||
return (
|
||||
<g>
|
||||
<circle cx={startX} cy={startY} fill={loopColor} r={2.5} />
|
||||
<path
|
||||
d={[
|
||||
`M${startX},${startY}`,
|
||||
`H${horizontalEnd - cornerRadius}`,
|
||||
`Q${horizontalEnd},${startY} ${horizontalEnd},${startY + cornerRadius}`,
|
||||
`V${midY - cornerRadius}`,
|
||||
`Q${horizontalEnd},${midY} ${horizontalEnd - cornerRadius},${midY}`,
|
||||
`H${emailCenterX + cornerRadius}`,
|
||||
`Q${emailCenterX},${midY} ${emailCenterX},${midY + cornerRadius}`,
|
||||
`V${verticalEnd}`,
|
||||
].join(' ')}
|
||||
fill="none"
|
||||
stroke={loopColor}
|
||||
strokeWidth={0.75}
|
||||
/>
|
||||
<path
|
||||
d={`M${emailCenterX - arrowHalf},${verticalEnd - arrowHalf} L${emailCenterX},${verticalEnd} L${emailCenterX + arrowHalf},${verticalEnd - arrowHalf}`}
|
||||
fill="none"
|
||||
stroke={loopColor}
|
||||
strokeWidth={0.75}
|
||||
/>
|
||||
<circle cx={emailCenterX} cy={verticalEnd} fill={loopColor} r={2.5} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowVisual({ active }: { active: boolean }) {
|
||||
const {
|
||||
canvasHandlers,
|
||||
draggingId: dragging,
|
||||
handlePointerDown,
|
||||
positions,
|
||||
} = usePointerDragPositions(() =>
|
||||
Object.fromEntries(
|
||||
WORKFLOW_GRAPH.nodes.map((node) => [node.id, { x: node.x, y: node.y }]),
|
||||
),
|
||||
);
|
||||
|
||||
const activeNodes = useWorkflowAnimation(active);
|
||||
|
||||
return (
|
||||
<Shell active={active} title="Workflow Runs">
|
||||
<Canvas {...canvasHandlers}>
|
||||
<SvgLayer>
|
||||
{WORKFLOW_GRAPH.edges.map((edge) => {
|
||||
const fromPosition = positions[edge.from];
|
||||
const toPosition = positions[edge.to];
|
||||
|
||||
if (!fromPosition || !toPosition) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DrawEdge
|
||||
circleRadius={2.5}
|
||||
from={getNodeCenter(fromPosition)}
|
||||
highlighted={
|
||||
activeNodes.has(edge.from) && activeNodes.has(edge.to)
|
||||
}
|
||||
key={`${edge.from}-${edge.to}`}
|
||||
to={getNodeCenter(toPosition)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<IteratorLoopPath positions={positions} />
|
||||
</SvgLayer>
|
||||
|
||||
{WORKFLOW_GRAPH.nodes.map((node) => {
|
||||
const position = positions[node.id];
|
||||
const Icon = WORKFLOW_GLYPHS.nodes[node.icon];
|
||||
const checkTint = node.dimmed
|
||||
? workflow.grayBackground
|
||||
: workflow.tealBackground;
|
||||
const checkInk = node.dimmed ? workflow.gray : workflow.green;
|
||||
|
||||
return (
|
||||
<NodeCard
|
||||
key={node.id}
|
||||
onPointerDown={(event) => handlePointerDown(node.id, event)}
|
||||
style={{
|
||||
cursor: dragging === node.id ? 'grabbing' : 'grab',
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
transition: dragging === node.id ? 'none' : 'box-shadow 0.15s',
|
||||
}}
|
||||
>
|
||||
<NodeIconBox>
|
||||
<Icon />
|
||||
</NodeIconBox>
|
||||
<NodeRight>
|
||||
<NodeLabelRow>
|
||||
<NodeLabel $ink={LABEL_TONES[node.labelTone]}>
|
||||
{node.type}
|
||||
</NodeLabel>
|
||||
{node.badge ? (
|
||||
<>
|
||||
<NodeLabel $ink={LABEL_TONES[node.labelTone]}>
|
||||
{node.badge}
|
||||
</NodeLabel>
|
||||
<NodeCheck
|
||||
$tint={checkTint}
|
||||
data-visible={activeNodes.has(node.id) ? '' : undefined}
|
||||
>
|
||||
<WORKFLOW_GLYPHS.Check color={checkInk} />
|
||||
</NodeCheck>
|
||||
</>
|
||||
) : null}
|
||||
</NodeLabelRow>
|
||||
<NodeName data-dimmed={node.dimmed ? '' : undefined}>
|
||||
{node.label}
|
||||
</NodeName>
|
||||
</NodeRight>
|
||||
</NodeCard>
|
||||
);
|
||||
})}
|
||||
|
||||
<IterationLabel
|
||||
style={{
|
||||
left: positions.iterator.x + NODE_WIDTH + 10,
|
||||
top: positions.iterator.y + NODE_HEIGHT / 2 - 9,
|
||||
}}
|
||||
>
|
||||
iteration 2/3
|
||||
</IterationLabel>
|
||||
</Canvas>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { type PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
|
||||
|
||||
import {
|
||||
type DataModelFieldIcon,
|
||||
type DataModelHeaderIcon,
|
||||
} from '../components/DataModelIcons';
|
||||
|
||||
export type EntityTone = keyof typeof PRODUCT_STEPPER_SCENE.entityTones;
|
||||
|
||||
export type EntityDefinition = {
|
||||
expandCount: number;
|
||||
fields: { icon: DataModelFieldIcon; label: string }[];
|
||||
headerIcon: DataModelHeaderIcon;
|
||||
id: string;
|
||||
isCustom: boolean;
|
||||
label: string;
|
||||
meta: string;
|
||||
tone: EntityTone;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type EntityConnection = {
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
export const DATA_MODEL_GRAPH: {
|
||||
connections: EntityConnection[];
|
||||
entities: EntityDefinition[];
|
||||
} = {
|
||||
entities: [
|
||||
{
|
||||
id: 'workspaces',
|
||||
label: 'Workspaces',
|
||||
meta: '22',
|
||||
isCustom: true,
|
||||
headerIcon: 'userScreenSmall',
|
||||
tone: 'green',
|
||||
fields: [
|
||||
{ icon: 'building', label: 'Company' },
|
||||
{ icon: 'user', label: 'Users' },
|
||||
],
|
||||
expandCount: 21,
|
||||
x: 40,
|
||||
y: 40,
|
||||
},
|
||||
{
|
||||
id: 'companies',
|
||||
label: 'Companies',
|
||||
meta: '39',
|
||||
isCustom: false,
|
||||
headerIcon: 'buildingSmall',
|
||||
tone: 'indigo',
|
||||
fields: [
|
||||
{ icon: 'apps', label: 'Workspace' },
|
||||
{ icon: 'tag', label: '31 fields' },
|
||||
],
|
||||
expandCount: 8,
|
||||
x: 290,
|
||||
y: 20,
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
label: 'Users',
|
||||
meta: '497',
|
||||
isCustom: true,
|
||||
headerIcon: 'usersSmall',
|
||||
tone: 'purple',
|
||||
fields: [
|
||||
{ icon: 'user', label: 'People' },
|
||||
{ icon: 'apps', label: 'Workspace' },
|
||||
],
|
||||
expandCount: 32,
|
||||
x: 40,
|
||||
y: 310,
|
||||
},
|
||||
{
|
||||
id: 'people',
|
||||
label: 'People',
|
||||
meta: '648',
|
||||
isCustom: false,
|
||||
headerIcon: 'userSmall',
|
||||
tone: 'indigo',
|
||||
fields: [
|
||||
{ icon: 'building', label: 'Company' },
|
||||
{ icon: 'user', label: 'Users' },
|
||||
{ icon: 'target', label: 'Opportunity' },
|
||||
],
|
||||
expandCount: 4,
|
||||
x: 280,
|
||||
y: 400,
|
||||
},
|
||||
{
|
||||
id: 'opportunities',
|
||||
label: 'Opportunities',
|
||||
meta: '42',
|
||||
isCustom: false,
|
||||
headerIcon: 'targetSmall',
|
||||
tone: 'red',
|
||||
fields: [
|
||||
{ icon: 'building', label: 'Company' },
|
||||
{ icon: 'tag', label: '12 fields' },
|
||||
],
|
||||
expandCount: 23,
|
||||
x: 380,
|
||||
y: 190,
|
||||
},
|
||||
],
|
||||
connections: [
|
||||
{ from: 'workspaces', to: 'companies' },
|
||||
{ from: 'workspaces', to: 'users' },
|
||||
{ from: 'users', to: 'people' },
|
||||
{ from: 'companies', to: 'people' },
|
||||
{ from: 'companies', to: 'opportunities' },
|
||||
{ from: 'people', to: 'opportunities' },
|
||||
],
|
||||
};
|
||||
@@ -1,144 +0,0 @@
|
||||
import { type PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
|
||||
|
||||
import {
|
||||
type LayoutFieldIconType,
|
||||
type LayoutNavIconType,
|
||||
} from '../components/LayoutIcons';
|
||||
|
||||
type NavTint = keyof typeof PRODUCT_STEPPER_SCENE.navTints;
|
||||
|
||||
export type LayoutFieldDefinition = {
|
||||
icon: LayoutFieldIconType;
|
||||
id: string;
|
||||
label: string;
|
||||
section: string;
|
||||
type: string;
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
export type LayoutNavItemDefinition = {
|
||||
background: NavTint;
|
||||
children?: { background: NavTint; icon: LayoutNavIconType; label: string }[];
|
||||
icon: LayoutNavIconType;
|
||||
isActive: boolean;
|
||||
isFolder?: boolean;
|
||||
label: string;
|
||||
suffix?: string;
|
||||
};
|
||||
|
||||
export const LAYOUT_EDITOR_CONTENT: {
|
||||
fields: LayoutFieldDefinition[];
|
||||
navItems: LayoutNavItemDefinition[];
|
||||
} = {
|
||||
fields: [
|
||||
{
|
||||
id: 'url',
|
||||
icon: 'link',
|
||||
label: 'URL',
|
||||
type: 'Link',
|
||||
section: 'General',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'account-owner',
|
||||
icon: 'user',
|
||||
label: 'Account Owner',
|
||||
type: 'Relation',
|
||||
section: 'General',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'revenue',
|
||||
icon: 'money',
|
||||
label: 'Revenue',
|
||||
type: 'Currency',
|
||||
section: 'General',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'icp',
|
||||
icon: 'target',
|
||||
label: 'ICP',
|
||||
type: 'Boolean',
|
||||
section: 'Additional',
|
||||
visible: false,
|
||||
},
|
||||
{
|
||||
id: 'employees',
|
||||
icon: 'users',
|
||||
label: 'Employees',
|
||||
type: 'Number',
|
||||
section: 'Other',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'address',
|
||||
icon: 'map',
|
||||
label: 'Address',
|
||||
type: 'Address',
|
||||
section: 'Other',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'creation-date',
|
||||
icon: 'calendar',
|
||||
label: 'Creation date',
|
||||
type: 'Date & Time',
|
||||
section: 'Other',
|
||||
visible: true,
|
||||
},
|
||||
],
|
||||
navItems: [
|
||||
{
|
||||
icon: 'building',
|
||||
label: 'Companies',
|
||||
isActive: true,
|
||||
background: 'indigo',
|
||||
},
|
||||
{ icon: 'user', label: 'People', isActive: false, background: 'indigo' },
|
||||
{
|
||||
icon: 'target',
|
||||
label: 'Opportunities',
|
||||
isActive: false,
|
||||
background: 'red',
|
||||
},
|
||||
{
|
||||
icon: 'checkbox',
|
||||
label: 'Tasks',
|
||||
isActive: false,
|
||||
background: 'teal',
|
||||
},
|
||||
{ icon: 'notes', label: 'Notes', isActive: false, background: 'teal' },
|
||||
{
|
||||
icon: 'letterS',
|
||||
label: 'Sales Dashboard',
|
||||
isActive: false,
|
||||
background: 'yellow',
|
||||
suffix: 'Dashboard',
|
||||
},
|
||||
{
|
||||
icon: 'automation',
|
||||
label: 'Workflows',
|
||||
isActive: false,
|
||||
background: 'peach',
|
||||
isFolder: true,
|
||||
children: [
|
||||
{ icon: 'automation', label: 'Workflows', background: 'gray' },
|
||||
{ icon: 'play', label: 'Workflows runs', background: 'gray' },
|
||||
{
|
||||
icon: 'versions',
|
||||
label: 'Workflows versions',
|
||||
background: 'gray',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ icon: 'ai', label: 'Claude', isActive: false, background: 'gray' },
|
||||
{
|
||||
icon: 'stripeS',
|
||||
label: 'Stripe',
|
||||
isActive: false,
|
||||
background: 'gray',
|
||||
isFolder: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,16 +1,6 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
import { type INFORMATIVE_MARKS } from '@/icons';
|
||||
|
||||
export type ProductStepperVisualKey = 'dataModel' | 'layout' | 'workflow';
|
||||
|
||||
export type ProductStepperStep = {
|
||||
body: MessageDescriptor;
|
||||
heading: MessageDescriptor;
|
||||
icon: keyof typeof INFORMATIVE_MARKS;
|
||||
visual: ProductStepperVisualKey;
|
||||
};
|
||||
import { type ProductStepperStep } from '../types/product-stepper-step';
|
||||
|
||||
export const PRODUCT_STEPPER_STEPS: readonly ProductStepperStep[] = [
|
||||
{
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { type WorkflowIconName } from '../components/WorkflowIcons';
|
||||
|
||||
export type WorkflowNodeDefinition = {
|
||||
badge?: string;
|
||||
dimmed?: boolean;
|
||||
icon: WorkflowIconName;
|
||||
id: string;
|
||||
label: string;
|
||||
labelTone: 'amber' | 'gray' | 'green';
|
||||
type: string;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type WorkflowEdgeDefinition = {
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
const TRUNK_X = 55;
|
||||
const RIGHT_X = 200;
|
||||
const LEFT_X = 5;
|
||||
|
||||
export const WORKFLOW_GRAPH: {
|
||||
animationSequence: string[];
|
||||
edges: WorkflowEdgeDefinition[];
|
||||
nodeHeightPx: number;
|
||||
nodeWidthPx: number;
|
||||
nodes: WorkflowNodeDefinition[];
|
||||
stepIntervalMs: number;
|
||||
} = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'trigger',
|
||||
type: 'Trigger',
|
||||
label: 'Record is Created',
|
||||
icon: 'playlistAdd',
|
||||
labelTone: 'green',
|
||||
x: TRUNK_X,
|
||||
y: 16,
|
||||
badge: '1',
|
||||
},
|
||||
{
|
||||
id: 'search',
|
||||
type: 'Action',
|
||||
label: 'Search Records',
|
||||
icon: 'search',
|
||||
labelTone: 'green',
|
||||
x: TRUNK_X,
|
||||
y: 92,
|
||||
badge: '1',
|
||||
},
|
||||
{
|
||||
id: 'iterator',
|
||||
type: 'Flow',
|
||||
label: 'Iterator',
|
||||
icon: 'repeat',
|
||||
labelTone: 'amber',
|
||||
x: TRUNK_X,
|
||||
y: 168,
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
type: 'Action',
|
||||
label: 'Send Email',
|
||||
icon: 'send',
|
||||
labelTone: 'amber',
|
||||
x: RIGHT_X,
|
||||
y: 280,
|
||||
},
|
||||
{
|
||||
id: 'update',
|
||||
type: 'Action',
|
||||
label: 'Update Record',
|
||||
icon: 'reload',
|
||||
labelTone: 'gray',
|
||||
x: LEFT_X,
|
||||
y: 340,
|
||||
badge: '3',
|
||||
dimmed: true,
|
||||
},
|
||||
{
|
||||
id: 'create',
|
||||
type: 'Action',
|
||||
label: 'Create Record',
|
||||
icon: 'plus',
|
||||
labelTone: 'green',
|
||||
x: RIGHT_X - 5,
|
||||
y: 400,
|
||||
badge: '1',
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ from: 'trigger', to: 'search' },
|
||||
{ from: 'search', to: 'iterator' },
|
||||
{ from: 'iterator', to: 'update' },
|
||||
{ from: 'email', to: 'create' },
|
||||
],
|
||||
animationSequence: [
|
||||
'trigger',
|
||||
'search',
|
||||
'iterator',
|
||||
'email',
|
||||
'update',
|
||||
'create',
|
||||
],
|
||||
stepIntervalMs: 800,
|
||||
nodeWidthPx: 170,
|
||||
nodeHeightPx: 48,
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
|
||||
import { type INFORMATIVE_MARKS } from '@/icons';
|
||||
|
||||
import { type ProductStepperVisualKey } from './product-stepper-visual-key';
|
||||
|
||||
export type ProductStepperStep = {
|
||||
body: MessageDescriptor;
|
||||
heading: MessageDescriptor;
|
||||
icon: keyof typeof INFORMATIVE_MARKS;
|
||||
visual: ProductStepperVisualKey;
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export type ProductStepperVisualKey = 'dataModel' | 'layout' | 'workflow';
|
||||
@@ -37,32 +37,32 @@ export const PRODUCT_STEPPER_SCENE = {
|
||||
},
|
||||
workflow: {
|
||||
green: '#30a46c',
|
||||
amber: '#946800',
|
||||
gray: FICTION_PALETTE.inkFaint,
|
||||
tealBackground: '#e7f9f5',
|
||||
grayBackground: '#f9f9f9',
|
||||
accents: {
|
||||
blue: '#3e63dd',
|
||||
gray: FICTION_PALETTE.inkMuted,
|
||||
green: '#193b2d',
|
||||
pink: '#d6409f',
|
||||
red: '#e5484d',
|
||||
},
|
||||
},
|
||||
navTints: {
|
||||
gray: FICTION_PALETTE.borderMedium,
|
||||
indigo: '#d9e2fc',
|
||||
peach: '#ffdcc3',
|
||||
red: '#fdd8d8',
|
||||
teal: '#c7ebe5',
|
||||
yellow: FICTION_PALETTE.yellowWash,
|
||||
navTiles: {
|
||||
blue: { background: '#d2deff', border: '#c1d0ff', icon: '#3a5bc7' },
|
||||
red: { background: '#ffcdce', border: '#fdbdbe', icon: '#ce2c31' },
|
||||
turquoise: { background: '#b8eae0', border: '#a1ded2', icon: '#008573' },
|
||||
gray: { background: '#ebebeb', border: '#d6d6d6', icon: '#666666' },
|
||||
orange: { background: '#ffd19a', border: '#ffc182', icon: '#cc4e00' },
|
||||
},
|
||||
layout: {
|
||||
accent: FICTION_PALETTE.accent,
|
||||
panelAccent: '#4a38f5',
|
||||
glass: 'rgba(255, 255, 255, 0.9)',
|
||||
dragWash: 'rgba(59, 130, 246, 0.04)',
|
||||
chipWash: 'rgba(0, 0, 0, 0.02)',
|
||||
saveBorder: 'rgba(255, 255, 255, 0.7)',
|
||||
fieldInk: FICTION_PALETTE.inkMuted,
|
||||
navInk: '#555',
|
||||
eyeInk: FICTION_PALETTE.inkFaint,
|
||||
eyeHiddenInk: FICTION_PALETTE.inkDisabled,
|
||||
letterSInk: '#35290f',
|
||||
stripeSInk: '#333',
|
||||
},
|
||||
cardShadow: '0 0 2px rgba(0, 0, 0, 0.08), 0 2px 2px rgba(0, 0, 0, 0.04)',
|
||||
nodeShadow: '0 0 2px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04)',
|
||||
|
||||
Reference in New Issue
Block a user