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.
357 lines
11 KiB
Vue
357 lines
11 KiB
Vue
<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>
|