init
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="cms-list-pagination mt-16">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="limit"
|
||||
:page-sizes="pageSizes"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@change="onChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const pageSizes = [10, 20, 50];
|
||||
|
||||
defineProps({
|
||||
total: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const page = defineModel<number>('page', { required: true });
|
||||
const limit = defineModel<number>('limit', { required: true });
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [page: number, limit: number];
|
||||
}>();
|
||||
|
||||
const onChange = (nextPage: number, nextLimit: number): void => {
|
||||
emit('change', nextPage, nextLimit);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/breakpoints' as *;
|
||||
|
||||
.cms-list-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: $cms-bp-tablet) {
|
||||
.cms-list-pagination {
|
||||
justify-content: center;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.cms-list-pagination :deep(.el-pagination) {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $cms-bp-phone) {
|
||||
.cms-list-pagination :deep(.el-pagination__sizes),
|
||||
.cms-list-pagination :deep(.el-pagination__total) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cms-list-pagination :deep(.el-pagination) {
|
||||
--el-pagination-button-width: 28px;
|
||||
--el-pagination-button-height: 28px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
:title="category === null ? 'New category' : 'Edit category'"
|
||||
width="420px"
|
||||
destroy-on-close
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@open="onOpen"
|
||||
>
|
||||
<el-form ref="formRef" label-position="top" :model="form" :rules="formRules">
|
||||
<el-form-item label="Name" prop="name">
|
||||
<el-input
|
||||
v-model="form.name"
|
||||
:maxlength="validationCategoryNameMaxLength"
|
||||
show-word-limit
|
||||
@input="formRef?.clearValidate('name')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Sort order" prop="sortOrder">
|
||||
<el-input-number
|
||||
v-model="form.sortOrder"
|
||||
:min="0"
|
||||
:step="1"
|
||||
@update:model-value="formRef?.clearValidate('sortOrder')"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="emit('update:modelValue', false)">Cancel</el-button>
|
||||
<el-button type="primary" :loading="saveSaving" @click="submitForm">
|
||||
{{ category === null ? 'Create' : 'Save' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { config } from '@/config';
|
||||
import { useCategoriesStore } from '@/stores/categories';
|
||||
import type { CreateOrUpdateCategoryPayload } from '@/types/category/CreateOrUpdateCategoryPayload';
|
||||
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
|
||||
import type { Category } from '@/types/product/Category';
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { reactive, ref, type PropType } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
category: {
|
||||
type: Object as PropType<Category | null>,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
}>();
|
||||
|
||||
const { createCategory, updateCategory } = useCategoriesStore();
|
||||
|
||||
const {
|
||||
validation: { categoryNameMaxLength: validationCategoryNameMaxLength }
|
||||
} = config;
|
||||
|
||||
const saveSaving = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
sortOrder: 0
|
||||
});
|
||||
|
||||
const formRules: FormRules = {
|
||||
name: [
|
||||
{ required: true, message: 'Name is required', trigger: 'blur' },
|
||||
{
|
||||
max: validationCategoryNameMaxLength,
|
||||
message: `At most ${validationCategoryNameMaxLength} characters`,
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
sortOrder: [{ required: true, message: 'Sort order is required', trigger: 'change' }]
|
||||
};
|
||||
|
||||
const onOpen = () => {
|
||||
if (props.category === null) {
|
||||
resetFormForCreate();
|
||||
} else {
|
||||
loadFormFromEntity(props.category);
|
||||
}
|
||||
};
|
||||
|
||||
const resetFormForCreate = () => {
|
||||
form.name = '';
|
||||
form.sortOrder = 0;
|
||||
formRef.value?.clearValidate();
|
||||
};
|
||||
|
||||
const loadFormFromEntity = ({ name, sortOrder }: Category) => {
|
||||
form.name = name;
|
||||
form.sortOrder = sortOrder;
|
||||
formRef.value?.clearValidate();
|
||||
};
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
saveSaving.value = true;
|
||||
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
|
||||
if (props.category === null) {
|
||||
await createCategory(payload);
|
||||
|
||||
ElMessage.success('Category created');
|
||||
} else {
|
||||
await updateCategory(props.category.id, payload);
|
||||
|
||||
ElMessage.success('Category saved');
|
||||
}
|
||||
|
||||
emit('update:modelValue', false);
|
||||
} catch (e) {
|
||||
const fallback = props.category === null ? 'Failed to create category' : 'Failed to save category';
|
||||
|
||||
const message = resolveAxiosErrorMessage(e, fallback);
|
||||
|
||||
ElMessage.error(message);
|
||||
} finally {
|
||||
saveSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const buildPayload = (): CreateOrUpdateCategoryPayload => ({
|
||||
name: form.name.trim(),
|
||||
sortOrder: form.sortOrder
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,378 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
:title="discountCode === null ? 'New discount code' : 'Edit discount code'"
|
||||
width="560px"
|
||||
destroy-on-close
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@open="onOpen"
|
||||
>
|
||||
<el-form ref="formRef" label-position="top" :model="form" :rules="formRules">
|
||||
<el-form-item label="Active">
|
||||
<el-switch v-model="form.isActive" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Code" prop="code">
|
||||
<el-input
|
||||
v-model="form.code"
|
||||
:maxlength="discountCodeMaxLength"
|
||||
show-word-limit
|
||||
@input="formRef?.clearValidate('code')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Type" prop="type">
|
||||
<el-radio-group v-model="form.type" @change="formRef?.validateField('value')">
|
||||
<el-radio :value="DiscountType.Percent">Percent</el-radio>
|
||||
<el-radio :value="DiscountType.Fixed">Fixed</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item
|
||||
:label="form.type === DiscountType.Percent ? 'Value (%)' : `Value (${shopFiatCurrency})`"
|
||||
prop="value"
|
||||
>
|
||||
<el-input-number
|
||||
v-model="form.value"
|
||||
:min="0"
|
||||
:max="form.type === DiscountType.Percent ? 100 : undefined"
|
||||
:precision="form.type === DiscountType.Percent ? 0 : 2"
|
||||
:step="1"
|
||||
@update:model-value="formRef?.clearValidate('value')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Validity period">
|
||||
<div class="form-item-stack">
|
||||
<el-switch v-model="alwaysValid" active-text="Always valid" />
|
||||
|
||||
<template v-if="!alwaysValid">
|
||||
<el-form-item prop="validFrom" label="Valid from" class="w-full mb-0">
|
||||
<el-date-picker
|
||||
v-model="form.validFrom"
|
||||
type="datetime"
|
||||
clearable
|
||||
class="w-full"
|
||||
@change="
|
||||
() => {
|
||||
formRef?.clearValidate('validFrom');
|
||||
formRef?.validateField('validUntil');
|
||||
}
|
||||
"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="validUntil" label="Valid until" class="w-full mb-0">
|
||||
<el-date-picker
|
||||
v-model="form.validUntil"
|
||||
type="datetime"
|
||||
clearable
|
||||
class="w-full"
|
||||
@change="formRef?.clearValidate('validUntil')"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Usage limit">
|
||||
<div class="form-item-stack">
|
||||
<el-switch v-model="noUsageLimit" active-text="No usage limit" />
|
||||
<el-input-number v-if="!noUsageLimit" v-model="form.maxRedemptions" :min="1" :step="1" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="`Minimum order (${shopFiatCurrency})`">
|
||||
<div class="form-item-stack">
|
||||
<el-switch v-model="noMinOrder" active-text="No minimum order" />
|
||||
<el-input-number
|
||||
v-if="!noMinOrder"
|
||||
v-model="form.minOrderAmount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.01"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Cannot combine with other codes">
|
||||
<el-switch v-model="form.isExclusive" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Applies to" prop="scope">
|
||||
<discount-scope-picker
|
||||
v-model="scope"
|
||||
:discount-code="discountCode"
|
||||
@change="formRef?.clearValidate('scope')"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="emit('update:modelValue', false)">Cancel</el-button>
|
||||
<el-button type="primary" :loading="saveSaving" @click="submitForm">
|
||||
{{ discountCode === null ? 'Create' : 'Save' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { config } from '@/config';
|
||||
import { useDiscountCodesStore } from '@/stores/discountCodes';
|
||||
import { DiscountType } from '@/types/discountCode/DiscountType';
|
||||
import type { CreateOrUpdateDiscountCodePayload } from '@/types/discountCode/CreateOrUpdateDiscountCodePayload';
|
||||
import type { DiscountCode } from '@/types/discountCode/DiscountCode';
|
||||
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
|
||||
import type { DiscountScope } from '@/types/discountCode/DiscountScope';
|
||||
import dayjs from '@/plugins/dayjs';
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { reactive, ref, type PropType } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
discountCode: {
|
||||
type: Object as PropType<DiscountCode | null>,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
}>();
|
||||
|
||||
const { createDiscountCode, updateDiscountCode } = useDiscountCodesStore();
|
||||
|
||||
const {
|
||||
shopFiatCurrency,
|
||||
validation: { discountCodeMaxLength }
|
||||
} = config;
|
||||
|
||||
const DEFAULT_MAX_REDEMPTIONS = 25;
|
||||
|
||||
const DEFAULT_DISCOUNT_SCOPE: DiscountScope = {
|
||||
applyToAll: true,
|
||||
categoryIds: [],
|
||||
productIds: [],
|
||||
variantIds: []
|
||||
};
|
||||
|
||||
const saveSaving = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
|
||||
const form = reactive({
|
||||
code: '',
|
||||
type: DiscountType.Percent,
|
||||
value: 0,
|
||||
isActive: true,
|
||||
maxRedemptions: DEFAULT_MAX_REDEMPTIONS as number | null,
|
||||
minOrderAmount: 0 as number | null,
|
||||
isExclusive: false,
|
||||
validFrom: null as string | Date | null,
|
||||
validUntil: null as string | Date | null
|
||||
});
|
||||
|
||||
const noUsageLimit = ref(true);
|
||||
const noMinOrder = ref(true);
|
||||
const alwaysValid = ref(true);
|
||||
const scope = ref<DiscountScope>({ ...DEFAULT_DISCOUNT_SCOPE });
|
||||
|
||||
const formRules: FormRules = {
|
||||
code: [
|
||||
{ required: true, message: 'Code is required', trigger: 'blur' },
|
||||
{
|
||||
max: discountCodeMaxLength,
|
||||
message: `At most ${discountCodeMaxLength} characters`,
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
type: [{ required: true, message: 'Type is required', trigger: 'change' }],
|
||||
value: [
|
||||
{ required: true, message: 'Value is required', trigger: 'change' },
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (form.type === DiscountType.Percent && value > 100) {
|
||||
callback(new Error('Percent value cannot exceed 100'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
validUntil: [
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (alwaysValid.value) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.validFrom && value && dayjs(form.validFrom).isAfter(dayjs(value))) {
|
||||
callback(new Error('Valid from must be before valid until'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
scope: [
|
||||
{
|
||||
validator: (_rule, _value, callback) => {
|
||||
const { applyToAll, categoryIds, productIds, variantIds } = scope.value;
|
||||
|
||||
if (!applyToAll && categoryIds.length === 0 && productIds.length === 0 && variantIds.length === 0) {
|
||||
callback(new Error('Select at least one category, product, or variant'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const onOpen = () => {
|
||||
if (props.discountCode === null) {
|
||||
resetFormForCreate();
|
||||
} else {
|
||||
loadFormFromEntity(props.discountCode);
|
||||
}
|
||||
};
|
||||
|
||||
const resetFormForCreate = () => {
|
||||
form.code = '';
|
||||
form.type = DiscountType.Percent;
|
||||
form.value = 10;
|
||||
form.isActive = true;
|
||||
form.isExclusive = false;
|
||||
noUsageLimit.value = true;
|
||||
form.maxRedemptions = DEFAULT_MAX_REDEMPTIONS;
|
||||
noMinOrder.value = true;
|
||||
form.minOrderAmount = 0;
|
||||
alwaysValid.value = true;
|
||||
form.validFrom = null;
|
||||
form.validUntil = null;
|
||||
scope.value = { ...DEFAULT_DISCOUNT_SCOPE };
|
||||
formRef.value?.clearValidate();
|
||||
};
|
||||
|
||||
const loadFormFromEntity = ({
|
||||
code,
|
||||
type,
|
||||
value,
|
||||
isActive,
|
||||
isExclusive,
|
||||
maxRedemptions,
|
||||
minOrderAmount,
|
||||
validFrom,
|
||||
validUntil,
|
||||
products,
|
||||
categories,
|
||||
variants
|
||||
}: DiscountCode) => {
|
||||
form.code = code;
|
||||
form.type = type;
|
||||
form.value = value;
|
||||
form.isActive = isActive;
|
||||
form.isExclusive = isExclusive;
|
||||
noUsageLimit.value = maxRedemptions === null;
|
||||
form.maxRedemptions = maxRedemptions ?? DEFAULT_MAX_REDEMPTIONS;
|
||||
noMinOrder.value = minOrderAmount === null;
|
||||
form.minOrderAmount = minOrderAmount ?? 0;
|
||||
alwaysValid.value = validFrom === null && validUntil === null;
|
||||
form.validFrom = validFrom;
|
||||
form.validUntil = validUntil;
|
||||
scope.value = getDiscountScopeFromEntity({ products, categories, variants });
|
||||
formRef.value?.clearValidate();
|
||||
};
|
||||
|
||||
const getDiscountScopeFromEntity = ({
|
||||
products,
|
||||
categories,
|
||||
variants
|
||||
}: Pick<DiscountCode, 'products' | 'categories' | 'variants'>): DiscountScope => ({
|
||||
applyToAll:
|
||||
(products &&
|
||||
products.length === 0 &&
|
||||
categories &&
|
||||
categories.length === 0 &&
|
||||
variants &&
|
||||
variants.length === 0) ??
|
||||
false,
|
||||
categoryIds: categories?.map(category => category.id) ?? [],
|
||||
productIds: products?.map(product => product.id) ?? [],
|
||||
variantIds: variants?.map(variant => variant.id) ?? []
|
||||
});
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
saveSaving.value = true;
|
||||
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
|
||||
if (props.discountCode === null) {
|
||||
await createDiscountCode(payload);
|
||||
|
||||
ElMessage.success('Discount code created');
|
||||
} else {
|
||||
await updateDiscountCode(props.discountCode.id, payload);
|
||||
|
||||
ElMessage.success('Discount code saved');
|
||||
}
|
||||
|
||||
emit('update:modelValue', false);
|
||||
} catch (e) {
|
||||
const fallback =
|
||||
props.discountCode === null ? 'Failed to create discount code' : 'Failed to save discount code';
|
||||
|
||||
const message = resolveAxiosErrorMessage(e, fallback);
|
||||
|
||||
ElMessage.error(message);
|
||||
} finally {
|
||||
saveSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const buildPayload = (): CreateOrUpdateDiscountCodePayload => {
|
||||
const { code, type, value, isActive, validFrom, validUntil, maxRedemptions, minOrderAmount, isExclusive } = form;
|
||||
|
||||
return {
|
||||
code: code.trim(),
|
||||
type,
|
||||
value,
|
||||
isActive,
|
||||
validFrom: alwaysValid.value ? null : validFrom,
|
||||
validUntil: alwaysValid.value ? null : validUntil,
|
||||
maxRedemptions: noUsageLimit.value ? null : maxRedemptions,
|
||||
minOrderAmount: noMinOrder.value ? null : minOrderAmount,
|
||||
isExclusive,
|
||||
productIds: scope.value.applyToAll ? [] : scope.value.productIds,
|
||||
categoryIds: scope.value.applyToAll ? [] : scope.value.categoryIds,
|
||||
variantIds: scope.value.applyToAll ? [] : scope.value.variantIds
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-item-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="Add variant"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@open="resetForm"
|
||||
>
|
||||
<el-form ref="formRef" label-position="top" :model="form" :rules="createOrEditCurrentProductVariantFormRules">
|
||||
<el-form-item label="Title" prop="title">
|
||||
<el-input
|
||||
v-model="form.title"
|
||||
:maxlength="validationProductTitleMaxLength"
|
||||
show-word-limit
|
||||
@input="formRef?.clearValidate('title')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`Price (${shopFiatCurrency})`" prop="price">
|
||||
<el-input-number
|
||||
v-model="form.price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="1"
|
||||
@update:model-value="formRef?.clearValidate('price')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="currentProductIsManual" label="Available units" prop="stockQuantity">
|
||||
<el-input-number
|
||||
v-model="form.stockQuantity"
|
||||
:min="0"
|
||||
:step="1"
|
||||
:precision="0"
|
||||
@update:model-value="formRef?.clearValidate('stockQuantity')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Sort order" prop="sortOrder">
|
||||
<el-input-number
|
||||
v-model="form.sortOrder"
|
||||
:min="0"
|
||||
:step="1"
|
||||
:precision="0"
|
||||
@update:model-value="formRef?.clearValidate('sortOrder')"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="emit('update:modelValue', false)">Cancel</el-button>
|
||||
<el-button type="primary" :loading="saveSaving" @click="submitForm">Create</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { config } from '@/config';
|
||||
import { ROUTE_NAMES } from '@/consts/routeNames';
|
||||
import { useProductsStore } from '@/stores/products';
|
||||
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
|
||||
import type { ProductVariantPayload } from '@/types/product/ProductVariantPayload';
|
||||
import { ElMessage, type FormInstance } from 'element-plus';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const productsStore = useProductsStore();
|
||||
|
||||
const { currentProduct, currentProductIsManual, currentProductIsAuto, createOrEditCurrentProductVariantFormRules } =
|
||||
storeToRefs(productsStore);
|
||||
|
||||
const { createProductVariant } = productsStore;
|
||||
|
||||
const {
|
||||
shopFiatCurrency,
|
||||
validation: { productTitleMaxLength: validationProductTitleMaxLength }
|
||||
} = config;
|
||||
|
||||
const saveSaving = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
price: 0,
|
||||
stockQuantity: 0,
|
||||
sortOrder: 0
|
||||
});
|
||||
|
||||
const resetForm = (): void => {
|
||||
form.title = '';
|
||||
form.price = 0;
|
||||
form.stockQuantity = 0;
|
||||
form.sortOrder = 0;
|
||||
formRef.value?.clearValidate();
|
||||
};
|
||||
|
||||
const submitForm = async (): Promise<void> => {
|
||||
const productId = currentProduct.value?.id;
|
||||
|
||||
if (!formRef.value || !productId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
saveSaving.value = true;
|
||||
|
||||
try {
|
||||
const payload: ProductVariantPayload = {
|
||||
title: form.title.trim(),
|
||||
price: form.price,
|
||||
stockQuantity: form.stockQuantity,
|
||||
sortOrder: form.sortOrder
|
||||
};
|
||||
|
||||
const created = await createProductVariant(productId, payload);
|
||||
|
||||
emit('update:modelValue', false);
|
||||
|
||||
ElMessage.success('Variant added');
|
||||
|
||||
if (currentProductIsAuto.value) {
|
||||
router.push({
|
||||
name: ROUTE_NAMES.ProductVariantDetail,
|
||||
params: { productId, variantId: created.id }
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
const fallback = 'Could not add variant';
|
||||
|
||||
const message = resolveAxiosErrorMessage(e, fallback);
|
||||
|
||||
ElMessage.error(message);
|
||||
} finally {
|
||||
saveSaving.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,243 @@
|
||||
<template>
|
||||
<div class="form-item-stack">
|
||||
<el-switch
|
||||
:model-value="modelValue.applyToAll"
|
||||
:validate-event="false"
|
||||
active-text="All products"
|
||||
@update:model-value="value => updateScope({ applyToAll: Boolean(value) })"
|
||||
/>
|
||||
|
||||
<template v-if="!modelValue.applyToAll">
|
||||
<div class="scope-field">
|
||||
<span class="secondary-text m-0">Applies to all products in category</span>
|
||||
<el-select
|
||||
:model-value="modelValue.categoryIds"
|
||||
:validate-event="false"
|
||||
multiple
|
||||
collapse-tags
|
||||
:max-collapse-tags="3"
|
||||
collapse-tags-tooltip
|
||||
class="cms-scope-select"
|
||||
placeholder="Select categories"
|
||||
@update:model-value="value => updateScope({ categoryIds: value })"
|
||||
>
|
||||
<el-option
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
:label="category.name"
|
||||
:value="category.id"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="scope-field">
|
||||
<span class="secondary-text m-0">Applies to all variants of the product</span>
|
||||
<el-select
|
||||
:model-value="modelValue.productIds"
|
||||
:validate-event="false"
|
||||
multiple
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
collapse-tags
|
||||
:max-collapse-tags="3"
|
||||
collapse-tags-tooltip
|
||||
class="cms-scope-select"
|
||||
:remote-method="loadProducts"
|
||||
:loading="productSearchLoading"
|
||||
placeholder="Search products"
|
||||
@update:model-value="value => updateScope({ productIds: value })"
|
||||
>
|
||||
<el-option
|
||||
v-for="product in productOptions"
|
||||
:key="product.id"
|
||||
:label="product.title"
|
||||
:value="product.id"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="scope-field">
|
||||
<span class="secondary-text m-0">Applies to selected variants of the product</span>
|
||||
<el-select
|
||||
:model-value="modelValue.variantIds"
|
||||
:validate-event="false"
|
||||
multiple
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
collapse-tags
|
||||
:max-collapse-tags="3"
|
||||
collapse-tags-tooltip
|
||||
class="cms-scope-select"
|
||||
:remote-method="loadVariants"
|
||||
:loading="variantSearchLoading"
|
||||
placeholder="Search variants"
|
||||
@update:model-value="value => updateScope({ variantIds: value })"
|
||||
>
|
||||
<el-option
|
||||
v-for="variant in variantOptions"
|
||||
:key="variant.id"
|
||||
:label="variant.label"
|
||||
:value="variant.id"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getProductTitle } from '@/utils/product/getProductTitle';
|
||||
import { getVariantLabel } from '@/utils/product/getVariantLabel';
|
||||
import { useCategoriesStore } from '@/stores/categories';
|
||||
import { useProductsStore } from '@/stores/products';
|
||||
import type { DiscountCode } from '@/types/discountCode/DiscountCode';
|
||||
import type { DiscountScope } from '@/types/discountCode/DiscountScope';
|
||||
import type { Product } from '@/types/product/Product';
|
||||
import type { ProductOption } from '@/types/product/ProductOption';
|
||||
import type { ProductVariant } from '@/types/product/ProductVariant';
|
||||
import type { VariantOption } from '@/types/product/VariantOption';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { onMounted, ref, type PropType } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object as PropType<DiscountScope>,
|
||||
required: true
|
||||
},
|
||||
discountCode: {
|
||||
type: Object as PropType<DiscountCode | null>,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: DiscountScope];
|
||||
change: [];
|
||||
}>();
|
||||
|
||||
const { fetchProducts, fetchProductVariants } = useProductsStore();
|
||||
const { categories } = storeToRefs(useCategoriesStore());
|
||||
|
||||
const productOptions = ref<ProductOption[]>([]);
|
||||
const variantOptions = ref<VariantOption[]>([]);
|
||||
const productSearchLoading = ref(false);
|
||||
const variantSearchLoading = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.discountCode) {
|
||||
mergeProductOptions(props.discountCode.products?.map(productOptionFromEntity) ?? []);
|
||||
mergeVariantOptions(props.discountCode.variants?.map(variantOptionFromEntity) ?? []);
|
||||
}
|
||||
});
|
||||
|
||||
const mergeProductOptions = (products: ProductOption[]): void => {
|
||||
const selectedIds = new Set(props.modelValue.productIds);
|
||||
|
||||
const byId = new Map(
|
||||
productOptions.value.filter(product => selectedIds.has(product.id)).map(product => [product.id, product])
|
||||
);
|
||||
|
||||
for (const product of products) {
|
||||
byId.set(product.id, product);
|
||||
}
|
||||
|
||||
productOptions.value = [...byId.values()].sort((a, b) => a.title.localeCompare(b.title));
|
||||
};
|
||||
|
||||
const productOptionFromEntity = (product: Pick<Product, 'id' | 'title'>): ProductOption => ({
|
||||
id: product.id,
|
||||
title: getProductTitle(product.title)
|
||||
});
|
||||
|
||||
const mergeVariantOptions = (variants: VariantOption[]): void => {
|
||||
const selectedIds = new Set(props.modelValue.variantIds);
|
||||
|
||||
const byId = new Map(
|
||||
variantOptions.value.filter(variant => selectedIds.has(variant.id)).map(variant => [variant.id, variant])
|
||||
);
|
||||
|
||||
for (const variant of variants) {
|
||||
byId.set(variant.id, variant);
|
||||
}
|
||||
|
||||
variantOptions.value = [...byId.values()].sort((a, b) => a.label.localeCompare(b.label));
|
||||
};
|
||||
|
||||
const variantOptionFromEntity = (variant: ProductVariant): VariantOption => ({
|
||||
id: variant.id,
|
||||
label: getVariantLabel(variant.title, variant.product?.title)
|
||||
});
|
||||
|
||||
const loadProducts = async (search: string) => {
|
||||
productSearchLoading.value = true;
|
||||
|
||||
try {
|
||||
const { items } = await fetchProducts({ search, page: 1, limit: 20 });
|
||||
|
||||
mergeProductOptions(items.map(product => productOptionFromEntity(product)));
|
||||
} catch {
|
||||
ElMessage.error('Failed to load products');
|
||||
} finally {
|
||||
productSearchLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadVariants = async (search: string) => {
|
||||
variantSearchLoading.value = true;
|
||||
|
||||
try {
|
||||
const { items } = await fetchProductVariants({ search, page: 1, limit: 20 });
|
||||
|
||||
mergeVariantOptions(items.map(variantOptionFromEntity));
|
||||
} catch {
|
||||
ElMessage.error('Failed to load variants');
|
||||
} finally {
|
||||
variantSearchLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateScope = (patch: Partial<DiscountScope>) => {
|
||||
emit('update:modelValue', { ...props.modelValue, ...patch });
|
||||
emit('change');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-item-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.scope-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.scope-field .cms-scope-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scope-field :deep(.el-select__wrapper) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.scope-field :deep(.el-select__selected-item) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.scope-field :deep(.el-select__tags-text) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: min(160px, 45vw);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card v-if="lines.length" shadow="never">
|
||||
<template #header>
|
||||
<span>Order cart</span>
|
||||
</template>
|
||||
|
||||
<div class="cms-table-scroll">
|
||||
<el-table :data="lines" stripe class="order-cart-table">
|
||||
<el-table-column label="Product" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<router-link
|
||||
:to="{ name: ROUTE_NAMES.ProductDetail, params: { id: row.productId } }"
|
||||
class="order-line-link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{{ row.productTitle }}
|
||||
</router-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="Variant" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<router-link
|
||||
:to="{
|
||||
name: ROUTE_NAMES.ProductVariantDetail,
|
||||
params: { productId: row.productId, variantId: row.variantId }
|
||||
}"
|
||||
class="order-line-link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{{ row.variantTitle }}
|
||||
</router-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="qty" label="Qty" width="70" />
|
||||
|
||||
<el-table-column label="Unit price" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ formatFiatPrice(row.unitPriceFiat, order.fiatCurrency) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="Line total" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ formatFiatPrice(row.lineSubtotalFiat, order.fiatCurrency) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="Delivery" width="150">
|
||||
<template #default="{ row }">
|
||||
{{ formatDeliveryMode(row.deliveryMode) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="Fulfillment" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="resolveLineFulfillmentTag(row).type" size="small">
|
||||
{{ resolveLineFulfillmentTag(row).label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column width="100" fixed="right" align="right" class-name="order-cart-action-col">
|
||||
<template #default="{ row }">
|
||||
<span v-if="order.failureReason" class="secondary-text">—</span>
|
||||
|
||||
<template v-else>
|
||||
<el-button
|
||||
v-if="row.deliveryMode === DeliveryMode.Auto"
|
||||
link
|
||||
type="primary"
|
||||
title="View delivery"
|
||||
@click="openAutoDelivery(row)"
|
||||
>
|
||||
View
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
v-else-if="!isManualLineFulfilled(row)"
|
||||
link
|
||||
type="primary"
|
||||
title="Mark as fulfilled"
|
||||
:loading="fulfillingLineId === row.id"
|
||||
@click="markAsFulfilled(row)"
|
||||
>
|
||||
Fulfill
|
||||
</el-button>
|
||||
|
||||
<span v-else class="secondary-text">—</span>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<order-line-auto-fulfillment-modal v-model="autoModalVisible" :line="selectedLine" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, type PropType } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { ROUTE_NAMES } from '@/consts/routeNames';
|
||||
import { useOrdersStore } from '@/stores/orders';
|
||||
import { ManualLineFulfillmentStatus } from '@/types/order/ManualLineFulfillmentStatus';
|
||||
import type { OrderLine } from '@/types/order/OrderLine';
|
||||
import type { OrderExtended } from '@/types/order/OrderExtended';
|
||||
import { DeliveryMode } from '@/types/product/DeliveryMode';
|
||||
import { formatDeliveryMode } from '@/utils/product/formatDeliveryMode';
|
||||
import { formatFiatPrice } from '@/utils/formatFiatPrice';
|
||||
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
|
||||
|
||||
const props = defineProps({
|
||||
order: {
|
||||
type: Object as PropType<OrderExtended>,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const ordersStore = useOrdersStore();
|
||||
|
||||
const { fulfillManualLine } = ordersStore;
|
||||
|
||||
const selectedLine = ref<OrderLine | null>(null);
|
||||
const autoModalVisible = ref(false);
|
||||
const fulfillingLineId = ref<string | null>(null);
|
||||
|
||||
const lines = computed(() => props.order.lines ?? []);
|
||||
|
||||
const isManualLineFulfilled = (line: OrderLine): boolean =>
|
||||
line.manualFulfillment?.status === ManualLineFulfillmentStatus.Fulfilled;
|
||||
|
||||
const openAutoDelivery = (line: OrderLine): void => {
|
||||
selectedLine.value = line;
|
||||
autoModalVisible.value = true;
|
||||
};
|
||||
|
||||
const markAsFulfilled = async (line: OrderLine): Promise<void> => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`Mark "${line.productTitle}" (${line.variantTitle}) as fulfilled? This cannot be undone.`,
|
||||
'Mark as fulfilled',
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: 'Mark fulfilled',
|
||||
cancelButtonText: 'Cancel'
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
fulfillingLineId.value = line.id;
|
||||
|
||||
try {
|
||||
await fulfillManualLine(props.order.id, line.id);
|
||||
|
||||
ElMessage.success('Line marked as fulfilled.');
|
||||
} catch (error) {
|
||||
const message = resolveAxiosErrorMessage(error, 'Could not mark line as fulfilled.');
|
||||
|
||||
ElMessage.error(message);
|
||||
} finally {
|
||||
fulfillingLineId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveLineFulfillmentTag = (line: OrderLine) => {
|
||||
if (props.order.failureReason) {
|
||||
return { label: 'Failed', type: 'danger' as const };
|
||||
}
|
||||
|
||||
if (line.deliveryMode === DeliveryMode.Auto) {
|
||||
return line.autoFulfillmentItems.length > 0
|
||||
? { label: 'Auto-delivered', type: 'success' as const }
|
||||
: { label: 'Pending', type: 'warning' as const };
|
||||
}
|
||||
|
||||
if (isManualLineFulfilled(line)) {
|
||||
return { label: 'Fulfilled', type: 'success' as const };
|
||||
}
|
||||
|
||||
return { label: 'Pending', type: 'warning' as const };
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.order-line-link {
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.order-line-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.order-cart-table :deep(.order-cart-action-col .cell) {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<span>Order chat (encrypted)</span>
|
||||
</template>
|
||||
|
||||
<p class="secondary-text m-0 mb-16">
|
||||
Messages are shared with the buyer on their order page. Use this for shipping, payment, or delivery
|
||||
questions.
|
||||
</p>
|
||||
|
||||
<div ref="threadRef" class="order-chat-thread flex flex-col gap-16 mb-16 pr-8">
|
||||
<template v-if="messages.length">
|
||||
<div
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
class="order-chat-message flex flex-col gap-8 w-full"
|
||||
:class="isBuyerMessage(message) ? 'order-chat-message--buyer' : 'order-chat-message--staff'"
|
||||
>
|
||||
<div class="order-chat-bubble py-12 px-12" :class="{ 'pr-32': !isBuyerMessage(message) }">
|
||||
<el-button
|
||||
v-if="!isBuyerMessage(message)"
|
||||
link
|
||||
type="danger"
|
||||
:loading="deletingMessageId === message.id"
|
||||
class="order-chat-delete py-0 px-4"
|
||||
@click="deleteMessage(message.id)"
|
||||
>
|
||||
×
|
||||
</el-button>
|
||||
<p class="order-chat-body m-0">{{ message.body }}</p>
|
||||
<div class="secondary-text mt-8">
|
||||
{{ isBuyerMessage(message) ? 'Buyer' : 'Shop' }} ·
|
||||
{{ formatRelativeTimeAgo(message.createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="secondary-text m-0">No messages yet. Send the first reply below.</p>
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" label-position="top" :model="form" :rules="rules" @submit.prevent="submitMessage">
|
||||
<el-form-item label="Your message" prop="body" class="mb-12">
|
||||
<el-input
|
||||
v-model="form.body"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:maxlength="messageMaxLength"
|
||||
show-word-limit
|
||||
placeholder="Write a reply to the buyer…"
|
||||
@input="formRef?.clearValidate('body')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button type="primary" native-type="submit" :loading="sending"> Send message </el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { config } from '@/config';
|
||||
import { useOrdersStore } from '@/stores/orders';
|
||||
import type { OrderMessage } from '@/types/order/OrderMessage';
|
||||
import { OrderMessageSender } from '@/types/order/OrderMessageSender';
|
||||
import { formatRelativeTimeAgo } from '@/utils/formatRelativeTimeAgo';
|
||||
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
|
||||
|
||||
const {
|
||||
validation: { orderMessageMaxLength: messageMaxLength }
|
||||
} = config;
|
||||
|
||||
const ordersStore = useOrdersStore();
|
||||
|
||||
const { currentOrder, currentOrderId } = storeToRefs(ordersStore);
|
||||
|
||||
const sending = ref(false);
|
||||
const deletingMessageId = ref<string | null>(null);
|
||||
const threadRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
|
||||
const form = reactive({
|
||||
body: ''
|
||||
});
|
||||
|
||||
const messages = computed(() => currentOrder.value?.messages ?? []);
|
||||
|
||||
onMounted(() => {
|
||||
scrollToBottom();
|
||||
});
|
||||
|
||||
watch(messages, (_newMessages, oldMessages) => {
|
||||
const element = threadRef.value;
|
||||
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasAtBottom = !oldMessages?.length || isScrolledToBottom(element);
|
||||
|
||||
if (wasAtBottom) {
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
const rules = computed<FormRules>(() => ({
|
||||
body: [
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
callback(new Error('Message is required'));
|
||||
return;
|
||||
}
|
||||
|
||||
callback();
|
||||
},
|
||||
trigger: ['blur', 'change']
|
||||
},
|
||||
{
|
||||
max: messageMaxLength,
|
||||
message: `At most ${messageMaxLength} characters`,
|
||||
trigger: ['blur', 'change']
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
const isBuyerMessage = (message: OrderMessage): boolean => message.sender === OrderMessageSender.Buyer;
|
||||
|
||||
const submitMessage = async () => {
|
||||
const formEl = formRef.value;
|
||||
|
||||
if (!formEl || sending.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await formEl.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentOrderId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
sending.value = true;
|
||||
|
||||
try {
|
||||
await ordersStore.sendMessage(currentOrderId.value, form.body.trim());
|
||||
|
||||
form.body = '';
|
||||
formEl.resetFields();
|
||||
|
||||
await scrollToBottom();
|
||||
} catch (error) {
|
||||
const fallback = 'Could not send message.';
|
||||
|
||||
const message = resolveAxiosErrorMessage(error, fallback);
|
||||
|
||||
ElMessage.error(message);
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMessage = async (messageId: string) => {
|
||||
if (!currentOrderId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
deletingMessageId.value = messageId;
|
||||
|
||||
try {
|
||||
await ordersStore.deleteMessage(currentOrderId.value, messageId);
|
||||
} catch (error) {
|
||||
const fallback = 'Could not delete message.';
|
||||
|
||||
const message = resolveAxiosErrorMessage(error, fallback);
|
||||
|
||||
ElMessage.error(message);
|
||||
} finally {
|
||||
deletingMessageId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const isScrolledToBottom = (element: HTMLElement): boolean => {
|
||||
const CHAT_BOTTOM_THRESHOLD_PX = 24;
|
||||
|
||||
return element.scrollHeight - element.scrollTop - element.clientHeight <= CHAT_BOTTOM_THRESHOLD_PX;
|
||||
};
|
||||
|
||||
const scrollToBottom = async (): Promise<void> => {
|
||||
await nextTick();
|
||||
|
||||
const element = threadRef.value;
|
||||
|
||||
if (element) {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.order-chat-thread {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.order-chat-message--buyer {
|
||||
align-self: flex-end;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.order-chat-message--staff {
|
||||
align-self: flex-start;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.order-chat-bubble {
|
||||
position: relative;
|
||||
border-radius: 14px;
|
||||
line-height: 1.45;
|
||||
max-width: min(100%, 560px);
|
||||
}
|
||||
|
||||
.order-chat-message--buyer .order-chat-bubble {
|
||||
background: var(--el-color-primary-light-9);
|
||||
border: 1px solid var(--el-color-primary-light-7);
|
||||
}
|
||||
|
||||
.order-chat-message--staff .order-chat-bubble {
|
||||
background: var(--el-fill-color-light);
|
||||
border: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
.order-chat-body {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.order-chat-delete {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
min-height: auto;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="Auto delivery"
|
||||
width="560px"
|
||||
destroy-on-close
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<template v-if="line">
|
||||
<p class="secondary-text m-0 mb-16 text-ellipsis" :title="lineSummary">
|
||||
{{ lineSummary }}
|
||||
</p>
|
||||
|
||||
<div v-if="items.length" class="auto-delivery-items flex flex-col gap-16">
|
||||
<section v-for="(item, index) in items" :key="item.id" class="auto-delivery-item p-12 box-border">
|
||||
<p class="auto-delivery-item__label m-0 mb-8"> Item {{ index + 1 }} </p>
|
||||
|
||||
<div v-if="item.contentSnapshot" class="auto-delivery-item__content mono m-0 mb-8">
|
||||
{{ item.contentSnapshot }}
|
||||
</div>
|
||||
|
||||
<ul
|
||||
v-if="item.attachments.length"
|
||||
class="auto-delivery-item__attachments m-0 p-0 flex flex-col gap-8"
|
||||
>
|
||||
<li
|
||||
v-for="attachment in item.attachments"
|
||||
:key="attachment.id"
|
||||
class="auto-delivery-item__attachment flex items-center justify-between gap-12"
|
||||
>
|
||||
<span class="flex-ellipsis" :title="attachment.originalFilename">
|
||||
{{ attachment.originalFilename }}
|
||||
</span>
|
||||
|
||||
<div class="auto-delivery-item__attachment-meta flex items-center gap-12">
|
||||
<span class="secondary-text">{{ formatFileSize(attachment.sizeBytes) }}</span>
|
||||
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:loading="downloadingAttachmentId === attachment.id"
|
||||
@click="downloadAttachment(item, attachment)"
|
||||
>
|
||||
Download
|
||||
</el-button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-if="!item.contentSnapshot && !item.attachments.length" class="secondary-text m-0">
|
||||
No delivery content recorded.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p v-else class="secondary-text m-0">No delivery items found.</p>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="emit('update:modelValue', false)">Close</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, type PropType } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useDigitalStockStore } from '@/stores/digitalStock';
|
||||
import type { OrderLine } from '@/types/order/OrderLine';
|
||||
import type { OrderLineAutoFulfillmentItem } from '@/types/order/OrderLineAutoFulfillmentItem';
|
||||
import type { OrderLineAutoFulfillmentItemAttachment } from '@/types/order/OrderLineAutoFulfillmentItemAttachment';
|
||||
import { formatFileSize } from '@/utils/formatFileSize';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
line: {
|
||||
type: Object as PropType<OrderLine | null>,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
}>();
|
||||
|
||||
const digitalStockStore = useDigitalStockStore();
|
||||
|
||||
const downloadingAttachmentId = ref<string | null>(null);
|
||||
|
||||
const items = computed((): OrderLineAutoFulfillmentItem[] => props.line?.autoFulfillmentItems ?? []);
|
||||
|
||||
const lineSummary = computed((): string => {
|
||||
const line = props.line;
|
||||
|
||||
if (!line) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${line.productTitle} · ${line.variantTitle}`;
|
||||
});
|
||||
|
||||
const downloadAttachment = async (
|
||||
item: OrderLineAutoFulfillmentItem,
|
||||
attachment: OrderLineAutoFulfillmentItemAttachment
|
||||
): Promise<void> => {
|
||||
const line = props.line;
|
||||
|
||||
if (!line) {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadingAttachmentId.value = attachment.id;
|
||||
|
||||
try {
|
||||
await digitalStockStore.downloadAttachment(
|
||||
line.productId,
|
||||
line.variantId,
|
||||
item.sourceDigitalStockItemId,
|
||||
attachment.sourceDigitalStockAttachmentId,
|
||||
attachment.originalFilename
|
||||
);
|
||||
} catch {
|
||||
ElMessage.error('Could not download attachment');
|
||||
} finally {
|
||||
downloadingAttachmentId.value = null;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auto-delivery-item {
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.auto-delivery-item__label {
|
||||
font-size: var(--el-font-size-small);
|
||||
font-weight: var(--el-font-weight-primary);
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.auto-delivery-item__content {
|
||||
font-size: var(--el-font-size-small);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.auto-delivery-item__attachments {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.auto-delivery-item__attachment {
|
||||
font-size: var(--el-font-size-small);
|
||||
}
|
||||
|
||||
.auto-delivery-item__attachment-meta {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<el-card v-if="hasManualLines" shadow="never">
|
||||
<template #header>
|
||||
<span>Manual shipping payment</span>
|
||||
</template>
|
||||
|
||||
<template v-if="canSetDeliveryCost">
|
||||
<div class="mb-16">
|
||||
<el-alert
|
||||
type="warning"
|
||||
title="Shipping quote required"
|
||||
description="Publish a shipping quote to create the delivery payment session for the buyer. Until then, they cannot pay for shipping and the order cannot be fulfilled."
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="deliveryCostFormRef"
|
||||
label-position="top"
|
||||
:model="deliveryCostForm"
|
||||
:rules="deliveryCostRules"
|
||||
>
|
||||
<el-form-item :label="`Shipping cost (${order.fiatCurrency})`" prop="deliveryCost">
|
||||
<div class="w-full">
|
||||
<el-input-number
|
||||
v-model="deliveryCostForm.deliveryCost"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="1"
|
||||
class="w-full"
|
||||
@update:model-value="deliveryCostFormRef?.clearValidate('deliveryCost')"
|
||||
/>
|
||||
<p class="secondary-text m-0">Set 0 for free shipping.</p>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-button type="primary" :loading="deliveryCostSaving" @click="submitDeliveryCost">
|
||||
Publish shipping quote
|
||||
</el-button>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<template v-else-if="quoted">
|
||||
<el-descriptions :column="1" class="mb-16 descriptions-row-labels">
|
||||
<el-descriptions-item label="Shipping cost">
|
||||
{{ formatFiatPrice(order.totals.shippingCostFiat ?? 0, order.fiatCurrency) }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item v-if="order.shippingInvoice" label="Payment expires">
|
||||
{{ formatDate(order.shippingInvoice.expiresAt) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<template v-if="order.shippingInvoice">
|
||||
<order-monero-payment-panel
|
||||
:invoice="order.shippingInvoice"
|
||||
rate-label="quote"
|
||||
empty-text="Shipping payment session not created yet."
|
||||
/>
|
||||
</template>
|
||||
|
||||
<p v-else class="secondary-text m-0">Free shipping — no payment required.</p>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
v-else-if="order.failureReason"
|
||||
type="error"
|
||||
title="Shipping quote unavailable"
|
||||
:description="failureReasonDescription"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, type PropType } from 'vue';
|
||||
import type { FormInstance, FormRules } from 'element-plus';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { useOrdersStore } from '@/stores/orders';
|
||||
import type { OrderExtended } from '@/types/order/OrderExtended';
|
||||
import { DeliveryMode } from '@/types/product/DeliveryMode';
|
||||
import { formatDate } from '@/utils/formatDate';
|
||||
import { formatFiatPrice } from '@/utils/formatFiatPrice';
|
||||
import { formatOrderFailureReason } from '@/utils/order/formatOrderFailureReason';
|
||||
import { isSet } from '@/utils/isSet';
|
||||
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
|
||||
|
||||
const props = defineProps({
|
||||
order: {
|
||||
type: Object as PropType<OrderExtended>,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const { setDeliveryCost } = useOrdersStore();
|
||||
|
||||
const deliveryCostSaving = ref(false);
|
||||
const deliveryCostFormRef = ref<FormInstance>();
|
||||
|
||||
const deliveryCostForm = reactive({
|
||||
deliveryCost: 0
|
||||
});
|
||||
|
||||
const quoted = computed(() => isSet(props.order.quotedAt));
|
||||
|
||||
const hasManualLines = computed(() =>
|
||||
(props.order.lines ?? []).some(line => line.deliveryMode === DeliveryMode.Manual)
|
||||
);
|
||||
|
||||
const canSetDeliveryCost = computed(() => !props.order.failureReason && !quoted.value && hasManualLines.value);
|
||||
|
||||
const failureReasonDescription = computed(() => {
|
||||
if (!props.order.failureReason) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const reason = formatOrderFailureReason(props.order.failureReason);
|
||||
|
||||
return `This order cannot be fulfilled (${reason}). A shipping quote cannot be published.`;
|
||||
});
|
||||
|
||||
const deliveryCostRules = computed<FormRules>(() => ({
|
||||
deliveryCost: [
|
||||
{
|
||||
required: true,
|
||||
message: 'Shipping cost is required',
|
||||
trigger: 'change'
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
const submitDeliveryCost = async () => {
|
||||
const formEl = deliveryCostFormRef.value;
|
||||
|
||||
if (!formEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await formEl.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const deliveryCost = deliveryCostForm.deliveryCost;
|
||||
|
||||
const formattedCost = formatFiatPrice(deliveryCost, props.order.fiatCurrency);
|
||||
|
||||
const buyerImpactNote =
|
||||
deliveryCost > 0
|
||||
? 'This will create a payment session for the buyer.'
|
||||
: 'This will publish the quote for the buyer; no payment is required for free shipping.';
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`You are about to set the shipping quote to ${formattedCost}. ${buyerImpactNote} This cannot be undone. Ensure the quote is correct.`,
|
||||
'Publish shipping quote',
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: 'Publish',
|
||||
cancelButtonText: 'Cancel'
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
deliveryCostSaving.value = true;
|
||||
|
||||
try {
|
||||
await setDeliveryCost(props.order.id, {
|
||||
deliveryCost
|
||||
});
|
||||
|
||||
ElMessage.success('Shipping quote published.');
|
||||
} catch (error) {
|
||||
const fallback = 'Could not publish shipping quote.';
|
||||
|
||||
const message = resolveAxiosErrorMessage(error, fallback);
|
||||
|
||||
ElMessage.error(message);
|
||||
} finally {
|
||||
deliveryCostSaving.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div v-if="invoice" class="min-w-0">
|
||||
<el-descriptions :column="1" class="mb-16 descriptions-row-labels">
|
||||
<el-descriptions-item v-if="invoice.statusLabel" label="Status">
|
||||
<el-tag :type="resolveInvoiceStatusTagType(invoice.statusLabel)">
|
||||
{{ invoice.statusLabel }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="Expected total">
|
||||
<span class="mono"
|
||||
>{{ invoice.expectedTotalCrypto }} {{ paymentMethodCryptoCurrency[invoice.paymentMethod] }}</span
|
||||
>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item v-if="fiatPerXmrAtCreation !== undefined" :label="`Rate at ${rateLabel}`">
|
||||
{{ formatFiatPrice(fiatPerXmrAtCreation, config.shopFiatCurrency) }} /
|
||||
{{ paymentMethodCryptoCurrency[invoice.paymentMethod] }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-descriptions :column="1" direction="vertical" class="mb-16">
|
||||
<el-descriptions-item label="Payment address">
|
||||
<span class="mono text-break">{{ invoice.paymentAddress }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<p class="secondary-text m-0 mb-8">Transactions</p>
|
||||
|
||||
<div v-if="payments.length" class="cms-table-scroll">
|
||||
<el-table :data="payments" stripe size="small" class="mb-0">
|
||||
<el-table-column label="Tx hash" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="mono">{{ row.txHash }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="Amount" width="140">
|
||||
<template #default="{ row }">
|
||||
<span class="mono"
|
||||
>{{ row.amountCrypto }} {{ paymentMethodCryptoCurrency[invoice.paymentMethod] }}</span
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="Detected" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="Confirmations" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.isConfirmed ? 'success' : 'warning'" size="small">
|
||||
{{ row.confirmationsLabel }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<p v-else class="secondary-text m-0">No transactions detected yet.</p>
|
||||
</div>
|
||||
|
||||
<p v-else class="secondary-text m-0">{{ emptyText }}</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, type PropType } from 'vue';
|
||||
import { config } from '@/config';
|
||||
import type { InvoiceExtended } from '@/types/payment/InvoiceExtended';
|
||||
import { paymentMethodCryptoCurrency } from '@/types/payment/PaymentMethod';
|
||||
import { formatDate } from '@/utils/formatDate';
|
||||
import { formatFiatPrice } from '@/utils/formatFiatPrice';
|
||||
import { resolveInvoiceStatusTagType } from '@/utils/order/resolveInvoiceStatusTagType';
|
||||
|
||||
const props = defineProps({
|
||||
invoice: {
|
||||
type: Object as PropType<InvoiceExtended | null | undefined>,
|
||||
default: undefined
|
||||
},
|
||||
rateLabel: {
|
||||
type: String,
|
||||
default: 'checkout'
|
||||
},
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: 'No Monero payment session.'
|
||||
}
|
||||
});
|
||||
|
||||
const payments = computed(() => props.invoice?.payments ?? []);
|
||||
|
||||
const fiatPerXmrAtCreation = computed(() => props.invoice?.moneroDetails?.fiatPerXmrAtCreation);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.el-descriptions__content) {
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<span>Order payment</span>
|
||||
</template>
|
||||
|
||||
<order-monero-payment-panel
|
||||
:invoice="order.checkoutInvoice"
|
||||
rate-label="checkout"
|
||||
empty-text="No checkout payment session."
|
||||
/>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type PropType } from 'vue';
|
||||
import type { OrderExtended } from '@/types/order/OrderExtended';
|
||||
|
||||
const props = defineProps({
|
||||
order: {
|
||||
type: Object as PropType<OrderExtended>,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<el-card class="order-summary-panel" shadow="never">
|
||||
<template #header>
|
||||
<span>Summary</span>
|
||||
</template>
|
||||
|
||||
<el-descriptions :column="1" direction="vertical" class="order-summary-panel__details">
|
||||
<el-descriptions-item label="Order ID">
|
||||
<span class="mono text-break">{{ order.id }}</span>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="Access token">
|
||||
<el-input
|
||||
class="mono access-token-input"
|
||||
:model-value="order.accessToken"
|
||||
type="password"
|
||||
show-password
|
||||
readonly
|
||||
/>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="Created">{{ formatDate(order.createdAt) }}</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item v-if="order.failureReason" label="Failure reason">
|
||||
<span class="failure-reason">{{ formatOrderFailureReason(order.failureReason) }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="order-summary-panel__totals order-totals pt-24">
|
||||
<div class="flex justify-between gap-12 mb-12">
|
||||
<span>Subtotal</span>
|
||||
<span>{{ subtotalFormatted }}</span>
|
||||
</div>
|
||||
|
||||
<p v-if="discounts.length > 0" class="order-totals__section m-0 mb-8">Discounts</p>
|
||||
|
||||
<div
|
||||
v-for="discount in discounts"
|
||||
:key="discount.id"
|
||||
class="flex justify-between gap-12 mb-4 order-totals__discount"
|
||||
>
|
||||
<span>{{ discount.code }}</span>
|
||||
<span>−{{ formatFiatPrice(discount.amountFiat, fiatCurrency) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between gap-12 mt-12 mb-12">
|
||||
<span>Total discounts</span>
|
||||
<span>{{ discountTotalFormatted }}</span>
|
||||
</div>
|
||||
|
||||
<template v-if="hasManualLines">
|
||||
<div class="flex justify-between gap-12 mt-12 mb-12 order-totals__order-total">
|
||||
<span>Total</span>
|
||||
<span>{{ totalFormatted }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between gap-12 mb-12">
|
||||
<span>Shipping</span>
|
||||
<span>{{ shippingFormatted }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between gap-12 order-totals__total pt-8">
|
||||
<span>Grand total</span>
|
||||
<span>{{ grandTotalFormatted }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="flex justify-between gap-12 order-totals__total pt-8 mt-12">
|
||||
<span>Total</span>
|
||||
<span>{{ totalFormatted }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, type PropType } from 'vue';
|
||||
import type { OrderExtended } from '@/types/order/OrderExtended';
|
||||
import { DeliveryMode } from '@/types/product/DeliveryMode';
|
||||
import { formatDate } from '@/utils/formatDate';
|
||||
import { formatFiatPrice } from '@/utils/formatFiatPrice';
|
||||
import { formatOrderFailureReason } from '@/utils/order/formatOrderFailureReason';
|
||||
|
||||
const props = defineProps({
|
||||
order: {
|
||||
type: Object as PropType<OrderExtended>,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const fiatCurrency = computed(() => props.order.fiatCurrency);
|
||||
|
||||
const discounts = computed(() => props.order.discounts ?? []);
|
||||
|
||||
const hasManualLines = computed(() =>
|
||||
(props.order.lines ?? []).some(line => line.deliveryMode === DeliveryMode.Manual)
|
||||
);
|
||||
|
||||
const subtotalFormatted = computed(() => formatFiatPrice(props.order.totals.subtotalFiat, fiatCurrency.value));
|
||||
|
||||
const discountTotalFormatted = computed(() =>
|
||||
formatOrderDiscountTotal(props.order.totals.discountTotalFiat, fiatCurrency.value)
|
||||
);
|
||||
|
||||
const totalFormatted = computed(() => formatFiatPrice(props.order.totals.totalFiat, fiatCurrency.value));
|
||||
|
||||
const shippingFormatted = computed(() => {
|
||||
const shippingAmount = props.order.totals.shippingCostFiat;
|
||||
|
||||
if (shippingAmount === null) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return formatFiatPrice(shippingAmount, fiatCurrency.value);
|
||||
});
|
||||
|
||||
const grandTotalFormatted = computed(() => {
|
||||
const grandTotal = props.order.totals.grandTotalFiat ?? props.order.totals.totalFiat;
|
||||
|
||||
return formatFiatPrice(grandTotal, fiatCurrency.value);
|
||||
});
|
||||
|
||||
const formatOrderDiscountTotal = (discountTotalFiat: number, currency: string): string => {
|
||||
if (discountTotalFiat <= 0) {
|
||||
return formatFiatPrice(0, currency);
|
||||
}
|
||||
|
||||
return `−${formatFiatPrice(discountTotalFiat, currency)}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.order-summary-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:deep(.el-card__body) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.order-summary-panel__details {
|
||||
flex-shrink: 0;
|
||||
|
||||
:deep(.el-descriptions__content) {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.order-summary-panel__totals {
|
||||
flex-shrink: 0;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.access-token-input {
|
||||
width: 100%;
|
||||
max-width: min(350px, 100%);
|
||||
}
|
||||
|
||||
.failure-reason {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.order-totals__section {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.order-totals__discount {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: var(--el-font-size-small);
|
||||
}
|
||||
|
||||
.order-totals__order-total {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.order-totals__total {
|
||||
font-size: var(--el-font-size-large);
|
||||
font-weight: 600;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,243 @@
|
||||
<template>
|
||||
<div v-if="editor" class="rich-text-editor">
|
||||
<div class="rich-text-editor-toolbar">
|
||||
<el-button-group>
|
||||
<el-button size="small" :type="editor.isActive('bold') ? 'primary' : 'default'" @click="bold">
|
||||
Bold
|
||||
</el-button>
|
||||
<el-button size="small" :type="editor.isActive('italic') ? 'primary' : 'default'" @click="italic">
|
||||
Italic
|
||||
</el-button>
|
||||
<el-button size="small" :type="editor.isActive('strike') ? 'primary' : 'default'" @click="strike">
|
||||
Strike
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
|
||||
<el-button-group class="rich-text-editor-toolbar__gap">
|
||||
<el-button
|
||||
size="small"
|
||||
:type="editor.isActive('heading', { level: 2 }) ? 'primary' : 'default'"
|
||||
@click="h2"
|
||||
>
|
||||
H2
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:type="editor.isActive('heading', { level: 3 }) ? 'primary' : 'default'"
|
||||
@click="h3"
|
||||
>
|
||||
H3
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
|
||||
<el-button-group class="rich-text-editor-toolbar__gap">
|
||||
<el-button
|
||||
size="small"
|
||||
:type="editor.isActive('bulletList') ? 'primary' : 'default'"
|
||||
@click="bulletList"
|
||||
>
|
||||
• List
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:type="editor.isActive('orderedList') ? 'primary' : 'default'"
|
||||
@click="orderedList"
|
||||
>
|
||||
1. List
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:type="editor.isActive('blockquote') ? 'primary' : 'default'"
|
||||
@click="blockquote"
|
||||
>
|
||||
Quote
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
|
||||
<el-button-group class="rich-text-editor-toolbar__gap">
|
||||
<el-button size="small" :type="editor.isActive('link') ? 'primary' : 'default'" @click="toggleLink">
|
||||
Link
|
||||
</el-button>
|
||||
<el-button size="small" @click="undo"> Undo </el-button>
|
||||
<el-button size="small" @click="redo"> Redo </el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
|
||||
<editor-content :editor="editor" class="rich-text-editor-body" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { EditorContent, useEditor } from '@tiptap/vue-3';
|
||||
import { onBeforeUnmount } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Write description…'
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
blur: [];
|
||||
}>();
|
||||
|
||||
const editor = useEditor({
|
||||
content: props.modelValue || '',
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
levels: [2, 3]
|
||||
}
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: props.placeholder
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
autolink: true,
|
||||
defaultProtocol: 'https',
|
||||
HTMLAttributes: {
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
target: '_blank'
|
||||
}
|
||||
})
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'rich-text-editor-content'
|
||||
}
|
||||
},
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
emit('update:modelValue', ed.getHTML());
|
||||
},
|
||||
onBlur: () => {
|
||||
emit('blur');
|
||||
}
|
||||
});
|
||||
|
||||
const bold = () => editor.value?.chain().focus().toggleBold().run();
|
||||
const italic = () => editor.value?.chain().focus().toggleItalic().run();
|
||||
const strike = () => editor.value?.chain().focus().toggleStrike().run();
|
||||
const h2 = () => editor.value?.chain().focus().toggleHeading({ level: 2 }).run();
|
||||
const h3 = () => editor.value?.chain().focus().toggleHeading({ level: 3 }).run();
|
||||
const bulletList = () => editor.value?.chain().focus().toggleBulletList().run();
|
||||
const orderedList = () => editor.value?.chain().focus().toggleOrderedList().run();
|
||||
const blockquote = () => editor.value?.chain().focus().toggleBlockquote().run();
|
||||
|
||||
const undo = () => editor.value?.chain().focus().undo().run();
|
||||
const redo = () => editor.value?.chain().focus().redo().run();
|
||||
|
||||
const toggleLink = (): void => {
|
||||
const ed = editor.value;
|
||||
|
||||
if (!ed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = ed.getAttributes('link').href as string | undefined;
|
||||
const url = window.prompt('Link URL (leave empty to remove)', previous ?? 'https://');
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.trim() === '') {
|
||||
ed.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
return;
|
||||
}
|
||||
|
||||
ed.chain().focus().extendMarkRange('link').setLink({ href: url.trim() }).run();
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editor.value?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rich-text-editor {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.rich-text-editor-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.rich-text-editor-toolbar__gap {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.rich-text-editor-body :deep(.rich-text-editor-content) {
|
||||
min-height: 220px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.rich-text-editor-body :deep(.rich-text-editor-content:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.rich-text-editor-body :deep(.rich-text-editor-content p.is-editor-empty:first-child::before) {
|
||||
float: left;
|
||||
height: 0;
|
||||
font-size: var(--el-font-size-base);
|
||||
line-height: var(--el-font-line-height-primary);
|
||||
color: var(--el-text-color-placeholder);
|
||||
pointer-events: none;
|
||||
content: attr(data-placeholder);
|
||||
}
|
||||
|
||||
.rich-text-editor-body :deep(.ProseMirror) {
|
||||
font-size: var(--el-font-size-base);
|
||||
line-height: var(--el-font-line-height-primary);
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.rich-text-editor-body :deep(.ProseMirror p) {
|
||||
margin: 0.35em 0;
|
||||
}
|
||||
|
||||
.rich-text-editor-body :deep(.ProseMirror h2) {
|
||||
margin: 0.6em 0 0.35em;
|
||||
font-size: 1.35em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rich-text-editor-body :deep(.ProseMirror h3) {
|
||||
margin: 0.55em 0 0.3em;
|
||||
font-size: 1.15em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rich-text-editor-body :deep(.ProseMirror ul),
|
||||
.rich-text-editor-body :deep(.ProseMirror ol) {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.rich-text-editor-body :deep(.ProseMirror blockquote) {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 0.75rem;
|
||||
border-left: 3px solid var(--el-border-color);
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.rich-text-editor-body :deep(.ProseMirror a) {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:aria-label="isDark ? 'Use light theme' : 'Use dark theme'"
|
||||
@click="toggleDarkMode"
|
||||
>
|
||||
<el-icon :size="18">
|
||||
<Sunny v-if="isDark" />
|
||||
<Moon v-else />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Moon, Sunny } from '@element-plus/icons-vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useColorSchemeStore } from '@/stores/colorScheme';
|
||||
|
||||
const colorSchemeStore = useColorSchemeStore();
|
||||
const { isDark } = storeToRefs(colorSchemeStore);
|
||||
|
||||
const { toggleDarkMode } = colorSchemeStore;
|
||||
</script>
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<span>Details</span>
|
||||
</template>
|
||||
|
||||
<el-form
|
||||
ref="variantFormRef"
|
||||
label-position="top"
|
||||
:model="variantForm"
|
||||
:rules="createOrEditCurrentProductVariantFormRules"
|
||||
>
|
||||
<el-form-item label="Title" prop="title">
|
||||
<el-input
|
||||
v-model="variantForm.title"
|
||||
:maxlength="validationProductTitleMaxLength"
|
||||
show-word-limit
|
||||
@input="variantFormRef?.clearValidate('title')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`Price (${shopFiatCurrency})`" prop="price">
|
||||
<el-input-number
|
||||
v-model="variantForm.price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="1"
|
||||
@update:model-value="variantFormRef?.clearValidate('price')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="currentProductIsManual" label="Available units" prop="stockQuantity">
|
||||
<el-input-number
|
||||
v-model="variantForm.stockQuantity"
|
||||
:min="0"
|
||||
:step="1"
|
||||
:precision="0"
|
||||
@update:model-value="variantFormRef?.clearValidate('stockQuantity')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Sort order" prop="sortOrder">
|
||||
<el-input-number
|
||||
v-model="variantForm.sortOrder"
|
||||
:min="0"
|
||||
:step="1"
|
||||
:precision="0"
|
||||
@update:model-value="variantFormRef?.clearValidate('sortOrder')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-button type="primary" :loading="variantSaving" @click="submitVariant">
|
||||
Save variant
|
||||
</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { config } from '@/config';
|
||||
import { useProductsStore } from '@/stores/products';
|
||||
import { ElMessage, type FormInstance } from 'element-plus';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
|
||||
const {
|
||||
shopFiatCurrency,
|
||||
validation: { productTitleMaxLength: validationProductTitleMaxLength }
|
||||
} = config;
|
||||
|
||||
const productsStore = useProductsStore();
|
||||
const {
|
||||
currentVariant,
|
||||
currentProductId,
|
||||
currentVariantId,
|
||||
currentProductIsManual,
|
||||
createOrEditCurrentProductVariantFormRules
|
||||
} = storeToRefs(productsStore);
|
||||
|
||||
const variantSaving = ref(false);
|
||||
const variantFormRef = ref<FormInstance>();
|
||||
const variantForm = reactive({
|
||||
title: '',
|
||||
price: 0,
|
||||
stockQuantity: 0,
|
||||
sortOrder: 0
|
||||
});
|
||||
|
||||
const loadVariantForm = (): void => {
|
||||
const variant = currentVariant.value;
|
||||
|
||||
if (!variant) {
|
||||
return;
|
||||
}
|
||||
|
||||
variantForm.title = variant.title;
|
||||
variantForm.price = variant.price;
|
||||
variantForm.stockQuantity = variant.stockQuantity ?? 0;
|
||||
variantForm.sortOrder = variant.sortOrder;
|
||||
variantFormRef.value?.clearValidate();
|
||||
};
|
||||
|
||||
watch(currentVariant, loadVariantForm, { immediate: true });
|
||||
|
||||
const submitVariant = async (): Promise<void> => {
|
||||
const form = variantFormRef.value;
|
||||
|
||||
if (!form || !currentProductId.value || !currentVariantId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await form.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
variantSaving.value = true;
|
||||
|
||||
try {
|
||||
await productsStore.updateProductVariant(currentProductId.value, currentVariantId.value, {
|
||||
title: variantForm.title.trim(),
|
||||
price: variantForm.price,
|
||||
stockQuantity: variantForm.stockQuantity,
|
||||
sortOrder: variantForm.sortOrder
|
||||
});
|
||||
|
||||
ElMessage.success('Variant saved');
|
||||
} catch {
|
||||
ElMessage.error('Could not save variant');
|
||||
} finally {
|
||||
variantSaving.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,763 @@
|
||||
<template>
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<span>Digital stock</span>
|
||||
</template>
|
||||
|
||||
<el-text tag="p" size="small" class="secondary-text w-full m-0 mb-16">
|
||||
Content along with all attachments is delivered automatically after purchase. Content and attachments are
|
||||
encrypted at rest on the server.
|
||||
</el-text>
|
||||
|
||||
<p class="mb-16">Currently available: {{ currentVariant?.stockAvailable }}</p>
|
||||
|
||||
<el-form
|
||||
ref="stockAddFormRef"
|
||||
label-position="top"
|
||||
:model="stockAddForm"
|
||||
:rules="stockContentRules"
|
||||
class="mb-24"
|
||||
>
|
||||
<el-form-item label="Add content" prop="content">
|
||||
<div class="flex gap-12 w-full">
|
||||
<el-input
|
||||
v-model="stockAddForm.content"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
class="flex-1"
|
||||
:disabled="stockRemoving"
|
||||
@input="stockAddFormRef?.clearValidate('content')"
|
||||
/>
|
||||
<el-button type="primary" :loading="stockAdding" :disabled="stockRemoving" @click="submitAddStock">
|
||||
Add
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Attachments (optional)">
|
||||
<div class="stock-attachments-field flex flex-col gap-12 w-full">
|
||||
<el-text tag="p" size="small" class="secondary-text w-full">
|
||||
{{ stockAttachmentsHint }}
|
||||
</el-text>
|
||||
|
||||
<div class="stock-attachment-upload-row flex items-center gap-12 min-w-0">
|
||||
<el-upload
|
||||
ref="stockAddUploadRef"
|
||||
:show-file-list="false"
|
||||
:auto-upload="false"
|
||||
multiple
|
||||
:accept="digitalStockAttachmentAccept"
|
||||
:disabled="
|
||||
stockAdding ||
|
||||
stockRemoving ||
|
||||
stockAddPendingFiles.length >= validationDigitalStockAttachmentsMax
|
||||
"
|
||||
:on-change="onStockAddFileChange"
|
||||
>
|
||||
<el-button
|
||||
type="default"
|
||||
:disabled="stockAddPendingFiles.length >= validationDigitalStockAttachmentsMax"
|
||||
>
|
||||
Select files
|
||||
</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
|
||||
<ul v-if="stockAddPendingFiles.length" class="stock-pending-files m-0 p-0">
|
||||
<li
|
||||
v-for="(file, index) in stockAddPendingFiles"
|
||||
:key="`${file.name}-${file.size}-${index}`"
|
||||
class="stock-pending-files__item flex items-center justify-between gap-12 py-4 min-w-0"
|
||||
>
|
||||
<el-text size="small" class="flex-ellipsis" :title="file.name">
|
||||
{{ file.name }}
|
||||
</el-text>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
class="flex-shrink-0"
|
||||
:disabled="stockAdding || stockRemoving"
|
||||
@click="removeStockAddPendingFile(index)"
|
||||
>
|
||||
Remove
|
||||
</el-button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="flex items-center gap-12 mb-16">
|
||||
<el-switch v-model="hideSold" active-text="Hide sold" @change="() => loadStockList({ page: 1 })" />
|
||||
</div>
|
||||
|
||||
<div class="cms-table-scroll">
|
||||
<el-table
|
||||
v-loading="stockListLoading || stockRemoving"
|
||||
:element-loading-text="stockRemoving ? 'Removing stock…' : 'Loading stock…'"
|
||||
:data="items"
|
||||
stripe
|
||||
empty-text="No stock lines yet"
|
||||
>
|
||||
<el-table-column prop="content" label="Content" min-width="200" show-overflow-tooltip />
|
||||
|
||||
<el-table-column label="Attachments" width="120">
|
||||
<template #default="{ row: item }">
|
||||
<el-tooltip
|
||||
v-if="item.attachments?.length"
|
||||
:content="formatAttachmentNames(item.attachments)"
|
||||
placement="top"
|
||||
>
|
||||
<span
|
||||
>{{ item.attachments.length }} file{{ item.attachments.length === 1 ? '' : 's' }}</span
|
||||
>
|
||||
</el-tooltip>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="isSold" label="Sold" width="80" />
|
||||
|
||||
<el-table-column label="Updated" width="180">
|
||||
<template #default="{ row: item }">
|
||||
{{ formatDate(item.updatedAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column width="180" fixed="right">
|
||||
<template #default="{ row: item }">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:disabled="item.isSold || stockRemoving"
|
||||
@click="openEditStock(item)"
|
||||
>
|
||||
Edit
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
:disabled="item.isSold || stockRemoving"
|
||||
@click="confirmRemoveStock(item)"
|
||||
>
|
||||
Delete
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<cms-list-pagination v-model:page="page" v-model:limit="limit" :total="total" @change="() => loadStockList()" />
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="stockEditVisible"
|
||||
title="Edit stock item"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
@closed="stockEditItem = null"
|
||||
>
|
||||
<el-form ref="stockEditFormRef" label-position="top" :model="stockEditForm" :rules="stockContentRules">
|
||||
<el-form-item label="Content" prop="content">
|
||||
<el-input
|
||||
v-model="stockEditForm.content"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
@input="stockEditFormRef?.clearValidate('content')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Attachments">
|
||||
<div class="stock-attachments-field flex flex-col gap-12 w-full">
|
||||
<el-text tag="p" size="small" class="secondary-text w-full">
|
||||
{{ stockAttachmentsHint }}
|
||||
</el-text>
|
||||
|
||||
<ul v-if="stockEditAttachments.length" class="stock-edit-attachments m-0 p-0 secondary-text">
|
||||
<li
|
||||
v-for="attachment in stockEditAttachments"
|
||||
:key="attachment.id"
|
||||
class="stock-edit-attachments__item flex items-center justify-between gap-12 py-4 min-w-0"
|
||||
>
|
||||
<div class="stock-edit-attachments__meta flex items-start gap-8 min-h-0 flex-1 min-w-0">
|
||||
<span
|
||||
class="flex-ellipsis"
|
||||
:title="attachment.originalFilename"
|
||||
>
|
||||
{{ attachment.originalFilename }}
|
||||
</span>
|
||||
<span class="secondary-text flex-shrink-0">
|
||||
{{ formatFileSize(attachment.sizeBytes) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="stock-edit-attachments__actions flex gap-4">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:loading="stockEditDownloadingId === attachment.id"
|
||||
:disabled="stockEditAttachmentBusy"
|
||||
@click="downloadStockAttachment(attachment)"
|
||||
>
|
||||
Download
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
:loading="stockEditRemovingAttachmentId === attachment.id"
|
||||
:disabled="stockEditItem?.isSold || stockEditAttachmentBusy"
|
||||
@click="confirmRemoveStockAttachment(attachment.id)"
|
||||
>
|
||||
Remove
|
||||
</el-button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<el-text v-else tag="p" size="small" class="secondary-text w-full m-0">No attachments yet.</el-text>
|
||||
|
||||
<div class="stock-attachment-upload-row flex items-center gap-12 min-w-0">
|
||||
<el-upload
|
||||
ref="stockEditUploadRef"
|
||||
:show-file-list="false"
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
:accept="digitalStockAttachmentAccept"
|
||||
:disabled="
|
||||
stockEditItem?.isSold ||
|
||||
stockEditAttachmentBusy ||
|
||||
stockEditAttachments.length >= validationDigitalStockAttachmentsMax
|
||||
"
|
||||
:on-change="onStockEditFileChange"
|
||||
:on-remove="onStockEditFileRemove"
|
||||
>
|
||||
<el-button
|
||||
type="default"
|
||||
:disabled="
|
||||
stockEditItem?.isSold ||
|
||||
stockEditAttachments.length >= validationDigitalStockAttachmentsMax
|
||||
"
|
||||
>
|
||||
Select file
|
||||
</el-button>
|
||||
</el-upload>
|
||||
|
||||
<template v-if="stockEditPendingFile">
|
||||
<el-text
|
||||
size="small"
|
||||
class="flex-ellipsis"
|
||||
:title="stockEditPendingFile.name"
|
||||
>
|
||||
{{ stockEditPendingFile.name }}
|
||||
</el-text>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="stockEditUploading"
|
||||
:disabled="stockEditItem?.isSold || stockEditAttachmentBusy"
|
||||
@click="submitStockEditUpload"
|
||||
>
|
||||
Upload
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="stockEditVisible = false">Cancel</el-button>
|
||||
<el-button type="primary" :loading="stockEditSaving" @click="submitEditStock">Save</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { config } from '@/config';
|
||||
import { useDigitalStockStore } from '@/stores/digitalStock';
|
||||
import { useProductsStore } from '@/stores/products';
|
||||
import { getPaginationLastPage } from '@/utils/getPaginationLastPage';
|
||||
import type { DigitalStockAttachment } from '@/types/product/DigitalStockAttachment';
|
||||
import type { DigitalStockItem } from '@/types/product/DigitalStockItem';
|
||||
import type { DigitalStockListQuery } from '@/types/product/DigitalStockListQuery';
|
||||
import { buildUploadHint } from '@/utils/upload/buildUploadHint';
|
||||
import { formatDate } from '@/utils/formatDate';
|
||||
import { formatFileSize } from '@/utils/formatFileSize';
|
||||
import { validateUpload } from '@/utils/upload/validateUpload';
|
||||
import {
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
type FormInstance,
|
||||
type FormRules,
|
||||
type UploadInstance,
|
||||
type UploadProps
|
||||
} from 'element-plus';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, nextTick, onBeforeMount, reactive, ref } from 'vue';
|
||||
|
||||
const {
|
||||
digitalStockAttachment: { accept: digitalStockAttachmentAccept, maxFileBytes: digitalStockAttachmentMaxFileBytes },
|
||||
validation: { digitalStockAttachmentsMax: validationDigitalStockAttachmentsMax }
|
||||
} = config;
|
||||
|
||||
const productsStore = useProductsStore();
|
||||
const digitalStockStore = useDigitalStockStore();
|
||||
|
||||
const { currentVariant, currentProductId, currentVariantId } = storeToRefs(productsStore);
|
||||
const { items } = storeToRefs(digitalStockStore);
|
||||
|
||||
const page = ref(1);
|
||||
const limit = ref(20);
|
||||
const total = ref(0);
|
||||
const hideSold = ref(true);
|
||||
|
||||
const stockAddFormRef = ref<FormInstance>();
|
||||
const stockAddForm = reactive({ content: '' });
|
||||
const stockAdding = ref(false);
|
||||
const stockRemoving = ref(false);
|
||||
const stockListLoading = ref(false);
|
||||
|
||||
const stockContentRules: FormRules = {
|
||||
content: [{ required: true, message: 'Required', trigger: 'blur' }]
|
||||
};
|
||||
|
||||
const stockEditVisible = ref(false);
|
||||
const stockEditItem = ref<DigitalStockItem | null>(null);
|
||||
const stockEditFormRef = ref<FormInstance>();
|
||||
const stockEditForm = reactive({ content: '' });
|
||||
const stockEditSaving = ref(false);
|
||||
|
||||
const stockAddUploadRef = ref<UploadInstance>();
|
||||
const stockAddPendingFiles = ref<File[]>([]);
|
||||
|
||||
const stockEditUploadRef = ref<UploadInstance>();
|
||||
const stockEditPendingFile = ref<File | null>(null);
|
||||
const stockEditUploading = ref(false);
|
||||
const stockEditDownloadingId = ref<string | null>(null);
|
||||
const stockEditRemovingAttachmentId = ref<string | null>(null);
|
||||
|
||||
let suppressPaginationChange = false;
|
||||
|
||||
onBeforeMount(() => {
|
||||
digitalStockStore.resetList();
|
||||
|
||||
loadStockList();
|
||||
});
|
||||
|
||||
const stockAttachmentsHint = computed(() =>
|
||||
buildUploadHint({
|
||||
allowedMimesCsv: digitalStockAttachmentAccept,
|
||||
maxFileBytes: digitalStockAttachmentMaxFileBytes,
|
||||
maxFiles: validationDigitalStockAttachmentsMax,
|
||||
maxFilesLabel: 'attachments per stock item',
|
||||
encryptedAtRest: true
|
||||
})
|
||||
);
|
||||
|
||||
const stockEditAttachments = computed(() => {
|
||||
const item = stockEditItem.value;
|
||||
|
||||
if (!item) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const current = items.value.find(stockItem => stockItem.id === item.id);
|
||||
|
||||
return current?.attachments ?? item.attachments ?? [];
|
||||
});
|
||||
|
||||
const stockEditAttachmentBusy = computed(
|
||||
() =>
|
||||
stockEditUploading.value ||
|
||||
stockEditDownloadingId.value !== null ||
|
||||
stockEditRemovingAttachmentId.value !== null
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetches the paginated digital stock list for the current variant.
|
||||
*
|
||||
* Query params are built from component refs (`page`, `limit`, `hideSold`), with optional
|
||||
* `overrides` for programmatic jumps (e.g. reset to page 1 on filter change, go to last
|
||||
* page after add). Server response reconciles `total`, `page`, and `limit`.
|
||||
*
|
||||
* `el-pagination` emits `@change` after programmatic v-model updates (flush: post), not only
|
||||
* on user clicks. Reconcile after fetch would otherwise trigger a duplicate fetch via
|
||||
* `@change="loadStockList()"`. We set `suppressPaginationChange` while assigning refs and
|
||||
* clear it after `nextTick()` so the echo call is ignored at the top of this function.
|
||||
*
|
||||
* @param overrides - Partial query merged with current refs; omitted keys fall back to refs.
|
||||
*/
|
||||
const loadStockList = async (overrides: Partial<DigitalStockListQuery> = {}): Promise<void> => {
|
||||
if (suppressPaginationChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentProductId.value || !currentVariantId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const query: DigitalStockListQuery = {
|
||||
page: overrides.page ?? page.value,
|
||||
limit: overrides.limit ?? limit.value,
|
||||
hideSold: overrides.hideSold ?? hideSold.value
|
||||
};
|
||||
|
||||
stockListLoading.value = true;
|
||||
|
||||
try {
|
||||
const data = await digitalStockStore.fetchList(currentProductId.value, currentVariantId.value, query);
|
||||
|
||||
suppressPaginationChange = true;
|
||||
total.value = data.total;
|
||||
page.value = data.page;
|
||||
limit.value = data.limit;
|
||||
await nextTick();
|
||||
suppressPaginationChange = false;
|
||||
} finally {
|
||||
stockListLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatAttachmentNames = (attachments: DigitalStockAttachment[]): string =>
|
||||
attachments.map(attachment => attachment.originalFilename).join(', ');
|
||||
|
||||
const clearStockAddForm = (): void => {
|
||||
stockAddForm.content = '';
|
||||
stockAddPendingFiles.value = [];
|
||||
stockAddUploadRef.value?.clearFiles();
|
||||
stockAddFormRef.value?.clearValidate();
|
||||
};
|
||||
|
||||
const submitAddStock = async (): Promise<void> => {
|
||||
const form = stockAddFormRef.value;
|
||||
|
||||
if (!form || !currentProductId.value || !currentVariantId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await form.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of stockAddPendingFiles.value) {
|
||||
const err = validateUpload(file, {
|
||||
allowedMimesCsv: digitalStockAttachmentAccept,
|
||||
maxFileBytes: digitalStockAttachmentMaxFileBytes
|
||||
});
|
||||
|
||||
if (err) {
|
||||
ElMessage.error(`${file.name}: ${err}`);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (stockAddPendingFiles.value.length > validationDigitalStockAttachmentsMax) {
|
||||
ElMessage.error(`A stock item can have at most ${validationDigitalStockAttachmentsMax} attachments`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
stockAdding.value = true;
|
||||
|
||||
const pendingFiles = [...stockAddPendingFiles.value];
|
||||
|
||||
try {
|
||||
const newItem = await digitalStockStore.addItem(
|
||||
currentProductId.value,
|
||||
currentVariantId.value,
|
||||
stockAddForm.content.trim()
|
||||
);
|
||||
|
||||
const newLastPage = getPaginationLastPage(total.value + 1, limit.value);
|
||||
|
||||
await loadStockList({ page: newLastPage });
|
||||
|
||||
clearStockAddForm();
|
||||
|
||||
if (pendingFiles.length === 0) {
|
||||
ElMessage.success('Stock item added');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
pendingFiles.map(file =>
|
||||
digitalStockStore.uploadAttachment(currentProductId.value!, currentVariantId.value!, newItem.id, file)
|
||||
)
|
||||
);
|
||||
|
||||
const failedCount = results.filter(result => result.status === 'rejected').length;
|
||||
|
||||
if (failedCount === 0) {
|
||||
ElMessage.success('Stock item added and attachments uploaded');
|
||||
} else if (failedCount === pendingFiles.length) {
|
||||
ElMessage.warning('Stock item added, but attachments could not be uploaded');
|
||||
} else {
|
||||
ElMessage.warning(
|
||||
`Stock item added, but ${failedCount} of ${pendingFiles.length} attachments could not be uploaded`
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('Could not add stock');
|
||||
} finally {
|
||||
stockAdding.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onStockAddFileChange: UploadProps['onChange'] = uploadFile => {
|
||||
const raw = uploadFile.raw;
|
||||
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (stockAddPendingFiles.value.length >= validationDigitalStockAttachmentsMax) {
|
||||
ElMessage.error(`A stock item can have at most ${validationDigitalStockAttachmentsMax} attachments`);
|
||||
stockAddUploadRef.value?.clearFiles();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const err = validateUpload(raw, {
|
||||
allowedMimesCsv: digitalStockAttachmentAccept,
|
||||
maxFileBytes: digitalStockAttachmentMaxFileBytes
|
||||
});
|
||||
|
||||
if (err) {
|
||||
ElMessage.error(err);
|
||||
stockAddUploadRef.value?.clearFiles();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
stockAddPendingFiles.value = [...stockAddPendingFiles.value, raw];
|
||||
stockAddUploadRef.value?.clearFiles();
|
||||
};
|
||||
|
||||
const removeStockAddPendingFile = (index: number): void => {
|
||||
stockAddPendingFiles.value = stockAddPendingFiles.value.filter((_, i) => i !== index);
|
||||
};
|
||||
|
||||
const openEditStock = (item: DigitalStockItem): void => {
|
||||
stockEditItem.value = item;
|
||||
stockEditForm.content = item.content;
|
||||
stockEditPendingFile.value = null;
|
||||
stockEditUploadRef.value?.clearFiles();
|
||||
stockEditVisible.value = true;
|
||||
};
|
||||
|
||||
const onStockEditFileChange: UploadProps['onChange'] = uploadFile => {
|
||||
const raw = uploadFile.raw;
|
||||
|
||||
if (raw) {
|
||||
const err = validateUpload(raw, {
|
||||
allowedMimesCsv: digitalStockAttachmentAccept,
|
||||
maxFileBytes: digitalStockAttachmentMaxFileBytes
|
||||
});
|
||||
|
||||
if (err) {
|
||||
ElMessage.error(err);
|
||||
stockEditUploadRef.value?.clearFiles();
|
||||
stockEditPendingFile.value = null;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
stockEditPendingFile.value = raw ?? null;
|
||||
};
|
||||
|
||||
const onStockEditFileRemove: UploadProps['onRemove'] = () => {
|
||||
stockEditPendingFile.value = null;
|
||||
};
|
||||
|
||||
const submitStockEditUpload = async (): Promise<void> => {
|
||||
const item = stockEditItem.value;
|
||||
const file = stockEditPendingFile.value;
|
||||
|
||||
if (!file || !item || !currentProductId.value || !currentVariantId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const err = validateUpload(file, {
|
||||
allowedMimesCsv: digitalStockAttachmentAccept,
|
||||
maxFileBytes: digitalStockAttachmentMaxFileBytes
|
||||
});
|
||||
|
||||
if (err) {
|
||||
ElMessage.error(err);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
stockEditUploading.value = true;
|
||||
|
||||
try {
|
||||
await digitalStockStore.uploadAttachment(currentProductId.value, currentVariantId.value, item.id, file);
|
||||
|
||||
stockEditPendingFile.value = null;
|
||||
stockEditUploadRef.value?.clearFiles();
|
||||
ElMessage.success('Attachment uploaded');
|
||||
} catch {
|
||||
ElMessage.error('Could not upload attachment');
|
||||
} finally {
|
||||
stockEditUploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadStockAttachment = async (attachment: DigitalStockAttachment): Promise<void> => {
|
||||
const item = stockEditItem.value;
|
||||
|
||||
if (!item || !currentProductId.value || !currentVariantId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
stockEditDownloadingId.value = attachment.id;
|
||||
|
||||
try {
|
||||
await digitalStockStore.downloadAttachment(
|
||||
currentProductId.value,
|
||||
currentVariantId.value,
|
||||
item.id,
|
||||
attachment.id,
|
||||
attachment.originalFilename
|
||||
);
|
||||
} catch {
|
||||
ElMessage.error('Could not download attachment');
|
||||
} finally {
|
||||
stockEditDownloadingId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmRemoveStockAttachment = async (attachmentId: string): Promise<void> => {
|
||||
const item = stockEditItem.value;
|
||||
|
||||
if (!item || !currentProductId.value || !currentVariantId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm('Remove this attachment?', 'Delete attachment', {
|
||||
type: 'warning',
|
||||
confirmButtonText: 'Delete',
|
||||
cancelButtonText: 'Cancel'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
stockEditRemovingAttachmentId.value = attachmentId;
|
||||
|
||||
try {
|
||||
await digitalStockStore.removeAttachment(currentProductId.value, currentVariantId.value, item.id, attachmentId);
|
||||
|
||||
ElMessage.success('Attachment removed');
|
||||
} catch {
|
||||
ElMessage.error('Could not remove attachment');
|
||||
} finally {
|
||||
stockEditRemovingAttachmentId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const submitEditStock = async (): Promise<void> => {
|
||||
const form = stockEditFormRef.value;
|
||||
const item = stockEditItem.value;
|
||||
|
||||
if (!form || !item || !currentProductId.value || !currentVariantId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await form.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
stockEditSaving.value = true;
|
||||
|
||||
try {
|
||||
await digitalStockStore.updateItem(
|
||||
currentProductId.value,
|
||||
currentVariantId.value,
|
||||
item.id,
|
||||
stockEditForm.content.trim()
|
||||
);
|
||||
stockEditVisible.value = false;
|
||||
ElMessage.success('Stock item updated');
|
||||
} catch {
|
||||
ElMessage.error('Could not update stock');
|
||||
} finally {
|
||||
stockEditSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmRemoveStock = async (item: DigitalStockItem): Promise<void> => {
|
||||
if (!currentProductId.value || !currentVariantId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm('Remove this stock line?', 'Delete stock item', {
|
||||
type: 'warning',
|
||||
confirmButtonText: 'Delete',
|
||||
cancelButtonText: 'Cancel'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
stockRemoving.value = true;
|
||||
|
||||
try {
|
||||
await digitalStockStore.removeItem(currentProductId.value, currentVariantId.value, item.id, item.isSold);
|
||||
|
||||
const nextTotal = Math.max(0, total.value - 1);
|
||||
const nextPage = Math.min(page.value, getPaginationLastPage(nextTotal, limit.value));
|
||||
|
||||
await loadStockList({ page: nextPage });
|
||||
|
||||
ElMessage.success('Stock item removed');
|
||||
} catch {
|
||||
ElMessage.error('Could not remove stock');
|
||||
} finally {
|
||||
stockRemoving.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.stock-attachments-field {
|
||||
line-height: 1.45;
|
||||
|
||||
:deep(.el-text) {
|
||||
line-height: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.stock-attachment-upload-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stock-pending-files,
|
||||
.stock-edit-attachments {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.stock-edit-attachments__item {
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.stock-edit-attachments__actions {
|
||||
flex-shrink: 0;
|
||||
|
||||
:deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,345 @@
|
||||
<template>
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<span>Images</span>
|
||||
</template>
|
||||
|
||||
<div class="variant-images-body flex flex-col gap-12">
|
||||
<el-text tag="p" size="small" class="secondary-text w-full m-0">
|
||||
{{ variantImagesHint }}
|
||||
</el-text>
|
||||
|
||||
<div v-if="variantImages.length" class="variant-images-grid gap-16">
|
||||
<div
|
||||
v-for="(image, index) in variantImages"
|
||||
:key="image.id"
|
||||
class="variant-image-item flex flex-col gap-8"
|
||||
>
|
||||
<div class="variant-image-preview-wrap">
|
||||
<img :src="resolveUploadPublicUrl(image.url)" class="variant-image-preview" />
|
||||
|
||||
<el-tag v-if="image.isThumbnail" size="small" type="success" class="variant-image-badge">
|
||||
Thumbnail
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="variant-image-actions flex flex-col gap-4">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:loading="isImageActionLoading(image.id, 'thumbnail')"
|
||||
:disabled="image.isThumbnail || imageActionSaving"
|
||||
@click="setThumbnail(image.id)"
|
||||
>
|
||||
Set thumbnail
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
:loading="isImageActionLoading(image.id, 'delete')"
|
||||
:disabled="imageActionSaving"
|
||||
@click="confirmRemoveImage(image.id)"
|
||||
>
|
||||
Delete
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="variant-image-reorder flex items-center justify-between gap-4">
|
||||
<div class="variant-image-reorder-buttons flex gap-4">
|
||||
<el-button link :disabled="index === 0 || imageActionSaving" @click="moveImage(index, -1)">
|
||||
←
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
:disabled="index === variantImages.length - 1 || imageActionSaving"
|
||||
@click="moveImage(index, 1)"
|
||||
>
|
||||
→
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-icon
|
||||
v-if="isImageActionLoading(image.id, 'reorder')"
|
||||
class="variant-image-reorder-loading is-loading"
|
||||
>
|
||||
<el-icon-loading />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="variant-image-upload-row flex items-center gap-12 min-w-0">
|
||||
<el-upload
|
||||
ref="imageUploadRef"
|
||||
:show-file-list="false"
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
:accept="productThumbAccept"
|
||||
:disabled="
|
||||
imageUploadSaving || imageActionSaving || variantImages.length >= validationVariantImagesMax
|
||||
"
|
||||
:on-change="onImageChange"
|
||||
:on-remove="onImageRemove"
|
||||
>
|
||||
<el-button type="default" :disabled="variantImages.length >= validationVariantImagesMax">
|
||||
Select image
|
||||
</el-button>
|
||||
</el-upload>
|
||||
|
||||
<template v-if="imagePendingFile">
|
||||
<el-text size="small" class="flex-ellipsis" :title="imagePendingFile.name">
|
||||
{{ imagePendingFile.name }}
|
||||
</el-text>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="imageUploadSaving"
|
||||
:disabled="imageActionSaving"
|
||||
@click="submitImageUpload"
|
||||
>
|
||||
Upload
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { config } from '@/config';
|
||||
import { useProductsStore } from '@/stores/products';
|
||||
import type { PendingImageAction } from '@/types/product/PendingImageAction';
|
||||
import type { VariantImage } from '@/types/product/VariantImage';
|
||||
import { buildUploadHint } from '@/utils/upload/buildUploadHint';
|
||||
import { resolveUploadPublicUrl } from '@/utils/upload/resolveUploadPublicUrl';
|
||||
import { validateUpload } from '@/utils/upload/validateUpload';
|
||||
import { ElMessage, ElMessageBox, type UploadInstance, type UploadProps } from 'element-plus';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const {
|
||||
productThumb: { accept: productThumbAccept, maxFileBytes: productThumbMaxFileBytes },
|
||||
validation: { variantImagesMax: validationVariantImagesMax }
|
||||
} = config;
|
||||
|
||||
const productsStore = useProductsStore();
|
||||
const { currentVariant, currentProductId, currentVariantId } = storeToRefs(productsStore);
|
||||
|
||||
const imageUploadRef = ref<UploadInstance>();
|
||||
const imagePendingFile = ref<File | null>(null);
|
||||
const imageUploadSaving = ref(false);
|
||||
const pendingImageAction = ref<PendingImageAction | null>(null);
|
||||
|
||||
const imageActionSaving = computed(() => pendingImageAction.value !== null);
|
||||
const variantImages = computed(() => currentVariant.value?.images ?? []);
|
||||
|
||||
const variantImagesHint = computed(() =>
|
||||
buildUploadHint({
|
||||
allowedMimesCsv: productThumbAccept,
|
||||
maxFileBytes: productThumbMaxFileBytes,
|
||||
maxFiles: validationVariantImagesMax,
|
||||
maxFilesLabel: 'images per variant'
|
||||
})
|
||||
);
|
||||
|
||||
const isImageActionLoading = (imageId: string, type: PendingImageAction['type']): boolean => {
|
||||
const pending = pendingImageAction.value;
|
||||
|
||||
return pending?.type === type && pending.imageId === imageId;
|
||||
};
|
||||
|
||||
const onImageChange: UploadProps['onChange'] = uploadFile => {
|
||||
const raw = uploadFile.raw;
|
||||
|
||||
if (raw) {
|
||||
const err = validateUpload(raw, {
|
||||
allowedMimesCsv: productThumbAccept,
|
||||
maxFileBytes: productThumbMaxFileBytes
|
||||
});
|
||||
|
||||
if (err) {
|
||||
ElMessage.error(err);
|
||||
imageUploadRef.value?.clearFiles();
|
||||
imagePendingFile.value = null;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
imagePendingFile.value = raw ?? null;
|
||||
};
|
||||
|
||||
const onImageRemove: UploadProps['onRemove'] = () => {
|
||||
imagePendingFile.value = null;
|
||||
};
|
||||
|
||||
const submitImageUpload = async (): Promise<void> => {
|
||||
const file = imagePendingFile.value;
|
||||
|
||||
if (!file || !currentProductId.value || !currentVariantId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const err = validateUpload(file, {
|
||||
allowedMimesCsv: productThumbAccept,
|
||||
maxFileBytes: productThumbMaxFileBytes
|
||||
});
|
||||
|
||||
if (err) {
|
||||
ElMessage.error(err);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
imageUploadSaving.value = true;
|
||||
|
||||
try {
|
||||
await productsStore.uploadVariantImage(currentProductId.value, currentVariantId.value, file);
|
||||
|
||||
imagePendingFile.value = null;
|
||||
imageUploadRef.value?.clearFiles();
|
||||
|
||||
ElMessage.success('Image uploaded');
|
||||
} catch {
|
||||
ElMessage.error('Could not upload image');
|
||||
} finally {
|
||||
imageUploadSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const setThumbnail = async (imageId: string): Promise<void> => {
|
||||
if (!currentProductId.value || !currentVariantId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingImageAction.value = { type: 'thumbnail', imageId };
|
||||
|
||||
try {
|
||||
await productsStore.setVariantImageThumbnail(currentProductId.value, currentVariantId.value, imageId);
|
||||
|
||||
ElMessage.success('Thumbnail updated');
|
||||
} catch {
|
||||
ElMessage.error('Could not set thumbnail');
|
||||
} finally {
|
||||
pendingImageAction.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmRemoveImage = async (imageId: string): Promise<void> => {
|
||||
if (!currentProductId.value || !currentVariantId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm('Remove this image?', 'Delete image', {
|
||||
type: 'warning',
|
||||
confirmButtonText: 'Delete',
|
||||
cancelButtonText: 'Cancel'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingImageAction.value = { type: 'delete', imageId };
|
||||
|
||||
try {
|
||||
await productsStore.removeVariantImage(currentProductId.value, currentVariantId.value, imageId);
|
||||
|
||||
ElMessage.success('Image removed');
|
||||
} catch {
|
||||
ElMessage.error('Could not remove image');
|
||||
} finally {
|
||||
pendingImageAction.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const moveImage = async (index: number, delta: number): Promise<void> => {
|
||||
if (!currentProductId.value || !currentVariantId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const images = [...variantImages.value];
|
||||
const targetIndex = index + delta;
|
||||
|
||||
if (targetIndex < 0 || targetIndex >= images.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const swapped = [...images];
|
||||
const [moved] = swapped.splice(index, 1);
|
||||
|
||||
swapped.splice(targetIndex, 0, moved);
|
||||
|
||||
pendingImageAction.value = { type: 'reorder', imageId: images[index].id };
|
||||
|
||||
try {
|
||||
await productsStore.reorderVariantImages(
|
||||
currentProductId.value,
|
||||
currentVariantId.value,
|
||||
swapped.map((image: VariantImage) => image.id)
|
||||
);
|
||||
|
||||
ElMessage.success('Images reordered');
|
||||
} catch {
|
||||
ElMessage.error('Could not reorder images');
|
||||
} finally {
|
||||
pendingImageAction.value = null;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.variant-images-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
}
|
||||
|
||||
.variant-image-item {
|
||||
position: relative;
|
||||
|
||||
.variant-image-preview-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.variant-image-preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
.variant-image-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 1;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.variant-image-actions {
|
||||
align-items: flex-start;
|
||||
|
||||
:deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.variant-image-reorder {
|
||||
.variant-image-reorder-buttons {
|
||||
:deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.variant-image-reorder-loading {
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.variant-image-upload-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<div v-loading="loading" class="detail-loading-host" element-loading-text="Loading wallet…">
|
||||
<el-empty v-if="!loading && loadError" description="Failed to load wallet status" />
|
||||
|
||||
<template v-if="!loading && !loadError && walletStatus">
|
||||
<div v-if="walletStatus.syncStatus !== MoneroWalletSyncStatus.Synced" class="mb-16">
|
||||
<el-alert type="warning" :closable="false" show-icon title="Wallet is syncing. Please wait." />
|
||||
</div>
|
||||
|
||||
<el-card class="mb-24" shadow="never">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-16">
|
||||
<span>Status</span>
|
||||
<el-button type="default" :loading="refreshing" @click="refreshStatus">Refresh</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="Network">{{ walletStatus.network }}</el-descriptions-item>
|
||||
<el-descriptions-item label="RPC version">{{ walletStatus.rpcVersion }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Wallet height">{{ walletStatus.walletHeight }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Daemon height">
|
||||
{{ walletStatus.daemonHeight ?? 'Unavailable' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Sync">
|
||||
<el-tag :type="resolveMoneroWalletSyncStatusTagType(walletStatus.syncStatus)" size="small">
|
||||
{{ resolveMoneroWalletSyncStatusLabel(walletStatus.syncStatus) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Total balance">{{ walletStatus.balanceXmr }} XMR</el-descriptions-item>
|
||||
<el-descriptions-item label="Unlocked balance">
|
||||
{{ walletStatus.unlockedBalanceXmr }} XMR
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card class="mb-24" shadow="never">
|
||||
<template #header>
|
||||
<span>Withdraw all</span>
|
||||
</template>
|
||||
|
||||
<el-form
|
||||
ref="withdrawFormRef"
|
||||
label-position="top"
|
||||
:model="withdrawForm"
|
||||
:rules="withdrawFormRules"
|
||||
@submit.prevent="onWithdrawSubmit"
|
||||
>
|
||||
<el-form-item label="Destination address" prop="destinationAddress">
|
||||
<el-input
|
||||
v-model="withdrawForm.destinationAddress"
|
||||
autocomplete="off"
|
||||
:placeholder="`${walletStatus.network} Monero address`"
|
||||
:disabled="withdrawing"
|
||||
@input="withdrawFormRef?.clearValidate('destinationAddress')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-button type="primary" native-type="submit" :loading="withdrawing">
|
||||
Withdraw all unlocked funds
|
||||
</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<span>Recovery seed</span>
|
||||
</template>
|
||||
|
||||
<p class="m-0 mb-16 secondary-text">
|
||||
This shop is non-custodial. You control the wallet seed. Anyone with the seed can spend all funds.
|
||||
Store it offline and never share it.
|
||||
</p>
|
||||
|
||||
<el-button type="danger" plain :loading="revealingSeed" @click="onRevealSeedClick">
|
||||
Reveal seed
|
||||
</el-button>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<el-dialog v-model="seedDialogVisible" title="Recovery seed" width="560px" destroy-on-close @closed="clearSeed">
|
||||
<el-alert type="error" :closable="false" show-icon title="Store this offline. Do not share it." />
|
||||
|
||||
<el-input v-model="revealedMnemonic" class="mt-16" type="textarea" :rows="4" readonly autocomplete="off" />
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { computed, onBeforeMount, reactive, ref } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { MoneroWalletSyncStatus } from '@/types/moneroWallet/MoneroWalletSyncStatus';
|
||||
import { useMoneroWalletStore } from '@/stores/moneroWallet';
|
||||
import { isMoneroStandardAddress } from '@/utils/monero/isMoneroStandardAddress';
|
||||
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
|
||||
|
||||
const moneroWalletStore = useMoneroWalletStore();
|
||||
|
||||
const { status: walletStatus } = storeToRefs(moneroWalletStore);
|
||||
|
||||
const { fetchStatus, withdrawAll, revealSeed } = moneroWalletStore;
|
||||
|
||||
const loading = ref(true);
|
||||
const loadError = ref(false);
|
||||
const refreshing = ref(false);
|
||||
const withdrawing = ref(false);
|
||||
const revealingSeed = ref(false);
|
||||
const withdrawFormRef = ref<FormInstance>();
|
||||
const withdrawForm = reactive({
|
||||
destinationAddress: ''
|
||||
});
|
||||
const seedDialogVisible = ref(false);
|
||||
const revealedMnemonic = ref('');
|
||||
|
||||
onBeforeMount(async () => {
|
||||
loading.value = true;
|
||||
|
||||
await loadWalletStatus();
|
||||
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
const withdrawFormRules = computed<FormRules>(() => ({
|
||||
destinationAddress: [
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (walletStatus.value?.syncStatus !== MoneroWalletSyncStatus.Synced) {
|
||||
callback(new Error('Wait until the wallet finishes syncing before withdrawing'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
callback(new Error('Enter a destination address'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const network = walletStatus.value?.network;
|
||||
|
||||
if (!network || !isMoneroStandardAddress(value, network)) {
|
||||
callback(new Error(`Enter a valid ${network ?? 'Monero'} address.`));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
callback();
|
||||
},
|
||||
trigger: ['blur', 'change']
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
const loadWalletStatus = async (): Promise<void> => {
|
||||
loadError.value = false;
|
||||
|
||||
try {
|
||||
await fetchStatus();
|
||||
} catch (error) {
|
||||
loadError.value = true;
|
||||
|
||||
ElMessage.error(resolveAxiosErrorMessage(error, 'Failed to load wallet status'));
|
||||
}
|
||||
};
|
||||
|
||||
const refreshStatus = async (): Promise<void> => {
|
||||
refreshing.value = true;
|
||||
|
||||
try {
|
||||
await loadWalletStatus();
|
||||
|
||||
ElMessage.success('Wallet status refreshed');
|
||||
} finally {
|
||||
refreshing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const promptForPassword = async (title: string): Promise<string | null> => {
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt('Enter your CMS password to continue.', title, {
|
||||
confirmButtonText: 'Continue',
|
||||
cancelButtonText: 'Cancel',
|
||||
inputType: 'password',
|
||||
inputValidator: value => (value.trim().length > 0 ? true : 'Password is required')
|
||||
});
|
||||
|
||||
return value.trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const onWithdrawSubmit = async (): Promise<void> => {
|
||||
const formEl = withdrawFormRef.value;
|
||||
|
||||
if (!formEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await formEl.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedAddress = withdrawForm.destinationAddress.trim();
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(`Withdraw all unlocked funds to:\n${trimmedAddress}`, 'Confirm withdrawal', {
|
||||
confirmButtonText: 'Continue',
|
||||
cancelButtonText: 'Cancel',
|
||||
type: 'warning'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const password = await promptForPassword('Confirm withdrawal');
|
||||
|
||||
if (!password) {
|
||||
return;
|
||||
}
|
||||
|
||||
withdrawing.value = true;
|
||||
|
||||
try {
|
||||
const result = await withdrawAll({
|
||||
destinationAddress: trimmedAddress,
|
||||
password
|
||||
});
|
||||
|
||||
ElMessage.success(`Withdrew ${result.amountXmr} XMR`);
|
||||
|
||||
if (result.txHashes.length > 0) {
|
||||
await ElMessageBox.alert(result.txHashes.join('\n'), 'Transaction hash(es)', {
|
||||
confirmButtonText: 'OK'
|
||||
});
|
||||
}
|
||||
|
||||
withdrawForm.destinationAddress = '';
|
||||
withdrawFormRef.value?.clearValidate();
|
||||
|
||||
await loadWalletStatus();
|
||||
} catch (error) {
|
||||
ElMessage.error({ message: resolveAxiosErrorMessage(error, 'Withdrawal failed'), duration: 5000 });
|
||||
} finally {
|
||||
withdrawing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onRevealSeedClick = async (): Promise<void> => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'The recovery seed grants full control over this shop wallet. Store it offline. Never share it or enter it on untrusted sites.',
|
||||
'Reveal recovery seed?',
|
||||
{
|
||||
confirmButtonText: 'I understand',
|
||||
cancelButtonText: 'Cancel',
|
||||
type: 'warning'
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const password = await promptForPassword('Reveal recovery seed');
|
||||
|
||||
if (!password) {
|
||||
return;
|
||||
}
|
||||
|
||||
revealingSeed.value = true;
|
||||
|
||||
try {
|
||||
const result = await revealSeed({ password });
|
||||
|
||||
revealedMnemonic.value = result.mnemonic;
|
||||
seedDialogVisible.value = true;
|
||||
} catch (error) {
|
||||
ElMessage.error(resolveAxiosErrorMessage(error, 'Could not reveal seed'));
|
||||
} finally {
|
||||
revealingSeed.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const clearSeed = (): void => {
|
||||
revealedMnemonic.value = '';
|
||||
};
|
||||
|
||||
const resolveMoneroWalletSyncStatusLabel = (syncStatus: MoneroWalletSyncStatus): string => {
|
||||
switch (syncStatus) {
|
||||
case MoneroWalletSyncStatus.Synced:
|
||||
return 'Synced';
|
||||
case MoneroWalletSyncStatus.Syncing:
|
||||
return 'Syncing';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
};
|
||||
|
||||
const resolveMoneroWalletSyncStatusTagType = (syncStatus: MoneroWalletSyncStatus): 'success' | 'warning' | 'info' => {
|
||||
switch (syncStatus) {
|
||||
case MoneroWalletSyncStatus.Synced:
|
||||
return 'success';
|
||||
case MoneroWalletSyncStatus.Syncing:
|
||||
return 'warning';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
Reference in New Issue
Block a user