feat(automations): parallel branch canvas, simple/advanced level per org, rules list UX

This commit is contained in:
2026-10-11 14:48:12 +03:30
parent b3b92abae9
commit 17638786a8
18 changed files with 957 additions and 354 deletions
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import AutomationNode from '@/components/automations/AutomationNode.vue';
import {
AUTOMATION_ACTION_LABELS,
AUTOMATION_ACTIONS,
@@ -10,6 +11,7 @@ import {
AUTOMATION_TRIGGER_LABELS,
MAX_AUTOMATION_STEPS,
isAutomationCondition,
splitAutomationBranches,
type AutomationAction,
type AutomationChainMode,
type AutomationStep,
@@ -24,6 +26,12 @@ export type AutomationAddPayload =
| { kind: 'action'; action: AutomationAction }
| { kind: 'condition' };
type AddAllow = 'both' | 'action' | 'condition';
type LaneItem =
| { kind: 'wire'; key: string; index: number; allow: AddAllow; end?: boolean }
| { kind: 'node'; key: string; index: number };
type BranchLane = { key: string; start: number; end: number; items: LaneItem[] };
const props = withDefaults(
defineProps<{
trigger: AutomationTrigger;
@@ -37,13 +45,92 @@ const props = withDefaults(
{ chainMode: 'stop', form: null, users: () => [], teams: () => [] },
);
/** شرطی که یک شاخهٔ جدید را شروع می‌کند (گام قبلی شرط نیست) */
function isBranchStart(index: number) {
if (props.chainMode !== 'branches') return false;
const emit = defineEmits<{
select: [id: 'trigger' | string];
add: [payload: AutomationAddPayload, index: number];
remove: [id: string];
removeRange: [start: number, end: number];
addBranch: [];
}>();
const addAt = ref<{ key: string; index: number; allow: AddAllow } | null>(null);
const canAdd = computed(() => props.steps.length < MAX_AUTOMATION_STEPS);
const actionCount = computed(
() => props.steps.filter((s) => !isAutomationCondition(s)).length,
);
const layout = computed((): { main: LaneItem[]; branches: BranchLane[] } => {
const steps = props.steps;
const { prefixEnd, branches } = splitAutomationBranches(steps);
if (props.chainMode !== 'branches' || !branches.length) {
const main: LaneItem[] = [];
steps.forEach((s, i) => {
main.push({ kind: 'wire', key: `w-${s.id}`, index: i, allow: 'both' });
main.push({ kind: 'node', key: s.id, index: i });
});
main.push({ kind: 'wire', key: 'w-end', index: steps.length, allow: 'both', end: true });
return { main, branches: [] };
}
const main: LaneItem[] = [];
for (let i = 0; i < prefixEnd; i++) {
main.push({ kind: 'wire', key: `w-${steps[i].id}`, index: i, allow: 'action' });
main.push({ kind: 'node', key: steps[i].id, index: i });
}
const lanes = branches.map((b): BranchLane => {
const items: LaneItem[] = [];
for (let j = b.start; j < b.end; j++) {
const allow: AddAllow =
j === b.start || isAutomationCondition(steps[j])
? 'condition'
: isAutomationCondition(steps[j - 1])
? 'both'
: 'action';
items.push({ kind: 'wire', key: `w-${steps[j].id}`, index: j, allow });
items.push({ kind: 'node', key: steps[j].id, index: j });
}
const last = steps[b.end - 1];
items.push({
kind: 'wire',
key: `w-end-${steps[b.start].id}`,
index: b.end,
allow: isAutomationCondition(last) ? 'both' : 'action',
end: true,
});
return { key: steps[b.start].id, start: b.start, end: b.end, items };
});
return { main, branches: lanes };
});
const forked = computed(() => layout.value.branches.length > 0);
const canAddBranch = computed(() => {
if (props.steps.length + 2 > MAX_AUTOMATION_STEPS) return false;
const last = props.steps[props.steps.length - 1];
return !last || !isAutomationCondition(last);
});
function branchActionCount(lane: BranchLane) {
return props.steps
.slice(lane.start, lane.end)
.filter((s) => !isAutomationCondition(s)).length;
}
function canRemoveBranch(lane: BranchLane) {
return actionCount.value - branchActionCount(lane) >= 1;
}
function canRemove(index: number) {
const step = props.steps[index];
if (!step || !isAutomationCondition(step)) return false;
const prev = props.steps[index - 1];
return !prev || !isAutomationCondition(prev);
if (!step || props.steps.length <= 1) return false;
if (!isAutomationCondition(step)) return actionCount.value > 1;
if (!forked.value) return true;
const lane = layout.value.branches.find((b) => index >= b.start && index < b.end);
if (!lane) return true;
const conds = props.steps
.slice(lane.start, lane.end)
.filter((s) => isAutomationCondition(s)).length;
return conds > 1;
}
function userName(id?: string | null) {
@@ -63,32 +150,12 @@ function assignHint(step: AutomationStep) {
return 'مسئول پرونده';
}
const emit = defineEmits<{
select: [id: 'trigger' | string];
add: [payload: AutomationAddPayload, index: number];
remove: [id: string];
}>();
const addAt = ref<number | null>(null);
const canAdd = computed(() => props.steps.length < MAX_AUTOMATION_STEPS);
const actionCount = computed(
() => props.steps.filter((s) => !isAutomationCondition(s)).length,
);
type NodeIcon = 'sparkle' | 'flag' | 'bell' | 'gift' | 'filter';
function actionIcon(action: AutomationAction): NodeIcon {
function actionIcon(action: AutomationAction) {
if (action === 'create_notification') return 'bell';
if (action === 'add_loyalty_points') return 'gift';
return 'flag';
}
function canRemove(step: AutomationStep) {
if (props.steps.length <= 1) return false;
if (isAutomationCondition(step)) return true;
return actionCount.value > 1;
}
function stepTitle(step: AutomationStep) {
if (isAutomationCondition(step)) {
const field = step.conditionField ?? 'has_person';
@@ -138,26 +205,29 @@ function stepHint(step: AutomationStep) {
return `${types[step.activityType ?? 'task'] ?? 'وظیفه'} — ${assignHint(step)}`;
}
function openAdd(index: number) {
function openAdd(item: { key: string; index: number; allow: AddAllow }) {
if (!canAdd.value) return;
addAt.value = addAt.value === index ? null : index;
addAt.value =
addAt.value?.key === item.key
? null
: { key: item.key, index: item.index, allow: item.allow };
}
function pickAction(action: AutomationAction) {
if (addAt.value == null) return;
emit('add', { kind: 'action', action }, addAt.value);
if (!addAt.value) return;
emit('add', { kind: 'action', action }, addAt.value.index);
addAt.value = null;
}
function pickCondition() {
if (addAt.value == null) return;
emit('add', { kind: 'condition' }, addAt.value);
if (!addAt.value) return;
emit('add', { kind: 'condition' }, addAt.value.index);
addAt.value = null;
}
function nodeClass(step: AutomationStep) {
if (isAutomationCondition(step)) return 'condition';
return step.action ?? 'create_activity';
function removeBranch(lane: BranchLane) {
addAt.value = null;
emit('removeRange', lane.start, lane.end);
}
</script>
@@ -179,81 +249,122 @@ function nodeClass(step: AutomationStep) {
</span>
</button>
<template v-for="(step, i) in steps" :key="step.id">
<div class="wire" :class="{ open: addAt === i }">
<template v-for="item in layout.main" :key="item.key">
<div
v-if="item.kind === 'wire'"
class="wire"
:class="{ open: addAt?.key === item.key, end: item.end }"
>
<button
v-if="canAdd"
class="plus"
:class="{ big: item.end }"
type="button"
title="افزودن گره"
@click.stop="openAdd(i)"
@click.stop="openAdd(item)"
>
<UiIcon name="plus" :size="14" />
<UiIcon name="plus" :size="item.end ? 16 : 14" />
</button>
</div>
<AutomationNode
v-else
:step="steps[item.index]"
:title="stepTitle(steps[item.index])"
:hint="stepHint(steps[item.index])"
:selected="selected === steps[item.index].id"
:removable="canRemove(item.index)"
@select="emit('select', steps[item.index].id)"
@remove="emit('remove', steps[item.index].id)"
/>
</template>
<div class="node-wrap">
<span v-if="isBranchStart(i)" class="branch-tag">شاخه</span>
<button
class="node action"
type="button"
:class="[nodeClass(step), { on: selected === step.id, 'branch-start': isBranchStart(i) }]"
@click="emit('select', step.id)"
>
<span class="icon">
<UiIcon
:name="isAutomationCondition(step) ? 'filter' : actionIcon(step.action ?? 'create_activity')"
:size="18"
<template v-if="forked">
<div class="wire stub" />
<div class="branches">
<div v-for="(lane, bi) in layout.branches" :key="lane.key" class="branch">
<div class="branch-tag">
<span>شاخه {{ toFaDigits(bi + 1) }}</span>
<button
v-if="canRemoveBranch(lane)"
type="button"
title="حذف این شاخه"
@click.stop="removeBranch(lane)"
>
<UiIcon name="trash" :size="12" />
</button>
</div>
<template v-for="item in lane.items" :key="item.key">
<div
v-if="item.kind === 'wire'"
class="wire"
:class="{ open: addAt?.key === item.key, end: item.end }"
>
<button
v-if="canAdd"
class="plus"
type="button"
:title="item.allow === 'condition' ? 'افزودن شرط («و»)' : 'افزودن گره'"
@click.stop="openAdd(item)"
>
<UiIcon name="plus" :size="14" />
</button>
</div>
<AutomationNode
v-else
:step="steps[item.index]"
:title="stepTitle(steps[item.index])"
:hint="stepHint(steps[item.index])"
:selected="selected === steps[item.index].id"
:removable="canRemove(item.index)"
@select="emit('select', steps[item.index].id)"
@remove="emit('remove', steps[item.index].id)"
/>
</span>
<span class="copy">
<span class="kind">{{ isAutomationCondition(step) ? 'شرط' : 'آنگاه' }}</span>
<strong>{{ stepTitle(step) }}</strong>
<em>{{ stepHint(step) }}</em>
</span>
</button>
</template>
</div>
<button
v-if="canRemove(step)"
class="trash"
class="add-branch"
type="button"
title="حذف"
@click.stop="emit('remove', step.id)"
:disabled="!canAddBranch"
:title="canAddBranch ? '' : 'ابتدا برای شاخهٔ آخر یک عمل بگذارید'"
@click.stop="emit('addBranch')"
>
<UiIcon name="trash" :size="14" />
<UiIcon name="plus" :size="14" />
شاخهٔ جدید
</button>
</div>
</template>
<div class="wire end" :class="{ open: addAt === steps.length }">
<button
v-if="canAdd"
class="plus big"
type="button"
title="افزودن گره بعدی"
@click.stop="openAdd(steps.length)"
>
<UiIcon name="plus" :size="16" />
</button>
</div>
</div>
<div v-if="addAt !== null" class="picker" @click.stop>
<p>گره بعدی را انتخاب کنید</p>
<div v-if="addAt" class="picker" @click.stop>
<p>
{{
addAt.allow === 'condition'
? 'شرط جدید با «و» به شرط‌های این شاخه اضافه می‌شود'
: 'گره بعدی را انتخاب کنید'
}}
</p>
<div class="picks">
<button type="button" class="condition" @click="pickCondition">
<button
v-if="addAt.allow !== 'action'"
type="button"
class="condition"
@click="pickCondition"
>
<UiIcon name="filter" :size="16" />
شرط
</button>
<button
v-for="action in AUTOMATION_ACTIONS"
:key="action"
type="button"
:class="action"
@click="pickAction(action)"
>
<UiIcon :name="actionIcon(action)" :size="16" />
{{ AUTOMATION_ACTION_LABELS[action] }}
</button>
<template v-if="addAt.allow !== 'condition'">
<button
v-for="action in AUTOMATION_ACTIONS"
:key="action"
type="button"
:class="action"
@click="pickAction(action)"
>
<UiIcon :name="actionIcon(action)" :size="16" />
{{ AUTOMATION_ACTION_LABELS[action] }}
</button>
</template>
</div>
<button class="cancel" type="button" @click="addAt = null">انصراف</button>
</div>
@@ -282,95 +393,26 @@ function nodeClass(step: AutomationStep) {
display: flex;
flex-direction: row;
align-items: center;
gap: 0;
min-width: max-content;
min-height: 16rem;
}
.node-wrap {
position: relative;
display: flex;
align-items: center;
}
.node {
width: 13.6rem;
display: flex;
align-items: center;
gap: 0.7rem;
text-align: start;
padding: 0.72rem 0.8rem;
border-radius: var(--radius);
border: 1px solid #d5ddd8;
background: #fff;
box-shadow: 0 10px 24px rgba(23, 33, 28, 0.06);
cursor: pointer;
color: inherit;
font: inherit;
}
.node.trigger {
width: 14.2rem;
min-height: 4.35rem;
display: flex;
align-items: center;
gap: 0.7rem;
text-align: start;
border-radius: 999px;
padding-inline: 1rem;
padding: 0.72rem 1rem;
border: 1px solid #fdba74;
background: #fff7ed;
border-color: #fdba74;
}
.node.action:not(.condition) {
border-radius: var(--radius);
border-inline-start-width: 5px;
}
.node.create_activity {
border-inline-start-color: #14b8a6;
}
.node.create_notification {
border-inline-start-color: #2563eb;
}
.node.add_loyalty_points {
border-inline-start-color: #7c3aed;
}
.node.condition {
width: 15.1rem;
min-height: 4.6rem;
padding-inline: 1.15rem;
border: 0;
border-radius: 0;
background: #fffbeb;
box-shadow: none;
clip-path: polygon(12% 0%, 88% 0%, 100% 50%, 88% 100%, 12% 100%, 0% 50%);
filter: drop-shadow(0 8px 14px rgba(180, 83, 9, 0.14));
}
.branch-tag {
position: absolute;
top: -1.35rem;
inset-inline-start: 50%;
transform: translateX(50%);
font-size: 0.68rem;
font-weight: 700;
color: #b45309;
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: 999px;
padding: 0.05rem 0.5rem;
white-space: nowrap;
}
.node.condition.branch-start {
background: #fef3c7;
}
.node.on {
border-color: #14b8a6;
box-shadow:
0 0 0 3px rgba(20, 184, 166, 0.18),
0 12px 24px rgba(15, 23, 42, 0.08);
box-shadow: 0 10px 24px rgba(23, 33, 28, 0.06);
cursor: pointer;
color: inherit;
font: inherit;
flex-shrink: 0;
}
.node.trigger.on {
@@ -380,46 +422,17 @@ function nodeClass(step: AutomationStep) {
0 12px 24px rgba(15, 23, 42, 0.08);
}
.node.condition.on {
box-shadow: none;
filter:
drop-shadow(0 0 0 3px rgba(217, 119, 6, 0.35))
drop-shadow(0 8px 14px rgba(180, 83, 9, 0.14));
}
.icon {
width: 2.35rem;
height: 2.35rem;
border-radius: var(--radius);
border-radius: 999px;
display: grid;
place-items: center;
flex-shrink: 0;
color: #0f766e;
background: #ecfdf8;
}
.node.trigger .icon {
border-radius: 999px;
color: #c2410c;
background: #ffedd5;
}
.node.create_notification .icon {
color: #1d4ed8;
background: #eff6ff;
}
.node.add_loyalty_points .icon {
color: #7c3aed;
background: #f5f3ff;
}
.node.condition .icon {
border-radius: var(--radius);
color: #b45309;
background: #ffedd5;
}
.copy {
display: grid;
gap: 0.08rem;
@@ -438,39 +451,6 @@ strong {
color: #0f172a;
}
em {
font-style: normal;
font-size: 0.72rem;
color: #64748b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trash {
position: absolute;
top: -0.45rem;
inset-inline-end: -0.45rem;
width: 1.55rem;
height: 1.55rem;
border-radius: 999px;
border: 1px solid #fecaca;
background: #fff;
color: #b91c1c;
display: grid;
place-items: center;
cursor: pointer;
opacity: 0;
pointer-events: none;
}
.node-wrap:hover .trash,
.node-wrap:focus-within .trash,
.node.on ~ .trash {
opacity: 1;
pointer-events: auto;
}
.wire {
position: relative;
width: 3.4rem;
@@ -490,6 +470,14 @@ em {
border-inline-end: 0;
}
.wire.stub {
width: 1.6rem;
}
.wire.stub::after {
display: none;
}
.plus {
position: absolute;
top: 50%;
@@ -522,17 +510,101 @@ em {
.wire.end {
width: 3.8rem;
background: repeating-linear-gradient(
90deg,
#cbd5e1 0 6px,
transparent 6px 10px
);
background: repeating-linear-gradient(90deg, #cbd5e1 0 6px, transparent 6px 10px);
}
.wire.end::after {
display: none;
}
/* انشعاب: یک خط عمودی که از وسط اولین شاخه تا وسط آخرین شاخه کشیده می‌شود */
.flow:has(.branches) {
padding-bottom: 2.6rem;
}
.branches {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.branch {
position: relative;
display: flex;
align-items: center;
padding-block: 1.3rem;
}
.branch::before {
content: '';
position: absolute;
inset-inline-start: 0;
width: 2px;
top: 0;
bottom: 0;
background: #cbd5e1;
}
.branch:first-of-type::before {
top: 50%;
}
.branch:last-of-type::before {
bottom: 50%;
}
.branch-tag {
position: absolute;
top: 0.05rem;
inset-inline-start: 0.9rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.68rem;
font-weight: 700;
color: #b45309;
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: 999px;
padding: 0.05rem 0.5rem;
white-space: nowrap;
}
.branch-tag button {
display: grid;
place-items: center;
border: 0;
background: none;
padding: 0;
color: #b91c1c;
cursor: pointer;
}
.add-branch {
position: absolute;
top: 100%;
inset-inline-start: 0.9rem;
display: inline-flex;
white-space: nowrap;
align-items: center;
gap: 0.35rem;
border: 1px dashed #f59e0b;
background: #fffbeb;
color: #b45309;
border-radius: 999px;
padding: 0.35rem 0.8rem;
font: inherit;
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
}
.add-branch:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.picker {
margin-top: 1rem;
background: #fff;
@@ -603,16 +675,8 @@ em {
}
@media (max-width: 700px) {
.node {
width: 12.4rem;
}
.node.trigger {
width: 13rem;
}
.node.condition {
width: 14rem;
}
}
</style>
@@ -37,6 +37,7 @@ const props = defineProps<{
forms: LeadFormDto[];
teams: TeamDto[];
activityTypeDefs: ActivityTypeDefDto[];
advanced: boolean;
}>();
const trigger = defineModel<AutomationTrigger>('trigger', { required: true });
@@ -53,7 +54,12 @@ const isCondition = computed(
() => !!selectedStep.value && isAutomationCondition(selectedStep.value),
);
const conditionFields = computed(() => conditionFieldsForTrigger(trigger.value));
const conditionFields = computed(() =>
conditionFieldsForTrigger(trigger.value).filter(
(f) =>
props.advanced || f !== 'form_field' || selectedStep.value?.conditionField === f,
),
);
const conditionOps = computed((): AutomationConditionOp[] =>
conditionOpsForField(selectedStep.value?.conditionField),
@@ -77,7 +83,14 @@ const needsConditionValue = computed(() => {
return op !== 'is_set' && op !== 'is_empty';
});
const assignModes = Object.keys(AUTOMATION_ASSIGN_MODE_LABELS) as AutomationAssignMode[];
const assignModes = computed(() =>
(Object.keys(AUTOMATION_ASSIGN_MODE_LABELS) as AutomationAssignMode[]).filter(
(m) =>
props.advanced ||
(m !== 'team_round_robin' && m !== 'users_round_robin') ||
selectedStep.value?.assignMode === m,
),
);
const dueUnits = [
{ value: 'now', label: 'همان لحظه (امروز)' },
@@ -197,7 +210,7 @@ watch(
<p v-if="trigger === 'lead_submitted' && !sourceId" class="hint">
برای شرط روی پاسخ‌های فرم، یک فرم مشخص را انتخاب کنید.
</p>
<label>
<label v-if="advanced || chainMode === 'branches'">
رفتار شرط‌ها
<select v-model="chainMode">
<option value="branches">{{ AUTOMATION_CHAIN_MODE_LABELS.branches }}</option>
@@ -205,9 +218,13 @@ watch(
</select>
</label>
<p v-if="chainMode === 'branches'" class="hint">
هر شرط یک شاخه را شروع می‌کند. شرط‌های پشت‌سرهم با «و» ترکیب می‌شوند و فقط
عمل‌های بعد از خودشان (تا شرط بعدی) را کنترل می‌کنند؛ مثلاً اگر دو گزینه تیک بخورد، هر دو
شاخه اجرا می‌شود.
شاخه‌ها روی بوم موازی نمایش داده می‌شوند و هر کدام جدا بررسی می‌شود. شرط‌های یک شاخه با «و»
ترکیب می‌شوند؛ مثلاً اگر دو گزینه تیک بخورد، هر دو شاخه اجرا می‌شود. برای شاخهٔ تازه از
دکمهٔ «شاخهٔ جدید» زیر بوم استفاده کنید.
</p>
<p v-else-if="!advanced" class="hint">
اتوماسیون ساده: شرط‌ها پشت‌سرهم بررسی می‌شوند و اولین شرط ناموفق بقیهٔ زنجیره را متوقف
می‌کند. اتوماسیون چندشاخه در نسخهٔ حرفه‌ای فعال است.
</p>
</template>
@@ -0,0 +1,205 @@
<script setup lang="ts">
import { computed } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import { isAutomationCondition, type AutomationStep } from '@mento/shared';
const props = defineProps<{
step: AutomationStep;
title: string;
hint: string;
selected: boolean;
removable: boolean;
}>();
const emit = defineEmits<{ select: []; remove: [] }>();
const isCondition = computed(() => isAutomationCondition(props.step));
const icon = computed(() => {
if (isCondition.value) return 'filter';
if (props.step.action === 'create_notification') return 'bell';
if (props.step.action === 'add_loyalty_points') return 'gift';
return 'flag';
});
const kindClass = computed(() =>
isCondition.value ? 'condition' : props.step.action ?? 'create_activity',
);
</script>
<template>
<div class="node-wrap">
<button
class="node"
type="button"
:class="[kindClass, { on: selected }]"
@click="emit('select')"
>
<span class="icon">
<UiIcon :name="icon" :size="18" />
</span>
<span class="copy">
<span class="kind">{{ isCondition ? 'شرط' : 'آنگاه' }}</span>
<strong>{{ title }}</strong>
<em>{{ hint }}</em>
</span>
</button>
<button
v-if="removable"
class="trash"
type="button"
title="حذف"
@click.stop="emit('remove')"
>
<UiIcon name="trash" :size="14" />
</button>
</div>
</template>
<style scoped>
.node-wrap {
position: relative;
display: flex;
align-items: center;
}
.node {
width: 13.6rem;
display: flex;
align-items: center;
gap: 0.7rem;
text-align: start;
padding: 0.72rem 0.8rem;
border-radius: var(--radius);
border: 1px solid #d5ddd8;
border-inline-start-width: 5px;
background: #fff;
box-shadow: 0 10px 24px rgba(23, 33, 28, 0.06);
cursor: pointer;
color: inherit;
font: inherit;
}
.node.create_activity {
border-inline-start-color: #14b8a6;
}
.node.create_notification {
border-inline-start-color: #2563eb;
}
.node.add_loyalty_points {
border-inline-start-color: #7c3aed;
}
.node.condition {
width: 15.1rem;
min-height: 4.6rem;
padding-inline: 1.15rem;
border: 0;
border-radius: 0;
background: #fffbeb;
box-shadow: none;
clip-path: polygon(12% 0%, 88% 0%, 100% 50%, 88% 100%, 12% 100%, 0% 50%);
filter: drop-shadow(0 8px 14px rgba(180, 83, 9, 0.14));
}
.node.on {
border-color: #14b8a6;
box-shadow:
0 0 0 3px rgba(20, 184, 166, 0.18),
0 12px 24px rgba(15, 23, 42, 0.08);
}
.node.condition.on {
background: #fde68a;
box-shadow: none;
}
.icon {
width: 2.35rem;
height: 2.35rem;
border-radius: var(--radius);
display: grid;
place-items: center;
flex-shrink: 0;
color: #0f766e;
background: #ecfdf8;
}
.node.create_notification .icon {
color: #1d4ed8;
background: #eff6ff;
}
.node.add_loyalty_points .icon {
color: #7c3aed;
background: #f5f3ff;
}
.node.condition .icon {
color: #b45309;
background: #ffedd5;
}
.copy {
display: grid;
gap: 0.08rem;
min-width: 0;
}
.kind {
font-size: 0.7rem;
font-weight: 700;
color: #94a3b8;
}
strong {
font-size: 0.88rem;
font-weight: 600;
color: #0f172a;
}
em {
font-style: normal;
font-size: 0.72rem;
color: #64748b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trash {
position: absolute;
top: -0.45rem;
inset-inline-end: -0.45rem;
width: 1.55rem;
height: 1.55rem;
border-radius: 999px;
border: 1px solid #fecaca;
background: #fff;
color: #b91c1c;
display: grid;
place-items: center;
cursor: pointer;
opacity: 0;
pointer-events: none;
}
.node-wrap:hover .trash,
.node-wrap:focus-within .trash,
.node.on ~ .trash {
opacity: 1;
pointer-events: auto;
}
@media (max-width: 700px) {
.node {
width: 12.4rem;
}
.node.condition {
width: 14rem;
}
}
</style>