monacousa-portal/components/dashboard/StatsCard.vue

332 lines
7.6 KiB
Vue

<template>
<div
v-motion
:initial="{ opacity: 0, y: 20, scale: 0.95 }"
:enter="{
opacity: 1,
y: 0,
scale: 1,
transition: {
delay: delay,
duration: 600,
type: 'spring',
stiffness: 200,
damping: 20
}
}"
:hovered="{
scale: 1.02,
y: -2,
transition: {
duration: 200
}
}"
class="stats-card"
>
<div class="stats-card-inner">
<!-- Icon Section -->
<div class="stats-icon" :style="{ background: iconBackground }">
<v-icon :color="iconColor" size="24">{{ icon }}</v-icon>
</div>
<!-- Content Section -->
<div class="stats-content">
<p class="stats-label">{{ label }}</p>
<div class="stats-value-wrapper">
<h3
class="stats-value"
v-motion
:initial="{ opacity: 0 }"
:visible="{
opacity: 1,
transition: {
delay: delay + 200,
duration: 800
}
}"
>
<span v-if="prefix">{{ prefix }}</span>
<AnimatedNumber :value="value" :duration="1500" :format="formatNumber" />
<span v-if="suffix">{{ suffix }}</span>
</h3>
<div
v-if="change !== undefined"
class="stats-change"
:class="changeClass"
v-motion
:initial="{ opacity: 0, scale: 0.8 }"
:visible="{
opacity: 1,
scale: 1,
transition: {
delay: delay + 400,
duration: 500,
type: 'spring'
}
}"
>
<v-icon size="16">
{{ change >= 0 ? 'mdi-trending-up' : 'mdi-trending-down' }}
</v-icon>
<span>{{ Math.abs(change) }}%</span>
</div>
</div>
<p v-if="subtitle" class="stats-subtitle">{{ subtitle }}</p>
</div>
<!-- Background Decoration -->
<div class="stats-decoration">
<svg viewBox="0 0 200 100" class="stats-chart">
<path
:d="sparklinePath"
fill="none"
:stroke="decorationColor"
stroke-width="2"
stroke-linecap="round"
opacity="0.2"
/>
<path
:d="sparklinePath"
fill="url(#gradient)"
opacity="0.1"
/>
<defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" :stop-color="decorationColor" stop-opacity="0.3" />
<stop offset="100%" :stop-color="decorationColor" stop-opacity="0" />
</linearGradient>
</defs>
</svg>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue';
interface Props {
label: string;
value: number;
icon: string;
iconColor?: string;
iconBackground?: string;
change?: number;
prefix?: string;
suffix?: string;
subtitle?: string;
delay?: number;
decorationColor?: string;
}
const props = withDefaults(defineProps<Props>(), {
iconColor: 'error',
iconBackground: 'linear-gradient(135deg, rgba(220, 38, 38, 0.1), rgba(220, 38, 38, 0.05))',
delay: 0,
decorationColor: '#dc2626'
});
// Animated number component
const AnimatedNumber = {
props: {
value: Number,
duration: { type: Number, default: 1000 },
format: Function
},
setup(props: any) {
const displayValue = ref(0);
onMounted(() => {
const startTime = Date.now();
const startValue = 0;
const endValue = props.value;
const updateValue = () => {
const now = Date.now();
const progress = Math.min((now - startTime) / props.duration, 1);
// Easing function for smooth animation
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
displayValue.value = startValue + (endValue - startValue) * easeOutQuart;
if (progress < 1) {
requestAnimationFrame(updateValue);
} else {
displayValue.value = endValue;
}
};
updateValue();
});
const formattedValue = computed(() => {
if (props.format) {
return props.format(displayValue.value);
}
return Math.round(displayValue.value).toLocaleString();
});
return () => formattedValue.value;
}
};
// Compute change indicator class
const changeClass = computed(() => {
if (props.change === undefined) return '';
return props.change >= 0 ? 'stats-change--positive' : 'stats-change--negative';
});
// Format number function
const formatNumber = (num: number) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return Math.round(num).toLocaleString();
};
// Generate random sparkline path
const sparklinePath = computed(() => {
const points = 10;
const width = 200;
const height = 100;
const values = Array.from({ length: points }, () => Math.random() * 0.6 + 0.2);
const path = values.map((value, index) => {
const x = (index / (points - 1)) * width;
const y = height - (value * height);
return `${index === 0 ? 'M' : 'L'} ${x} ${y}`;
}).join(' ');
return `${path} L ${width} ${height} L 0 ${height} Z`;
});
</script>
<style scoped lang="scss">
.stats-card {
position: relative;
height: 100%;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.9),
rgba(255, 255, 255, 0.7)
);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 1rem;
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);
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
&:hover {
box-shadow:
0 12px 40px rgba(0, 0, 0, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
}
.stats-card-inner {
position: relative;
padding: 1.5rem;
height: 100%;
display: flex;
flex-direction: column;
z-index: 1;
}
.stats-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.stats-content {
flex: 1;
display: flex;
flex-direction: column;
}
.stats-label {
font-size: 0.875rem;
color: rgb(107, 114, 128);
margin: 0 0 0.5rem 0;
font-weight: 500;
}
.stats-value-wrapper {
display: flex;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 0.25rem;
}
.stats-value {
font-size: 2rem;
font-weight: 700;
margin: 0;
background: linear-gradient(135deg, #dc2626, #b91c1c);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1.2;
}
.stats-change {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
&--positive {
background: rgba(34, 197, 94, 0.1);
color: rgb(34, 197, 94);
}
&--negative {
background: rgba(239, 68, 68, 0.1);
color: rgb(239, 68, 68);
}
}
.stats-subtitle {
font-size: 0.75rem;
color: rgb(156, 163, 175);
margin: 0.25rem 0 0 0;
}
.stats-decoration {
position: absolute;
bottom: 0;
right: 0;
width: 60%;
height: 50%;
pointer-events: none;
}
.stats-chart {
width: 100%;
height: 100%;
}
@media (max-width: 640px) {
.stats-value {
font-size: 1.5rem;
}
.stats-card-inner {
padding: 1rem;
}
}
</style>