feat(freight): shop freight templates, server-side checkout fees, company dictionary (add-freight-templates)
This commit is contained in:
@@ -0,0 +1,449 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
FreightRegionRule,
|
||||
FreightRegionRuleInput,
|
||||
FreightTemplate,
|
||||
FreightTemplateInput,
|
||||
} from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { t: translate } = useI18n();
|
||||
const { load: loadMoney, fmt, shopCurrency, majorToMinor, minorToMajor } = useMoney();
|
||||
|
||||
interface RuleForm {
|
||||
regions: string;
|
||||
firstFeeMajor: string;
|
||||
firstUnit: string;
|
||||
additionalFeeMajor: string;
|
||||
additionalUnit: string;
|
||||
}
|
||||
|
||||
const templates = ref<FreightTemplate[]>([]);
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const actionId = ref("");
|
||||
const error = ref("");
|
||||
const message = ref("");
|
||||
const editingId = ref("");
|
||||
|
||||
const form = reactive({
|
||||
name: "",
|
||||
pricingMethod: "by_piece" as FreightTemplate["pricing_method"],
|
||||
firstFeeMajor: "0",
|
||||
firstUnit: "1",
|
||||
additionalFeeMajor: "0",
|
||||
additionalUnit: "1",
|
||||
freeThresholdMajor: "",
|
||||
alwaysFree: false,
|
||||
isDefault: false,
|
||||
});
|
||||
const rules = ref<RuleForm[]>([]);
|
||||
|
||||
/** Unit word (pcs / g) for the given pricing method; many table + form call sites. */
|
||||
function unitOf(method: FreightTemplate["pricing_method"]): string {
|
||||
return method === "by_weight" ? translate("freight.unitGram") : translate("freight.unitPiece");
|
||||
}
|
||||
|
||||
function parseUnit(value: string): number | null {
|
||||
const clean = value.trim();
|
||||
return /^[1-9]\d*$/.test(clean) ? Number(clean) : null;
|
||||
}
|
||||
|
||||
function toRuleInput(rule: FreightRegionRule): FreightRegionRuleInput {
|
||||
return {
|
||||
regions: rule.regions,
|
||||
first_fee_minor: rule.first_fee_minor,
|
||||
first_unit: rule.first_unit,
|
||||
additional_fee_minor: rule.additional_fee_minor,
|
||||
additional_unit: rule.additional_unit,
|
||||
};
|
||||
}
|
||||
|
||||
/** Validated payload, or the first validation error to show. */
|
||||
function payload(): FreightTemplateInput | string {
|
||||
const name = form.name.trim();
|
||||
if (!name) return translate("freight.nameRequired");
|
||||
const firstFee = majorToMinor(form.firstFeeMajor, shopCurrency.value);
|
||||
const additionalFee = majorToMinor(form.additionalFeeMajor, shopCurrency.value);
|
||||
if (firstFee === null || additionalFee === null) return translate("freight.feeInvalid");
|
||||
const firstUnit = parseUnit(form.firstUnit);
|
||||
const additionalUnit = parseUnit(form.additionalUnit);
|
||||
if (firstUnit === null || additionalUnit === null) return translate("freight.unitInvalid");
|
||||
let threshold: number | null = null;
|
||||
if (form.freeThresholdMajor.trim()) {
|
||||
const parsed = majorToMinor(form.freeThresholdMajor, shopCurrency.value);
|
||||
if (parsed === null) return translate("freight.thresholdInvalid");
|
||||
threshold = parsed;
|
||||
}
|
||||
const regionRules: FreightRegionRuleInput[] = [];
|
||||
for (const rule of rules.value) {
|
||||
const regions = rule.regions
|
||||
.split(",")
|
||||
.map((region) => region.trim())
|
||||
.filter((region) => region.length > 0);
|
||||
if (!regions.length) return translate("freight.ruleRegionsRequired");
|
||||
const ruleFirstFee = majorToMinor(rule.firstFeeMajor, shopCurrency.value);
|
||||
const ruleAdditionalFee = majorToMinor(rule.additionalFeeMajor, shopCurrency.value);
|
||||
if (ruleFirstFee === null || ruleAdditionalFee === null) return translate("freight.feeInvalid");
|
||||
const ruleFirstUnit = parseUnit(rule.firstUnit);
|
||||
const ruleAdditionalUnit = parseUnit(rule.additionalUnit);
|
||||
if (ruleFirstUnit === null || ruleAdditionalUnit === null)
|
||||
return translate("freight.unitInvalid");
|
||||
regionRules.push({
|
||||
regions,
|
||||
first_fee_minor: ruleFirstFee,
|
||||
first_unit: ruleFirstUnit,
|
||||
additional_fee_minor: ruleAdditionalFee,
|
||||
additional_unit: ruleAdditionalUnit,
|
||||
});
|
||||
}
|
||||
return {
|
||||
name,
|
||||
is_default: form.isDefault,
|
||||
always_free: form.alwaysFree,
|
||||
pricing_method: form.pricingMethod,
|
||||
first_fee_minor: firstFee,
|
||||
first_unit: firstUnit,
|
||||
additional_fee_minor: additionalFee,
|
||||
additional_unit: additionalUnit,
|
||||
free_threshold_minor: threshold,
|
||||
region_rules: regionRules,
|
||||
};
|
||||
}
|
||||
|
||||
function resetForm(): void {
|
||||
editingId.value = "";
|
||||
form.name = "";
|
||||
form.pricingMethod = "by_piece";
|
||||
form.firstFeeMajor = "0";
|
||||
form.firstUnit = "1";
|
||||
form.additionalFeeMajor = "0";
|
||||
form.additionalUnit = "1";
|
||||
form.freeThresholdMajor = "";
|
||||
form.alwaysFree = false;
|
||||
form.isDefault = false;
|
||||
rules.value = [];
|
||||
error.value = "";
|
||||
}
|
||||
|
||||
function addRule(): void {
|
||||
rules.value.push({
|
||||
regions: "",
|
||||
firstFeeMajor: "0",
|
||||
firstUnit: "1",
|
||||
additionalFeeMajor: "0",
|
||||
additionalUnit: "1",
|
||||
});
|
||||
}
|
||||
|
||||
function removeRule(index: number): void {
|
||||
if (!confirm(translate("freight.deleteRuleConfirm"))) return;
|
||||
rules.value.splice(index, 1);
|
||||
}
|
||||
|
||||
function edit(template: FreightTemplate): void {
|
||||
editingId.value = template.id;
|
||||
form.name = template.name;
|
||||
form.pricingMethod = template.pricing_method;
|
||||
form.firstFeeMajor = minorToMajor(template.first_fee_minor, shopCurrency.value);
|
||||
form.firstUnit = String(template.first_unit);
|
||||
form.additionalFeeMajor = minorToMajor(template.additional_fee_minor, shopCurrency.value);
|
||||
form.additionalUnit = String(template.additional_unit);
|
||||
form.freeThresholdMajor =
|
||||
template.free_threshold_minor === null
|
||||
? ""
|
||||
: minorToMajor(template.free_threshold_minor, shopCurrency.value);
|
||||
form.alwaysFree = template.always_free;
|
||||
form.isDefault = template.is_default;
|
||||
rules.value = template.region_rules.map((rule) => ({
|
||||
regions: rule.regions.join(", "),
|
||||
firstFeeMajor: minorToMajor(rule.first_fee_minor, shopCurrency.value),
|
||||
firstUnit: String(rule.first_unit),
|
||||
additionalFeeMajor: minorToMajor(rule.additional_fee_minor, shopCurrency.value),
|
||||
additionalUnit: String(rule.additional_unit),
|
||||
}));
|
||||
error.value = "";
|
||||
message.value = "";
|
||||
}
|
||||
|
||||
async function loadTemplates(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
templates.value = await $api.shop.listFreightTemplates();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
error.value = "";
|
||||
message.value = "";
|
||||
const body = payload();
|
||||
if (typeof body === "string") {
|
||||
error.value = body;
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
if (editingId.value) await $api.shop.updateFreightTemplate(editingId.value, body);
|
||||
else await $api.shop.createFreightTemplate(body);
|
||||
message.value = translate("freight.saved");
|
||||
resetForm();
|
||||
await loadTemplates();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setDefault(template: FreightTemplate): Promise<void> {
|
||||
actionId.value = template.id;
|
||||
error.value = "";
|
||||
try {
|
||||
await $api.shop.updateFreightTemplate(template.id, {
|
||||
name: template.name,
|
||||
is_default: true,
|
||||
always_free: template.always_free,
|
||||
pricing_method: template.pricing_method,
|
||||
first_fee_minor: template.first_fee_minor,
|
||||
first_unit: template.first_unit,
|
||||
additional_fee_minor: template.additional_fee_minor,
|
||||
additional_unit: template.additional_unit,
|
||||
free_threshold_minor: template.free_threshold_minor,
|
||||
region_rules: template.region_rules.map(toRuleInput),
|
||||
});
|
||||
message.value = translate("freight.defaultSaved");
|
||||
await loadTemplates();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
actionId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(template: FreightTemplate): Promise<void> {
|
||||
if (!confirm(translate("freight.deleteConfirm"))) return;
|
||||
actionId.value = template.id;
|
||||
error.value = "";
|
||||
message.value = "";
|
||||
try {
|
||||
await $api.shop.deleteFreightTemplate(template.id);
|
||||
message.value = translate("freight.deleted");
|
||||
if (editingId.value === template.id) resetForm();
|
||||
await loadTemplates();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
actionId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadMoney();
|
||||
resetForm();
|
||||
await loadTemplates();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VPage :title="$t('freight.title')">
|
||||
<div v-if="error" class="text-danger my-2 text-sm" role="alert">{{ error }}</div>
|
||||
<div v-if="message" class="text-muted my-2 text-sm" role="status">{{ message }}</div>
|
||||
|
||||
<VCard class="mb-5">
|
||||
<h2 class="mb-4 text-base font-semibold">
|
||||
{{ editingId ? $t("freight.editTemplate") : $t("freight.newTemplate") }}
|
||||
</h2>
|
||||
<div class="my-3 grid [grid-template-columns:repeat(auto-fit,minmax(220px,1fr))] gap-3">
|
||||
<VField :label="$t('freight.name')">
|
||||
<VInput id="freight-name" v-model="form.name" type="text" />
|
||||
</VField>
|
||||
<VField :label="$t('freight.pricingMethod')">
|
||||
<select
|
||||
id="freight-pricing-method"
|
||||
v-model="form.pricingMethod"
|
||||
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 w-full rounded-md border px-3 py-2 text-sm focus:outline-2"
|
||||
>
|
||||
<option value="by_piece">{{ $t("freight.methodByPiece") }}</option>
|
||||
<option value="by_weight">{{ $t("freight.methodByWeight") }}</option>
|
||||
</select>
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.firstFeeMajor')} · ${shopCurrency}`">
|
||||
<VInput id="freight-first-fee" v-model="form.firstFeeMajor" inputmode="decimal" />
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.firstUnit')} (${unitOf(form.pricingMethod)})`">
|
||||
<VInput id="freight-first-unit" v-model="form.firstUnit" inputmode="numeric" />
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.additionalFeeMajor')} · ${shopCurrency}`">
|
||||
<VInput
|
||||
id="freight-additional-fee"
|
||||
v-model="form.additionalFeeMajor"
|
||||
inputmode="decimal"
|
||||
/>
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.additionalUnit')} (${unitOf(form.pricingMethod)})`">
|
||||
<VInput id="freight-additional-unit" v-model="form.additionalUnit" inputmode="numeric" />
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.freeThresholdMajor')} · ${shopCurrency}`">
|
||||
<VInput
|
||||
id="freight-free-threshold"
|
||||
v-model="form.freeThresholdMajor"
|
||||
inputmode="decimal"
|
||||
/>
|
||||
</VField>
|
||||
<div class="grid content-start gap-2">
|
||||
<label class="text-text flex items-center gap-2 text-sm font-medium"
|
||||
><input v-model="form.alwaysFree" type="checkbox" class="accent-primary h-4 w-4" />
|
||||
{{ $t("freight.alwaysFree") }}</label
|
||||
>
|
||||
<label class="text-text flex items-center gap-2 text-sm font-medium"
|
||||
><input v-model="form.isDefault" type="checkbox" class="accent-primary h-4 w-4" />
|
||||
{{ $t("freight.isDefault") }}</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-2 text-base font-semibold">{{ $t("freight.regionRules") }}</h3>
|
||||
<div v-if="!rules.length" class="text-muted mb-3 text-sm">
|
||||
{{ $t("freight.noRegionRules") }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(rule, index) in rules"
|
||||
:key="index"
|
||||
class="border-border bg-bg mb-3 rounded-md border p-3"
|
||||
>
|
||||
<div class="grid [grid-template-columns:repeat(auto-fit,minmax(200px,1fr))] gap-3">
|
||||
<VField :label="$t('freight.ruleRegions')">
|
||||
<VInput
|
||||
:id="`rule-regions-${index}`"
|
||||
v-model="rule.regions"
|
||||
type="text"
|
||||
:placeholder="$t('freight.ruleRegions')"
|
||||
/>
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.firstFeeMajor')} · ${shopCurrency}`">
|
||||
<VInput
|
||||
:id="`rule-first-fee-${index}`"
|
||||
v-model="rule.firstFeeMajor"
|
||||
inputmode="decimal"
|
||||
/>
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.firstUnit')} (${unitOf(form.pricingMethod)})`">
|
||||
<VInput :id="`rule-first-unit-${index}`" v-model="rule.firstUnit" inputmode="numeric" />
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.additionalFeeMajor')} · ${shopCurrency}`">
|
||||
<VInput
|
||||
:id="`rule-additional-fee-${index}`"
|
||||
v-model="rule.additionalFeeMajor"
|
||||
inputmode="decimal"
|
||||
/>
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.additionalUnit')} (${unitOf(form.pricingMethod)})`">
|
||||
<VInput
|
||||
:id="`rule-additional-unit-${index}`"
|
||||
v-model="rule.additionalUnit"
|
||||
inputmode="numeric"
|
||||
/>
|
||||
</VField>
|
||||
</div>
|
||||
<VBtn size="sm" variant="danger" @click="removeRule(index)">
|
||||
{{ $t("freight.deleteRule") }}
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<VBtn @click="addRule">{{ $t("freight.addRule") }}</VBtn>
|
||||
<VBtn variant="primary" :disabled="saving" @click="submit">
|
||||
{{ editingId ? $t("freight.updateTemplate") : $t("freight.createTemplate") }}
|
||||
</VBtn>
|
||||
<VBtn v-if="editingId" @click="resetForm">{{ $t("common.cancel") }}</VBtn>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<p v-if="loading" class="text-muted">{{ $t("common.loading") }}</p>
|
||||
<VCard v-else-if="!templates.length" class="text-muted">{{ $t("common.empty") }}</VCard>
|
||||
<VTable v-else>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("freight.name") }}</th>
|
||||
<th>{{ $t("freight.pricingMethod") }}</th>
|
||||
<th>
|
||||
{{ $t("freight.firstFeeShort") }} / {{ $t("freight.additionalFeeShort") }}
|
||||
</th>
|
||||
<th>{{ $t("freight.freeThresholdMajor") }}</th>
|
||||
<th>{{ $t("freight.isDefault") }}</th>
|
||||
<th>{{ $t("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="template in templates" :key="template.id">
|
||||
<td>
|
||||
<div class="font-medium">{{ template.name }}</div>
|
||||
<ul v-if="template.region_rules.length" class="text-muted mt-1 grid gap-0.5 text-xs">
|
||||
<li v-for="rule in template.region_rules" :key="rule.id">
|
||||
{{ rule.regions.join(", ") }}:
|
||||
{{ fmt(rule.first_fee_minor, shopCurrency) }}/{{ rule.first_unit }}
|
||||
{{ unitOf(template.pricing_method) }}
|
||||
+ {{ fmt(rule.additional_fee_minor, shopCurrency) }}/{{ rule.additional_unit }}
|
||||
{{ unitOf(template.pricing_method) }}
|
||||
</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td>
|
||||
{{
|
||||
template.pricing_method === "by_weight"
|
||||
? $t("freight.methodByWeight")
|
||||
: $t("freight.methodByPiece")
|
||||
}}
|
||||
</td>
|
||||
<td>
|
||||
<div>
|
||||
{{ fmt(template.first_fee_minor, shopCurrency) }} / {{ template.first_unit }}
|
||||
{{ unitOf(template.pricing_method) }}
|
||||
</div>
|
||||
<div>
|
||||
+ {{ fmt(template.additional_fee_minor, shopCurrency) }} /
|
||||
{{ template.additional_unit }} {{ unitOf(template.pricing_method) }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<VBadge v-if="template.always_free" tone="green">{{ $t("freight.alwaysFree") }}</VBadge>
|
||||
<span v-else-if="template.free_threshold_minor !== null">
|
||||
{{ fmt(template.free_threshold_minor, shopCurrency) }}
|
||||
</span>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
<td>
|
||||
<VBadge v-if="template.is_default" tone="blue">{{ $t("freight.defaultTag") }}</VBadge>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
<td class="flex flex-wrap gap-1.5">
|
||||
<VBtn size="sm" :disabled="actionId === template.id" @click="edit(template)">{{
|
||||
$t("shop.edit")
|
||||
}}</VBtn>
|
||||
<VBtn
|
||||
v-if="!template.is_default"
|
||||
size="sm"
|
||||
:disabled="actionId === template.id"
|
||||
@click="setDefault(template)"
|
||||
>{{ $t("freight.setDefault") }}</VBtn
|
||||
>
|
||||
<VBtn
|
||||
size="sm"
|
||||
variant="danger"
|
||||
:disabled="actionId === template.id"
|
||||
@click="remove(template)"
|
||||
>{{ $t("common.delete") }}</VBtn
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</VPage>
|
||||
</template>
|
||||
Reference in New Issue
Block a user