Files
portal/app/resources/js/views/projects/NewProjectDialog.vue
T

771 lines
35 KiB
Vue
Raw Normal View History

<template>
<v-dialog :model-value="modelValue" max-width="720" @update:model-value="$emit('update:modelValue', $event)">
<v-card style="position: relative">
<DevIndexBadge
:index="mode === 'edit' ? 19 : 18"
:label="mode === 'edit' ? 'EditProjectDialog' : 'NewProjectDialog'"
:dialog-mode="true"
style="top: 12px; right: 12px"
/>
<v-card-title>{{ mode === 'edit' ? 'Редактирование проекта' : 'Новый проект' }}</v-card-title>
<v-card-text>
<!-- Косяк 04 / вариант C: для нового клиента без реквизитов шаг 1
(короткие реквизиты) прямо в визарде, без ухода на /settings. -->
<div v-if="step === 'requisites'" data-testid="req-step">
<div class="steps-head mb-3">
<span class="step-on">1. Реквизиты</span>
<span class="step-sep"></span>
<span class="step-off">2. Проект</span>
</div>
<v-alert type="info" variant="tonal" density="comfortable" class="mb-4">
Сначала короткие реквизиты компании без них нельзя создать первый проект. Это пара минут.
</v-alert>
<v-select
v-model="reqForm.subject_type"
:items="subjectTypeItems"
item-title="title"
item-value="value"
label="Тип лица"
density="comfortable"
class="ld-input-quiet"
data-testid="req-subject-type"
:error-messages="reqErrors.subject_type"
/>
<v-text-field
v-model="reqForm.contact_name"
label="Контактное имя"
density="comfortable"
class="ld-input-quiet"
data-testid="req-contact-name"
:error-messages="reqErrors.contact_name"
/>
<v-text-field
v-model="reqForm.contact_phone"
label="Контактный телефон"
hint="Примем в любом формате — приведём сами"
persistent-hint
density="comfortable"
class="ld-input-quiet"
data-testid="req-contact-phone"
:error-messages="reqErrors.contact_phone"
/>
<v-text-field
v-if="reqForm.subject_type === 'legal_entity' || reqForm.subject_type === 'sole_proprietor'"
v-model="reqForm.inn"
label="ИНН"
density="comfortable"
class="ld-input-quiet mt-2"
data-testid="req-inn"
:error-messages="reqErrors.inn"
/>
<v-alert
v-if="reqGeneralError"
type="error"
variant="tonal"
density="compact"
class="mt-2"
>
{{ reqGeneralError }}
</v-alert>
</div>
<template v-else>
<!-- Баннер-объявление: когда пойдут лиды (спека 2026-06-22-project-source-edit-lock-ux). -->
<v-alert
v-if="mode !== 'edit'"
type="info"
variant="tonal"
density="comfortable"
class="mb-4"
data-testid="np-lead-banner"
>
📣 Лидерра поставит проект в сбор сразу после создания. Первые лиды пойдут с {{ leadStart }}.
</v-alert>
<div class="d-flex align-center mb-3 text-body-2 text-medium-emphasis" data-testid="np-boost-hint">
<span>Как увеличить количество сделок</span>
<v-tooltip
text="Ваш лимит распределяется на нескольких поставщиков равномерно. Даже если лимит не выбирается полностью, просто увеличьте лимит — и сделок придёт больше."
location="top"
max-width="280"
>
<template #activator="{ props: tip }">
<v-icon
v-bind="tip"
size="14"
class="src-hint ml-1"
icon="mdi-help-circle-outline"
aria-label="Как увеличить количество сделок"
tabindex="0"
/>
</template>
</v-tooltip>
</div>
<div class="d-flex align-center mb-1 text-body-2 text-medium-emphasis">
<span>Откуда собирать заявки</span>
<v-tooltip
text="Сайт — заявки с сайтов вашей ниши. Звонок — звонки на номер. СМС — заявки по СМС. Выберите подходящий источник."
location="top"
max-width="280"
>
<template #activator="{ props: tip }">
<v-icon
v-bind="tip"
size="14"
class="src-hint ml-1"
icon="mdi-help-circle-outline"
aria-label="Чем отличаются источники"
tabindex="0"
/>
</template>
</v-tooltip>
</div>
<v-tabs v-model="form.signal_type" :disabled="mode === 'edit'" color="primary">
<v-tab value="site"><v-icon start>mdi-web</v-icon>Сайт</v-tab>
<v-tab value="call"><v-icon start>mdi-phone</v-icon>Звонок</v-tab>
<v-tab value="sms"><v-icon start>mdi-message-text</v-icon>СМС</v-tab>
</v-tabs>
<v-tabs-window v-model="form.signal_type" class="mt-4">
<v-tabs-window-item value="site">
<div class="source-hint text-caption text-medium-emphasis mb-2">
Укажите сайт в вашей нише, по теме которого нужны заявки приведём вам таких же клиентов
</div>
<v-text-field
v-model="form.signal_identifier"
label="Сайт, с которого нужны заявки"
placeholder="okna-moskva.ru"
class="ld-input-quiet"
data-testid="np-source-identifier"
:error-messages="errors.signal_identifier"
/>
</v-tabs-window-item>
<v-tabs-window-item value="call">
<div class="source-hint text-caption text-medium-emphasis mb-2">
Укажите телефон в вашей нише, по звонкам на который соберём вам заявки
</div>
<v-text-field
v-model="form.signal_identifier"
label="Телефон, по которому идут клиенты"
placeholder="79161234567"
hint="Можно вводить с +7, 8, скобками и пробелами — приведём к виду 79161234567"
persistent-hint
class="ld-input-quiet"
data-testid="np-source-identifier"
:error-messages="errors.signal_identifier"
/>
</v-tabs-window-item>
<v-tabs-window-item value="sms">
<div class="source-hint text-caption text-medium-emphasis mb-2">
Источник отправитель SMS и (опционально) ключевое слово в тексте
</div>
<v-combobox
v-model="form.sms_senders"
label="Отправители (до 11 символов каждый)"
multiple
chips
clearable
:error-messages="errors.sms_senders"
/>
<v-text-field
v-model="form.sms_keyword"
label="Ключевое слово (опционально)"
hint="Если пусто — проект подключится только к B3"
class="ld-input-quiet"
:error-messages="errors.sms_keyword"
/>
</v-tabs-window-item>
</v-tabs-window>
<!-- Эпик 3.1: объявление о вступлении правок количества/региона/дней (без блокировки). -->
<v-alert
v-if="showAppliesFromBanner"
type="info"
variant="tonal"
density="comfortable"
class="mt-3"
data-testid="np-applies-from-banner"
>
Ближайший сбор уже идёт по текущим настройкам. Новые количество, регионы
и дни приёма вступят в силу с {{ appliesFromDate }}.
</v-alert>
<v-divider class="my-4" />
<v-text-field
v-model="form.name"
label="Название проекта"
class="ld-input-quiet"
:error-messages="errors.name"
/>
<v-text-field
v-model.number="form.daily_limit_target"
label="Лимит лидов в день"
type="number"
min="1"
max="10000"
class="ld-input-quiet"
data-testid="np-limit"
hint="Сколько заявок в день вы готовы принимать и оплачивать. Можно менять в любой момент."
persistent-hint
:error-messages="errors.daily_limit_target"
/>
<v-autocomplete
v-model:search="regionSearch"
:model-value="form.regions"
:items="selectableRegions"
item-title="name"
item-value="code"
label="Регионы"
:disabled="vsyaRfConfirmed"
multiple
chips
closable-chips
clearable
density="comfortable"
class="ld-input-quiet"
data-testid="regions-autocomplete"
:error-messages="errors.regions"
@update:model-value="onRegionsChange"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps">
<template #subtitle>
{{ FEDERAL_DISTRICT_NAMES[item.raw.federalDistrict] || '' }}
</template>
</v-list-item>
</template>
</v-autocomplete>
<v-checkbox
:model-value="vsyaRf"
label="Вся РФ (все регионы)"
density="comfortable"
hide-details
data-testid="vsya-rf-checkbox"
@update:model-value="(v: boolean | null) => (v ? chooseVsyaRf() : cancelVsyaRf())"
/>
<v-alert
v-if="vsyaRf"
type="info"
variant="tonal"
density="compact"
class="mt-2"
data-testid="vsya-rf-confirmed"
>
Проект будет получать лиды по всей России — по всем субъектам страны.
</v-alert>
<v-alert
v-if="generalError"
type="error"
variant="tonal"
density="compact"
class="mt-3"
closable
@click:close="generalError = null"
>
{{ generalError }}
</v-alert>
<div class="mt-3">
<span class="text-caption">Дни недели приёма</span>
<v-btn-toggle v-model="selectedDays" multiple density="comfortable" class="mt-1">
<v-btn v-for="(day, i) in dayLabels" :key="i" :value="i">{{ day }}</v-btn>
</v-btn-toggle>
<div class="mt-1">
<v-btn size="small" variant="text" @click="setWorkdays('weekdays')">Будни</v-btn>
<v-btn size="small" variant="text" @click="setWorkdays('all')">Все дни</v-btn>
</div>
</div>
</template>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="close">Отмена</v-btn>
<v-btn
v-if="step === 'requisites'"
color="primary"
:loading="reqSaving"
data-testid="req-next-btn"
@click="saveRequisites"
>
Далее: к проекту
</v-btn>
<v-btn v-else color="primary" :loading="saving" data-testid="submit-btn" @click="submit">
{{ mode === 'edit' ? 'Сохранить' : 'Создать' }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<ProjectLimitOverloadDialog
v-model="overloadOpen"
:payload="overloadPayload"
@save-blocked="onOverloadSaveBlocked"
@set-zero="onOverloadSetZero"
/>
<!-- Эпик 3.2: подтверждение смены источника на залоченном проекте. -->
<v-dialog v-model="sourceConfirmOpen" max-width="430">
<v-card data-testid="np-source-change-confirm">
<v-card-title>Сменить источник?</v-card-title>
<v-card-text>{{ sourceConfirmText }}</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" data-testid="np-source-confirm-no" @click="cancelSourceChange">Отмена</v-btn>
<v-btn color="primary" data-testid="np-source-confirm-yes" @click="confirmSourceChange">
Сменить источник
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch, computed } from 'vue';
import { apiClient, ensureCsrfCookie, extractErrorMessage } from '../../api/client';
import { getRequisites, updateRequisites, type Requisites } from '../../api/requisites';
import { firstLeadDate, formatLeadDate } from '../../utils/leadDate';
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
import type { Project } from '../../stores/projectsStore';
import DevIndexBadge from '../../components/DevIndexBadge.vue';
import ProjectLimitOverloadDialog from '../../components/projects/ProjectLimitOverloadDialog.vue';
const selectableRegions = REGIONS.filter((r) => r.code !== 0);
// Дата старта лидов для баннера нового проекта (правило слепка 18:00 МСК).
const leadStart = computed(() => firstLeadDate());
// Эпик 3 (редизайн 2026-06-25): в edit-режиме источник редактируем (не readonly).
// Замок заменён объявлением о вступлении + подтверждением смены источника —
// раздача доводит хвост по старому источнику через слепок.
const sourceLocked = computed(() => props.mode === 'edit' && props.project?.source_locked === true);
const slepokFieldsDirty = computed(() => {
const p = props.project;
if (!p) return false;
const regionsChanged = JSON.stringify([...form.regions].sort()) !== JSON.stringify([...(p.regions ?? [])].sort());
return (
form.daily_limit_target !== p.daily_limit_target ||
form.delivery_days_mask !== (p.delivery_days_mask ?? 127) ||
regionsChanged
);
});
const showAppliesFromBanner = computed(() => sourceLocked.value && slepokFieldsDirty.value);
// Time-aware дата вступления (правило слепка 18:00 МСК) — объявление актуально по времени суток.
const appliesFromDate = computed(() => firstLeadDate());
const sourceDirty = computed(() => {
const p = props.project;
if (!p) return false;
if (form.signal_type === 'sms') {
return (
JSON.stringify(form.sms_senders) !== JSON.stringify(p.sms_senders ?? []) ||
form.sms_keyword !== (p.sms_keyword ?? '')
);
}
return form.signal_identifier !== (p.signal_identifier ?? '');
});
const sourceConfirmOpen = ref(false);
const sourceConfirmText = computed(() => {
// Эпик 6.3: единый текст правила из API (ProjectRuleMessages). Fallback — локальный.
const fromApi = props.project?.source_change_message;
if (fromApi) {
return `Мы уже ведём сбор на завтра. ${fromApi} Подтвердите смену источника.`;
}
const d = formatLeadDate(props.project?.source_unlock_at);
return (
'Мы уже ведём сбор на завтра. Лиды по старому источнику придут завтра в любом случае. ' +
(d ? `Послезавтра (после ${d}) — уже по новому. ` : '') +
'Подтвердите смену источника.'
);
});
function cancelSourceChange(): void {
sourceConfirmOpen.value = false;
}
async function confirmSourceChange(): Promise<void> {
sourceConfirmOpen.value = false;
await persist();
}
const props = defineProps<{
modelValue: boolean;
mode?: 'create' | 'edit';
project?: Project | null;
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
saved: [appliesFrom: string | null];
}>();
// Plan 6: regions = subject codes (1..89) — backend dual-writes region_mask/region_mode.
// Пустой массив = вся РФ.
const form = reactive({
name: '',
signal_type: 'site' as 'site' | 'call' | 'sms',
signal_identifier: '',
sms_senders: [] as string[],
sms_keyword: '',
daily_limit_target: 50,
regions: [] as number[],
delivery_days_mask: 127,
});
const errors = reactive<Record<string, string[]>>({});
const saving = ref(false);
const generalError = ref<string | null>(null);
// Косяк 04 / вариант C: визард создания. step='requisites' — новый клиент без
// реквизитов заполняет их прямо здесь (шаг 1), затем step='project' (шаг 2).
// Реквизиты неполны ⟺ проектов ещё нет (гейт G1/SP2 не пускал без них), поэтому
// решение по шагу принимаем по light-complete, без отдельного счётчика проектов.
const step = ref<'requisites' | 'project'>('project');
const reqForm = reactive({ subject_type: '', contact_name: '', contact_phone: '', inn: '' });
const reqErrors = reactive<Record<string, string[]>>({});
const reqSaving = ref(false);
const reqGeneralError = ref<string | null>(null);
const subjectTypeItems = [
{ value: 'individual', title: 'Физлицо' },
{ value: 'sole_proprietor', title: 'ИП' },
{ value: 'legal_entity', title: 'Юрлицо' },
];
// Зеркало RequisitesService::isLightComplete — тип лица + имя + телефон (+ ИНН для юр/ИП).
function reqLightComplete(r: Requisites | null): boolean {
if (!r || !r.subject_type || !r.contact_name?.trim() || !r.contact_phone?.trim()) {
return false;
}
if ((r.subject_type === 'legal_entity' || r.subject_type === 'sole_proprietor') && !r.inn?.trim()) {
return false;
}
return true;
}
async function initCreateStep(): Promise<void> {
step.value = 'project';
try {
const r = await getRequisites();
if (!reqLightComplete(r)) {
step.value = 'requisites';
if (r) {
reqForm.subject_type = r.subject_type ?? '';
reqForm.contact_name = r.contact_name ?? '';
reqForm.contact_phone = r.contact_phone ?? '';
reqForm.inn = r.inn ?? '';
}
}
} catch {
// fail-open: при ошибке чтения не блокируем — бэкенд-гейт защитит на submit (422 → шаг 1).
step.value = 'project';
}
}
async function saveRequisites(): Promise<void> {
Object.keys(reqErrors).forEach((k) => delete reqErrors[k]);
reqGeneralError.value = null;
reqSaving.value = true;
try {
await updateRequisites({
subject_type: reqForm.subject_type as Requisites['subject_type'],
contact_name: reqForm.contact_name,
contact_phone: reqForm.contact_phone,
inn: reqForm.inn || null,
});
step.value = 'project';
} catch (e: unknown) {
const err = e as { response?: { status?: number; data?: { errors?: Record<string, string[]> } } };
if (err.response?.status === 422 && err.response.data?.errors) {
Object.assign(reqErrors, err.response.data.errors);
} else {
reqGeneralError.value = extractErrorMessage(e);
}
} finally {
reqSaving.value = false;
}
}
// Spec C §6.2: префлайт баланса — диалог перегрузки лимита по 409.
interface OverloadPayloadShape {
current_balance_rub: string;
current_capacity_leads: number;
would_be_required_leads: number;
deficit_leads: number;
}
const overloadOpen = ref(false);
const overloadPayload = ref<OverloadPayloadShape | null>(null);
// Plan 4 Task 4 + косяк 03: явная «Вся РФ» одной галочкой (защита от случайной
// «всей РФ» сохранена — нужен осознанный клик), без отдельной кнопки подтверждения.
// vsyaRf — чекбокс выбран; vsyaRfConfirmed — true сразу при установке галочки.
// На бэке regions=[] (Вся РФ) и «забыл» неотличимы → гейт намеренно UI-only.
const vsyaRf = ref(false);
const vsyaRfConfirmed = ref(false);
function chooseVsyaRf(): void {
vsyaRf.value = true;
confirmVsyaRf();
}
function confirmVsyaRf(): void {
vsyaRfConfirmed.value = true;
form.regions = []; // Вся РФ → пустой массив субъектов
delete errors.regions; // косяк 03: галочка сразу снимает зависшую ошибку
}
function cancelVsyaRf(): void {
vsyaRf.value = false;
vsyaRfConfirmed.value = false;
}
function onRegionsChange(codes: number[]): void {
form.regions = Array.isArray(codes) ? codes : [];
// После выбора субъекта очищаем строку поиска (иначе набранное «красно»
// остаётся в поле рядом с чипом).
regionSearch.value = '';
if (form.regions.length > 0) {
// Взаимоисключение: выбор конкретных субъектов снимает «Вся РФ».
vsyaRf.value = false;
vsyaRfConfirmed.value = false;
delete errors.regions;
}
}
const regionSearch = ref('');
const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
const selectedDays = ref<number[]>([0, 1, 2, 3, 4, 5, 6]);
watch(selectedDays, (days) => {
form.delivery_days_mask = days.reduce((acc, d) => acc | (1 << d), 0);
});
function setWorkdays(preset: 'weekdays' | 'all') {
if (preset === 'weekdays') selectedDays.value = [0, 1, 2, 3, 4];
else selectedDays.value = [0, 1, 2, 3, 4, 5, 6];
}
watch(
() => props.modelValue,
(open) => {
if (open) generalError.value = null;
if (open) {
delete errors.regions;
}
if (open && props.mode === 'edit' && props.project) {
step.value = 'project'; // редактирование — всегда форма проекта, без шага реквизитов
Object.assign(form, props.project);
form.regions = Array.isArray(props.project.regions) ? [...props.project.regions] : [];
const days: number[] = [];
for (let i = 0; i < 7; i++) if (form.delivery_days_mask & (1 << i)) days.push(i);
selectedDays.value = days;
// Существующий проект с пустыми регионами = «Вся РФ» (предзаполняем подтверждённым).
vsyaRf.value = form.regions.length === 0;
vsyaRfConfirmed.value = form.regions.length === 0;
} else if (open) {
Object.assign(form, {
name: '',
signal_type: 'site',
signal_identifier: '',
sms_senders: [],
sms_keyword: '',
daily_limit_target: 50,
regions: [],
delivery_days_mask: 127,
});
selectedDays.value = [0, 1, 2, 3, 4, 5, 6];
vsyaRf.value = false;
vsyaRfConfirmed.value = false;
// Косяк 04 / вариант C: решаем, нужен ли шаг реквизитов (новый клиент без них).
Object.assign(reqForm, { subject_type: '', contact_name: '', contact_phone: '', inn: '' });
Object.keys(reqErrors).forEach((k) => delete reqErrors[k]);
reqGeneralError.value = null;
void initCreateStep();
}
},
{ immediate: true },
);
async function persist(extra: Record<string, unknown> = {}): Promise<void> {
saving.value = true;
try {
await ensureCsrfCookie();
const body: Record<string, unknown> = { ...form, ...extra };
// M (балансовый блок findings): для site/call не отправляем sms-поля. Форма
// держит sms_senders=null/[] и sms_keyword, а UpdateProjectRequest валидирует
// sms_senders как array|min:1 → null давал молчаливый 422 (поле sms на форме
// site/call не отрисовано, ошибка не видна). Заодно не триггерим зря snapshot-guard.
if (form.signal_type !== 'sms') {
delete body.sms_senders;
delete body.sms_keyword;
}
let appliesFrom: string | null = null;
if (props.mode === 'edit' && props.project) {
const { data } = await apiClient.patch(`/api/projects/${props.project.id}`, body);
// Backend кладёт applies_from только когда правка задела slepok-чувствительные поля.
appliesFrom = data?.data?.applies_from ?? null;
} else {
await apiClient.post('/api/projects', body);
// Create НЕ генерирует applies_from (новый проект сразу попадает в snapshot).
}
overloadOpen.value = false;
emit('saved', appliesFrom);
close();
} catch (e: unknown) {
const err = e as {
response?: { status?: number; data?: { error?: string; errors?: Record<string, string[]> } };
};
// G1/SP3b + косяк 04: гейт первого проекта — показываем шаг реквизитов
// прямо в этом окне (запасной путь, обычно шаг 1 показан ещё при открытии).
if (err.response?.status === 422 && err.response.data?.error === 'requisites_required') {
step.value = 'requisites';
void initCreateStep();
}
// Spec C §6.2: лимит превышает баланс — открываем диалог перегрузки.
else if (err.response?.status === 409 && err.response.data?.error === 'balance_insufficient') {
overloadPayload.value = err.response.data as OverloadPayloadShape;
overloadOpen.value = true;
} else if (err.response?.status === 422 && err.response.data?.errors) {
Object.assign(errors, err.response.data.errors);
} else {
generalError.value = extractErrorMessage(e);
}
} finally {
saving.value = false;
}
}
async function submit() {
generalError.value = null;
Object.keys(errors).forEach((k) => delete errors[k]);
// F-NEWPROJECT-1: клиентская валидация обязательных полей — показываем ошибку
// сразу, не дожидаясь ответа сервера (сервер всё равно валидирует повторно).
const clientErrors: Record<string, string[]> = {};
if (!form.name.trim()) {
clientErrors.name = ['Введите название проекта'];
}
if (form.signal_type === 'sms') {
if (form.sms_senders.length === 0) {
clientErrors.sms_senders = ['Укажите хотя бы одного отправителя'];
}
} else if (!form.signal_identifier.trim()) {
clientErrors.signal_identifier = [
form.signal_type === 'call' ? 'Введите номер в вашей нише' : 'Введите домен сайта в вашей нише',
];
}
// Гейт обязательного региона: нужны либо субъекты, либо подтверждённая «Вся РФ».
if (form.regions.length === 0 && !vsyaRfConfirmed.value) {
clientErrors.regions = ['Выберите регион или подтвердите «Вся РФ»'];
}
if (Object.keys(clientErrors).length > 0) {
Object.assign(errors, clientErrors);
return;
}
// Эпик 3.2: смена источника на залоченном проекте — через подтверждение.
if (sourceLocked.value && sourceDirty.value) {
sourceConfirmOpen.value = true;
return;
}
await persist();
}
// Spec C §6.2 — исходы диалога перегрузки лимита.
async function onOverloadSaveBlocked(): Promise<void> {
await persist({ force_save_blocked: true });
}
async function onOverloadSetZero(): Promise<void> {
form.daily_limit_target = 0;
overloadOpen.value = false;
await persist();
}
function close() {
emit('update:modelValue', false);
}
defineExpose({
chooseVsyaRf,
confirmVsyaRf,
cancelVsyaRf,
onRegionsChange,
vsyaRf,
vsyaRfConfirmed,
form,
submit,
step,
reqForm,
saveRequisites,
});
</script>
<style scoped>
.source-hint {
line-height: 1.4;
padding: 4px 2px;
}
.steps-head {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
}
.steps-head .step-on {
color: var(--liderra-teal, #0f6e56);
font-weight: 650;
}
.steps-head .step-off {
color: #6b6f72;
}
.steps-head .step-sep {
flex: 1;
height: 1px;
background: var(--liderra-line, #e6e2d6);
}
.ld-input-quiet :deep(.v-field) {
border-radius: var(--radius-8);
}
.ld-input-quiet :deep(.v-field__outline__start),
.ld-input-quiet :deep(.v-field__outline__end),
.ld-input-quiet :deep(.v-field__outline__notch::before),
.ld-input-quiet :deep(.v-field__outline__notch::after) {
border-color: var(--liderra-line);
opacity: 1;
transition: border-color 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ld-input-quiet :deep(.v-field:hover .v-field__outline__start),
.ld-input-quiet :deep(.v-field:hover .v-field__outline__end),
.ld-input-quiet :deep(.v-field:hover .v-field__outline__notch::before),
.ld-input-quiet :deep(.v-field:hover .v-field__outline__notch::after) {
border-color: var(--liderra-line-strong);
opacity: 1;
}
.ld-input-quiet :deep(.v-field--focused .v-field__outline__start),
.ld-input-quiet :deep(.v-field--focused .v-field__outline__end),
.ld-input-quiet :deep(.v-field--focused .v-field__outline__notch::before),
.ld-input-quiet :deep(.v-field--focused .v-field__outline__notch::after) {
border-color: var(--liderra-teal);
opacity: 1;
}
.ld-input-quiet :deep(.v-field--error:not(.v-field--disabled) .v-field__outline__start),
.ld-input-quiet :deep(.v-field--error:not(.v-field--disabled) .v-field__outline__end),
.ld-input-quiet :deep(.v-field--error:not(.v-field--disabled) .v-field__outline__notch::before),
.ld-input-quiet :deep(.v-field--error:not(.v-field--disabled) .v-field__outline__notch::after) {
border-color: currentColor;
opacity: 1;
}
</style>