feat(shop-admin): manage group-buying activities
Add a group-buying page where a shop user lists, creates, edits, and deletes its own activities, configuring the SKU, localized name and description, group price, required paid members, active window, and group lifetime, and seeing each activity's open groups. Expose it as a group-buying navigation entry.
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
<script setup lang="ts">
|
||||
import type { Currency, GroupBuyingActivityView } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { locale, t: translate } = useI18n();
|
||||
const { load: loadMoney, fmt } = useMoney();
|
||||
|
||||
const activities = ref<GroupBuyingActivityView[]>([]);
|
||||
const currencies = ref<Currency[]>([]);
|
||||
const skuOptions = ref<{ id: string; label: string }[]>([]);
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const actionId = ref("");
|
||||
const error = ref("");
|
||||
const message = ref("");
|
||||
const editingId = ref("");
|
||||
|
||||
const form = reactive({
|
||||
skuId: "",
|
||||
nameEn: "",
|
||||
nameZh: "",
|
||||
descriptionEn: "",
|
||||
descriptionZh: "",
|
||||
groupPriceMinor: 0,
|
||||
currency: "USD",
|
||||
requiredMembers: 2,
|
||||
groupLifetimeHours: 24,
|
||||
startsAt: "",
|
||||
endsAt: "",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
function toLocalInput(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function resetForm(): void {
|
||||
const now = new Date();
|
||||
const later = new Date(now.getTime() + 7 * 24 * 3600 * 1000);
|
||||
editingId.value = "";
|
||||
form.skuId = skuOptions.value[0]?.id ?? "";
|
||||
form.nameEn = "";
|
||||
form.nameZh = "";
|
||||
form.descriptionEn = "";
|
||||
form.descriptionZh = "";
|
||||
form.groupPriceMinor = 0;
|
||||
form.currency =
|
||||
currencies.value.find((c) => c.is_base)?.code ?? currencies.value[0]?.code ?? "USD";
|
||||
form.requiredMembers = 2;
|
||||
form.groupLifetimeHours = 24;
|
||||
form.startsAt = toLocalInput(now.toISOString());
|
||||
form.endsAt = toLocalInput(later.toISOString());
|
||||
form.enabled = true;
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
activities.value = await $api.shop.listGroupBuyingActivities();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCatalog(): Promise<void> {
|
||||
try {
|
||||
const page = await $api.shop.listMyProducts({ per_page: 100 });
|
||||
skuOptions.value = page.items.flatMap((product) =>
|
||||
(product.skus ?? []).map((sku) => ({
|
||||
id: sku.id,
|
||||
label: `${product.name[locale.value] ?? product.name.en ?? product.slug} · ${sku.sku_code}`,
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
skuOptions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCurrencies(): Promise<void> {
|
||||
try {
|
||||
currencies.value = (await $api.listCurrencies()).filter((c) => c.enabled);
|
||||
} catch {
|
||||
currencies.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
error.value = "";
|
||||
message.value = "";
|
||||
if (!form.nameEn.trim() || !form.nameZh.trim()) {
|
||||
error.value = translate("shop.groupNameRequired");
|
||||
return;
|
||||
}
|
||||
if (!form.skuId) {
|
||||
error.value = translate("shop.groupSkuRequired");
|
||||
return;
|
||||
}
|
||||
if (!(Number(form.groupPriceMinor) > 0)) {
|
||||
error.value = translate("shop.groupPriceInvalid");
|
||||
return;
|
||||
}
|
||||
if (Number(form.requiredMembers) < 2) {
|
||||
error.value = translate("shop.groupMembersInvalid");
|
||||
return;
|
||||
}
|
||||
if (!(Number(form.groupLifetimeHours) > 0)) {
|
||||
error.value = translate("shop.groupLifetimeInvalid");
|
||||
return;
|
||||
}
|
||||
if (new Date(form.endsAt) < new Date(form.startsAt)) {
|
||||
error.value = translate("shop.groupWindowInvalid");
|
||||
return;
|
||||
}
|
||||
const description =
|
||||
form.descriptionEn.trim() && form.descriptionZh.trim()
|
||||
? { en: form.descriptionEn.trim(), zh: form.descriptionZh.trim() }
|
||||
: null;
|
||||
const body = {
|
||||
sku_id: form.skuId,
|
||||
name: { en: form.nameEn.trim(), zh: form.nameZh.trim() },
|
||||
description,
|
||||
group_price_minor: Number(form.groupPriceMinor),
|
||||
currency: form.currency,
|
||||
required_members: Number(form.requiredMembers),
|
||||
starts_at: new Date(form.startsAt).toISOString(),
|
||||
ends_at: new Date(form.endsAt).toISOString(),
|
||||
group_lifetime_hours: Number(form.groupLifetimeHours),
|
||||
enabled: form.enabled,
|
||||
};
|
||||
saving.value = true;
|
||||
try {
|
||||
if (editingId.value) await $api.shop.updateGroupBuyingActivity(editingId.value, body);
|
||||
else await $api.shop.createGroupBuyingActivity(body);
|
||||
message.value = translate("shop.groupActivitySaved");
|
||||
resetForm();
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function edit(activity: GroupBuyingActivityView): void {
|
||||
editingId.value = activity.id;
|
||||
form.skuId = activity.sku_id;
|
||||
form.nameEn = activity.name.en ?? "";
|
||||
form.nameZh = activity.name.zh ?? "";
|
||||
form.descriptionEn = activity.description?.en ?? "";
|
||||
form.descriptionZh = activity.description?.zh ?? "";
|
||||
form.groupPriceMinor = activity.group_price_minor;
|
||||
form.currency = activity.currency;
|
||||
form.requiredMembers = activity.required_members;
|
||||
form.groupLifetimeHours = activity.group_lifetime_hours;
|
||||
form.startsAt = toLocalInput(activity.starts_at);
|
||||
form.endsAt = toLocalInput(activity.ends_at);
|
||||
form.enabled = activity.enabled;
|
||||
}
|
||||
|
||||
async function remove(activity: GroupBuyingActivityView): Promise<void> {
|
||||
actionId.value = activity.id;
|
||||
error.value = "";
|
||||
try {
|
||||
await $api.shop.deleteGroupBuyingActivity(activity.id);
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
actionId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadMoney();
|
||||
await Promise.all([loadCurrencies(), loadCatalog()]);
|
||||
resetForm();
|
||||
await load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<h1 class="page-title">{{ $t("shop.groupBuyingList") }}</h1>
|
||||
<div v-if="error" class="error-text" role="alert">{{ error }}</div>
|
||||
<div v-if="message" class="muted" role="status">{{ message }}</div>
|
||||
|
||||
<section class="card create-card">
|
||||
<h2>{{ editingId ? $t("shop.editGroupActivity") : $t("shop.newGroupActivity") }}</h2>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
<span>{{ $t("shop.groupSku") }}</span>
|
||||
<select v-model="form.skuId" class="input">
|
||||
<option value="">{{ $t("shop.flashSelectSku") }}</option>
|
||||
<option v-for="option in skuOptions" :key="option.id" :value="option.id">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t("shop.groupNameEn") }}</span>
|
||||
<input v-model="form.nameEn" class="input" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t("shop.groupNameZh") }}</span>
|
||||
<input v-model="form.nameZh" class="input" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t("shop.groupDescriptionEn") }}</span>
|
||||
<input v-model="form.descriptionEn" class="input" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t("shop.groupDescriptionZh") }}</span>
|
||||
<input v-model="form.descriptionZh" class="input" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t("shop.groupPrice") }}</span>
|
||||
<input v-model.number="form.groupPriceMinor" class="input" type="number" min="1" step="1" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t("shop.couponCurrency") }}</span>
|
||||
<select v-model="form.currency" class="input">
|
||||
<option v-for="c in currencies" :key="c.code" :value="c.code">{{ c.code }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t("shop.groupRequiredMembers") }}</span>
|
||||
<input v-model.number="form.requiredMembers" class="input" type="number" min="2" step="1" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t("shop.groupLifetimeHours") }}</span>
|
||||
<input v-model.number="form.groupLifetimeHours" class="input" type="number" min="1" step="1" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t("shop.groupStartsAt") }}</span>
|
||||
<input v-model="form.startsAt" class="input" type="datetime-local" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t("shop.groupEndsAt") }}</span>
|
||||
<input v-model="form.endsAt" class="input" type="datetime-local" />
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input v-model="form.enabled" type="checkbox" />
|
||||
<span>{{ $t("shop.groupEnabled") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn primary" :disabled="saving" @click="save">
|
||||
{{ editingId ? $t("shop.updateGroupActivity") : $t("shop.createGroupActivity") }}
|
||||
</button>
|
||||
<button v-if="editingId" class="btn" type="button" @click="resetForm">
|
||||
{{ $t("common.cancel") }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
|
||||
<div v-else-if="!activities.length" class="card muted">{{ $t("common.empty") }}</div>
|
||||
<div v-else class="table-wrap card">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("shop.groupSku") }}</th>
|
||||
<th>{{ $t("shop.groupNameEn") }}</th>
|
||||
<th>{{ $t("shop.groupPrice") }}</th>
|
||||
<th>{{ $t("shop.groupRequiredMembers") }}</th>
|
||||
<th>{{ $t("shop.groupLifetimeHours") }}</th>
|
||||
<th>{{ $t("shop.groupStartsAt") }}</th>
|
||||
<th>{{ $t("shop.groupOpenGroups") }}</th>
|
||||
<th>{{ $t("common.status") }}</th>
|
||||
<th>{{ $t("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="activity in activities" :key="activity.id">
|
||||
<td>{{ activity.sku_code }}</td>
|
||||
<td>{{ activity.name[locale] ?? activity.name.en }}</td>
|
||||
<td>{{ fmt(activity.group_price_minor, activity.currency) }}</td>
|
||||
<td>{{ activity.required_members }}</td>
|
||||
<td>{{ activity.group_lifetime_hours }}</td>
|
||||
<td>{{ activity.starts_at.slice(0, 10) }}</td>
|
||||
<td>
|
||||
<template v-if="activity.open_groups.length">
|
||||
<span v-for="group in activity.open_groups" :key="group.id" class="badge orange">
|
||||
{{ $t("shop.groupPaidMembers") }} {{ group.paid_member_count }}/{{ group.required_members }}
|
||||
</span>
|
||||
</template>
|
||||
<span v-else class="muted">{{ $t("shop.groupNoOpenGroups") }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :class="activity.enabled ? 'green' : 'red'">
|
||||
{{ activity.enabled ? $t("shop.enabledOn") : $t("shop.enabledOff") }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="action-row">
|
||||
<button class="btn sm" :disabled="actionId === activity.id" @click="edit(activity)">
|
||||
{{ $t("shop.edit") }}
|
||||
</button>
|
||||
<button class="btn sm" :disabled="actionId === activity.id" @click="remove(activity)">
|
||||
{{ $t("common.delete") }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.create-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.create-card h2 {
|
||||
font-size: 16px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.form-grid label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.form-grid .checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
padding: 0;
|
||||
}
|
||||
.table {
|
||||
min-width: 1100px;
|
||||
}
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user