Files
vmall/apps/mall/pages/user/membership.vue
T
james 9904696e76 feat: wave 2 migration (P3, P5, P7 openspec changes)
Implements, verifies, and archives the three remaining Wave 2 changes from
openspec/MIGRATION-PLAN.md.

- add-wallet-settlement (P3): demo recharge, guarded withdrawal freeze and
  one-time admin review, paginated own fund entries, idempotent per-shop
  weekly/monthly settlement statements with commission rate and one-time
  payout confirmation.
- add-merchant-onboarding (P5): personal/enterprise applications with one live
  application per user, guarded review with mandatory rejection reason, and
  transactional shop + owner provisioning returning one-time credentials;
  mall onboarding/status pages and an admin review console.
- add-membership-messaging (P7): platform member levels, append-only growth
  accrual on order completion with guarded one-way leveling, order/shipment/
  refund system messages with unread/read state and soft deletion, plus the
  mall header unread badge.

Backend: migrations 0019-0023, new wallet, settlement, merchant_onboarding,
membership and messaging modules, event hooks in order/fulfillment/aftersale,
and integration suites for each. Shared contract extended and all three
frontends updated; code indexes, domain docs, backend guidelines and the
migration tracker synced.

Verification: cargo test -p vmall-api green twice consecutively; mall, admin
and shop-admin builds pass; browser smoke on every new surface; openspec
validate --all --strict green (33 passed).

The three changes share the @vmall/shared contract, the mall mock adapter and
per-app locale/nav files, so they are committed together to keep every commit
buildable.
2026-09-25 15:25:29 +00:00

213 lines
7.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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 type { GrowthLogEntry, MembershipStatus } from "@vmall/shared";
import { t as localizedText } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const status = ref<MembershipStatus | null>(null);
const logs = ref<GrowthLogEntry[]>([]);
const logPage = ref(1);
const logTotal = ref(0);
const logPerPage = ref(20);
const loading = ref(true);
const error = ref("");
const logError = ref("");
const REASON_KEYS: Record<string, string> = {
order_complete: "membership.reason_order_complete",
};
function levelName(name: Record<string, string>): string {
return localizedText(name, locale.value);
}
function reasonLabel(reason: string): string {
const key = REASON_KEYS[reason];
return key ? t(key) : reason;
}
async function loadStatus(): Promise<void> {
status.value = await $api.getMembership();
}
async function loadLogs(page: number): Promise<void> {
const result = await $api.listGrowthLogs(page);
logs.value = result.items;
logPage.value = result.page;
logTotal.value = result.total;
logPerPage.value = result.per_page;
}
async function changeLogPage(page: number): Promise<void> {
logError.value = "";
try {
await loadLogs(page);
} catch {
logError.value = t("membership.historyFailed");
}
}
async function load(): Promise<void> {
loading.value = true;
error.value = "";
try {
await Promise.all([loadStatus(), loadLogs(1)]);
} catch {
error.value = t("membership.loadFailed");
} finally {
loading.value = false;
}
}
/**
* Progress inside the current band: from the level's own threshold to the next
* one. Below the lowest threshold the band starts at zero. Growth is a plain
* integer, so the percentage is integer arithmetic with one rounding.
*/
const progressPercent = computed<number | null>(() => {
const current = status.value;
if (!current || !current.next_level) return null;
const base = current.level?.growth_threshold ?? 0;
const span = current.next_level.growth_threshold - base;
if (span <= 0) return null;
const within = Math.min(Math.max(current.growth_total - base, 0), span);
return Math.round((within / span) * 100);
});
onMounted(() => void load());
</script>
<template>
<VCard class="min-h-[560px]">
<h1 class="text-text mb-4 text-xl font-bold">{{ t("membership.title") }}</h1>
<p
v-if="error"
role="alert"
class="border-danger/30 bg-danger/10 text-danger mb-4 border px-3.5 py-2.5 text-sm"
>
{{ error }}
</p>
<div v-else-if="loading" class="text-muted py-10">{{ t("common.loading") }}</div>
<p v-else-if="!status" class="text-muted py-10">{{ t("membership.loadFailed") }}</p>
<template v-else>
<div class="mb-6 grid gap-3 sm:grid-cols-2">
<div class="border-border bg-primary-soft border p-[18px]">
<span class="text-muted block text-xs">{{ t("membership.currentLevel") }}</span>
<div v-if="status.level" class="mt-2.5 flex items-center gap-2.5">
<span
class="border-primary/40 text-primary flex h-9 w-9 items-center justify-center rounded-full border text-sm font-bold"
>{{ status.level.icon }}</span
>
<strong class="text-primary text-xl">{{ levelName(status.level.name) }}</strong>
</div>
<strong v-else class="text-muted mt-2.5 block text-xl">{{
t("membership.noLevel")
}}</strong>
<p v-if="status.level" class="text-muted mt-2 mb-0 text-xs">
{{ t("membership.benefits") }}: {{ levelName(status.level.benefits) }}
</p>
<p v-else-if="status.next_level" class="text-muted mt-2 mb-0 text-xs">
{{
t("membership.noLevelHint", {
threshold: status.next_level.growth_threshold,
unit: t("membership.growthUnit"),
level: levelName(status.next_level.name),
})
}}
</p>
</div>
<div class="border-border bg-bg border p-[18px]">
<span class="text-muted block text-xs">{{ t("membership.growthTotal") }}</span>
<strong class="text-primary mt-2.5 block text-xl">{{ status.growth_total }}</strong>
<span class="text-muted mt-1 block text-xs">{{ t("membership.growthUnit") }}</span>
</div>
</div>
<section class="border-border mb-6 rounded-md border p-4">
<template v-if="status.next_level">
<h2 class="text-text m-0 text-lg font-semibold">
{{ t("membership.progressTitle", { level: levelName(status.next_level.name) }) }}
</h2>
<p class="text-muted mt-1 mb-3 text-xs">
{{
t("membership.progressThreshold", {
current: status.growth_total,
threshold: status.next_level.growth_threshold,
unit: t("membership.growthUnit"),
})
}}
</p>
<div
class="bg-border h-2.5 w-full overflow-hidden rounded-full"
role="progressbar"
:aria-valuenow="progressPercent ?? 0"
aria-valuemin="0"
aria-valuemax="100"
:aria-label="t('membership.progressTitle', { level: levelName(status.next_level.name) })"
>
<div
class="bg-primary h-full rounded-full transition-all"
:style="{ width: `${progressPercent ?? 0}%` }"
/>
</div>
<p class="text-primary mt-2 mb-0 text-xs font-medium">
{{
t("membership.remaining", {
remaining: status.next_level.remaining,
unit: t("membership.growthUnit"),
})
}}
</p>
</template>
<template v-else-if="status.level">
<h2 class="text-text m-0 text-lg font-semibold">{{ t("membership.topLevel") }}</h2>
<p class="text-muted mt-1 mb-0 text-xs">{{ t("membership.topLevelHint") }}</p>
</template>
<p v-else class="text-muted m-0 text-sm">{{ t("membership.noLevel") }}</p>
</section>
<section>
<h2 class="text-text mb-3 text-lg font-semibold">{{ t("membership.historyTitle") }}</h2>
<p v-if="logError" role="alert" class="text-danger mb-3 text-sm">{{ logError }}</p>
<UiEmptyState v-if="logs.length === 0" :text="t('membership.historyEmpty')" />
<template v-else>
<VTable>
<thead>
<tr>
<th>{{ t("membership.historyDate") }}</th>
<th>{{ t("membership.historyReason") }}</th>
<th class="text-right!">{{ t("membership.historyDelta") }}</th>
<th class="text-right!">{{ t("membership.historyTotal") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in logs" :key="entry.id">
<td class="text-muted text-xs whitespace-nowrap">
{{ entry.created_at.slice(0, 10) }}
</td>
<td>{{ reasonLabel(entry.reason) }}</td>
<td
class="text-right font-semibold"
:class="entry.delta < 0 ? 'text-danger' : 'text-success'"
>
{{ entry.delta < 0 ? "−" : "+" }}{{ Math.abs(entry.delta) }}
</td>
<td class="text-right">{{ entry.growth_total }}</td>
</tr>
</tbody>
</VTable>
<UiPagination
:page="logPage"
:total="logTotal"
:per-page="logPerPage"
@change="changeLogPage"
/>
</template>
</section>
</template>
</VCard>
</template>