Rework the product preview section around three real screens captured at deviceScaleFactor 2 instead of the previous 900 px shots taken at a 75% browser zoom, which read as blurry. - capture the conversation workspace, the agent customization page and the visitor widget, then crop each one to a consistent frame - drop the tight 480 px widget crop and its leftover gray strip - restructure the preview section with one lead line and a caption per shot, and fold the local start-up steps into a shorter quick start - keep the browser overlay out of the captures and note the method in the asset manifest - finish the visible ReplyPier rename (sidebar wordmark, widget footer, page title, login screen, demo page) so screenshots and UI agree
726 lines
23 KiB
Vue
726 lines
23 KiB
Vue
<!--
|
||
Copyright 2024-2026 ChatterMate
|
||
|
||
Licensed under the Apache License, Version 2.0 (the "License");
|
||
you may not use this file except in compliance with the License.
|
||
You may obtain a copy of the License at
|
||
|
||
http://www.apache.org/licenses/LICENSE-2.0
|
||
|
||
Unless required by applicable law or agreed to in writing, software
|
||
distributed under the License is distributed on an "AS IS" BASIS,
|
||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
See the License for the specific language governing permissions and
|
||
limitations under the License.
|
||
-->
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted, watch, computed } from 'vue'
|
||
import { useAuth } from '@/composables/useAuth'
|
||
import { useTheme } from '@/composables/useTheme'
|
||
import AppSidebar from '@/components/layout/AppSidebar.vue'
|
||
import BottomNav from '@/components/layout/BottomNav.vue'
|
||
import MoreSheet from '@/components/layout/MoreSheet.vue'
|
||
import { navIconSvg } from '@/components/layout/navIcons'
|
||
import NotificationList from '@/components/notifications/NotificationList.vue'
|
||
import EnablePushPrompt from '@/components/notifications/EnablePushPrompt.vue'
|
||
import { userService } from '@/services/user'
|
||
import { permissionChecks } from '@/utils/permissions'
|
||
import type { User } from '@/types/user'
|
||
import { useNotifications } from '@/composables/useNotifications'
|
||
import { notificationService } from '@/services/notification'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { updateUserStatus } from '@/services/users'
|
||
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
|
||
import { useBreakpoint } from '@/composables/useBreakpoint'
|
||
|
||
const props = defineProps<{
|
||
hideSidebar?: boolean
|
||
hideHeader?: boolean
|
||
/** Full-bleed workspace pages (inbox) draw their own column padding */
|
||
flush?: boolean
|
||
}>()
|
||
|
||
// Initialize sidebar state based on current route and screen size
|
||
const route = useRoute()
|
||
const { isMobile: isPhone, isTablet: isMobile } = useBreakpoint()
|
||
const isSidebarOpen = ref(
|
||
route.path !== '/conversations' && !isMobile.value
|
||
)
|
||
const showNotifications = ref(false)
|
||
const showMoreSheet = ref(false)
|
||
const currentUser = ref<User>(userService.getCurrentUser() as User)
|
||
const userName = ref(userService.getUserName())
|
||
const userRole = ref(userService.getUserRole())
|
||
const unreadCount = ref(0)
|
||
const statusUpdating = ref(false)
|
||
const { logout } = useAuth()
|
||
const { mode: themeMode, toggle: toggleTheme } = useTheme()
|
||
const themeTitle = computed(() =>
|
||
themeMode.value === 'dark' ? '主题:深色 — 点击切换为浅色'
|
||
: themeMode.value === 'light' ? '主题:浅色 — 点击切换为跟随系统'
|
||
: '主题:跟随系统 — 点击切换为深色'
|
||
)
|
||
// Attaches push listeners when permission is already granted; the permission
|
||
// request itself only happens from the EnablePushPrompt user gesture.
|
||
const { enableNotifications } = useNotifications()
|
||
const router = useRouter()
|
||
|
||
const PAGE_TITLES: Record<string, string> = {
|
||
'/ai-agents': 'AI 客服',
|
||
'/human-agents': '人工客服',
|
||
'/conversations': '会话工作台',
|
||
'/tickets': '工单',
|
||
'/people': '联系人',
|
||
'/knowledge': '知识库',
|
||
'/faq': '帮助中心',
|
||
'/analytics': '数据分析',
|
||
'/settings/organization': '组织',
|
||
'/settings/subscription': '订阅',
|
||
'/settings/ticketing': '工单设置',
|
||
'/settings/integrations': '集成',
|
||
'/settings/app-integrations': '应用集成',
|
||
'/settings/widget-apps': '网站组件',
|
||
'/settings/ai-config': 'AI 配置',
|
||
'/settings/user': '个人设置',
|
||
}
|
||
const pageTitle = computed(() => PAGE_TITLES[route.path] || '')
|
||
|
||
// Breadcrumb group per page, mirroring the sidebar sections so the two can
|
||
// never tell a different story about where you are.
|
||
const PAGE_GROUPS: Record<string, { group: string; en: string }> = {
|
||
'/conversations': { group: '接待', en: 'Reception' },
|
||
'/tickets': { group: '接待', en: 'Reception' },
|
||
'/people': { group: '接待', en: 'Reception' },
|
||
'/ai-agents': { group: '智能', en: 'Intelligence' },
|
||
'/knowledge': { group: '智能', en: 'Intelligence' },
|
||
'/faq': { group: '智能', en: 'Intelligence' },
|
||
'/human-agents': { group: '团队', en: 'Team' },
|
||
'/analytics': { group: '团队', en: 'Team' },
|
||
}
|
||
const pageCrumb = computed(() => {
|
||
if (PAGE_GROUPS[route.path]) return PAGE_GROUPS[route.path]
|
||
if (route.path.startsWith('/settings')) return { group: '配置', en: 'Settings' }
|
||
return { group: '工作台', en: 'Workspace' }
|
||
})
|
||
|
||
// Initialize enterprise features
|
||
const { hasEnterpriseModule, subscriptionStore, initializeSubscriptionStore, showMessageLimitWarning, messageLimitStatus, enterpriseComponent, moduleImports } = useEnterpriseFeatures()
|
||
// Promo strip / usage nudge / post-signup popup (enterprise only; empty in OSS)
|
||
const PromoSurfaces = enterpriseComponent(moduleImports.promoSurfaces)
|
||
// "Finish setup" bar until the org's agent is live (enterprise only; empty in OSS)
|
||
const SetupProgressBar = enterpriseComponent(moduleImports.setupProgressBar)
|
||
// Always-on offer pill in the header: survives the strip being dismissed
|
||
const PromoHeaderPill = enterpriseComponent(moduleImports.promoHeaderPill)
|
||
// Help button opening the hosted support chat (empty in OSS)
|
||
const SupportChatButton = enterpriseComponent(moduleImports.supportChatButton)
|
||
|
||
const isLoadingPlan = computed(() => subscriptionStore.value.isLoadingPlan)
|
||
const isInTrial = computed(() => subscriptionStore.value.isInTrial)
|
||
const trialDaysLeft = computed(() => subscriptionStore.value.trialDaysLeft)
|
||
|
||
const toggleOnlineStatus = async () => {
|
||
if (statusUpdating.value) return
|
||
|
||
try {
|
||
statusUpdating.value = true
|
||
const newStatus = !currentUser.value?.is_online
|
||
await updateUserStatus(currentUser.value?.id as string, newStatus)
|
||
|
||
// Update local state
|
||
currentUser.value = {
|
||
...currentUser.value,
|
||
is_online: newStatus,
|
||
last_seen: new Date().toISOString()
|
||
} as User
|
||
|
||
userService.setCurrentUser(currentUser.value as User)
|
||
} catch (error) {
|
||
console.error('Failed to update status:', error)
|
||
} finally {
|
||
statusUpdating.value = false
|
||
}
|
||
}
|
||
|
||
// Bottom nav shows on phones only; hidden in fullscreen workflows and on the
|
||
// full-screen chat pane (mobile chat detail = /conversations with ?session=)
|
||
const showBottomNav = computed(() =>
|
||
isPhone.value &&
|
||
!props.hideSidebar &&
|
||
!(route.path === '/conversations' && route.query.session)
|
||
)
|
||
|
||
// Watch for route changes to update sidebar state and close menus
|
||
watch(
|
||
() => route.path,
|
||
(newPath) => {
|
||
showNotifications.value = false
|
||
showMoreSheet.value = false
|
||
|
||
// Set sidebar state based on route and screen size
|
||
if (newPath === '/conversations') {
|
||
isSidebarOpen.value = false // Collapsed for conversations
|
||
} else if (isMobile.value) {
|
||
isSidebarOpen.value = false // Collapsed on mobile by default
|
||
} else {
|
||
isSidebarOpen.value = true // Expanded for all other routes on desktop
|
||
}
|
||
}
|
||
)
|
||
|
||
const fetchUnreadCount = async () => {
|
||
try {
|
||
unreadCount.value = await notificationService.getUnreadCount()
|
||
} catch (err) {
|
||
console.error('Error fetching unread count:', err)
|
||
}
|
||
}
|
||
|
||
// React to breakpoint changes (was a manual resize listener)
|
||
watch(isMobile, (mobile) => {
|
||
if (mobile && isSidebarOpen.value) {
|
||
isSidebarOpen.value = false
|
||
} else if (!mobile && !isSidebarOpen.value && route.path !== '/conversations') {
|
||
isSidebarOpen.value = true
|
||
}
|
||
})
|
||
|
||
onMounted(() => {
|
||
fetchUnreadCount()
|
||
|
||
if (hasEnterpriseModule) {
|
||
initializeSubscriptionStore().then(() => {
|
||
subscriptionStore.value.fetchCurrentPlan().then(() => {
|
||
}).catch((err: Error) => {
|
||
console.error('Error fetching current plan:', err)
|
||
})
|
||
}).catch((err: Error) => {
|
||
console.error('Error initializing subscription store:', err)
|
||
})
|
||
}
|
||
})
|
||
|
||
const toggleSidebar = () => {
|
||
isSidebarOpen.value = !isSidebarOpen.value
|
||
}
|
||
|
||
const closeSidebar = () => {
|
||
// On mobile, close the sidebar when clicking backdrop
|
||
if (isMobile.value) {
|
||
isSidebarOpen.value = false
|
||
}
|
||
}
|
||
|
||
const navigateToUpgrade = () => {
|
||
router.push('/settings/subscription')
|
||
}
|
||
|
||
// The usage warning is for everyone — an agent hitting a message ceiling needs
|
||
// to know why replies stop. The two remedies are not: both lead to pages a
|
||
// non-admin cannot open, so they rendered as doors into a 403.
|
||
const canManageSubscription = permissionChecks.canManageSubscription()
|
||
const canViewAIConfig = permissionChecks.canViewAIConfig()
|
||
|
||
// Computed for layout classes
|
||
const layoutClasses = computed(() => ({
|
||
'sidebar-collapsed': !isSidebarOpen.value || props.hideSidebar,
|
||
'header-hidden': props.hideHeader,
|
||
'fullscreen-workflow': props.hideSidebar && props.hideHeader,
|
||
'flush-content': props.flush,
|
||
'has-bottom-nav': showBottomNav.value
|
||
}))
|
||
|
||
const openNotificationsFromSheet = () => {
|
||
showMoreSheet.value = false
|
||
showNotifications.value = true
|
||
}
|
||
|
||
</script>
|
||
|
||
<template>
|
||
<div class="dashboard-layout cm-shell" :class="layoutClasses">
|
||
<!-- Backdrop for mobile -->
|
||
<div
|
||
v-if="!props.hideSidebar && isSidebarOpen"
|
||
class="sidebar-backdrop"
|
||
@click="closeSidebar"
|
||
></div>
|
||
|
||
<AppSidebar
|
||
v-if="!props.hideSidebar"
|
||
:isCollapsed="!isSidebarOpen"
|
||
:user-name="userName"
|
||
:user-role="userRole"
|
||
:is-online="currentUser?.is_online"
|
||
:status-updating="statusUpdating"
|
||
@toggle="toggleSidebar"
|
||
@toggle-status="toggleOnlineStatus"
|
||
@logout="logout"
|
||
/>
|
||
|
||
<!-- Main Content -->
|
||
<div class="main-content">
|
||
<!-- Message Limit Warning Banner -->
|
||
<div v-if="!props.hideHeader && hasEnterpriseModule && showMessageLimitWarning && messageLimitStatus"
|
||
class="message-limit-banner"
|
||
:class="messageLimitStatus.type">
|
||
<div class="banner-content">
|
||
<div class="banner-text">
|
||
<span class="banner-icon" v-if="messageLimitStatus.type === 'error'">⚠️</span>
|
||
<span class="banner-icon" v-else>ℹ️</span>
|
||
{{ messageLimitStatus.message }}
|
||
</div>
|
||
<div v-if="canManageSubscription || canViewAIConfig" class="banner-actions">
|
||
<button
|
||
v-if="canManageSubscription"
|
||
class="action-button"
|
||
@click="navigateToUpgrade"
|
||
>
|
||
升级套餐
|
||
</button>
|
||
<button
|
||
v-if="canViewAIConfig"
|
||
class="action-button secondary"
|
||
@click="router.push('/settings/ai-config')"
|
||
>
|
||
配置自有模型
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="usage-bar">
|
||
<div class="usage-progress"
|
||
:style="{ width: `${Math.min(messageLimitStatus.percentage, 100)}%` }"
|
||
:class="{ 'exceeded': messageLimitStatus.percentage >= 100 }">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Promo offer surfaces (strip, usage nudge, first-landing popup) -->
|
||
<PromoSurfaces
|
||
v-if="!props.hideHeader && hasEnterpriseModule"
|
||
:hide-usage-card="showMessageLimitWarning"
|
||
/>
|
||
|
||
<!-- Header -->
|
||
<header v-if="!props.hideHeader" class="topbar">
|
||
<button class="hamburger-menu" @click="toggleSidebar" aria-label="切换菜单">
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M3 12H21M3 6H21M3 18H21" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||
</svg>
|
||
</button>
|
||
|
||
<div class="crumb">
|
||
<span class="en">{{ pageCrumb.en }}</span>
|
||
<span class="sep">/</span>
|
||
<span>{{ pageCrumb.group }}</span>
|
||
<span class="sep">/</span>
|
||
<b>{{ pageTitle }}</b>
|
||
</div>
|
||
|
||
<div class="topbar-spacer"></div>
|
||
|
||
<SupportChatButton v-if="hasEnterpriseModule" />
|
||
<PromoHeaderPill v-if="hasEnterpriseModule" />
|
||
<div v-if="hasEnterpriseModule && (isLoadingPlan || isInTrial)" class="plan-display">
|
||
<div v-if="isLoadingPlan" class="plan-loading">
|
||
<span class="loading-spinner"></span>
|
||
加载中…
|
||
</div>
|
||
<div v-else-if="isInTrial" class="trial-info">
|
||
<span
|
||
class="trial-badge"
|
||
:class="{ clickable: canManageSubscription }"
|
||
@click="canManageSubscription && navigateToUpgrade()"
|
||
>
|
||
试用(剩余 {{ trialDaysLeft }} 天)
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
class="presence"
|
||
:data-state="currentUser?.is_online ? 'online' : 'away'"
|
||
:disabled="statusUpdating"
|
||
:title="currentUser?.is_online ? '在线接待中 — 点击暂停接收新的转人工会话' : '暂时离开 — 点击恢复接待'"
|
||
@click="toggleOnlineStatus"
|
||
>
|
||
<span class="pulse"></span>
|
||
<span>{{ currentUser?.is_online ? '在线接待' : '暂时离开' }}</span>
|
||
</button>
|
||
|
||
<button class="icon-btn" @click="toggleTheme" :title="themeTitle" :aria-label="themeTitle"
|
||
v-html="navIconSvg(themeMode === 'dark' ? 'moon' : themeMode === 'light' ? 'sun' : 'monitor', 18)">
|
||
</button>
|
||
|
||
<button class="icon-btn" @click="showNotifications = !showNotifications" aria-label="通知">
|
||
<span v-html="navIconSvg('bell', 18)"></span>
|
||
<span v-if="unreadCount > 0" class="nav-badge alert badge-float">
|
||
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
||
</span>
|
||
</button>
|
||
</header>
|
||
|
||
<!-- Main Content Area -->
|
||
<main class="content">
|
||
<!-- The agents page shows its own copy, beside the wizard and list -->
|
||
<SetupProgressBar v-if="route.name !== 'ai-agents'" />
|
||
<slot></slot>
|
||
</main>
|
||
|
||
<!-- Footer: omitted for full-page layouts (hideHeader) and workspace pages
|
||
(flush, e.g. the inbox) — those pages size themselves to the viewport, so a
|
||
footer would clip the last message / message input out of view. -->
|
||
<footer v-if="!props.hideHeader && !props.flush" class="footer">
|
||
<div class="footer-content">
|
||
<p>© 2024 ReplyPier。保留所有权利。</p>
|
||
<nav class="footer-links">
|
||
<a href="https://chattermate.chat/privacy_policy.html">隐私政策</a>
|
||
<a href="https://chattermate.chat/terms_and_conditions.html">服务条款</a>
|
||
</nav>
|
||
</div>
|
||
</footer>
|
||
</div>
|
||
|
||
<!-- Notification drawer (fixed) — outside the header so the More sheet
|
||
can open it on pages that hide the header (e.g. Inbox) -->
|
||
<NotificationList :is-open="showNotifications" @close="showNotifications = false"
|
||
@notification-read="fetchUnreadCount" />
|
||
|
||
<EnablePushPrompt @enable="enableNotifications" />
|
||
|
||
<!-- Mobile app shell -->
|
||
<BottomNav
|
||
v-if="showBottomNav"
|
||
:unread-count="unreadCount"
|
||
:more-open="showMoreSheet"
|
||
@more="showMoreSheet = true"
|
||
/>
|
||
<MoreSheet
|
||
:open="showMoreSheet"
|
||
:is-online="currentUser?.is_online"
|
||
:status-updating="statusUpdating"
|
||
:theme-mode="themeMode"
|
||
:unread-count="unreadCount"
|
||
@close="showMoreSheet = false"
|
||
@toggle-status="toggleOnlineStatus"
|
||
@toggle-theme="toggleTheme"
|
||
@notifications="openNotificationsFromSheet"
|
||
@logout="logout"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 墨与瓷 shell. The shared .cm-shell rules in components.css carry the
|
||
sidebar / topbar look; this block only owns what is specific to the
|
||
dashboard frame (grid, banners, footer, mobile overlay). */
|
||
.dashboard-layout {
|
||
display: grid;
|
||
/* The sidebar sets its own width so the collapsed rail can shrink it */
|
||
grid-template-columns: auto minmax(0, 1fr);
|
||
height: 100vh;
|
||
height: 100dvh;
|
||
width: 100%;
|
||
overflow: hidden;
|
||
position: relative;
|
||
background: var(--paper);
|
||
color: var(--ink);
|
||
transition: grid-template-columns var(--t-med);
|
||
}
|
||
|
||
.dashboard-layout.fullscreen-workflow {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.sidebar-backdrop {
|
||
display: none;
|
||
}
|
||
|
||
.main-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
height: 100vh;
|
||
height: 100dvh;
|
||
/* The topbar must not scroll away with the page: the content pane owns
|
||
the scrollbar instead of the whole column. */
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ── Topbar ────────────────────────────────────────── */
|
||
.topbar {
|
||
/* Standalone PWA: content extends under the status bar (black-translucent) */
|
||
padding-top: var(--safe-top);
|
||
height: calc(64px + var(--safe-top));
|
||
}
|
||
|
||
.hamburger-menu {
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 38px;
|
||
height: 38px;
|
||
margin-left: -8px;
|
||
border: 1px solid transparent;
|
||
border-radius: var(--radius-md);
|
||
background: none;
|
||
color: var(--text2);
|
||
cursor: pointer;
|
||
transition: background-color var(--t-fast), border-color var(--t-fast);
|
||
}
|
||
|
||
.hamburger-menu:hover {
|
||
background: var(--surface);
|
||
border-color: var(--line);
|
||
}
|
||
|
||
.hamburger-menu svg {
|
||
display: block;
|
||
}
|
||
|
||
.badge-float {
|
||
position: absolute;
|
||
top: 2px;
|
||
right: 2px;
|
||
min-width: 16px;
|
||
height: 16px;
|
||
padding: 0 4px;
|
||
font-size: 10px;
|
||
box-shadow: 0 0 0 2px var(--paper);
|
||
}
|
||
|
||
.presence:disabled {
|
||
opacity: 0.6;
|
||
cursor: progress;
|
||
}
|
||
|
||
/* ── Enterprise plan chips ─────────────────────────── */
|
||
.plan-display {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-md);
|
||
}
|
||
|
||
.plan-loading {
|
||
color: var(--muted);
|
||
font-size: var(--text-sm);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.trial-info {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.trial-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 3px 10px;
|
||
border-radius: var(--radius-pill);
|
||
border: 1px solid var(--brass-line);
|
||
background: var(--brass-soft);
|
||
color: var(--brass);
|
||
font-size: 12.5px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.trial-badge.clickable {
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* ── Content ───────────────────────────────────────── */
|
||
.content {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
padding: 40px 48px;
|
||
}
|
||
|
||
/* Full-page layouts (ConversationsView) size themselves to the viewport, so
|
||
the frame must not add padding or a footer below them. */
|
||
.dashboard-layout.header-hidden .content,
|
||
.dashboard-layout.flush-content .content {
|
||
padding: 0;
|
||
}
|
||
|
||
/* ── Footer ────────────────────────────────────────── */
|
||
.footer {
|
||
border-top: 1px solid var(--line);
|
||
padding: 16px 32px;
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.footer-content {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 16px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.footer-links {
|
||
display: flex;
|
||
gap: var(--space-lg);
|
||
}
|
||
|
||
.footer-links a {
|
||
color: var(--muted);
|
||
transition: color var(--t-fast);
|
||
}
|
||
|
||
.footer-links a:hover {
|
||
color: var(--ink);
|
||
}
|
||
|
||
/* ── Usage / limit banner ──────────────────────────── */
|
||
.message-limit-banner {
|
||
position: relative;
|
||
padding: 14px 28px;
|
||
background: var(--warning-bg);
|
||
border-bottom: 1px solid var(--brass-line);
|
||
width: 100%;
|
||
overflow: hidden;
|
||
color: var(--ink-2);
|
||
font-size: 13.5px;
|
||
}
|
||
|
||
.message-limit-banner.error {
|
||
background: var(--error-bg);
|
||
border-bottom: 1px solid var(--vermilion-line);
|
||
}
|
||
|
||
.banner-content {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: var(--space-md);
|
||
}
|
||
|
||
.banner-text {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-weight: 500;
|
||
word-break: break-word;
|
||
flex: 1;
|
||
}
|
||
|
||
.banner-icon {
|
||
font-size: 1.1em;
|
||
}
|
||
|
||
.banner-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.action-button {
|
||
padding: 6px 12px;
|
||
border-radius: var(--radius-sm);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
border: 1px solid transparent;
|
||
background: var(--primary-bg);
|
||
color: var(--primary-fg);
|
||
}
|
||
|
||
.action-button.secondary {
|
||
background: var(--surface);
|
||
color: var(--text);
|
||
border-color: var(--line-2);
|
||
}
|
||
|
||
.usage-bar {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 2px;
|
||
background: var(--line);
|
||
}
|
||
|
||
.usage-progress {
|
||
height: 100%;
|
||
background: var(--brass);
|
||
transition: width 0.3s ease;
|
||
}
|
||
|
||
.usage-progress.exceeded {
|
||
background: var(--vermilion);
|
||
}
|
||
|
||
.loading-spinner {
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 50%;
|
||
border: 2px solid var(--line-2);
|
||
border-top-color: var(--ink);
|
||
animation: spin 0.8s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
/* ── Responsive ────────────────────────────────────── */
|
||
@media (max-width: 1024px) {
|
||
.dashboard-layout {
|
||
grid-template-columns: minmax(0, 1fr);
|
||
}
|
||
|
||
.hamburger-menu {
|
||
display: flex;
|
||
}
|
||
|
||
.sidebar-backdrop {
|
||
display: block;
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 999;
|
||
background: var(--scrim);
|
||
backdrop-filter: blur(2px);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 1100px) {
|
||
.content {
|
||
padding: 28px 24px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.content {
|
||
padding: 20px 18px;
|
||
}
|
||
|
||
.crumb {
|
||
font-size: 12.5px;
|
||
}
|
||
|
||
.crumb .en,
|
||
.crumb .sep:first-of-type {
|
||
display: none;
|
||
}
|
||
|
||
.presence span:last-child {
|
||
display: none;
|
||
}
|
||
|
||
.presence {
|
||
padding: 0 12px;
|
||
}
|
||
}
|
||
|
||
/* Bottom nav sits above the content on phones */
|
||
.dashboard-layout.has-bottom-nav .main-content {
|
||
padding-bottom: calc(var(--bottom-nav-height) + var(--safe-bottom));
|
||
}
|
||
</style>
|