Files
CRM_Montzar/apps/web/src/components/gamification/ClubLeaderboard.vue
T

379 lines
8.4 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import type { GamificationLeaderboardDto, GamificationLeaderEntryDto, GamificationPeriod } from '@mento/shared';
import { GAMIFICATION_PERIOD_LABELS } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { apiError, faNum } from '@/lib/gamification';
const PERIODS: GamificationPeriod[] = ['today', 'week', 'month', 'quarter', 'year', 'all'];
const period = ref<GamificationPeriod>('month');
const board = ref<GamificationLeaderboardDto | null>(null);
const loading = ref(false);
const error = ref('');
async function load() {
loading.value = true;
try {
const { data } = await api.get<GamificationLeaderboardDto>('/gamification/leaderboard', {
params: { period: period.value, limit: 50 },
});
board.value = data;
error.value = '';
} catch (e) {
error.value = apiError(e, 'بارگذاری جدول امتیازات ممکن نشد.');
} finally {
loading.value = false;
}
}
watch(period, load);
onMounted(() => {
void load();
window.addEventListener('gamification:updated', load);
});
onBeforeUnmount(() => window.removeEventListener('gamification:updated', load));
const top3 = computed(() => (board.value?.items ?? []).slice(0, 3).filter((x) => x.score > 0));
const rest = computed(() => {
const items = board.value?.items ?? [];
return top3.value.length ? items.slice(top3.value.length) : items;
});
const meOutside = computed(() => {
const b = board.value;
if (!b?.me) return null;
return b.items.some((i) => i.userId === b.me!.userId) ? null : b.me;
});
function changeLabel(e: GamificationLeaderEntryDto) {
if (!e.change) return '';
return `${toFaDigits(Math.abs(e.change))}`;
}
const MEDALS = ['#f59e0b', '#94a3b8', '#c2410c'];
</script>
<template>
<section class="board">
<header class="b-head">
<div>
<h3><UiIcon name="trophy" :size="18" /> جدول امتیازات</h3>
<small v-if="board">{{ toFaDigits(board.totalUsers) }} همکار در رقابت</small>
</div>
<div class="periods" role="tablist">
<button
v-for="p in PERIODS"
:key="p"
type="button"
role="tab"
:aria-selected="period === p"
:class="{ on: period === p }"
@click="period = p"
>
{{ GAMIFICATION_PERIOD_LABELS[p] }}
</button>
</div>
</header>
<p v-if="error" class="err">{{ error }}</p>
<div v-if="top3.length" class="top3" :class="{ loading }">
<div
v-for="(e, i) in top3"
:key="e.userId"
class="t-card"
:class="{ me: e.isMe }"
:style="{ '--m': MEDALS[i] }"
>
<span class="t-rank">{{ toFaDigits(e.rank) }}</span>
<UserAvatar :user-id="e.userId" :has-avatar="e.hasAvatar" :name="e.fullName" :size="46" />
<strong>{{ e.isMe ? 'شما' : e.fullName }}</strong>
<small>{{ e.levelName }}</small>
<b>{{ faNum(e.score) }} <span>امتیاز</span></b>
</div>
</div>
<ol class="rows" :class="{ loading }">
<li v-for="e in rest" :key="e.userId" :class="{ me: e.isMe }">
<span class="rank">{{ toFaDigits(e.rank) }}</span>
<UserAvatar :user-id="e.userId" :has-avatar="e.hasAvatar" :name="e.fullName" :size="32" />
<span class="who">
<b>{{ e.isMe ? 'شما' : e.fullName }}</b>
<small>{{ e.levelName }}</small>
</span>
<span v-if="e.change" class="chg" :class="e.change > 0 ? 'up' : 'down'">
{{ e.change > 0 ? '▲' : '▼' }}{{ changeLabel(e) }}
</span>
<span class="score">{{ faNum(e.score) }}</span>
</li>
<template v-if="meOutside">
<li class="sep" aria-hidden="true">⋯</li>
<li class="me">
<span class="rank">{{ toFaDigits(meOutside.rank) }}</span>
<UserAvatar :user-id="meOutside.userId" :has-avatar="meOutside.hasAvatar" :name="meOutside.fullName" :size="32" />
<span class="who">
<b>شما</b>
<small>{{ meOutside.levelName }}</small>
</span>
<span v-if="meOutside.change" class="chg" :class="meOutside.change > 0 ? 'up' : 'down'">
{{ meOutside.change > 0 ? '▲' : '▼' }}{{ changeLabel(meOutside) }}
</span>
<span class="score">{{ faNum(meOutside.score) }}</span>
</li>
</template>
<li v-if="board && !board.items.length" class="empty">هنوز کسی در این بازه امتیاز نگرفته است.</li>
</ol>
</section>
</template>
<style scoped>
.board {
background: var(--surface);
border: 1px solid var(--line);
border-radius: calc(var(--radius) + 4px);
padding: var(--sp-4);
box-shadow: var(--shadow-xs);
display: grid;
gap: var(--sp-3);
min-width: 0;
}
.b-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
flex-wrap: wrap;
}
.b-head h3 {
margin: 0;
display: flex;
align-items: center;
gap: 6px;
font-size: var(--fs-md);
color: var(--ink);
}
.b-head h3 :deep(svg) {
color: #f59e0b;
}
.b-head small {
font-size: var(--fs-xs);
color: var(--muted);
}
.periods {
display: flex;
gap: 4px;
background: var(--surface-subtle);
border-radius: 999px;
padding: 3px;
max-width: 100%;
overflow-x: auto;
scrollbar-width: none;
}
.periods::-webkit-scrollbar {
display: none;
}
.periods button {
border: 0;
background: none;
border-radius: 999px;
padding: 0.3rem 0.7rem;
font: inherit;
font-size: var(--fs-xs);
font-weight: 700;
color: var(--muted);
cursor: pointer;
white-space: nowrap;
flex: 0 0 auto;
}
.periods button.on {
background: var(--surface);
color: var(--accent-deep);
box-shadow: var(--shadow-xs);
}
.err {
margin: 0;
color: var(--danger);
font-size: var(--fs-sm);
}
.top3 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--sp-2);
}
.t-card {
--m: #f59e0b;
position: relative;
display: grid;
justify-items: center;
gap: 3px;
padding: var(--sp-3) var(--sp-2);
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--m) 35%, transparent);
background: linear-gradient(180deg, color-mix(in srgb, var(--m) 12%, var(--surface)), var(--surface));
text-align: center;
min-width: 0;
}
.t-card.me {
box-shadow: 0 0 0 2px var(--accent);
}
.t-rank {
position: absolute;
top: 8px;
inset-inline-start: 8px;
width: 22px;
height: 22px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--m);
color: #fff;
font-size: 0.75rem;
font-weight: 800;
}
.t-card strong {
font-size: var(--fs-sm);
color: var(--ink);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.t-card small {
font-size: 0.7rem;
color: var(--muted);
}
.t-card b {
font-size: var(--fs-md);
font-weight: 800;
color: var(--ink);
}
.t-card b span {
font-size: 0.68rem;
font-weight: 600;
color: var(--muted);
}
.rows {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 4px;
}
.rows li {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 7px var(--sp-2);
border-radius: 10px;
transition: background 0.15s ease;
}
.rows li:hover {
background: var(--surface-hover);
}
.rows li.me {
background: var(--accent-subtle);
box-shadow: inset 0 0 0 1px var(--accent-soft);
}
.rows li.sep {
justify-content: center;
color: var(--muted-light);
padding: 0;
}
.rank {
width: 26px;
text-align: center;
font-weight: 800;
color: var(--muted);
font-size: var(--fs-sm);
flex: 0 0 auto;
}
.who {
flex: 1;
min-width: 0;
display: grid;
}
.who b {
font-size: var(--fs-sm);
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.who small {
font-size: 0.68rem;
color: var(--muted);
}
.chg {
font-size: 0.7rem;
font-weight: 800;
flex: 0 0 auto;
}
.chg.up {
color: var(--success-deep);
}
.chg.down {
color: var(--muted-light);
}
.score {
font-weight: 800;
color: var(--ink);
font-size: var(--fs-sm);
min-width: 52px;
text-align: end;
flex: 0 0 auto;
}
.loading {
opacity: 0.55;
}
.empty {
justify-content: center;
color: var(--muted);
font-size: var(--fs-sm);
}
@media (max-width: 520px) {
.top3 {
gap: 6px;
}
.t-card {
padding: var(--sp-2) 4px;
}
}
</style>