Files
CRM_Montzar/apps/web/src/components/activities/ActivityCommentSheet.vue
T

1026 lines
25 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import { api } from '@/lib/api';
import {
calendarDaysBetween,
formatClockFa,
formatJalaliDay,
} from '@/lib/jalali';
import { prepareActivityAttachment } from '@/lib/activity-attachment';
import { startVoiceRecorder, type VoiceRecorder } from '@/lib/voice-note';
import { appendSpokenText } from '@/lib/spoken-text';
import UiIcon from '@/components/UiIcon.vue';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
import type { ActivityDto, ActivityType, OrganizationDto, OrgUserDto } from '@mento/shared';
const props = defineProps<{
activity: ActivityDto;
saving?: boolean;
save: (payload: { text: string; photoOnly: boolean }) => Promise<void>;
}>();
const emit = defineEmits<{
close: [];
}>();
const typeIcon: Record<ActivityType, 'phone' | 'users' | 'check' | 'chat'> = {
call: 'phone',
meeting: 'users',
task: 'check',
note: 'chat',
};
const PHOTO_LIMIT = 8;
const VOICE_LIMIT = 5;
const FILE_LIMIT = 8;
type PendingPhoto = { localId: string; name: string; dataUrl: string; file: File };
type PendingVoice = { localId: string; name: string; url: string; file: File };
type PendingFile = { localId: string; name: string; file: File };
const comment = ref('');
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const pendingPhotos = ref<PendingPhoto[]>([]);
const pendingVoices = ref<PendingVoice[]>([]);
const pendingFiles = ref<PendingFile[]>([]);
const uploadedCount = ref(0);
const photoBusy = ref(false);
const uploading = ref(false);
const photoError = ref('');
const fileInput = ref<HTMLInputElement | null>(null);
const voiceNoteOn = ref(false);
const voiceNoteSeconds = ref(0);
let voiceNote: VoiceRecorder | null = null;
const orgName = ref('سازمان');
const users = ref<OrgUserDto[]>([]);
const mentionOpen = ref(false);
const voiceTip = ref(localStorage.getItem('mento.voiceTipSeen') !== '1');
const voiceError = ref('');
const recording = ref(false);
const voiceBusy = ref(false);
const subtitle = computed(() => {
const a = props.activity;
return a.deal?.title || a.company?.name || a.person?.fullName || '';
});
const dueLabel = computed(() => {
const due = props.activity.dueAt;
if (!due) return 'بدون موعد';
const d = new Date(due);
const days = calendarDaysBetween(d, new Date());
const clock = formatClockFa(d);
if (days === 0) return `امروز ${clock}`;
if (days === 1) return `دیروز ${clock}`;
if (days === -1) return `فردا ${clock}`;
return `${formatJalaliDay(d)} ${clock}`;
});
const now = ref(new Date());
let clockTimer: ReturnType<typeof setInterval> | undefined;
const nowLabel = computed(
() => `${formatJalaliDay(now.value)} ساعت ${formatClockFa(now.value)}`,
);
function imageCount(kind: 'image' | 'audio' | 'file') {
return (
props.activity.images?.filter((image) => {
const mime = image.mimeType.toLowerCase();
if (kind === 'audio') return mime.startsWith('audio/');
if (kind === 'image') return mime.startsWith('image/');
return !mime.startsWith('audio/') && !mime.startsWith('image/');
}).length ?? 0
);
}
const canSubmit = computed(
() =>
(comment.value.trim().length > 0 ||
pendingPhotos.value.length > 0 ||
pendingVoices.value.length > 0 ||
pendingFiles.value.length > 0 ||
uploadedCount.value > 0) &&
!props.saving &&
!voiceBusy.value &&
!photoBusy.value &&
!uploading.value &&
!voiceNoteOn.value,
);
function onVoiceTranscript(text: string) {
comment.value = appendSpokenText(comment.value, text);
}
function onVoiceBusy(active: boolean) {
voiceBusy.value = active;
if (active && voiceTip.value) {
voiceTip.value = false;
localStorage.setItem('mento.voiceTipSeen', '1');
}
}
function apiMessage(error: unknown, fallback: string) {
const raw = (error as { response?: { data?: { message?: string | string[] } } }).response?.data
?.message;
if (Array.isArray(raw)) return raw.join('، ');
if (typeof raw === 'string' && raw.trim()) return raw;
return fallback;
}
async function onFilesPicked(event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
input.value = '';
if (!files.length) return;
photoError.value = '';
for (const file of files) {
photoBusy.value = true;
try {
const prepared = await prepareActivityAttachment(file);
if (prepared.kind === 'image') {
if (imageCount('image') + pendingPhotos.value.length >= PHOTO_LIMIT) {
photoError.value = `حداکثر ${PHOTO_LIMIT} عکس برای هر فعالیت.`;
continue;
}
pendingPhotos.value = [
...pendingPhotos.value,
{ localId: crypto.randomUUID(), name: prepared.name, dataUrl: prepared.previewUrl, file: prepared.file },
];
} else if (prepared.kind === 'audio') {
if (imageCount('audio') + pendingVoices.value.length >= VOICE_LIMIT) {
URL.revokeObjectURL(prepared.previewUrl);
photoError.value = `حداکثر ${VOICE_LIMIT} صوت برای هر فعالیت.`;
continue;
}
pendingVoices.value = [
...pendingVoices.value,
{ localId: crypto.randomUUID(), name: prepared.name, url: prepared.previewUrl, file: prepared.file },
];
} else if (imageCount('file') + pendingFiles.value.length >= FILE_LIMIT) {
photoError.value = `حداکثر ${FILE_LIMIT} فایل برای هر فعالیت.`;
} else {
pendingFiles.value = [
...pendingFiles.value,
{ localId: crypto.randomUUID(), name: prepared.name, file: prepared.file },
];
}
} catch (error) {
photoError.value = error instanceof Error ? error.message : 'افزودن فایل ناموفق بود.';
} finally {
photoBusy.value = false;
}
}
}
function removePendingPhoto(localId: string) {
pendingPhotos.value = pendingPhotos.value.filter((photo) => photo.localId !== localId);
}
function removePendingVoice(localId: string) {
const voice = pendingVoices.value.find((item) => item.localId === localId);
if (voice) URL.revokeObjectURL(voice.url);
pendingVoices.value = pendingVoices.value.filter((item) => item.localId !== localId);
}
function removePendingFile(localId: string) {
pendingFiles.value = pendingFiles.value.filter((item) => item.localId !== localId);
}
function voiceClock(total: number) {
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
}
function addVoiceNote(file: File) {
if (imageCount('audio') + pendingVoices.value.length >= VOICE_LIMIT) {
photoError.value = `حداکثر ${VOICE_LIMIT} صوت برای هر فعالیت.`;
return;
}
pendingVoices.value = [
...pendingVoices.value,
{ localId: crypto.randomUUID(), name: file.name, url: URL.createObjectURL(file), file },
];
}
async function toggleVoiceNote() {
photoError.value = '';
if (voiceNote) {
const handle = voiceNote;
voiceNote = null;
voiceNoteOn.value = false;
try {
addVoiceNote(await handle.stop());
} catch (error) {
photoError.value = error instanceof Error ? error.message : 'ضبط صوت ناموفق بود.';
}
return;
}
try {
voiceNoteSeconds.value = 0;
voiceNote = await startVoiceRecorder({
onTick: (seconds) => {
voiceNoteSeconds.value = seconds;
},
onAutoStop: (file) => {
voiceNote = null;
voiceNoteOn.value = false;
addVoiceNote(file);
},
});
voiceNoteOn.value = true;
} catch (error) {
photoError.value = error instanceof Error ? error.message : 'ضبط صوت ناموفق بود.';
}
}
async function uploadQueue(items: Array<{ localId: string; name: string; file: File }>, drop: (localId: string) => void) {
for (const item of items) {
const form = new FormData();
form.append('file', item.file, item.name);
await api.post(`/activities/${props.activity.id}/images`, form);
drop(item.localId);
uploadedCount.value += 1;
}
}
async function uploadPendingPhotos() {
await uploadQueue([...pendingPhotos.value], (localId) => {
pendingPhotos.value = pendingPhotos.value.filter((item) => item.localId !== localId);
});
await uploadQueue([...pendingVoices.value], (localId) => {
const voice = pendingVoices.value.find((item) => item.localId === localId);
if (voice) URL.revokeObjectURL(voice.url);
pendingVoices.value = pendingVoices.value.filter((item) => item.localId !== localId);
});
await uploadQueue([...pendingFiles.value], (localId) => {
pendingFiles.value = pendingFiles.value.filter((item) => item.localId !== localId);
});
}
async function submit() {
const text = comment.value.trim();
if (!canSubmit.value) return;
photoError.value = '';
uploading.value = true;
const hadPhoto =
pendingPhotos.value.length > 0 ||
pendingVoices.value.length > 0 ||
pendingFiles.value.length > 0 ||
uploadedCount.value > 0;
try {
await uploadPendingPhotos();
await props.save({ text, photoOnly: hadPhoto && !text });
} catch (error) {
photoError.value =
pendingPhotos.value.length > 0
? apiMessage(error, 'افزودن فایل ناموفق بود.')
: apiMessage(error, 'ثبت نظر ناموفق بود.');
} finally {
uploading.value = false;
}
}
function close() {
emit('close');
}
function insertMention(name: string) {
const token = `@${name} `;
const el = textareaRef.value;
if (!el) {
comment.value += token;
mentionOpen.value = false;
return;
}
const start = el.selectionStart ?? comment.value.length;
const end = el.selectionEnd ?? start;
comment.value = `${comment.value.slice(0, start)}${token}${comment.value.slice(end)}`;
mentionOpen.value = false;
void nextTick(() => {
el.focus();
const pos = start + token.length;
el.setSelectionRange(pos, pos);
});
}
function onKeydown(ev: KeyboardEvent) {
if (ev.key === 'Escape') {
ev.preventDefault();
if (mentionOpen.value) {
mentionOpen.value = false;
return;
}
if (recording.value) return;
close();
}
}
onMounted(async () => {
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', onKeydown);
clockTimer = setInterval(() => {
now.value = new Date();
}, 30_000);
await nextTick();
textareaRef.value?.focus();
try {
const [{ data: org }, usersRes] = await Promise.all([
api.get<OrganizationDto>('/organizations/current'),
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
]);
if (org.name) orgName.value = org.name;
users.value = usersRes.data;
if (!users.value.length && props.activity.assignee) {
users.value = [
{
id: props.activity.assignee.id,
email: '',
fullName: props.activity.assignee.fullName,
role: 'sales',
createdAt: '',
},
];
}
} catch {
orgName.value = 'سازمان';
}
});
onBeforeUnmount(() => {
voiceNote?.cancel();
for (const voice of pendingVoices.value) URL.revokeObjectURL(voice.url);
document.body.style.overflow = '';
window.removeEventListener('keydown', onKeydown);
clearInterval(clockTimer);
});
</script>
<template>
<Teleport to="body">
<div class="layer" role="presentation">
<div class="backdrop" aria-hidden="true" />
<section
class="sheet"
role="dialog"
aria-modal="true"
aria-labelledby="comment-sheet-title"
>
<div class="handle" aria-hidden="true" />
<header class="head">
<button class="icon" type="button" aria-label="ثبت نظر" :disabled="!canSubmit" @click="submit">
<UiIcon name="check-plain" :size="22" />
</button>
<h2 id="comment-sheet-title">ثبت نظر</h2>
<button class="icon" type="button" aria-label="بستن" @click="close">
<UiIcon name="x" :size="20" />
</button>
</header>
<div class="body">
<article class="ctx">
<div class="ctx-copy">
<h3>{{ activity.title }}</h3>
<p v-if="subtitle" class="sub">
<UiIcon name="building" :size="14" />
{{ subtitle }}
</p>
<div class="meta">
<span>
<UiIcon name="user" :size="14" />
{{ activity.assignee?.fullName || 'بدون مسئول' }}
</span>
<i />
<span title="موعد فعالیت">
<UiIcon name="calendar" :size="14" />
موعد: {{ dueLabel }}
</span>
</div>
<p class="stamp">
<UiIcon name="clock" :size="14" />
زمان ثبت نظر: {{ nowLabel }}
</p>
</div>
<span class="type" :class="activity.type">
<UiIcon :name="typeIcon[activity.type]" :size="22" />
</span>
</article>
<div class="editor">
<textarea
ref="textareaRef"
v-model="comment"
:disabled="recording"
rows="6"
placeholder="متن خود را بنویسید..."
/>
<p v-if="photoError" class="photo-err">{{ photoError }}</p>
<div v-if="pendingPhotos.length" class="photo-grid">
<figure v-for="photo in pendingPhotos" :key="photo.localId">
<img :src="photo.dataUrl" :alt="photo.name" />
<button type="button" aria-label="حذف عکس" :disabled="uploading" @click="removePendingPhoto(photo.localId)">
×
</button>
</figure>
</div>
<div v-if="pendingVoices.length" class="voice-list">
<div v-for="voice in pendingVoices" :key="voice.localId" class="voice-item">
<audio :src="voice.url" controls preload="metadata" />
<button type="button" aria-label="حذف صوت" :disabled="uploading" @click="removePendingVoice(voice.localId)">
×
</button>
</div>
</div>
<ul v-if="pendingFiles.length" class="file-list">
<li v-for="file in pendingFiles" :key="file.localId">
<UiIcon name="document" :size="15" />
<span>{{ file.name }}</span>
<button type="button" aria-label="حذف فایل" :disabled="uploading" @click="removePendingFile(file.localId)">
×
</button>
</li>
</ul>
<div class="tools">
<div class="tool-cluster">
<div class="tool-wrap">
<button
class="tool"
type="button"
aria-label="منشن"
:disabled="recording || voiceNoteOn"
@click="mentionOpen = !mentionOpen"
>
@
</button>
<ul v-if="mentionOpen" class="mentions">
<li v-if="!users.length" class="empty">کاربری برای منشن یافت نشد.</li>
<li v-for="user in users" :key="user.id">
<button type="button" @click="insertMention(user.fullName)">
{{ user.fullName }}
</button>
</li>
</ul>
</div>
<button
class="tool"
type="button"
aria-label="پیوست"
title="پیوست"
:disabled="photoBusy || uploading || recording || voiceNoteOn"
@click="fileInput?.click()"
>
<UiIcon name="clip" :size="16" />
</button>
<input ref="fileInput" class="photo-file" type="file" multiple @change="onFilesPicked" />
<button
class="tool"
type="button"
:class="{ 'voice-on': voiceNoteOn }"
:aria-label="voiceNoteOn ? 'توقف ضبط پیام صوتی' : 'ضبط پیام صوتی'"
:title="voiceNoteOn ? 'توقف ضبط پیام صوتی' : 'ضبط پیام صوتی'"
:disabled="photoBusy || uploading || recording"
@click="toggleVoiceNote"
>
<UiIcon name="mic" :size="16" />
</button>
<span v-if="voiceNoteOn" class="rec-time">{{ voiceClock(voiceNoteSeconds) }}</span>
</div>
<div class="tool-wrap">
<p v-if="voiceTip && !recording" class="tip">تایپ صوتی را امتحان کنید! 🎙️✨</p>
<VoiceToTextButton
variant="toolbar"
hide-error
:disabled="saving || voiceNoteOn"
@transcript="onVoiceTranscript"
@recording="recording = $event"
@busy="onVoiceBusy"
@error="voiceError = $event"
/>
</div>
</div>
</div>
<p v-if="voiceError" class="voice-err">{{ voiceError }}</p>
<p class="notice">
<UiIcon name="lightbulb" :size="18" />
یادداشت شما فقط برای اعضای تیم و مدیران {{ orgName }} قابل مشاهده خواهد بود.
</p>
<div class="actions">
<button class="act primary" type="button" :disabled="!canSubmit" @click="submit">
{{ recording ? 'در حال ضبط…' : uploading || saving ? 'در حال ثبت…' : 'ثبت نظر' }}
</button>
<button class="act ghost" type="button" :disabled="saving || voiceBusy || uploading" @click="close">انصراف</button>
</div>
</div>
</section>
</div>
</Teleport>
</template>
<style scoped>
.layer {
position: fixed;
inset: 0;
z-index: 90;
display: flex;
justify-content: center;
align-items: stretch;
}
.backdrop {
display: none;
}
.sheet {
width: 100%;
height: 100dvh;
background: #fff;
display: flex;
flex-direction: column;
font-family: IRANSans, Tahoma, sans-serif;
}
.handle {
width: 42px;
height: 4px;
margin: 0.55rem auto 0.15rem;
border-radius: 999px;
background: #d4d4d8;
}
.head {
display: grid;
grid-template-columns: 40px 1fr 40px;
align-items: center;
padding: 0.15rem 0.7rem 0.45rem;
}
.head h2 {
margin: 0;
text-align: center;
font-size: 16px;
font-weight: 500;
color: #0f172a;
}
.icon {
width: 40px;
height: 40px;
border: 0;
background: none;
color: #0f766e;
display: grid;
place-items: center;
cursor: pointer;
}
.icon:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.body {
flex: 1;
overflow: auto;
padding: 0.35rem 1rem calc(1rem + env(safe-area-inset-bottom));
display: grid;
align-content: start;
gap: 0.85rem;
}
.ctx {
display: flex;
gap: 0.8rem;
align-items: flex-start;
padding: 0.9rem 0.95rem;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
background: #fff;
}
.type {
width: 46px;
height: 46px;
border-radius: 999px;
display: grid;
place-items: center;
flex-shrink: 0;
color: #fff;
}
.type.call {
background: #0d9488;
}
.type.meeting {
background: #7c3aed;
}
.type.task {
background: #ea580c;
}
.type.note {
background: #2563eb;
}
.ctx-copy {
min-width: 0;
flex: 1;
}
.ctx-copy h3 {
margin: 0.1rem 0 0;
font-size: 0.98rem;
font-weight: 600;
line-height: 1.55;
}
.sub,
.meta span {
display: flex;
align-items: center;
gap: 0.3rem;
color: #64748b;
font-size: 0.8rem;
}
.sub {
margin: 0.28rem 0 0;
}
.meta {
display: flex;
align-items: center;
gap: 0.7rem;
margin-top: 0.7rem;
padding-top: 0.65rem;
border-top: 1px solid #f1f5f9;
}
.stamp {
display: flex;
align-items: center;
gap: 0.3rem;
margin: 0.55rem 0 0;
color: #0f766e;
font-size: 0.8rem;
font-weight: 600;
}
.meta i {
width: 1px;
height: 14px;
background: #e2e8f0;
}
.editor {
border: 1px solid #e2e8f0;
border-radius: var(--radius);
background: #fff;
min-height: 11.5rem;
display: flex;
flex-direction: column;
}
.editor textarea {
flex: 1;
width: 100%;
min-height: 8.5rem;
border: 0;
resize: none;
padding: 0.9rem 0.95rem 0.2rem;
font: inherit;
font-size: 0.95rem;
line-height: 1.9;
color: #0f172a;
background: transparent;
}
.editor textarea:focus {
outline: none;
}
.editor textarea::placeholder {
color: #94a3b8;
}
.photo-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
padding: 0 0.7rem;
}
.photo-row small {
color: #64748b;
font-size: 0.75rem;
}
.photo-add {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px dashed #99f6e4;
background: #f0fdfa;
color: #0f766e;
border-radius: var(--radius);
padding: 0.35rem 0.7rem;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.photo-add:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.photo-file {
display: none;
}
.photo-err {
margin: 0;
padding: 0 0.7rem;
color: #b91c1c;
font-size: 0.8rem;
}
.photo-grid {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
padding: 0 0.7rem;
}
.photo-grid figure {
position: relative;
margin: 0;
width: 4.5rem;
height: 4.5rem;
}
.photo-grid img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0.6rem;
border: 1px solid #e2e8f0;
}
.photo-grid figure button {
position: absolute;
top: 0.15rem;
inset-inline-end: 0.15rem;
width: 1.2rem;
height: 1.2rem;
border: 0;
border-radius: 999px;
background: rgba(15, 23, 42, 0.72);
color: #fff;
cursor: pointer;
line-height: 1;
}
.tools {
display: flex;
justify-content: space-between;
align-items: flex-end;
padding: 0.35rem 0.5rem 0.5rem;
}
.tool-cluster {
display: flex;
align-items: center;
gap: 0.35rem;
}
.tool.voice-on {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.rec-time {
color: #b91c1c;
font-size: 0.78rem;
font-weight: 700;
}
.voice-list {
display: grid;
gap: 0.35rem;
padding: 0 0.5rem;
}
.voice-item {
display: flex;
align-items: center;
gap: 0.35rem;
}
.voice-item audio {
flex: 1;
min-width: 0;
height: 2.1rem;
}
.voice-item button,
.file-list button {
width: 1.4rem;
height: 1.4rem;
border: 0;
border-radius: 999px;
background: #e2e8f0;
cursor: pointer;
}
.file-list {
list-style: none;
margin: 0;
padding: 0 0.5rem;
display: grid;
gap: 0.3rem;
}
.file-list li {
display: flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
}
.file-list span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-wrap {
position: relative;
}
.tool {
width: 36px;
height: 36px;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
background: #fff;
color: #0f766e;
display: grid;
place-items: center;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.tool-wrap :deep(.vtt:not(.on):not(.busy)) {
color: #0d9488;
}
.tip {
position: absolute;
inset-inline-end: 0;
bottom: calc(100% + 8px);
margin: 0;
white-space: nowrap;
background: #111827;
color: #fff;
font-size: 0.74rem;
font-weight: 700;
padding: 0.42rem 0.65rem;
border-radius: var(--radius);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.18);
}
.tip::after {
content: '';
position: absolute;
inset-inline-end: 14px;
top: 100%;
border: 6px solid transparent;
border-top-color: #111827;
}
.mentions {
position: absolute;
inset-inline-start: 0;
bottom: calc(100% + 6px);
margin: 0;
padding: 0.3rem;
list-style: none;
min-width: 11.5rem;
max-height: 12rem;
overflow: auto;
background: #fff;
border-radius: var(--radius);
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.16);
z-index: 2;
}
.mentions button,
.mentions .empty {
width: 100%;
border: 0;
background: none;
text-align: start;
font: inherit;
padding: 0.55rem 0.65rem;
border-radius: var(--radius);
color: #0f172a;
cursor: pointer;
}
.mentions .empty {
color: #94a3b8;
cursor: default;
}
.mentions button:hover {
background: #f1f5f9;
}
.voice-err {
margin: -0.35rem 0 0;
color: #b91c1c;
font-size: 0.8rem;
}
.notice {
margin: 0;
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.72rem 0.8rem;
border-radius: var(--radius);
background: #ecfdf5;
color: #0f766e;
font-size: 0.8rem;
line-height: 1.7;
}
.notice :deep(.ico) {
margin-top: 0.12rem;
flex-shrink: 0;
}
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.65rem;
}
.act {
min-height: var(--control-h);
height: var(--control-h);
border-radius: var(--radius);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.act.primary {
border: 0;
background: #14b8a6;
color: #fff;
}
.act.ghost {
border: 1.5px solid #14b8a6;
background: #fff;
color: #0f766e;
}
.act:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@media (min-width: 900px) {
.layer {
background: rgba(15, 23, 42, 0.28);
align-items: center;
padding: 1.2rem;
}
.backdrop {
display: block;
position: absolute;
inset: 0;
border: 0;
background: transparent;
cursor: pointer;
}
.sheet {
position: relative;
max-width: 480px;
height: min(92dvh, 860px);
border-radius: var(--radius, 10px);
box-shadow: 0 28px 70px rgba(15, 23, 42, 0.22);
}
}
</style>