This commit is contained in:
2026-08-28 17:31:02 +02:00
commit 2b30e8bd39
694 changed files with 49243 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.git
.gitignore
Dockerfile*
npm-debug.log
+11
View File
@@ -0,0 +1,11 @@
FROM node:20-alpine
WORKDIR /cms
COPY package*.json .
RUN npm ci
ADD . .
CMD ["npm", "run", "dev"]
+13
View File
@@ -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>
+3649
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -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

+88
View File
@@ -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>
+75
View File
@@ -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']
}
}
+68
View File
@@ -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>
+243
View File
@@ -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>
+206
View File
@@ -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>
+251
View File
@@ -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>
+25
View File
@@ -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>
+186
View File
@@ -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>
+243
View File
@@ -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>
+24
View File
@@ -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>
+312
View File
@@ -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>
+63
View File
@@ -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);
};
+51
View File
@@ -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)
}
};
+13
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
export const UNTITLED_PRODUCT_TITLE = '(untitled)';
+11
View File
@@ -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');
+39
View File
@@ -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);
}
);
+6
View File
@@ -0,0 +1,6 @@
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
export default dayjs;
+106
View File
@@ -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;
+31
View File
@@ -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
};
});
+57
View File
@@ -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
};
});
+46
View File
@@ -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
};
});
+148
View File
@@ -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
};
});
+67
View File
@@ -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
};
});
+39
View File
@@ -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
};
});
+104
View File
@@ -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
};
});
+335
View File
@@ -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
};
});
+87
View File
@@ -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
};
});
+2
View File
@@ -0,0 +1,2 @@
$cms-bp-tablet: 767px;
$cms-bp-phone: 480px;
+124
View File
@@ -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;
}
}
+158
View File
@@ -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;
}
}
+7
View File
@@ -0,0 +1,7 @@
export type BuildUploadHintOptions = {
allowedMimesCsv: string;
maxFileBytes: number;
maxFiles?: number;
maxFilesLabel?: string;
encryptedAtRest?: boolean;
};
+6
View File
@@ -0,0 +1,6 @@
export type PaginatedResponse<T> = {
items: T[];
total: number;
page: number;
limit: number;
};
+4
View File
@@ -0,0 +1,4 @@
export type UploadValidationOptions = {
allowedMimesCsv: string;
maxFileBytes: number;
};
+7
View File
@@ -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;
}
+9
View File
@@ -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'
}
+5
View File
@@ -0,0 +1,5 @@
export type OrderDiscount = {
id: string;
code: string;
amountFiat: number;
};
+25
View File
@@ -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'
}
+18
View File
@@ -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;
};
+18
View File
@@ -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;
};
+8
View File
@@ -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'
}
+8
View File
@@ -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;
};
+5
View File
@@ -0,0 +1,5 @@
export enum OrderStatus {
Unfulfilled = 'unfulfilled',
Fulfilled = 'fulfilled',
Unfulfillable = 'unfulfillable'
}
+7
View File
@@ -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;
}
+18
View File
@@ -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[];
};
+9
View File
@@ -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;
};
+7
View File
@@ -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;
};
+4
View File
@@ -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';
+7
View File
@@ -0,0 +1,7 @@
export enum PaymentMethod {
Xmr = 'xmr'
}
export const paymentMethodCryptoCurrency: Record<PaymentMethod, string> = {
[PaymentMethod.Xmr]: 'XMR'
};
+7
View File
@@ -0,0 +1,7 @@
export type Category = {
id: string;
name: string;
sortOrder: number;
createdAt: string;
updatedAt: string;
};
+4
View File
@@ -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;
}
+10
View File
@@ -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 };
+15
View File
@@ -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;
}
+4
View File
@@ -0,0 +1,4 @@
export type ProductOption = {
id: string;
title: string;
};
+16
View File
@@ -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[];
}
+8
View File
@@ -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