monacousa-portal/components/ui/StatsCard.vue

369 lines
7.7 KiB
Vue

<template>
<div
v-motion
:initial="{ opacity: 0, y: 20 }"
:enter="{
opacity: 1,
y: 0,
transition: {
delay: delay * 100,
type: 'spring',
stiffness: 200,
damping: 20
}
}"
class="stats-card"
:class="[
`stats-card--${variant}`,
{ 'stats-card--clickable': clickable }
]"
@click="$emit('click')"
>
<div class="stats-card__header">
<div class="stats-card__icon-wrapper">
<Icon
:name="icon"
class="stats-card__icon"
/>
</div>
<div v-if="trend" class="stats-card__trend" :class="`stats-card__trend--${trend.type}`">
<Icon
:name="trend.type === 'up' ? 'trending-up' : trend.type === 'down' ? 'trending-down' : 'minus'"
class="stats-card__trend-icon"
/>
<span>{{ trend.value }}%</span>
</div>
</div>
<div class="stats-card__content">
<h3 class="stats-card__label">{{ label }}</h3>
<div class="stats-card__value-wrapper">
<span v-if="prefix" class="stats-card__prefix">{{ prefix }}</span>
<AnimatedNumber
:value="value"
:duration="1500"
:format="format"
class="stats-card__value"
/>
<span v-if="suffix" class="stats-card__suffix">{{ suffix }}</span>
</div>
<p v-if="description" class="stats-card__description">{{ description }}</p>
</div>
<div v-if="progress !== undefined" class="stats-card__progress">
<div class="stats-card__progress-bar">
<div
class="stats-card__progress-fill"
:style="{ width: `${Math.min(100, Math.max(0, progress))}%` }"
/>
</div>
<span class="stats-card__progress-label">{{ progress }}% Complete</span>
</div>
<div v-if="sparkline" class="stats-card__sparkline">
<svg
viewBox="0 0 100 40"
preserveAspectRatio="none"
class="stats-card__sparkline-svg"
>
<polyline
:points="sparklinePoints"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
:points="`${sparklinePoints} 100,40 0,40`"
fill="currentColor"
fill-opacity="0.1"
/>
</svg>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Icon from '~/components/ui/Icon.vue'
import AnimatedNumber from '~/components/ui/AnimatedNumber.vue'
interface Trend {
type: 'up' | 'down' | 'neutral'
value: number
}
interface Props {
label: string
value: number
icon: string
variant?: 'glass' | 'solid' | 'gradient' | 'outline'
prefix?: string
suffix?: string
description?: string
trend?: Trend
progress?: number
sparkline?: number[]
clickable?: boolean
format?: (value: number) => string
delay?: number
}
const props = withDefaults(defineProps<Props>(), {
variant: 'glass',
clickable: false,
delay: 0,
format: (value: number) => value.toLocaleString()
})
defineEmits<{
click: []
}>()
const sparklinePoints = computed(() => {
if (!props.sparkline || props.sparkline.length === 0) return ''
const data = props.sparkline
const max = Math.max(...data)
const min = Math.min(...data)
const range = max - min || 1
return data
.map((value, index) => {
const x = (index / (data.length - 1)) * 100
const y = 40 - ((value - min) / range) * 35
return `${x},${y}`
})
.join(' ')
})
</script>
<style scoped lang="scss">
.stats-card {
position: relative;
padding: 1.5rem;
border-radius: 16px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
// Glass variant
&--glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
&:hover {
background: rgba(255, 255, 255, 0.8);
box-shadow:
0 12px 40px rgba(0, 0, 0, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
transform: translateY(-2px);
}
}
// Solid variant
&--solid {
background: white;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
&:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
}
}
// Gradient variant
&--gradient {
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.05) 0%,
rgba(220, 38, 38, 0.02) 100%);
backdrop-filter: blur(20px);
border: 1px solid rgba(220, 38, 38, 0.1);
box-shadow: 0 8px 32px rgba(220, 38, 38, 0.08);
&:hover {
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.08) 0%,
rgba(220, 38, 38, 0.03) 100%);
transform: translateY(-2px);
}
}
// Outline variant
&--outline {
background: transparent;
border: 2px solid rgba(220, 38, 38, 0.2);
&:hover {
background: rgba(220, 38, 38, 0.05);
border-color: rgba(220, 38, 38, 0.3);
transform: translateY(-2px);
}
}
&--clickable {
cursor: pointer;
}
&__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 1rem;
}
&__icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.1) 0%,
rgba(220, 38, 38, 0.05) 100%);
border-radius: 12px;
}
&__icon {
width: 1.5rem;
height: 1.5rem;
color: #dc2626;
}
&__trend {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
&--up {
color: #10b981;
background: rgba(16, 185, 129, 0.1);
}
&--down {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
&--neutral {
color: #6b7280;
background: rgba(107, 114, 128, 0.1);
}
}
&__trend-icon {
width: 1rem;
height: 1rem;
}
&__content {
margin-bottom: 1rem;
}
&__label {
font-size: 0.875rem;
font-weight: 500;
color: #6b7280;
margin: 0 0 0.5rem;
}
&__value-wrapper {
display: flex;
align-items: baseline;
gap: 0.25rem;
margin-bottom: 0.5rem;
}
&__value {
font-size: 2rem;
font-weight: 700;
color: #27272a;
line-height: 1;
}
&__prefix,
&__suffix {
font-size: 1.25rem;
font-weight: 500;
color: #6b7280;
}
&__description {
font-size: 0.875rem;
color: #6b7280;
margin: 0;
}
&__progress {
margin-top: 1rem;
}
&__progress-bar {
height: 6px;
background: rgba(220, 38, 38, 0.1);
border-radius: 3px;
overflow: hidden;
margin-bottom: 0.5rem;
}
&__progress-fill {
height: 100%;
background: linear-gradient(90deg, #dc2626 0%, #b91c1c 100%);
border-radius: 3px;
transition: width 1s cubic-bezier(0.4, 0, 0.2, 1);
}
&__progress-label {
font-size: 0.75rem;
color: #6b7280;
}
&__sparkline {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 40px;
opacity: 0.5;
}
&__sparkline-svg {
width: 100%;
height: 100%;
color: #dc2626;
}
}
// Dark mode support
@media (prefers-color-scheme: dark) {
.stats-card {
&--glass {
background: rgba(30, 30, 30, 0.7);
border-color: rgba(255, 255, 255, 0.1);
}
&--solid {
background: #27272a;
}
&__value {
color: white;
}
&__label,
&__description,
&__progress-label {
color: #a3a3a3;
}
}
}
</style>