init
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
Dockerfile*
|
||||
npm-debug.log
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /cms
|
||||
|
||||
COPY package*.json .
|
||||
|
||||
RUN npm ci
|
||||
|
||||
ADD . .
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>cms</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3649
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "cms",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"author": "nobswebdev",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@tiptap/extension-link": "^3.22.5",
|
||||
"@tiptap/extension-placeholder": "^3.22.5",
|
||||
"@tiptap/starter-kit": "^3.22.5",
|
||||
"@tiptap/vue-3": "^3.22.5",
|
||||
"axios": "^1.15.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"decimal.js": "^10.6.0",
|
||||
"element-plus": "^2.13.6",
|
||||
"pinia": "^3.0.4",
|
||||
"pretty-bytes": "^7.1.0",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.2",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"sass-embedded": "^1.99.0",
|
||||
"typescript": "~6.0.2",
|
||||
"unplugin-vue-components": "^32.0.0",
|
||||
"vite": "^8.0.4",
|
||||
"vue-tsc": "^3.2.6"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<el-container direction="vertical">
|
||||
<el-header v-if="!route.meta.hideNav" height="auto" class="cms-header">
|
||||
<div class="cms-header__bar flex items-center gap-12">
|
||||
<div class="logo">Nullcart CMS</div>
|
||||
|
||||
<el-menu
|
||||
mode="horizontal"
|
||||
:ellipsis="true"
|
||||
menu-trigger="click"
|
||||
close-on-click-outside
|
||||
:default-active="menuActive"
|
||||
class="cms-nav-menu"
|
||||
router
|
||||
>
|
||||
<el-menu-item index="/products">Products</el-menu-item>
|
||||
<el-menu-item index="/categories">Categories</el-menu-item>
|
||||
<el-menu-item index="/discount-codes">Discount codes</el-menu-item>
|
||||
<el-menu-item index="/orders">Orders</el-menu-item>
|
||||
<el-menu-item index="/wallet">Wallet</el-menu-item>
|
||||
<el-menu-item index="/settings">Settings</el-menu-item>
|
||||
</el-menu>
|
||||
|
||||
<div class="cms-header__actions flex items-center gap-8">
|
||||
<theme-toggle />
|
||||
<el-button link type="primary" @click="onSignOut">Sign out</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-main class="main-wrapper">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeMount } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ROUTE_NAMES } from '@/consts/routeNames';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useColorSchemeStore } from '@/stores/colorScheme';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const colorSchemeStore = useColorSchemeStore();
|
||||
|
||||
const menuActive = computed(() => route.meta.activeMenu ?? route.path);
|
||||
|
||||
onBeforeMount(() => {
|
||||
colorSchemeStore.initColorScheme();
|
||||
});
|
||||
|
||||
const onSignOut = () => {
|
||||
authStore.clearSession();
|
||||
|
||||
router.push({ name: ROUTE_NAMES.Login });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cms-header__bar {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
flex-shrink: 0;
|
||||
font-size: var(--el-font-size-large);
|
||||
font-weight: var(--el-font-weight-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cms-nav-menu {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-bottom: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.cms-header__actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.main-wrapper {
|
||||
min-height: calc(100vh - var(--el-header-height));
|
||||
}
|
||||
</style>
|
||||
Vendored
+75
@@ -0,0 +1,75 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint: disable
|
||||
// oxlint-disable
|
||||
// ------
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
CmsListPagination: typeof import('./components/CmsListPagination.vue')['default']
|
||||
CreateOrEditCategoryModal: typeof import('./components/CreateOrEditCategoryModal.vue')['default']
|
||||
CreateOrEditDiscountCodeModal: typeof import('./components/CreateOrEditDiscountCodeModal.vue')['default']
|
||||
CreateProductVariantModal: typeof import('./components/CreateProductVariantModal.vue')['default']
|
||||
DiscountScopePicker: typeof import('./components/DiscountScopePicker.vue')['default']
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElIconLoading: typeof import('@element-plus/icons-vue')['Loading']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElMain: typeof import('element-plus/es')['ElMain']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElText: typeof import('element-plus/es')['ElText']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||
MoneroWallet: typeof import('./components/wallet/MoneroWallet.vue')['default']
|
||||
OrderCartPanel: typeof import('./components/OrderCartPanel.vue')['default']
|
||||
OrderChatPanel: typeof import('./components/OrderChatPanel.vue')['default']
|
||||
OrderLineAutoFulfillmentModal: typeof import('./components/OrderLineAutoFulfillmentModal.vue')['default']
|
||||
OrderManualShippingQuotePanel: typeof import('./components/OrderManualShippingQuotePanel.vue')['default']
|
||||
OrderMoneroPaymentPanel: typeof import('./components/OrderMoneroPaymentPanel.vue')['default']
|
||||
OrderPaymentPanel: typeof import('./components/OrderPaymentPanel.vue')['default']
|
||||
OrderSummaryPanel: typeof import('./components/OrderSummaryPanel.vue')['default']
|
||||
RichTextEditor: typeof import('./components/RichTextEditor.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
ThemeToggle: typeof import('./components/ThemeToggle.vue')['default']
|
||||
VariantDetailDetailsCard: typeof import('./components/variantDetail/VariantDetailDetailsCard.vue')['default']
|
||||
VariantDetailDigitalStockSection: typeof import('./components/variantDetail/VariantDetailDigitalStockSection.vue')['default']
|
||||
VariantDetailImagesCard: typeof import('./components/variantDetail/VariantDetailImagesCard.vue')['default']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,63 @@
|
||||
import { onScopeDispose, watch } from 'vue';
|
||||
import type { UsePollingOptions } from '@/types/UsePollingOptions';
|
||||
|
||||
export const usePolling = (
|
||||
callback: () => void | Promise<void>,
|
||||
{ intervalMs, enabled, immediate = false }: UsePollingOptions
|
||||
): void => {
|
||||
let timerId: ReturnType<typeof setInterval> | null = null;
|
||||
let inFlight = false;
|
||||
|
||||
const tick = async (): Promise<void> => {
|
||||
if (inFlight || enabled?.value === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
inFlight = true;
|
||||
|
||||
try {
|
||||
await callback();
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
const start = (): void => {
|
||||
if (timerId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (immediate) {
|
||||
tick();
|
||||
}
|
||||
|
||||
timerId = setInterval(() => tick(), intervalMs);
|
||||
};
|
||||
|
||||
const stop = (): void => {
|
||||
if (timerId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(timerId);
|
||||
timerId = null;
|
||||
};
|
||||
|
||||
if (enabled) {
|
||||
watch(
|
||||
enabled,
|
||||
isEnabled => {
|
||||
if (isEnabled) {
|
||||
start();
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
|
||||
onScopeDispose(stop);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
export function env(name: keyof ImportMetaEnv): string {
|
||||
const value = import.meta.env[name];
|
||||
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
const apiBaseUrl = env('VITE_API_BASE_URL');
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
baseUrl: apiBaseUrl,
|
||||
rootUrl: apiBaseUrl.split('/api')[0]
|
||||
},
|
||||
|
||||
shopFiatCurrency: env('VITE_SHOP_FIAT_CURRENCY'),
|
||||
|
||||
productThumb: {
|
||||
accept: env('VITE_PRODUCT_THUMB_ALLOWED_MIMES'),
|
||||
maxFileBytes: parseInt(env('VITE_PRODUCT_THUMB_MAX_FILE_BYTES'), 10)
|
||||
},
|
||||
|
||||
shopLogo: {
|
||||
accept: env('VITE_SHOP_LOGO_ALLOWED_MIMES'),
|
||||
maxFileBytes: parseInt(env('VITE_SHOP_LOGO_MAX_FILE_BYTES'), 10)
|
||||
},
|
||||
|
||||
shopFavicon: {
|
||||
accept: env('VITE_SHOP_FAVICON_ALLOWED_MIMES'),
|
||||
maxFileBytes: parseInt(env('VITE_SHOP_FAVICON_MAX_FILE_BYTES'), 10)
|
||||
},
|
||||
|
||||
digitalStockAttachment: {
|
||||
accept: env('VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES'),
|
||||
maxFileBytes: parseInt(env('VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES'), 10)
|
||||
},
|
||||
|
||||
validation: {
|
||||
productTitleMaxLength: parseInt(env('VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH'), 10),
|
||||
categoryNameMaxLength: parseInt(env('VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH'), 10),
|
||||
discountCodeMaxLength: parseInt(env('VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH'), 10),
|
||||
variantImagesMax: parseInt(env('VITE_VALIDATION_VARIANT_IMAGES_MAX'), 10),
|
||||
digitalStockAttachmentsMax: parseInt(env('VITE_VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX'), 10),
|
||||
shippingNoteMinLength: parseInt(env('VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH'), 10),
|
||||
shippingNoteMaxLength: parseInt(env('VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH'), 10),
|
||||
orderMessageMaxLength: parseInt(env('VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH'), 10)
|
||||
},
|
||||
|
||||
orders: {
|
||||
detailPollIntervalMs: parseInt(env('VITE_ORDERS_DETAIL_POLL_INTERVAL_MS'), 10)
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
export const ROUTE_NAMES = {
|
||||
Login: 'CmsLogin',
|
||||
ProductDetail: 'ProductDetail',
|
||||
ProductVariantDetail: 'ProductVariantDetail',
|
||||
Products: 'Products',
|
||||
Categories: 'Categories',
|
||||
DiscountCodes: 'DiscountCodes',
|
||||
ShopSettings: 'ShopSettings',
|
||||
Notifications: 'Notifications',
|
||||
Wallet: 'Wallet',
|
||||
Orders: 'Orders',
|
||||
OrderDetail: 'OrderDetail'
|
||||
} as const;
|
||||
@@ -0,0 +1 @@
|
||||
export const UNTITLED_PRODUCT_TITLE = '(untitled)';
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import ElementPlus from 'element-plus';
|
||||
import 'element-plus/dist/index.css';
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css';
|
||||
import './styles/utils.scss';
|
||||
import './styles/responsive.scss';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
|
||||
createApp(App).use(createPinia()).use(ElementPlus).use(router).mount('#app');
|
||||
@@ -0,0 +1,39 @@
|
||||
import axios, { type AxiosError, HttpStatusCode } from 'axios';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import router from '@/router';
|
||||
import { ROUTE_NAMES } from '@/consts/routeNames';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { config } from '@/config';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: config.api.baseUrl,
|
||||
withCredentials: true
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
response => response,
|
||||
(error: AxiosError) => {
|
||||
const status = error.response?.status;
|
||||
const url = error.config?.url ?? '';
|
||||
|
||||
if (status === HttpStatusCode.Unauthorized && !url.includes('/auth/login')) {
|
||||
ElMessage.error('Your session ended. Please sign in again.');
|
||||
|
||||
const { clearSession } = useAuthStore();
|
||||
|
||||
clearSession();
|
||||
|
||||
router.push({ name: ROUTE_NAMES.Login });
|
||||
}
|
||||
|
||||
if (status === HttpStatusCode.Forbidden) {
|
||||
ElMessage.error('You do not have access to this resource.');
|
||||
}
|
||||
|
||||
if (status === HttpStatusCode.TooManyRequests) {
|
||||
ElMessage.error('Too many requests. Please slow down and try again.');
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export default dayjs;
|
||||
@@ -0,0 +1,106 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { ROUTE_NAMES } from '@/consts/routeNames';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: ROUTE_NAMES.Login,
|
||||
component: () => import('../views/CmsLoginView.vue'),
|
||||
meta: { title: 'CMS - Sign in', hideNav: true }
|
||||
},
|
||||
{
|
||||
path: '/products/:id',
|
||||
name: ROUTE_NAMES.ProductDetail,
|
||||
component: () => import('../views/CmsProductDetailView.vue'),
|
||||
meta: { requiresAuth: true, title: 'CMS - Product', activeMenu: '/products' }
|
||||
},
|
||||
{
|
||||
path: '/products/:productId/variants/:variantId',
|
||||
name: ROUTE_NAMES.ProductVariantDetail,
|
||||
component: () => import('../views/CmsProductVariantDetailView.vue'),
|
||||
meta: { requiresAuth: true, title: 'CMS - Variant', activeMenu: '/products' }
|
||||
},
|
||||
{
|
||||
path: '/products',
|
||||
name: ROUTE_NAMES.Products,
|
||||
component: () => import('../views/CmsProductsView.vue'),
|
||||
meta: { requiresAuth: true, title: 'CMS - Products' }
|
||||
},
|
||||
{
|
||||
path: '/categories',
|
||||
name: ROUTE_NAMES.Categories,
|
||||
component: () => import('../views/CmsCategoriesView.vue'),
|
||||
meta: { requiresAuth: true, title: 'CMS - Categories' }
|
||||
},
|
||||
{
|
||||
path: '/discount-codes',
|
||||
name: ROUTE_NAMES.DiscountCodes,
|
||||
component: () => import('../views/CmsDiscountCodesView.vue'),
|
||||
meta: { requiresAuth: true, title: 'CMS - Discount codes' }
|
||||
},
|
||||
{
|
||||
path: '/orders/:id',
|
||||
name: ROUTE_NAMES.OrderDetail,
|
||||
component: () => import('../views/CmsOrderDetailView.vue'),
|
||||
meta: { requiresAuth: true, title: 'CMS - Order', activeMenu: '/orders' }
|
||||
},
|
||||
{
|
||||
path: '/orders',
|
||||
name: ROUTE_NAMES.Orders,
|
||||
component: () => import('../views/CmsOrdersView.vue'),
|
||||
meta: { requiresAuth: true, title: 'CMS - Orders' }
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
component: () => import('../views/CmsSettingsLayout.vue'),
|
||||
meta: { requiresAuth: true, title: 'CMS - Settings', activeMenu: '/settings' },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: ROUTE_NAMES.ShopSettings,
|
||||
component: () => import('../views/CmsShopSettingsView.vue'),
|
||||
meta: { requiresAuth: true, title: 'CMS - Shop settings', activeMenu: '/settings' }
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
name: ROUTE_NAMES.Notifications,
|
||||
component: () => import('../views/CmsNotificationsView.vue'),
|
||||
meta: { requiresAuth: true, title: 'CMS - Notifications', activeMenu: '/settings' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/wallet',
|
||||
name: ROUTE_NAMES.Wallet,
|
||||
component: () => import('../views/CmsWalletView.vue'),
|
||||
meta: { requiresAuth: true, title: 'CMS - Wallet' }
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: { name: ROUTE_NAMES.Login }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
router.beforeEach(to => {
|
||||
const auth = useAuthStore();
|
||||
|
||||
if (to.meta.requiresAuth && !auth.isSignedIn) {
|
||||
return { name: ROUTE_NAMES.Login };
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
router.afterEach(to => {
|
||||
const title = to.meta.title;
|
||||
|
||||
if (title && typeof title === 'string') {
|
||||
document.title = title;
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { api } from '@/plugins/axios';
|
||||
|
||||
const CMS_AUTH_STORAGE_KEY = 'cms-auth';
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const isSignedIn = ref(localStorage.getItem(CMS_AUTH_STORAGE_KEY) === '1');
|
||||
|
||||
const markSignedIn = () => {
|
||||
localStorage.setItem(CMS_AUTH_STORAGE_KEY, '1');
|
||||
isSignedIn.value = true;
|
||||
};
|
||||
|
||||
const clearSession = () => {
|
||||
localStorage.removeItem(CMS_AUTH_STORAGE_KEY);
|
||||
isSignedIn.value = false;
|
||||
};
|
||||
|
||||
const login = async (password: string) => {
|
||||
await api.post('/auth/login', { password });
|
||||
|
||||
markSignedIn();
|
||||
};
|
||||
|
||||
return {
|
||||
isSignedIn,
|
||||
login,
|
||||
clearSession
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { api } from '@/plugins/axios';
|
||||
import type { CreateOrUpdateCategoryPayload } from '@/types/category/CreateOrUpdateCategoryPayload';
|
||||
import type { Category } from '@/types/product/Category';
|
||||
|
||||
export const useCategoriesStore = defineStore('categories', () => {
|
||||
const categories = ref<Category[]>([]);
|
||||
|
||||
const upsertCategory = (data: Category): void => {
|
||||
const idx = categories.value.findIndex(c => c.id === data.id);
|
||||
|
||||
if (idx !== -1) {
|
||||
categories.value[idx] = data;
|
||||
} else {
|
||||
categories.value.push(data);
|
||||
}
|
||||
|
||||
categories.value.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
|
||||
};
|
||||
|
||||
const fetchAll = async (): Promise<void> => {
|
||||
const { data } = await api.get<Category[]>('/categories');
|
||||
|
||||
categories.value = data;
|
||||
};
|
||||
|
||||
const createCategory = async (payload: CreateOrUpdateCategoryPayload): Promise<Category> => {
|
||||
const { data } = await api.post<Category>('/categories', payload);
|
||||
|
||||
upsertCategory(data);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const updateCategory = async (id: string, payload: CreateOrUpdateCategoryPayload): Promise<Category> => {
|
||||
const { data } = await api.patch<Category>(`/categories/${id}`, payload);
|
||||
|
||||
upsertCategory(data);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const removeCategory = async (id: string): Promise<void> => {
|
||||
await api.delete(`/categories/${id}`);
|
||||
|
||||
categories.value = categories.value.filter(c => c.id !== id);
|
||||
};
|
||||
|
||||
return {
|
||||
categories,
|
||||
fetchAll,
|
||||
createCategory,
|
||||
updateCategory,
|
||||
removeCategory
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const STORAGE_KEY = 'cms-color-mode';
|
||||
|
||||
const resolveInitialDark = (): boolean => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
|
||||
if (saved === 'dark') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (saved === 'light') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const useColorSchemeStore = defineStore('colorScheme', () => {
|
||||
const isDark = ref(false);
|
||||
|
||||
const initColorScheme = (): void => {
|
||||
isDark.value = resolveInitialDark();
|
||||
|
||||
document.documentElement.classList.toggle('dark', isDark.value);
|
||||
};
|
||||
|
||||
const toggleDarkMode = (): void => {
|
||||
isDark.value = !isDark.value;
|
||||
|
||||
document.documentElement.classList.toggle('dark', isDark.value);
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, isDark.value ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
return {
|
||||
isDark,
|
||||
initColorScheme,
|
||||
toggleDarkMode
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { api } from '@/plugins/axios';
|
||||
import type { PaginatedResponse } from '@/types/PaginatedResponse';
|
||||
import type { DigitalStockItem } from '@/types/product/DigitalStockItem';
|
||||
import type { DigitalStockListQuery } from '@/types/product/DigitalStockListQuery';
|
||||
import { useProductsStore } from './products';
|
||||
|
||||
export const useDigitalStockStore = defineStore('digitalStock', () => {
|
||||
const items = ref<DigitalStockItem[]>([]);
|
||||
|
||||
const resetList = (): void => {
|
||||
items.value = [];
|
||||
};
|
||||
|
||||
const upsertItemInList = (item: DigitalStockItem): void => {
|
||||
const idx = items.value.findIndex(row => row.id === item.id);
|
||||
|
||||
if (idx === -1) {
|
||||
items.value.push(item);
|
||||
} else {
|
||||
items.value[idx] = item;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchList = async (
|
||||
productId: string,
|
||||
variantId: string,
|
||||
{ page, limit, hideSold }: DigitalStockListQuery
|
||||
): Promise<PaginatedResponse<DigitalStockItem>> => {
|
||||
const { data } = await api.get<PaginatedResponse<DigitalStockItem>>(
|
||||
`/products/${productId}/variants/${variantId}/digital-stock-items`,
|
||||
{ params: { page, limit, hideSold: hideSold ? 1 : 0 } }
|
||||
);
|
||||
|
||||
items.value = data.items;
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const addItem = async (productId: string, variantId: string, content: string): Promise<DigitalStockItem> => {
|
||||
const { data } = await api.post<DigitalStockItem>(
|
||||
`/products/${productId}/variants/${variantId}/digital-stock-items`,
|
||||
{ content }
|
||||
);
|
||||
|
||||
const { adjustVariantStockAvailable } = useProductsStore();
|
||||
|
||||
adjustVariantStockAvailable(productId, variantId, 1);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const updateItem = async (
|
||||
productId: string,
|
||||
variantId: string,
|
||||
itemId: string,
|
||||
content: string
|
||||
): Promise<DigitalStockItem> => {
|
||||
const { data } = await api.patch<DigitalStockItem>(
|
||||
`/products/${productId}/variants/${variantId}/digital-stock-items/${itemId}`,
|
||||
{ content }
|
||||
);
|
||||
|
||||
upsertItemInList(data);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const removeItem = async (productId: string, variantId: string, itemId: string, isSold: boolean): Promise<void> => {
|
||||
await api.delete(`/products/${productId}/variants/${variantId}/digital-stock-items/${itemId}`);
|
||||
|
||||
if (!isSold) {
|
||||
const { adjustVariantStockAvailable } = useProductsStore();
|
||||
|
||||
adjustVariantStockAvailable(productId, variantId, -1);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadAttachment = async (
|
||||
productId: string,
|
||||
variantId: string,
|
||||
itemId: string,
|
||||
file: File
|
||||
): Promise<DigitalStockItem> => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('file', file);
|
||||
|
||||
const { data } = await api.post<DigitalStockItem>(
|
||||
`/products/${productId}/variants/${variantId}/digital-stock-items/${itemId}/attachments`,
|
||||
formData
|
||||
);
|
||||
|
||||
upsertItemInList(data);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const removeAttachment = async (
|
||||
productId: string,
|
||||
variantId: string,
|
||||
itemId: string,
|
||||
attachmentId: string
|
||||
): Promise<DigitalStockItem> => {
|
||||
const { data } = await api.delete<DigitalStockItem>(
|
||||
`/products/${productId}/variants/${variantId}/digital-stock-items/${itemId}/attachments/${attachmentId}`
|
||||
);
|
||||
|
||||
upsertItemInList(data);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const downloadAttachment = async (
|
||||
productId: string,
|
||||
variantId: string,
|
||||
itemId: string,
|
||||
attachmentId: string,
|
||||
filename: string
|
||||
): Promise<void> => {
|
||||
const { data } = await api.get<Blob>(
|
||||
`/products/${productId}/variants/${variantId}/digital-stock-items/${itemId}/attachments/${attachmentId}/download`,
|
||||
{ responseType: 'blob' }
|
||||
);
|
||||
|
||||
const url = URL.createObjectURL(data);
|
||||
const anchor = document.createElement('a');
|
||||
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return {
|
||||
items,
|
||||
resetList,
|
||||
fetchList,
|
||||
addItem,
|
||||
updateItem,
|
||||
removeItem,
|
||||
uploadAttachment,
|
||||
removeAttachment,
|
||||
downloadAttachment
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { api } from '@/plugins/axios';
|
||||
import type { CreateOrUpdateDiscountCodePayload } from '@/types/discountCode/CreateOrUpdateDiscountCodePayload';
|
||||
import type { DiscountCode } from '@/types/discountCode/DiscountCode';
|
||||
|
||||
export const useDiscountCodesStore = defineStore('discountCodes', () => {
|
||||
const discountCodes = ref<DiscountCode[]>([]);
|
||||
|
||||
const upsertDiscountCode = (data: DiscountCode): void => {
|
||||
const idx = discountCodes.value.findIndex(c => c.id === data.id);
|
||||
|
||||
if (idx !== -1) {
|
||||
discountCodes.value[idx] = data;
|
||||
} else {
|
||||
discountCodes.value.unshift(data);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAll = async (): Promise<void> => {
|
||||
const { data } = await api.get<DiscountCode[]>('/discount-codes');
|
||||
|
||||
discountCodes.value = data;
|
||||
};
|
||||
|
||||
const fetchDiscountCodeById = async (id: string): Promise<DiscountCode> => {
|
||||
const { data } = await api.get<DiscountCode>(`/discount-codes/${id}`);
|
||||
|
||||
upsertDiscountCode(data);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const createDiscountCode = async (payload: CreateOrUpdateDiscountCodePayload): Promise<DiscountCode> => {
|
||||
const { data } = await api.post<DiscountCode>('/discount-codes', payload);
|
||||
|
||||
upsertDiscountCode(data);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const updateDiscountCode = async (
|
||||
id: string,
|
||||
payload: CreateOrUpdateDiscountCodePayload
|
||||
): Promise<DiscountCode> => {
|
||||
const { data } = await api.patch<DiscountCode>(`/discount-codes/${id}`, payload);
|
||||
|
||||
upsertDiscountCode(data);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const removeDiscountCode = async (id: string): Promise<void> => {
|
||||
await api.delete(`/discount-codes/${id}`);
|
||||
|
||||
discountCodes.value = discountCodes.value.filter(c => c.id !== id);
|
||||
};
|
||||
|
||||
return {
|
||||
discountCodes,
|
||||
fetchAll,
|
||||
fetchDiscountCodeById,
|
||||
createDiscountCode,
|
||||
updateDiscountCode,
|
||||
removeDiscountCode
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { api } from '@/plugins/axios';
|
||||
import type { MoneroWalletRevealSeedPayload } from '@/types/moneroWallet/MoneroWalletRevealSeedPayload';
|
||||
import type { MoneroWalletRevealSeedResult } from '@/types/moneroWallet/MoneroWalletRevealSeedResult';
|
||||
import type { MoneroWalletStatus } from '@/types/moneroWallet/MoneroWalletStatus';
|
||||
import type { MoneroWalletWithdrawPayload } from '@/types/moneroWallet/MoneroWalletWithdrawPayload';
|
||||
import type { MoneroWalletWithdrawResult } from '@/types/moneroWallet/MoneroWalletWithdrawResult';
|
||||
|
||||
export const useMoneroWalletStore = defineStore('moneroWallet', () => {
|
||||
const status = ref<MoneroWalletStatus | null>(null);
|
||||
|
||||
const fetchStatus = async (): Promise<MoneroWalletStatus> => {
|
||||
const { data } = await api.get<MoneroWalletStatus>('/monero-wallet');
|
||||
|
||||
status.value = data;
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const withdrawAll = async (payload: MoneroWalletWithdrawPayload): Promise<MoneroWalletWithdrawResult> => {
|
||||
const { data } = await api.post<MoneroWalletWithdrawResult>('/monero-wallet/withdraw', payload);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const revealSeed = async (payload: MoneroWalletRevealSeedPayload): Promise<MoneroWalletRevealSeedResult> => {
|
||||
const { data } = await api.post<MoneroWalletRevealSeedResult>('/monero-wallet/reveal-seed', payload);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
return {
|
||||
status,
|
||||
fetchStatus,
|
||||
withdrawAll,
|
||||
revealSeed
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { ROUTE_NAMES } from '@/consts/routeNames';
|
||||
import { api } from '@/plugins/axios';
|
||||
import type { PaginatedResponse } from '@/types/PaginatedResponse';
|
||||
import type { OrderExtended } from '@/types/order/OrderExtended';
|
||||
import type { OrderListItem } from '@/types/order/OrderListItem';
|
||||
import type { OrderMessage } from '@/types/order/OrderMessage';
|
||||
import type { SetDeliveryCostPayload } from '@/types/order/SetDeliveryCostPayload';
|
||||
|
||||
export const useOrdersStore = defineStore('orders', () => {
|
||||
const route = useRoute();
|
||||
|
||||
const orderList = ref<OrderListItem[]>([]);
|
||||
const currentOrder = ref<OrderExtended | null>(null);
|
||||
|
||||
const currentOrderId = computed<string | null>(() => {
|
||||
if (route.name !== ROUTE_NAMES.OrderDetail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = route.params.id;
|
||||
|
||||
return typeof id === 'string' && id ? id : null;
|
||||
});
|
||||
|
||||
const fetchList = async ({
|
||||
page,
|
||||
limit
|
||||
}: {
|
||||
page: number;
|
||||
limit: number;
|
||||
}): Promise<PaginatedResponse<OrderListItem>> => {
|
||||
const { data } = await api.get<PaginatedResponse<OrderListItem>>('/orders', {
|
||||
params: { page, limit }
|
||||
});
|
||||
|
||||
orderList.value = data.items;
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const fetchById = async (id: string): Promise<OrderExtended> => {
|
||||
const { data } = await api.get<OrderExtended>(`/orders/${id}`);
|
||||
|
||||
currentOrder.value = data;
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const markChatRead = async (id: string): Promise<void> => {
|
||||
await api.post(`/orders/${id}/messages/mark-read`);
|
||||
};
|
||||
|
||||
const sendMessage = async (id: string, body: string): Promise<OrderMessage[]> => {
|
||||
const { data } = await api.post<OrderMessage[]>(`/orders/${id}/messages`, { body });
|
||||
|
||||
if (currentOrder.value?.id === id) {
|
||||
currentOrder.value = { ...currentOrder.value, messages: data };
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const deleteMessage = async (orderId: string, messageId: string): Promise<OrderMessage[]> => {
|
||||
const { data } = await api.delete<OrderMessage[]>(`/orders/${orderId}/messages/${messageId}`);
|
||||
|
||||
if (currentOrder.value?.id === orderId) {
|
||||
currentOrder.value = { ...currentOrder.value, messages: data };
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const setDeliveryCost = async (id: string, payload: SetDeliveryCostPayload): Promise<OrderExtended> => {
|
||||
const { data } = await api.post<OrderExtended>(`/orders/${id}/delivery-cost`, payload);
|
||||
|
||||
currentOrder.value = data;
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const fulfillManualLine = async (orderId: string, lineId: string): Promise<OrderExtended> => {
|
||||
const { data } = await api.post<OrderExtended>(`/orders/${orderId}/lines/${lineId}/fulfill`);
|
||||
|
||||
currentOrder.value = data;
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
return {
|
||||
orderList,
|
||||
currentOrder,
|
||||
currentOrderId,
|
||||
fetchList,
|
||||
fetchById,
|
||||
markChatRead,
|
||||
sendMessage,
|
||||
deleteMessage,
|
||||
setDeliveryCost,
|
||||
fulfillManualLine
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import type { FormRules } from 'element-plus';
|
||||
import { config } from '@/config';
|
||||
import { ROUTE_NAMES } from '@/consts/routeNames';
|
||||
import { api } from '@/plugins/axios';
|
||||
import type { PaginatedResponse } from '@/types/PaginatedResponse';
|
||||
import type { ProductWithVariantsExtended } from '@/types/product/ProductWithVariantsExtended';
|
||||
import { DeliveryMode } from '@/types/product/DeliveryMode';
|
||||
import type { UpdateProductPayload } from '@/types/product/UpdateProductPayload';
|
||||
import type { ProductVariantPayload } from '@/types/product/ProductVariantPayload';
|
||||
import type { ProductVariant } from '@/types/product/ProductVariant';
|
||||
import type { ProductVariantExtended } from '@/types/product/ProductVariantExtended';
|
||||
import { compareProductVariants } from '@/utils/product/compareProductVariants';
|
||||
import { formatDeliveryMode } from '@/utils/product/formatDeliveryMode';
|
||||
import { getProductTitle } from '@/utils/product/getProductTitle';
|
||||
|
||||
const {
|
||||
validation: { productTitleMaxLength: validationProductTitleMaxLength }
|
||||
} = config;
|
||||
|
||||
export const useProductsStore = defineStore('products', () => {
|
||||
const route = useRoute();
|
||||
const products = ref<ProductWithVariantsExtended[]>([]);
|
||||
|
||||
const currentProductId = computed<string | null>(() => {
|
||||
if (route.name === ROUTE_NAMES.ProductDetail) {
|
||||
const id = route.params.id;
|
||||
|
||||
return typeof id === 'string' && id ? id : null;
|
||||
}
|
||||
|
||||
if (route.name === ROUTE_NAMES.ProductVariantDetail) {
|
||||
const id = route.params.productId;
|
||||
|
||||
return typeof id === 'string' && id ? id : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const currentVariantId = computed<string | null>(() => {
|
||||
if (route.name !== ROUTE_NAMES.ProductVariantDetail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = route.params.variantId;
|
||||
|
||||
return typeof id === 'string' && id ? id : null;
|
||||
});
|
||||
|
||||
const currentProduct = computed<ProductWithVariantsExtended | null>(() => {
|
||||
if (!currentProductId.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return products.value.find(product => product.id === currentProductId.value) ?? null;
|
||||
});
|
||||
|
||||
const currentVariant = computed<ProductVariantExtended | null>(() => {
|
||||
if (!currentVariantId.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return currentProduct.value?.variants.find(variant => variant.id === currentVariantId.value) ?? null;
|
||||
});
|
||||
|
||||
const currentDeliveryModeLabel = computed(() =>
|
||||
currentProduct.value ? formatDeliveryMode(currentProduct.value.deliveryMode) : null
|
||||
);
|
||||
|
||||
const currentProductDisplayTitle = computed(() =>
|
||||
currentProduct.value ? getProductTitle(currentProduct.value.title) : null
|
||||
);
|
||||
|
||||
const currentProductIsManual = computed(
|
||||
() => currentProduct.value?.deliveryMode === DeliveryMode.Manual
|
||||
);
|
||||
|
||||
const currentProductIsAuto = computed(
|
||||
() => currentProduct.value?.deliveryMode === DeliveryMode.Auto
|
||||
);
|
||||
|
||||
const canDeleteCurrentVariant = computed(() => (currentProduct.value?.variants.length ?? 0) > 1);
|
||||
|
||||
const createOrEditCurrentProductVariantFormRules = computed<FormRules>(() => ({
|
||||
title: [
|
||||
{ required: true, message: 'Required', trigger: 'blur' },
|
||||
{
|
||||
max: validationProductTitleMaxLength,
|
||||
message: `At most ${validationProductTitleMaxLength} characters`,
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
price: [{ required: true, message: 'Required', trigger: 'change' }],
|
||||
...(currentProductIsManual.value
|
||||
? {
|
||||
stockQuantity: [
|
||||
{ required: true, message: 'Stock quantity is required', trigger: 'change' },
|
||||
{ type: 'integer', message: 'Stock quantity must be a whole number', trigger: 'change' }
|
||||
]
|
||||
}
|
||||
: {}),
|
||||
sortOrder: [
|
||||
{ required: true, message: 'Sort order is required', trigger: 'change' },
|
||||
{ type: 'integer', message: 'Sort order must be a whole number', trigger: 'change' }
|
||||
]
|
||||
}));
|
||||
|
||||
const upsertProduct = (data: ProductWithVariantsExtended): void => {
|
||||
const idx = products.value.findIndex(r => r.id === data.id);
|
||||
|
||||
if (idx !== -1) {
|
||||
products.value[idx] = data;
|
||||
} else {
|
||||
products.value.unshift(data);
|
||||
}
|
||||
};
|
||||
|
||||
const upsertProductVariant = (productId: string, variant: ProductVariantExtended): void => {
|
||||
const product = products.value.find(p => p.id === productId);
|
||||
|
||||
if (!product) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = product.variants.findIndex(v => v.id === variant.id);
|
||||
|
||||
if (idx === -1) {
|
||||
product.variants.push(variant);
|
||||
} else {
|
||||
product.variants[idx] = variant;
|
||||
}
|
||||
|
||||
product.variants.sort(compareProductVariants);
|
||||
};
|
||||
|
||||
const adjustVariantStockAvailable = (productId: string, variantId: string, delta: number): void => {
|
||||
const product = products.value.find(p => p.id === productId);
|
||||
const variant = product?.variants.find(v => v.id === variantId);
|
||||
|
||||
if (variant) {
|
||||
variant.stockAvailable = Math.max(0, variant.stockAvailable + delta);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProducts = async ({
|
||||
page,
|
||||
limit,
|
||||
search = ''
|
||||
}: {
|
||||
page: number;
|
||||
limit: number;
|
||||
search?: string;
|
||||
}): Promise<PaginatedResponse<ProductWithVariantsExtended>> => {
|
||||
const { data } = await api.get<PaginatedResponse<ProductWithVariantsExtended>>('/products', {
|
||||
params: { page, limit, search }
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const fetchProductVariants = async ({
|
||||
search = '',
|
||||
page = 1,
|
||||
limit = 20
|
||||
}: {
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
} = {}): Promise<PaginatedResponse<ProductVariant>> => {
|
||||
const { data } = await api.get<PaginatedResponse<ProductVariant>>('/products/variants', {
|
||||
params: { search, page, limit }
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const fetchProductById = async (id: string): Promise<ProductWithVariantsExtended> => {
|
||||
const { data } = await api.get<ProductWithVariantsExtended>(`/products/${id}`);
|
||||
|
||||
upsertProduct(data);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const createDraftProduct = async (deliveryMode: DeliveryMode): Promise<ProductWithVariantsExtended> => {
|
||||
const { data } = await api.post<ProductWithVariantsExtended>('/products', { deliveryMode });
|
||||
|
||||
upsertProduct(data);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const updateProduct = async (id: string, payload: UpdateProductPayload): Promise<ProductWithVariantsExtended> => {
|
||||
const { data } = await api.patch<ProductWithVariantsExtended>(`/products/${id}`, payload);
|
||||
|
||||
upsertProduct(data);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const createProductVariant = async (
|
||||
productId: string,
|
||||
payload: ProductVariantPayload
|
||||
): Promise<ProductVariantExtended> => {
|
||||
const { data } = await api.post<ProductVariantExtended>(`/products/${productId}/variants`, payload);
|
||||
|
||||
upsertProductVariant(productId, data);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const updateProductVariant = async (
|
||||
productId: string,
|
||||
variantId: string,
|
||||
payload: ProductVariantPayload
|
||||
): Promise<ProductVariantExtended> => {
|
||||
const { data } = await api.patch<ProductVariantExtended>(
|
||||
`/products/${productId}/variants/${variantId}`,
|
||||
payload
|
||||
);
|
||||
|
||||
upsertProductVariant(productId, data);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const deleteProductVariant = async (productId: string, variantId: string): Promise<void> => {
|
||||
await api.delete(`/products/${productId}/variants/${variantId}`);
|
||||
|
||||
const product = products.value.find(p => p.id === productId);
|
||||
|
||||
if (product) {
|
||||
product.variants = product.variants.filter(v => v.id !== variantId);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadVariantImage = async (
|
||||
productId: string,
|
||||
variantId: string,
|
||||
file: File
|
||||
): Promise<ProductVariantExtended> => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('file', file);
|
||||
|
||||
const { data } = await api.post<ProductVariantExtended>(
|
||||
`/products/${productId}/variants/${variantId}/images`,
|
||||
formData
|
||||
);
|
||||
|
||||
upsertProductVariant(productId, data);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const removeVariantImage = async (
|
||||
productId: string,
|
||||
variantId: string,
|
||||
imageId: string
|
||||
): Promise<ProductVariantExtended> => {
|
||||
const { data } = await api.delete<ProductVariantExtended>(
|
||||
`/products/${productId}/variants/${variantId}/images/${imageId}`
|
||||
);
|
||||
|
||||
upsertProductVariant(productId, data);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const setVariantImageThumbnail = async (
|
||||
productId: string,
|
||||
variantId: string,
|
||||
imageId: string
|
||||
): Promise<ProductVariantExtended> => {
|
||||
const { data } = await api.patch<ProductVariantExtended>(
|
||||
`/products/${productId}/variants/${variantId}/images/${imageId}/set-thumbnail`
|
||||
);
|
||||
|
||||
upsertProductVariant(productId, data);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const reorderVariantImages = async (
|
||||
productId: string,
|
||||
variantId: string,
|
||||
imageIds: string[]
|
||||
): Promise<ProductVariantExtended> => {
|
||||
const { data } = await api.patch<ProductVariantExtended>(
|
||||
`/products/${productId}/variants/${variantId}/images/reorder`,
|
||||
{ imageIds }
|
||||
);
|
||||
|
||||
upsertProductVariant(productId, data);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const deleteProduct = async (id: string): Promise<void> => {
|
||||
await api.delete(`/products/${id}`);
|
||||
|
||||
products.value = products.value.filter(p => p.id !== id);
|
||||
};
|
||||
|
||||
return {
|
||||
products,
|
||||
currentProduct,
|
||||
currentVariant,
|
||||
currentProductId,
|
||||
currentVariantId,
|
||||
currentDeliveryModeLabel,
|
||||
currentProductDisplayTitle,
|
||||
currentProductIsManual,
|
||||
currentProductIsAuto,
|
||||
canDeleteCurrentVariant,
|
||||
createOrEditCurrentProductVariantFormRules,
|
||||
fetchProducts,
|
||||
fetchProductVariants,
|
||||
fetchProductById,
|
||||
adjustVariantStockAvailable,
|
||||
createDraftProduct,
|
||||
updateProduct,
|
||||
createProductVariant,
|
||||
updateProductVariant,
|
||||
deleteProductVariant,
|
||||
uploadVariantImage,
|
||||
removeVariantImage,
|
||||
setVariantImageThumbnail,
|
||||
reorderVariantImages,
|
||||
deleteProduct
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { api } from '@/plugins/axios';
|
||||
import type { ConnectSimplexNotificationsPayload } from '@/types/shopSettings/ConnectSimplexNotificationsPayload';
|
||||
import type { ShopSettings } from '@/types/shopSettings/ShopSettings';
|
||||
import type { UpdateNotificationsPayload } from '@/types/shopSettings/UpdateNotificationsPayload';
|
||||
import type { UpdateShippingNotePayload } from '@/types/shopSettings/UpdateShippingNotePayload';
|
||||
import type { UpdateSimplexLinkPayload } from '@/types/shopSettings/UpdateSimplexLinkPayload';
|
||||
|
||||
export const useShopSettingsStore = defineStore('shopSettings', () => {
|
||||
const settings = ref<ShopSettings | null>(null);
|
||||
|
||||
const fetchShopSettings = async (): Promise<ShopSettings> => {
|
||||
const { data } = await api.get<ShopSettings>('/shop-settings');
|
||||
|
||||
settings.value = data;
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const updateSimplexLink = async (payload: UpdateSimplexLinkPayload): Promise<ShopSettings> => {
|
||||
const { data } = await api.patch<ShopSettings>('/shop-settings/simplex-link', payload);
|
||||
|
||||
settings.value = data;
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const updateShippingNote = async (payload: UpdateShippingNotePayload): Promise<ShopSettings> => {
|
||||
const { data } = await api.patch<ShopSettings>('/shop-settings/shipping-note', payload);
|
||||
|
||||
settings.value = data;
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const updateNotifications = async (payload: UpdateNotificationsPayload): Promise<ShopSettings> => {
|
||||
const { data } = await api.patch<ShopSettings>('/shop-settings/notifications', payload);
|
||||
|
||||
settings.value = data;
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const connectSimplexNotifications = async (payload: ConnectSimplexNotificationsPayload): Promise<ShopSettings> => {
|
||||
const { data } = await api.post<ShopSettings>('/shop-settings/simplex-connect', payload);
|
||||
|
||||
settings.value = data;
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const uploadShopLogo = async (file: File): Promise<ShopSettings> => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('file', file);
|
||||
|
||||
const { data } = await api.post<ShopSettings>('/shop-settings/logo', formData);
|
||||
|
||||
settings.value = data;
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const uploadShopFavicon = async (file: File): Promise<ShopSettings> => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('file', file);
|
||||
|
||||
const { data } = await api.post<ShopSettings>('/shop-settings/favicon', formData);
|
||||
|
||||
settings.value = data;
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
return {
|
||||
settings,
|
||||
fetchShopSettings,
|
||||
updateSimplexLink,
|
||||
updateShippingNote,
|
||||
updateNotifications,
|
||||
connectSimplexNotifications,
|
||||
uploadShopLogo,
|
||||
uploadShopFavicon
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
$cms-bp-tablet: 767px;
|
||||
$cms-bp-phone: 480px;
|
||||
@@ -0,0 +1,124 @@
|
||||
@use './breakpoints' as *;
|
||||
|
||||
.cms-table-scroll {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.cms-page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cms-gate {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.el-select__popper {
|
||||
max-width: min(560px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.el-select-dropdown__item {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: $cms-bp-tablet) {
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.el-header {
|
||||
--el-header-padding: 0 10px;
|
||||
}
|
||||
|
||||
.el-main,
|
||||
.el-main.main-wrapper {
|
||||
--el-main-padding: 12px 10px;
|
||||
padding: 12px 10px !important;
|
||||
}
|
||||
|
||||
.cms-page-header {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
width: calc(100vw - 32px) !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.order-detail-row {
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
row-gap: 24px;
|
||||
|
||||
> .el-col {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cms-gate {
|
||||
padding: 12px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $cms-bp-phone) {
|
||||
.el-header {
|
||||
--el-header-padding: 0 5px;
|
||||
}
|
||||
|
||||
.cms-header.el-header {
|
||||
--el-header-padding: 12px 5px 0;
|
||||
}
|
||||
|
||||
.cms-header__bar {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.cms-header__bar .logo {
|
||||
flex-basis: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.el-main,
|
||||
.el-main.main-wrapper {
|
||||
--el-main-padding: 12px 5px;
|
||||
padding: 12px 5px !important;
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
width: calc(100vw - 10px) !important;
|
||||
}
|
||||
|
||||
.el-picker__popper {
|
||||
max-width: calc(100vw - 10px);
|
||||
}
|
||||
|
||||
.el-date-picker {
|
||||
width: min(322px, calc(100vw - 10px)) !important;
|
||||
}
|
||||
|
||||
.el-date-picker .el-picker-panel__content {
|
||||
width: min(292px, calc(100vw - 30px)) !important;
|
||||
}
|
||||
|
||||
.el-time-panel {
|
||||
width: min(180px, calc(100vw - 10px)) !important;
|
||||
}
|
||||
|
||||
.el-select__popper {
|
||||
max-width: calc(100vw - 10px);
|
||||
}
|
||||
|
||||
.cms-gate {
|
||||
padding: 12px 5px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
$space-scale: 0, 2, 4, 6, 8, 10, 12, 16, 20, 24, 32, 40, 48, 64;
|
||||
|
||||
@each $s in $space-scale {
|
||||
.m-#{$s} {
|
||||
margin: #{$s}px;
|
||||
}
|
||||
.mt-#{$s} {
|
||||
margin-top: #{$s}px;
|
||||
}
|
||||
.mr-#{$s} {
|
||||
margin-right: #{$s}px;
|
||||
}
|
||||
.mb-#{$s} {
|
||||
margin-bottom: #{$s}px;
|
||||
}
|
||||
.ml-#{$s} {
|
||||
margin-left: #{$s}px;
|
||||
}
|
||||
.mx-#{$s} {
|
||||
margin-left: #{$s}px;
|
||||
margin-right: #{$s}px;
|
||||
}
|
||||
.my-#{$s} {
|
||||
margin-top: #{$s}px;
|
||||
margin-bottom: #{$s}px;
|
||||
}
|
||||
|
||||
.p-#{$s} {
|
||||
padding: #{$s}px;
|
||||
}
|
||||
.pt-#{$s} {
|
||||
padding-top: #{$s}px;
|
||||
}
|
||||
.pr-#{$s} {
|
||||
padding-right: #{$s}px;
|
||||
}
|
||||
.pb-#{$s} {
|
||||
padding-bottom: #{$s}px;
|
||||
}
|
||||
.pl-#{$s} {
|
||||
padding-left: #{$s}px;
|
||||
}
|
||||
.px-#{$s} {
|
||||
padding-left: #{$s}px;
|
||||
padding-right: #{$s}px;
|
||||
}
|
||||
.py-#{$s} {
|
||||
padding-top: #{$s}px;
|
||||
padding-bottom: #{$s}px;
|
||||
}
|
||||
|
||||
.gap-#{$s} {
|
||||
gap: #{$s}px;
|
||||
}
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.box-border {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1 1 0%;
|
||||
}
|
||||
|
||||
.flex-shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.min-h-0 {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.min-w-0 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.text-ellipsis {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.flex-ellipsis {
|
||||
flex: 1 1 0%;
|
||||
min-width: 0;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.text-break {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.items-start {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.self-start {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.clickable-table .el-table__body tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.detail-loading-host {
|
||||
min-height: min(50vh, 440px);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: monospace;
|
||||
|
||||
input,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.secondary-text {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: var(--el-font-size-small);
|
||||
}
|
||||
|
||||
.descriptions-row-labels {
|
||||
.el-descriptions__label {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type BuildUploadHintOptions = {
|
||||
allowedMimesCsv: string;
|
||||
maxFileBytes: number;
|
||||
maxFiles?: number;
|
||||
maxFilesLabel?: string;
|
||||
encryptedAtRest?: boolean;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export type PaginatedResponse<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export type UploadValidationOptions = {
|
||||
allowedMimesCsv: string;
|
||||
maxFileBytes: number;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
export type UsePollingOptions = {
|
||||
intervalMs: number;
|
||||
enabled?: Ref<boolean>;
|
||||
immediate?: boolean;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export type CreateOrUpdateCategoryPayload = {
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { DiscountType } from './DiscountType';
|
||||
|
||||
export type CreateOrUpdateDiscountCodePayload = {
|
||||
code: string;
|
||||
type: DiscountType;
|
||||
value: number;
|
||||
isActive: boolean;
|
||||
validFrom: string | Date | null;
|
||||
validUntil: string | Date | null;
|
||||
maxRedemptions: number | null;
|
||||
minOrderAmount: number | null;
|
||||
isExclusive: boolean;
|
||||
productIds: string[];
|
||||
categoryIds: string[];
|
||||
variantIds: string[];
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Category } from '@/types/product/Category';
|
||||
import type { Product } from '@/types/product/Product';
|
||||
import type { ProductVariant } from '@/types/product/ProductVariant';
|
||||
import type { DiscountType } from './DiscountType';
|
||||
|
||||
export type DiscountCode = {
|
||||
id: string;
|
||||
code: string;
|
||||
type: DiscountType;
|
||||
value: number;
|
||||
isActive: boolean;
|
||||
validFrom: string | null;
|
||||
validUntil: string | null;
|
||||
maxRedemptions: number | null;
|
||||
redemptionCount: number;
|
||||
minOrderAmount: number | null;
|
||||
isExclusive: boolean;
|
||||
products?: Product[];
|
||||
categories?: Category[];
|
||||
variants?: ProductVariant[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export type DiscountScope = {
|
||||
applyToAll: boolean;
|
||||
categoryIds: string[];
|
||||
productIds: string[];
|
||||
variantIds: string[];
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum DiscountType {
|
||||
Percent = 'percent',
|
||||
Fixed = 'fixed'
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type MoneroNetwork = 'mainnet' | 'stagenet';
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface MoneroWalletRevealSeedPayload {
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface MoneroWalletRevealSeedResult {
|
||||
mnemonic: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { MoneroNetwork } from './MoneroNetwork';
|
||||
import type { MoneroWalletSyncStatus } from './MoneroWalletSyncStatus';
|
||||
|
||||
export interface MoneroWalletStatus {
|
||||
network: MoneroNetwork;
|
||||
rpcVersion: string;
|
||||
walletHeight: number;
|
||||
daemonHeight: number | null;
|
||||
syncStatus: MoneroWalletSyncStatus;
|
||||
balanceXmr: string;
|
||||
unlockedBalanceXmr: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum MoneroWalletSyncStatus {
|
||||
Synced = 'synced',
|
||||
Syncing = 'syncing',
|
||||
Unknown = 'unknown'
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface MoneroWalletWithdrawPayload {
|
||||
destinationAddress: string;
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface MoneroWalletWithdrawResult {
|
||||
txHashes: string[];
|
||||
amountXmr: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type InvoiceState = {
|
||||
isAwaitingPayment: boolean;
|
||||
isUnderpaid: boolean;
|
||||
isPaidSufficient: boolean;
|
||||
isPaidAwaitingConfirmations: boolean;
|
||||
isPaidAndConfirmed: boolean;
|
||||
isExpired: boolean;
|
||||
hasPendingConfirmations: boolean;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum ManualLineFulfillmentStatus {
|
||||
Pending = 'pending',
|
||||
Fulfilled = 'fulfilled'
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type OrderDiscount = {
|
||||
id: string;
|
||||
code: string;
|
||||
amountFiat: number;
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { InvoiceExtended } from '@/types/payment/InvoiceExtended';
|
||||
import type { OrderFailureReason } from './OrderFailureReason';
|
||||
import type { OrderLine } from './OrderLine';
|
||||
import type { OrderMessage } from './OrderMessage';
|
||||
import type { OrderState } from './OrderState';
|
||||
import type { OrderTotals } from './OrderTotals';
|
||||
import type { OrderDiscount } from './OrderDiscount';
|
||||
|
||||
export type OrderExtended = {
|
||||
id: string;
|
||||
state: OrderState;
|
||||
totals: OrderTotals;
|
||||
fiatCurrency: string;
|
||||
failureReason: OrderFailureReason | null;
|
||||
accessTokenSavedConfirmedAt: string | null;
|
||||
quotedAt: string | null;
|
||||
accessToken: string;
|
||||
lines?: OrderLine[];
|
||||
discounts?: OrderDiscount[];
|
||||
checkoutInvoice?: InvoiceExtended | null;
|
||||
shippingInvoice?: InvoiceExtended | null;
|
||||
messages?: OrderMessage[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum OrderFailureReason {
|
||||
StockUnavailable = 'stock_unavailable',
|
||||
DiscountExhausted = 'discount_exhausted'
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { DeliveryMode } from '@/types/product/DeliveryMode';
|
||||
import type { OrderLineAutoFulfillmentItem } from './OrderLineAutoFulfillmentItem';
|
||||
import type { OrderLineManualFulfillment } from './OrderLineManualFulfillment';
|
||||
|
||||
export type OrderLine = {
|
||||
id: string;
|
||||
productId: string;
|
||||
variantId: string;
|
||||
productTitle: string;
|
||||
variantTitle: string;
|
||||
thumbnailUrl: string | null;
|
||||
qty: number;
|
||||
unitPriceFiat: number;
|
||||
lineSubtotalFiat: number;
|
||||
deliveryMode: DeliveryMode;
|
||||
autoFulfillmentItems: OrderLineAutoFulfillmentItem[];
|
||||
manualFulfillment: OrderLineManualFulfillment | null;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { OrderLineAutoFulfillmentItemAttachment } from './OrderLineAutoFulfillmentItemAttachment';
|
||||
|
||||
export type OrderLineAutoFulfillmentItem = {
|
||||
id: string;
|
||||
sortOrder: number;
|
||||
contentSnapshot: string;
|
||||
sourceDigitalStockItemId: string;
|
||||
attachments: OrderLineAutoFulfillmentItemAttachment[];
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export type OrderLineAutoFulfillmentItemAttachment = {
|
||||
id: string;
|
||||
storageKey: string;
|
||||
sourceDigitalStockAttachmentId: string;
|
||||
originalFilename: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ManualLineFulfillmentStatus } from './ManualLineFulfillmentStatus';
|
||||
|
||||
export type OrderLineManualFulfillment = {
|
||||
id: string;
|
||||
status: ManualLineFulfillmentStatus;
|
||||
fulfilledAt: string | null;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { InvoiceStatusLabel } from '@/types/payment/InvoiceStatusLabel';
|
||||
import type { OrderFailureReason } from '@/types/order/OrderFailureReason';
|
||||
import type { OrderStatus } from '@/types/order/OrderStatus';
|
||||
|
||||
export type OrderListItem = {
|
||||
id: string;
|
||||
status: OrderStatus;
|
||||
checkoutPaymentLabel: InvoiceStatusLabel | null;
|
||||
shippingPaymentLabel: InvoiceStatusLabel | null;
|
||||
totalFiat: number;
|
||||
grandTotalFiat: number | null;
|
||||
fiatCurrency: string;
|
||||
lineCount: number;
|
||||
unreadMessageCount: number;
|
||||
failureReason: OrderFailureReason | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { OrderMessageSender } from './OrderMessageSender';
|
||||
|
||||
export type OrderMessage = {
|
||||
id: string;
|
||||
sender: OrderMessageSender;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum OrderMessageSender {
|
||||
Buyer = 'buyer',
|
||||
Staff = 'staff'
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { InvoiceState } from './InvoiceState';
|
||||
import type { OrderStatus } from './OrderStatus';
|
||||
|
||||
export type OrderState = {
|
||||
status: OrderStatus;
|
||||
checkoutInvoiceState: InvoiceState | null;
|
||||
shippingInvoiceState: InvoiceState | null;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum OrderStatus {
|
||||
Unfulfilled = 'unfulfilled',
|
||||
Fulfilled = 'fulfilled',
|
||||
Unfulfillable = 'unfulfillable'
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type OrderTotals = {
|
||||
subtotalFiat: number;
|
||||
discountTotalFiat: number;
|
||||
totalFiat: number;
|
||||
shippingCostFiat: number | null;
|
||||
grandTotalFiat: number | null;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface SetDeliveryCostPayload {
|
||||
deliveryCost: number;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { InvoiceMoneroDetails } from './InvoiceMoneroDetails';
|
||||
import type { InvoicePayment } from './InvoicePayment';
|
||||
import type { InvoiceReason } from './InvoiceReason';
|
||||
import type { PaymentMethod } from './PaymentMethod';
|
||||
|
||||
export type Invoice = {
|
||||
id: string;
|
||||
reason: InvoiceReason;
|
||||
paymentMethod: PaymentMethod;
|
||||
amountFiat: number;
|
||||
fiatCurrency: string;
|
||||
expiresAt: string;
|
||||
paymentAddress: string;
|
||||
expectedTotalAtomic: string;
|
||||
createdAt: string;
|
||||
moneroDetails?: InvoiceMoneroDetails | null;
|
||||
payments?: InvoicePayment[];
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Invoice } from './Invoice';
|
||||
import type { InvoicePaymentExtended } from './InvoicePaymentExtended';
|
||||
import type { InvoiceStatusLabel } from './InvoiceStatusLabel';
|
||||
|
||||
export type InvoiceExtended = Omit<Invoice, 'payments'> & {
|
||||
statusLabel: InvoiceStatusLabel | null;
|
||||
expectedTotalCrypto: string;
|
||||
payments: InvoicePaymentExtended[];
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export type InvoiceMoneroDetails = {
|
||||
id: string;
|
||||
paymentAddressIndex: number;
|
||||
fiatPerXmrAtCreation: number;
|
||||
requiredConfirmations: number;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export type InvoicePayment = {
|
||||
id: string;
|
||||
txHash: string;
|
||||
amountAtomic: string;
|
||||
confirmations: number;
|
||||
createdAt: string;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { InvoicePayment } from './InvoicePayment';
|
||||
|
||||
export type InvoicePaymentExtended = InvoicePayment & {
|
||||
amountCrypto: string;
|
||||
isConfirmed: boolean;
|
||||
confirmationsLabel: string;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum InvoiceReason {
|
||||
Checkout = 'checkout',
|
||||
Shipping = 'shipping'
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type InvoiceStatusLabel =
|
||||
| 'Payment confirmed'
|
||||
| 'Awaiting confirmations'
|
||||
| 'Partial payment received'
|
||||
| 'Payment expired'
|
||||
| 'Awaiting payment';
|
||||
@@ -0,0 +1,7 @@
|
||||
export enum PaymentMethod {
|
||||
Xmr = 'xmr'
|
||||
}
|
||||
|
||||
export const paymentMethodCryptoCurrency: Record<PaymentMethod, string> = {
|
||||
[PaymentMethod.Xmr]: 'XMR'
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export type Category = {
|
||||
id: string;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum DeliveryMode {
|
||||
Auto = 'auto',
|
||||
Manual = 'manual'
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface DigitalStockAttachment {
|
||||
id: string;
|
||||
originalFilename: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { DigitalStockAttachment } from './DigitalStockAttachment';
|
||||
|
||||
export interface DigitalStockItem {
|
||||
id: string;
|
||||
content: string;
|
||||
isSold: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
attachments?: DigitalStockAttachment[];
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type DigitalStockListQuery = {
|
||||
page: number;
|
||||
limit: number;
|
||||
hideSold: boolean;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export type PendingImageAction =
|
||||
| { type: 'thumbnail'; imageId: string }
|
||||
| { type: 'delete'; imageId: string }
|
||||
| { type: 'reorder'; imageId: string };
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { DeliveryMode } from './DeliveryMode';
|
||||
import type { ProductVariantExtended } from './ProductVariantExtended';
|
||||
import type { Category } from './Category';
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
title: string;
|
||||
deliveryMode: DeliveryMode;
|
||||
isDraft: boolean;
|
||||
descriptionHtml: string;
|
||||
variants?: ProductVariantExtended[];
|
||||
categories?: Category[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type ProductOption = {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { DigitalStockItem } from './DigitalStockItem';
|
||||
import type { Product } from './Product';
|
||||
import type { VariantImage } from './VariantImage';
|
||||
|
||||
export interface ProductVariant {
|
||||
id: string;
|
||||
title: string;
|
||||
price: number;
|
||||
stockQuantity: number | null;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
digitalStockItems?: DigitalStockItem[];
|
||||
images?: VariantImage[];
|
||||
product?: Product;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ProductVariant } from './ProductVariant';
|
||||
|
||||
export interface ProductVariantExtended extends ProductVariant {
|
||||
stockAvailable: number;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface ProductVariantPayload {
|
||||
title: string;
|
||||
price: number;
|
||||
stockQuantity: number;
|
||||
sortOrder: number;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Product } from './Product';
|
||||
import type { ProductVariantExtended } from './ProductVariantExtended';
|
||||
|
||||
export interface ProductWithVariantsExtended extends Omit<Product, 'variants'> {
|
||||
variants: ProductVariantExtended[];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface UpdateProductPayload {
|
||||
title: string;
|
||||
isDraft: boolean;
|
||||
descriptionHtml: string;
|
||||
categoryIds: string[];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface VariantImage {
|
||||
id: string;
|
||||
url: string;
|
||||
sortOrder: number;
|
||||
isThumbnail: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user