init
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="cms-page-header mb-24">
|
||||
<h3 class="m-0">Categories</h3>
|
||||
<el-button type="primary" @click="openCreate"> New category </el-button>
|
||||
</div>
|
||||
|
||||
<div class="cms-table-scroll">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="categories"
|
||||
stripe
|
||||
empty-text="No categories yet"
|
||||
class="clickable-table"
|
||||
@row-click="openEdit"
|
||||
>
|
||||
<el-table-column prop="name" label="Name" min-width="160" show-overflow-tooltip />
|
||||
|
||||
<el-table-column prop="sortOrder" label="Sort order" width="120" />
|
||||
|
||||
<el-table-column label="Updated" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.updatedAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click.stop="openEdit(row)"> Edit </el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
:loading="deletingCategoryId === row.id"
|
||||
@click.stop="confirmDelete(row)"
|
||||
>
|
||||
Delete
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<create-or-edit-category-modal v-model="modalVisible" :category="editingCategory" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useCategoriesStore } from '@/stores/categories';
|
||||
import type { Category } from '@/types/product/Category';
|
||||
import { formatDate } from '@/utils/formatDate';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { onBeforeMount, ref } from 'vue';
|
||||
|
||||
const categoriesStore = useCategoriesStore();
|
||||
|
||||
const { categories } = storeToRefs(categoriesStore);
|
||||
|
||||
const loading = ref(false);
|
||||
const modalVisible = ref(false);
|
||||
const editingCategory = ref<Category | null>(null);
|
||||
const deletingCategoryId = ref<string | null>(null);
|
||||
|
||||
onBeforeMount(async () => {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
await categoriesStore.fetchAll();
|
||||
} catch {
|
||||
ElMessage.error('Failed to load categories');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
editingCategory.value = null;
|
||||
modalVisible.value = true;
|
||||
};
|
||||
|
||||
const openEdit = (row: Category) => {
|
||||
editingCategory.value = row;
|
||||
modalVisible.value = true;
|
||||
};
|
||||
|
||||
const confirmDelete = async (row: Category) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`Delete category "${row.name}"? Products will be unlinked.`, 'Delete category', {
|
||||
confirmButtonText: 'Delete',
|
||||
cancelButtonText: 'Cancel',
|
||||
type: 'warning'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
deletingCategoryId.value = row.id;
|
||||
|
||||
try {
|
||||
await categoriesStore.removeCategory(row.id);
|
||||
|
||||
ElMessage.success('Category deleted');
|
||||
} catch {
|
||||
ElMessage.error('Failed to delete category');
|
||||
} finally {
|
||||
deletingCategoryId.value = null;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,274 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="cms-page-header mb-24">
|
||||
<h3 class="m-0">Discount codes</h3>
|
||||
<el-button type="primary" @click="openCreate"> New code </el-button>
|
||||
</div>
|
||||
|
||||
<div class="cms-table-scroll">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="discountCodes"
|
||||
stripe
|
||||
empty-text="No discount codes yet"
|
||||
class="clickable-table"
|
||||
@row-click="openEdit"
|
||||
>
|
||||
<el-table-column prop="code" label="Code" min-width="120" show-overflow-tooltip />
|
||||
|
||||
<el-table-column label="Type" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ formatDiscountType(row.type) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="Value" width="100">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.type === DiscountType.Percent">{{ row.value }}%</template>
|
||||
<template v-else>{{ formatFiatPrice(row.value, config.shopFiatCurrency) }}</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="Active" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.isActive ? 'success' : 'info'" size="small">
|
||||
{{ row.isActive ? 'Active' : 'Inactive' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="Validity" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ formatDiscountValidity(row.validFrom, row.validUntil) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="Usage" width="110">
|
||||
<template #default="{ row }">
|
||||
{{ formatDiscountUsage(row.redemptionCount, row.maxRedemptions) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column :label="`Min. order (${config.shopFiatCurrency})`" width="130">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.minOrderAmount !== null">
|
||||
{{ formatFiatPrice(row.minOrderAmount, config.shopFiatCurrency) }}
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="Exclusive" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="warning" size="small"> {{ row.isExclusive ? 'Yes' : 'No' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="Applies to" width="140">
|
||||
<template #default="{ row }">
|
||||
<template
|
||||
v-if="row.products.length === 0 && row.categories.length === 0 && row.variants.length === 0"
|
||||
>
|
||||
All
|
||||
</template>
|
||||
<el-tooltip v-else :content="formatDiscountScope(row)" placement="top">
|
||||
<span class="text-ellipsis">{{ formatDiscountScopeSummary(row) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="Updated" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.updatedAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:loading="editingDiscountCodeId === row.id"
|
||||
@click.stop="openEdit(row)"
|
||||
>
|
||||
Edit
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
:loading="deletingDiscountCodeId === row.id"
|
||||
@click.stop="confirmDelete(row)"
|
||||
>
|
||||
Delete
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<create-or-edit-discount-code-modal v-model="modalVisible" :discount-code="editingDiscountCode" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getProductTitle } from '@/utils/product/getProductTitle';
|
||||
import { getVariantLabel } from '@/utils/product/getVariantLabel';
|
||||
import { config } from '@/config';
|
||||
import { useCategoriesStore } from '@/stores/categories';
|
||||
import { useDiscountCodesStore } from '@/stores/discountCodes';
|
||||
import { DiscountType } from '@/types/discountCode/DiscountType';
|
||||
import type { DiscountCode } from '@/types/discountCode/DiscountCode';
|
||||
import type { Product } from '@/types/product/Product';
|
||||
import { formatDate } from '@/utils/formatDate';
|
||||
import { formatFiatPrice } from '@/utils/formatFiatPrice';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { onBeforeMount, ref } from 'vue';
|
||||
|
||||
const discountCodesStore = useDiscountCodesStore();
|
||||
const categoriesStore = useCategoriesStore();
|
||||
const { discountCodes } = storeToRefs(discountCodesStore);
|
||||
|
||||
const loading = ref(false);
|
||||
const modalVisible = ref(false);
|
||||
const editingDiscountCode = ref<DiscountCode | null>(null);
|
||||
const editingDiscountCodeId = ref<string | null>(null);
|
||||
const deletingDiscountCodeId = ref<string | null>(null);
|
||||
|
||||
onBeforeMount(async () => {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
await Promise.all([discountCodesStore.fetchAll(), categoriesStore.fetchAll()]);
|
||||
} catch {
|
||||
ElMessage.error('Failed to load data');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
editingDiscountCode.value = null;
|
||||
modalVisible.value = true;
|
||||
};
|
||||
|
||||
const openEdit = async (discountCode: DiscountCode) => {
|
||||
if (editingDiscountCodeId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
editingDiscountCodeId.value = discountCode.id;
|
||||
|
||||
try {
|
||||
editingDiscountCode.value = await discountCodesStore.fetchDiscountCodeById(discountCode.id);
|
||||
|
||||
modalVisible.value = true;
|
||||
} catch {
|
||||
ElMessage.error('Failed to load discount code');
|
||||
} finally {
|
||||
editingDiscountCodeId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async (discountCode: DiscountCode) => {
|
||||
if (deletingDiscountCodeId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`Delete discount code «${discountCode.code}»? This cannot be undone.`,
|
||||
'Delete discount code',
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: 'Delete',
|
||||
cancelButtonText: 'Cancel'
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
deletingDiscountCodeId.value = discountCode.id;
|
||||
|
||||
try {
|
||||
await discountCodesStore.removeDiscountCode(discountCode.id);
|
||||
|
||||
ElMessage.success('Discount code deleted');
|
||||
} catch {
|
||||
ElMessage.error('Failed to delete discount code');
|
||||
} finally {
|
||||
deletingDiscountCodeId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDiscountType = (type: DiscountType): string => {
|
||||
return type === DiscountType.Percent ? 'Percent' : 'Fixed';
|
||||
};
|
||||
|
||||
const formatDiscountValidity = (validFrom: string | null, validUntil: string | null): string => {
|
||||
if (!validFrom && !validUntil) {
|
||||
return '∞';
|
||||
}
|
||||
|
||||
const from = validFrom ? formatDate(validFrom) : '∞';
|
||||
const until = validUntil ? formatDate(validUntil) : '∞';
|
||||
|
||||
return `${from} – ${until}`;
|
||||
};
|
||||
|
||||
const formatDiscountProducts = (products: Product[]): string => {
|
||||
return products.map(p => getProductTitle(p.title)).join(', ');
|
||||
};
|
||||
|
||||
const formatDiscountScopeSummary = ({ products, categories, variants }: DiscountCode): string => {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (categories && categories.length > 0) {
|
||||
parts.push(`${categories.length} ${categories.length === 1 ? 'category' : 'categories'}`);
|
||||
}
|
||||
|
||||
if (products && products.length > 0) {
|
||||
parts.push(`${products.length} ${products.length === 1 ? 'product' : 'products'}`);
|
||||
}
|
||||
|
||||
if (variants && variants.length > 0) {
|
||||
parts.push(`${variants.length} ${variants.length === 1 ? 'variant' : 'variants'}`);
|
||||
}
|
||||
|
||||
return parts.join(', ');
|
||||
};
|
||||
|
||||
const formatDiscountScope = ({ products, categories, variants }: DiscountCode): string => {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (categories && categories.length > 0) {
|
||||
parts.push(
|
||||
`Categories: ${[...categories]
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))
|
||||
.map(category => category.name)
|
||||
.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
if (products && products.length > 0) {
|
||||
parts.push(`Products: ${formatDiscountProducts(products)}`);
|
||||
}
|
||||
|
||||
if (variants && variants.length > 0) {
|
||||
parts.push(
|
||||
`Variants: ${variants.map(variant => getVariantLabel(variant.title, variant.product?.title)).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
};
|
||||
|
||||
const formatDiscountUsage = (redemptionCount: number, maxRedemptions: number | null): string => {
|
||||
if (maxRedemptions === null) {
|
||||
return `${redemptionCount} / ∞`;
|
||||
}
|
||||
|
||||
return `${redemptionCount} / ${maxRedemptions}`;
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="cms-gate box-border">
|
||||
<div class="cms-gate-theme-toggle">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<el-card class="cms-gate-card" shadow="hover">
|
||||
<template #header>
|
||||
<span>CMS Login</span>
|
||||
</template>
|
||||
|
||||
<el-form ref="formRef" label-position="top" :model="form" :rules="rules" @submit.prevent="onSubmit">
|
||||
<el-form-item label="Password" prop="password" :error="passwordServerError">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="Enter password"
|
||||
autocomplete="current-password"
|
||||
size="large"
|
||||
clearable
|
||||
@input="
|
||||
() => {
|
||||
passwordServerError = '';
|
||||
formRef?.clearValidate('password');
|
||||
}
|
||||
"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button type="primary" native-type="submit" class="mt-8 w-full" size="large" :loading="loading">
|
||||
Continue
|
||||
</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { isAxiosError, HttpStatusCode } from 'axios';
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ROUTE_NAMES } from '@/consts/routeNames';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
|
||||
const form = reactive({
|
||||
password: ''
|
||||
});
|
||||
|
||||
const passwordServerError = ref('');
|
||||
|
||||
const rules: FormRules = {
|
||||
password: [{ required: true, message: 'Password is required', trigger: 'blur' }]
|
||||
};
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const onSubmit = async () => {
|
||||
const formEl = formRef.value;
|
||||
|
||||
if (!formEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
passwordServerError.value = '';
|
||||
|
||||
try {
|
||||
await formEl.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
await authStore.login(form.password);
|
||||
|
||||
ElMessage.success('Signed in');
|
||||
|
||||
router.push({ name: ROUTE_NAMES.Products });
|
||||
} catch (e) {
|
||||
if (isAxiosError(e) && e.response?.status === HttpStatusCode.Unauthorized) {
|
||||
passwordServerError.value = 'Wrong password';
|
||||
return;
|
||||
}
|
||||
|
||||
ElMessage.error('Something went wrong');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cms-gate {
|
||||
position: relative;
|
||||
min-height: calc(100vh - 48px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cms-gate-theme-toggle {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.cms-gate-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<div v-loading="loading" class="detail-loading-host" element-loading-text="Loading notification settings…">
|
||||
<el-empty v-if="!loading && loadError" description="Failed to load notification settings" />
|
||||
|
||||
<template v-if="!loading && !loadError && settings">
|
||||
<el-card class="mb-24" shadow="never">
|
||||
<template #header>
|
||||
<span>Shop bot connection</span>
|
||||
</template>
|
||||
|
||||
<div class="mb-16">
|
||||
<el-text tag="p" size="small" class="secondary-text w-full">
|
||||
Connect the shop bot to your personal SimpleX contact link. The bot sends alerts to your SimpleX
|
||||
app. Accept the bot contact request while connecting.
|
||||
</el-text>
|
||||
</div>
|
||||
|
||||
<div v-if="settings.simplexNotificationConnected" class="mb-16">
|
||||
<el-alert
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="Shop bot connected"
|
||||
description="Notifications can be sent to your SimpleX app when enabled below."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="mb-16">
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="Shop bot not connected"
|
||||
description="Paste your SimpleX contact link and click Connect. Accept the bot request in your SimpleX app while the connection is in progress."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="connectFormRef"
|
||||
label-position="top"
|
||||
:model="connectForm"
|
||||
:rules="connectRules"
|
||||
@submit.prevent="submitConnect"
|
||||
>
|
||||
<el-form-item label="Your SimpleX contact link" prop="simplexNotificationLink">
|
||||
<el-input
|
||||
v-model="connectForm.simplexNotificationLink"
|
||||
placeholder="https://simplex.chat/contact#…"
|
||||
:disabled="connecting"
|
||||
@input="connectFormRef?.clearValidate('simplexNotificationLink')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-button type="primary" native-type="submit" :loading="connecting">
|
||||
{{ settings.simplexNotificationConnected ? 'Reconnect' : 'Connect' }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<span>Notification preferences</span>
|
||||
</template>
|
||||
|
||||
<div v-if="!settings.simplexNotificationConnected" class="mb-16">
|
||||
<el-text tag="p" size="small" class="secondary-text w-full">
|
||||
Connect the shop bot above before enabling notifications.
|
||||
</el-text>
|
||||
</div>
|
||||
|
||||
<div class="mb-16 flex flex-col gap-12">
|
||||
<div class="flex items-center justify-between gap-16">
|
||||
<span>Enable notifications</span>
|
||||
<el-switch
|
||||
v-model="notificationsForm.notificationsEnabled"
|
||||
:disabled="!settings.simplexNotificationConnected"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-16">
|
||||
<span>New orders</span>
|
||||
<el-switch
|
||||
v-model="notificationsForm.notifyOnNewOrder"
|
||||
:disabled="
|
||||
!settings.simplexNotificationConnected || !notificationsForm.notificationsEnabled
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-16">
|
||||
<span>New messages on orders</span>
|
||||
<el-switch
|
||||
v-model="notificationsForm.notifyOnOrderMessage"
|
||||
:disabled="
|
||||
!settings.simplexNotificationConnected || !notificationsForm.notificationsEnabled
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="notificationsSaving"
|
||||
:disabled="!settings.simplexNotificationConnected"
|
||||
@click="submitNotifications"
|
||||
>
|
||||
Save preferences
|
||||
</el-button>
|
||||
</el-card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useShopSettingsStore } from '@/stores/shopSettings';
|
||||
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { onBeforeMount, reactive, ref } from 'vue';
|
||||
|
||||
const shopSettingsStore = useShopSettingsStore();
|
||||
|
||||
const { settings } = storeToRefs(shopSettingsStore);
|
||||
|
||||
const { fetchShopSettings, updateNotifications, connectSimplexNotifications } = shopSettingsStore;
|
||||
|
||||
const loading = ref(false);
|
||||
const loadError = ref(false);
|
||||
const connecting = ref(false);
|
||||
const notificationsSaving = ref(false);
|
||||
const connectFormRef = ref<FormInstance>();
|
||||
|
||||
const connectForm = reactive({
|
||||
simplexNotificationLink: ''
|
||||
});
|
||||
|
||||
const notificationsForm = reactive({
|
||||
notificationsEnabled: false,
|
||||
notifyOnNewOrder: true,
|
||||
notifyOnOrderMessage: true
|
||||
});
|
||||
|
||||
const connectRules: FormRules = {
|
||||
simplexNotificationLink: [{ required: true, message: 'SimpleX contact link is required', trigger: 'blur' }]
|
||||
};
|
||||
|
||||
onBeforeMount(async () => {
|
||||
loadError.value = false;
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
await loadSettings();
|
||||
} catch {
|
||||
loadError.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const loadSettings = async (): Promise<void> => {
|
||||
const { simplexNotificationLink, simplexLink, notificationsEnabled, notifyOnNewOrder, notifyOnOrderMessage } =
|
||||
await fetchShopSettings();
|
||||
|
||||
connectForm.simplexNotificationLink = simplexNotificationLink ?? simplexLink ?? '';
|
||||
notificationsForm.notificationsEnabled = notificationsEnabled;
|
||||
notificationsForm.notifyOnNewOrder = notifyOnNewOrder;
|
||||
notificationsForm.notifyOnOrderMessage = notifyOnOrderMessage;
|
||||
};
|
||||
|
||||
const submitConnect = async (): Promise<void> => {
|
||||
const form = connectFormRef.value;
|
||||
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await form.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
connecting.value = true;
|
||||
|
||||
try {
|
||||
await connectSimplexNotifications({
|
||||
simplexNotificationLink: connectForm.simplexNotificationLink.trim()
|
||||
});
|
||||
|
||||
ElMessage.success('Shop bot connected');
|
||||
} catch (e) {
|
||||
const fallback = 'Failed to connect shop bot';
|
||||
const message = resolveAxiosErrorMessage(e, fallback);
|
||||
|
||||
ElMessage.error(message);
|
||||
} finally {
|
||||
connecting.value = false;
|
||||
|
||||
try {
|
||||
await loadSettings();
|
||||
} catch {
|
||||
console.error('Failed to load settings after connecting shop bot');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const submitNotifications = async (): Promise<void> => {
|
||||
notificationsSaving.value = true;
|
||||
|
||||
const { notificationsEnabled, notifyOnNewOrder, notifyOnOrderMessage } = notificationsForm;
|
||||
|
||||
try {
|
||||
const data = await updateNotifications({
|
||||
notificationsEnabled,
|
||||
notifyOnNewOrder,
|
||||
notifyOnOrderMessage
|
||||
});
|
||||
|
||||
notificationsForm.notificationsEnabled = data.notificationsEnabled;
|
||||
notificationsForm.notifyOnNewOrder = data.notifyOnNewOrder;
|
||||
notificationsForm.notifyOnOrderMessage = data.notifyOnOrderMessage;
|
||||
|
||||
ElMessage.success('Notification preferences saved');
|
||||
} catch (e) {
|
||||
const fallback = 'Failed to save notification preferences';
|
||||
const message = resolveAxiosErrorMessage(e, fallback);
|
||||
|
||||
ElMessage.error(message);
|
||||
} finally {
|
||||
notificationsSaving.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center mb-16">
|
||||
<el-button link type="primary" @click="router.push({ name: ROUTE_NAMES.Orders })"> ← Orders </el-button>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="detail-loading-host" element-loading-text="Loading order…">
|
||||
<el-empty v-if="!loading && loadError" description="Order not found" />
|
||||
|
||||
<template v-if="!loading && !loadError && currentOrder">
|
||||
<div class="flex items-center gap-12 mb-16">
|
||||
<h3 class="m-0">Order</h3>
|
||||
<el-tag :type="resolveOrderStatusTagType(currentOrder.state.status)" size="small">
|
||||
{{ capitalizeFirstLetter(currentOrder.state.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="24" class="order-detail-row order-detail-row--stretch mb-24">
|
||||
<el-col :xs="24" :md="10" class="order-detail-col min-w-0">
|
||||
<order-summary-panel :order="currentOrder" />
|
||||
</el-col>
|
||||
<el-col :xs="24" :md="14" class="order-detail-col min-w-0">
|
||||
<order-chat-panel />
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<order-cart-panel class="mb-24" :order="currentOrder" />
|
||||
|
||||
<el-row :gutter="24" class="order-detail-row mb-0">
|
||||
<el-col :xs="24" :md="12" class="min-w-0">
|
||||
<order-payment-panel :order="currentOrder" />
|
||||
</el-col>
|
||||
<el-col :xs="24" :md="12" class="min-w-0">
|
||||
<order-manual-shipping-quote-panel :order="currentOrder" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeMount, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { config } from '@/config';
|
||||
import { usePolling } from '@/composables/usePolling';
|
||||
import { ROUTE_NAMES } from '@/consts/routeNames';
|
||||
import { useOrdersStore } from '@/stores/orders';
|
||||
import { capitalizeFirstLetter } from '@/utils/capitalizeFirstLetter';
|
||||
import { resolveOrderStatusTagType } from '@/utils/order/resolveOrderStatusTagType';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const ordersStore = useOrdersStore();
|
||||
|
||||
const { fetchById, markChatRead } = ordersStore;
|
||||
|
||||
const { currentOrder, currentOrderId } = storeToRefs(ordersStore);
|
||||
|
||||
const loading = ref(true);
|
||||
const loadError = ref(false);
|
||||
const pollingEnabled = ref(false);
|
||||
|
||||
onBeforeMount(async () => {
|
||||
if (!currentOrderId.value) {
|
||||
loadError.value = true;
|
||||
loading.value = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
await fetchOrderAndMarkChatRead(currentOrderId.value);
|
||||
} catch {
|
||||
loadError.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
pollingEnabled.value = !loadError.value;
|
||||
}
|
||||
});
|
||||
|
||||
usePolling(
|
||||
async () => {
|
||||
if (!currentOrderId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fetchOrderAndMarkChatRead(currentOrderId.value);
|
||||
},
|
||||
{
|
||||
intervalMs: config.orders.detailPollIntervalMs,
|
||||
enabled: pollingEnabled
|
||||
}
|
||||
);
|
||||
|
||||
const fetchOrderAndMarkChatRead = async (orderId: string): Promise<void> => {
|
||||
await fetchById(orderId);
|
||||
|
||||
try {
|
||||
await markChatRead(orderId);
|
||||
} catch {
|
||||
console.error('Failed to mark chat as read', orderId);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.order-detail-row--stretch {
|
||||
.order-detail-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:deep(.el-card) {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="m-0 mb-24">Orders</h3>
|
||||
|
||||
<div class="cms-table-scroll">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="orderList"
|
||||
stripe
|
||||
empty-text="No orders yet"
|
||||
class="clickable-table"
|
||||
@row-click="row => router.push({ name: ROUTE_NAMES.OrderDetail, params: { id: row.id } })"
|
||||
>
|
||||
<el-table-column label="Status" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="resolveOrderStatusTagType(row.status)" size="small">
|
||||
{{ capitalizeFirstLetter(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="Checkout" width="300">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
v-if="row.checkoutPaymentLabel"
|
||||
:type="resolveInvoiceStatusTagType(row.checkoutPaymentLabel)"
|
||||
size="small"
|
||||
>
|
||||
{{ row.checkoutPaymentLabel }}
|
||||
</el-tag>
|
||||
<span v-else class="secondary-text">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="Shipping" width="300">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
v-if="row.shippingPaymentLabel"
|
||||
:type="resolveInvoiceStatusTagType(row.shippingPaymentLabel)"
|
||||
size="small"
|
||||
>
|
||||
{{ row.shippingPaymentLabel }}
|
||||
</el-tag>
|
||||
<span v-else class="secondary-text">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="Total" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ formatFiatPrice(row.grandTotalFiat ?? row.totalFiat, row.fiatCurrency) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="Items" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.lineCount }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="Unread" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-badge v-if="row.unreadMessageCount > 0" :value="row.unreadMessageCount" />
|
||||
<span v-else class="secondary-text">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="Created" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click.stop="router.push({ name: ROUTE_NAMES.OrderDetail, params: { id: row.id } })"
|
||||
>
|
||||
Open
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<cms-list-pagination v-model:page="page" v-model:limit="limit" :total="total" @change="loadOrders" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeMount, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { ROUTE_NAMES } from '@/consts/routeNames';
|
||||
import { useOrdersStore } from '@/stores/orders';
|
||||
import { capitalizeFirstLetter } from '@/utils/capitalizeFirstLetter';
|
||||
import { formatDate } from '@/utils/formatDate';
|
||||
import { formatFiatPrice } from '@/utils/formatFiatPrice';
|
||||
import { resolveInvoiceStatusTagType } from '@/utils/order/resolveInvoiceStatusTagType';
|
||||
import { resolveOrderStatusTagType } from '@/utils/order/resolveOrderStatusTagType';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const ordersStore = useOrdersStore();
|
||||
|
||||
const { orderList } = storeToRefs(ordersStore);
|
||||
|
||||
const loading = ref(false);
|
||||
const page = ref(1);
|
||||
const limit = ref(20);
|
||||
const total = ref(0);
|
||||
|
||||
onBeforeMount(() => {
|
||||
loadOrders();
|
||||
});
|
||||
|
||||
const loadOrders = async (): Promise<void> => {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const data = await ordersStore.fetchList({
|
||||
page: page.value,
|
||||
limit: limit.value
|
||||
});
|
||||
|
||||
total.value = data.total;
|
||||
page.value = data.page;
|
||||
limit.value = data.limit;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,296 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center mb-16">
|
||||
<el-button link type="primary" @click="router.push({ name: ROUTE_NAMES.Products })"> ← Products </el-button>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="detail-loading-host" element-loading-text="Loading product…">
|
||||
<el-empty v-if="!loading && loadError" description="Product not found" />
|
||||
|
||||
<template v-if="!loading && !loadError">
|
||||
<div v-loading="deleteSaving" element-loading-text="Deleting product…">
|
||||
<div class="flex items-center gap-12 mb-16">
|
||||
<h3 class="m-0">Edit product</h3>
|
||||
<el-tag v-if="currentDeliveryModeLabel" size="small">{{ currentDeliveryModeLabel }}</el-tag>
|
||||
</div>
|
||||
<div v-if="currentProduct?.isDraft" class="mb-16">
|
||||
<el-alert
|
||||
type="info"
|
||||
title="Draft"
|
||||
description="This product is hidden from the storefront until you publish it."
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-card class="mb-24" shadow="never">
|
||||
<template #header>
|
||||
<span>Details</span>
|
||||
</template>
|
||||
|
||||
<el-form ref="productFormRef" label-position="top" :model="productForm" :rules="productRules">
|
||||
<el-form-item label="Live" class="mb-4">
|
||||
<el-switch v-model="publishSwitch" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Title" prop="title">
|
||||
<el-input
|
||||
v-model="productForm.title"
|
||||
:maxlength="validationProductTitleMaxLength"
|
||||
show-word-limit
|
||||
@input="productFormRef?.clearValidate('title')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Categories">
|
||||
<el-select
|
||||
v-model="selectedCategoryIds"
|
||||
multiple
|
||||
collapse-tags
|
||||
:max-collapse-tags="3"
|
||||
collapse-tags-tooltip
|
||||
placeholder="Select categories"
|
||||
>
|
||||
<el-option
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
:label="category.name"
|
||||
:value="category.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Description">
|
||||
<rich-text-editor
|
||||
v-model="productForm.descriptionHtml"
|
||||
placeholder="Describe the product…"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-button type="primary" :loading="productSaving" @click="submitProduct">
|
||||
Save product
|
||||
</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mb-24">
|
||||
<template #header>
|
||||
<div class="cms-page-header">
|
||||
<span>Variants</span>
|
||||
|
||||
<el-button type="primary" size="small" @click="variantModalVisible = true">
|
||||
Add variant
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="cms-table-scroll">
|
||||
<el-table
|
||||
:data="currentProduct?.variants ?? []"
|
||||
stripe
|
||||
empty-text="No variants yet"
|
||||
class="clickable-table"
|
||||
@row-click="goToVariantPage"
|
||||
>
|
||||
<el-table-column prop="sortOrder" label="Order" width="80" />
|
||||
<el-table-column prop="title" label="Title" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="Price" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ formatFiatPrice(row.price, shopFiatCurrency) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="stockAvailable" label="Stock" width="100" />
|
||||
<el-table-column width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click.stop="goToVariantPage(row)"
|
||||
>Open</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-button type="danger" plain :loading="deleteSaving" @click="confirmDeleteProduct">
|
||||
Delete product
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<create-product-variant-modal v-model="variantModalVisible" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { config } from '@/config';
|
||||
import { ROUTE_NAMES } from '@/consts/routeNames';
|
||||
import { useCategoriesStore } from '@/stores/categories';
|
||||
import { useProductsStore } from '@/stores/products';
|
||||
import type { ProductVariantExtended } from '@/types/product/ProductVariantExtended';
|
||||
import { formatFiatPrice } from '@/utils/formatFiatPrice';
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, onBeforeMount, reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
const {
|
||||
shopFiatCurrency,
|
||||
validation: { productTitleMaxLength: validationProductTitleMaxLength }
|
||||
} = config;
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const productsStore = useProductsStore();
|
||||
const categoriesStore = useCategoriesStore();
|
||||
const { fetchAll: fetchCategories } = categoriesStore;
|
||||
|
||||
const { currentProduct, currentDeliveryModeLabel, currentProductDisplayTitle } = storeToRefs(productsStore);
|
||||
const { categories } = storeToRefs(categoriesStore);
|
||||
|
||||
const selectedCategoryIds = ref<string[]>([]);
|
||||
|
||||
const loading = ref(false);
|
||||
const loadError = ref(false);
|
||||
|
||||
const productFormRef = ref<FormInstance>();
|
||||
const productForm = reactive({
|
||||
title: '',
|
||||
descriptionHtml: '',
|
||||
isDraft: true
|
||||
});
|
||||
|
||||
const productSaving = ref(false);
|
||||
const deleteSaving = ref(false);
|
||||
|
||||
const productRules: FormRules = {
|
||||
title: [
|
||||
{ required: true, message: 'Required', trigger: 'blur' },
|
||||
{
|
||||
max: validationProductTitleMaxLength,
|
||||
message: `At most ${validationProductTitleMaxLength} characters`,
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const variantModalVisible = ref(false);
|
||||
|
||||
onBeforeMount(async () => {
|
||||
loadError.value = false;
|
||||
|
||||
const id = route.params.id as string;
|
||||
|
||||
if (!id) {
|
||||
loadError.value = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
await Promise.all([productsStore.fetchProductById(id), fetchCategories()]);
|
||||
|
||||
if (currentProduct.value) {
|
||||
productForm.title = currentProduct.value.title;
|
||||
productForm.descriptionHtml = currentProduct.value.descriptionHtml;
|
||||
productForm.isDraft = currentProduct.value.isDraft;
|
||||
selectedCategoryIds.value = (currentProduct.value.categories ?? []).map(c => c.id);
|
||||
|
||||
productFormRef.value?.clearValidate();
|
||||
}
|
||||
} catch {
|
||||
loadError.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const publishSwitch = computed<boolean>({
|
||||
get: () => !productForm.isDraft,
|
||||
set: (published: boolean) => {
|
||||
productForm.isDraft = !published;
|
||||
}
|
||||
});
|
||||
|
||||
const submitProduct = async (): Promise<void> => {
|
||||
const form = productFormRef.value;
|
||||
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await form.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
productSaving.value = true;
|
||||
|
||||
try {
|
||||
await productsStore.updateProduct(route.params.id as string, {
|
||||
title: productForm.title.trim(),
|
||||
descriptionHtml: productForm.descriptionHtml,
|
||||
isDraft: productForm.isDraft,
|
||||
categoryIds: selectedCategoryIds.value
|
||||
});
|
||||
|
||||
ElMessage.success('Product saved');
|
||||
} catch {
|
||||
ElMessage.error('Could not save product');
|
||||
} finally {
|
||||
productSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goToVariantPage = (row: ProductVariantExtended): void => {
|
||||
const productId = currentProduct.value?.id;
|
||||
|
||||
if (!productId) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.push({
|
||||
name: ROUTE_NAMES.ProductVariantDetail,
|
||||
params: { productId, variantId: row.id }
|
||||
});
|
||||
};
|
||||
|
||||
const confirmDeleteProduct = async (): Promise<void> => {
|
||||
const pid = currentProduct.value?.id;
|
||||
|
||||
if (!pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`Delete «${currentProductDisplayTitle.value}»? This cannot be undone.`,
|
||||
'Delete product',
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: 'Delete',
|
||||
cancelButtonText: 'Cancel'
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteSaving.value = true;
|
||||
|
||||
try {
|
||||
await productsStore.deleteProduct(pid);
|
||||
ElMessage.success('Product deleted');
|
||||
router.push({ name: ROUTE_NAMES.Products });
|
||||
} catch {
|
||||
ElMessage.error('Could not delete product');
|
||||
} finally {
|
||||
deleteSaving.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center mb-16">
|
||||
<el-button link type="primary" @click="goToProduct"> ← Product </el-button>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="detail-loading-host" element-loading-text="Loading variant…">
|
||||
<el-empty v-if="!loading && loadError" description="Variant not found" />
|
||||
|
||||
<template v-if="!loading && !loadError && currentVariant">
|
||||
<div v-loading="deleteSaving" element-loading-text="Deleting variant…">
|
||||
<div class="variant-detail-header flex items-center gap-12 mb-16 min-w-0">
|
||||
<h3 class="m-0 flex-ellipsis" :title="currentVariant.title">
|
||||
{{ currentVariant.title }}
|
||||
</h3>
|
||||
<el-tag v-if="currentDeliveryModeLabel" size="small" class="flex-shrink-0">
|
||||
{{ currentDeliveryModeLabel }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<p
|
||||
v-if="currentProductDisplayTitle"
|
||||
class="secondary-text m-0 mb-16 text-ellipsis"
|
||||
:title="currentProductDisplayTitle"
|
||||
>
|
||||
{{ currentProductDisplayTitle }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-24">
|
||||
<variant-detail-details-card />
|
||||
<variant-detail-images-card />
|
||||
<variant-detail-digital-stock-section v-if="currentProductIsAuto" />
|
||||
|
||||
<el-button
|
||||
class="self-start"
|
||||
type="danger"
|
||||
plain
|
||||
:loading="deleteSaving"
|
||||
:disabled="!canDeleteCurrentVariant"
|
||||
@click="confirmDeleteVariant"
|
||||
>
|
||||
Delete variant
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ROUTE_NAMES } from '@/consts/routeNames';
|
||||
import { useProductsStore } from '@/stores/products';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { onBeforeMount, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
const productsStore = useProductsStore();
|
||||
const {
|
||||
currentVariant,
|
||||
currentProductId,
|
||||
currentVariantId,
|
||||
currentDeliveryModeLabel,
|
||||
currentProductDisplayTitle,
|
||||
currentProductIsAuto,
|
||||
canDeleteCurrentVariant
|
||||
} = storeToRefs(productsStore);
|
||||
|
||||
const loading = ref(false);
|
||||
const loadError = ref(false);
|
||||
const deleteSaving = ref(false);
|
||||
|
||||
onBeforeMount(async () => {
|
||||
loadError.value = false;
|
||||
|
||||
if (!currentProductId.value || !currentVariantId.value) {
|
||||
loadError.value = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
await productsStore.fetchProductById(currentProductId.value);
|
||||
|
||||
if (!currentVariant.value) {
|
||||
loadError.value = true;
|
||||
}
|
||||
} catch {
|
||||
loadError.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const goToProduct = (): void => {
|
||||
router.push({ name: ROUTE_NAMES.ProductDetail, params: { id: currentProductId.value } });
|
||||
};
|
||||
|
||||
const confirmDeleteVariant = async (): Promise<void> => {
|
||||
if (!currentProductId.value || !currentVariantId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`Delete «${currentVariant.value?.title}»? This cannot be undone.`,
|
||||
'Delete variant',
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: 'Delete',
|
||||
cancelButtonText: 'Cancel'
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteSaving.value = true;
|
||||
|
||||
try {
|
||||
await productsStore.deleteProductVariant(currentProductId.value, currentVariantId.value);
|
||||
ElMessage.success('Variant removed');
|
||||
goToProduct();
|
||||
} catch {
|
||||
ElMessage.error('Could not remove variant');
|
||||
} finally {
|
||||
deleteSaving.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="cms-page-header mb-24">
|
||||
<h3 class="m-0">Products</h3>
|
||||
<el-button type="primary" @click="createTypeDialogVisible = true"> New product </el-button>
|
||||
</div>
|
||||
|
||||
<div class="cms-table-scroll">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="products"
|
||||
stripe
|
||||
empty-text="No products yet"
|
||||
class="clickable-table"
|
||||
@row-click="row => router.push({ name: ROUTE_NAMES.ProductDetail, params: { id: row.id } })"
|
||||
>
|
||||
<el-table-column prop="title" label="Title" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ getProductTitle(row.title) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="Status" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.isDraft" type="info" size="small">Draft</el-tag>
|
||||
<el-tag v-else type="success" size="small">Live</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="Delivery" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ formatDeliveryMode(row.deliveryMode) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="Categories" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ formatProductCategories(row.categories) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="Stock (available)" width="150">
|
||||
<template #default="{ row }">
|
||||
{{ getProductTotalStock(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Updated" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.updatedAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click.stop="router.push({ name: ROUTE_NAMES.ProductDetail, params: { id: row.id } })"
|
||||
>
|
||||
Open
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<cms-list-pagination v-model:page="page" v-model:limit="limit" :total="total" @change="loadProducts" />
|
||||
|
||||
<el-dialog v-model="createTypeDialogVisible" title="New product" width="400px" destroy-on-close>
|
||||
<p class="mb-16">Choose delivery mode. This cannot be changed later.</p>
|
||||
|
||||
<el-radio-group v-model="createDeliveryMode" class="create-delivery-mode-radio-group">
|
||||
<el-radio :value="DeliveryMode.Auto">Auto-delivered</el-radio>
|
||||
<el-radio :value="DeliveryMode.Manual">Manually fulfilled</el-radio>
|
||||
</el-radio-group>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="createTypeDialogVisible = false">Cancel</el-button>
|
||||
<el-button type="primary" :loading="createDraftLoading" @click="handleNewProductDraft">
|
||||
Create draft
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ROUTE_NAMES } from '@/consts/routeNames';
|
||||
import { useProductsStore } from '@/stores/products';
|
||||
import type { Category } from '@/types/product/Category';
|
||||
import type { ProductWithVariantsExtended } from '@/types/product/ProductWithVariantsExtended';
|
||||
import { DeliveryMode } from '@/types/product/DeliveryMode';
|
||||
import { HttpStatusCode, isAxiosError } from 'axios';
|
||||
import { formatDate } from '@/utils/formatDate';
|
||||
import { formatDeliveryMode } from '@/utils/product/formatDeliveryMode';
|
||||
import { getProductTitle } from '@/utils/product/getProductTitle';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { onBeforeMount, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
const productsStore = useProductsStore();
|
||||
const { products } = storeToRefs(productsStore);
|
||||
|
||||
const loading = ref(false);
|
||||
const createDraftLoading = ref(false);
|
||||
const createTypeDialogVisible = ref(false);
|
||||
const createDeliveryMode = ref<DeliveryMode>(DeliveryMode.Auto);
|
||||
const page = ref(1);
|
||||
const limit = ref(20);
|
||||
const total = ref(0);
|
||||
|
||||
onBeforeMount(() => {
|
||||
loadProducts();
|
||||
});
|
||||
|
||||
const loadProducts = async (): Promise<void> => {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const data = await productsStore.fetchProducts({
|
||||
page: page.value,
|
||||
limit: limit.value
|
||||
});
|
||||
|
||||
products.value = data.items;
|
||||
total.value = data.total;
|
||||
page.value = data.page;
|
||||
limit.value = data.limit;
|
||||
} catch (e) {
|
||||
if (!isAxiosError(e) || e.response?.status !== HttpStatusCode.Unauthorized) {
|
||||
ElMessage.error('Failed to load products');
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewProductDraft = async (): Promise<void> => {
|
||||
createDraftLoading.value = true;
|
||||
|
||||
try {
|
||||
const created = await productsStore.createDraftProduct(createDeliveryMode.value);
|
||||
createTypeDialogVisible.value = false;
|
||||
ElMessage.success('Draft created — add details and publish when ready');
|
||||
router.push({ name: ROUTE_NAMES.ProductDetail, params: { id: created.id } });
|
||||
} catch {
|
||||
ElMessage.error('Could not create draft product');
|
||||
} finally {
|
||||
createDraftLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const getProductTotalStock = (product: ProductWithVariantsExtended): number =>
|
||||
(product.variants ?? []).reduce((sum, variant) => sum + variant.stockAvailable, 0);
|
||||
|
||||
const formatProductCategories = (categories: Category[] | undefined): string => {
|
||||
if (!categories?.length) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return [...categories]
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))
|
||||
.map(category => category.name)
|
||||
.join(', ');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.create-delivery-mode-radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="m-0 mb-16">Settings</h3>
|
||||
|
||||
<el-tabs :model-value="activeTab" class="mb-24" @tab-change="onTabChange">
|
||||
<el-tab-pane label="Shop" name="shop" />
|
||||
<el-tab-pane label="Notifications" name="notifications" />
|
||||
</el-tabs>
|
||||
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ROUTE_NAMES } from '@/consts/routeNames';
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const activeTab = computed(() => (route.name === ROUTE_NAMES.Notifications ? 'notifications' : 'shop'));
|
||||
|
||||
const onTabChange = (tabName: string | number) => {
|
||||
if (tabName === 'notifications') {
|
||||
router.push({ name: ROUTE_NAMES.Notifications });
|
||||
return;
|
||||
}
|
||||
|
||||
router.push({ name: ROUTE_NAMES.ShopSettings });
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,488 @@
|
||||
<template>
|
||||
<div v-loading="loading" class="detail-loading-host" element-loading-text="Loading shop settings…">
|
||||
<el-empty v-if="!loading && loadError" description="Failed to load shop settings" />
|
||||
|
||||
<template v-if="!loading && !loadError && settings">
|
||||
<div v-if="!settings.isSetupComplete" class="mb-16">
|
||||
<el-alert type="warning" title="Setup incomplete" :closable="false" show-icon>
|
||||
<ul class="m-0 pl-20">
|
||||
<li v-if="!settings.setupChecklist.logo">Upload a shop logo</li>
|
||||
<li v-if="!settings.setupChecklist.favicon">Upload a shop favicon</li>
|
||||
<li v-if="!settings.setupChecklist.simplexLink"
|
||||
>Set a Simplex contact link for the storefront</li
|
||||
>
|
||||
<li v-if="!settings.setupChecklist.shippingNote">Add typical shipping costs for buyers</li>
|
||||
</ul>
|
||||
</el-alert>
|
||||
</div>
|
||||
|
||||
<div v-else class="mb-16">
|
||||
<el-alert
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="Shop setup complete"
|
||||
description="Your storefront branding and buyer-facing settings are configured."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-card class="mb-24" shadow="never">
|
||||
<template #header>
|
||||
<span>Shop info</span>
|
||||
</template>
|
||||
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="Shop name">{{ settings.shopName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Fiat currency">{{ settings.shopFiatCurrency }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Monero confirmation tiers">
|
||||
<ul class="m-0 pl-20">
|
||||
<li v-for="(tier, index) in settings.monero.confirmationTiers" :key="index">
|
||||
{{ formatMoneroConfirmationTier(tier, settings.shopFiatCurrency) }}
|
||||
</li>
|
||||
</ul>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card class="mb-24" shadow="never">
|
||||
<template #header>
|
||||
<span>Logo</span>
|
||||
</template>
|
||||
|
||||
<div class="mb-16 flex flex-col items-start gap-4">
|
||||
<el-text tag="p" size="small" class="secondary-text w-full">
|
||||
{{ shopLogoHint }}
|
||||
</el-text>
|
||||
|
||||
<el-text tag="p" size="small" class="secondary-text w-full">
|
||||
Recommended: 200×50 px wide logo. Shown in the storefront header at this size.
|
||||
</el-text>
|
||||
</div>
|
||||
|
||||
<div v-if="settings.logoUrl" class="mb-16">
|
||||
<img :src="resolveUploadPublicUrl(settings.logoUrl)" alt="Shop logo" class="shop-logo-preview" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-12 min-w-0">
|
||||
<el-upload
|
||||
ref="logoUploadRef"
|
||||
:show-file-list="false"
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
:accept="shopLogoAccept"
|
||||
:disabled="logoUploadSaving"
|
||||
:on-change="onLogoChange"
|
||||
:on-remove="onLogoRemove"
|
||||
>
|
||||
<el-button type="default">Select image</el-button>
|
||||
</el-upload>
|
||||
|
||||
<template v-if="logoPendingFile">
|
||||
<el-text size="small" class="flex-ellipsis" :title="logoPendingFile.name">
|
||||
{{ logoPendingFile.name }}
|
||||
</el-text>
|
||||
<el-button type="primary" :loading="logoUploadSaving" @click="submitLogoUpload">
|
||||
Upload
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="mb-24" shadow="never">
|
||||
<template #header>
|
||||
<span>Favicon</span>
|
||||
</template>
|
||||
|
||||
<div class="mb-16 flex flex-col items-start gap-4">
|
||||
<el-text tag="p" size="small" class="secondary-text w-full">
|
||||
{{ shopFaviconHint }}
|
||||
</el-text>
|
||||
|
||||
<el-text tag="p" size="small" class="secondary-text w-full">
|
||||
Recommended: square icon, e.g. 32×32 or 48×48 px. Shown as the browser tab icon on the
|
||||
storefront.
|
||||
</el-text>
|
||||
</div>
|
||||
|
||||
<div v-if="settings.faviconUrl" class="mb-16">
|
||||
<img
|
||||
:src="resolveUploadPublicUrl(settings.faviconUrl)"
|
||||
alt="Shop favicon"
|
||||
class="shop-favicon-preview"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-12 min-w-0">
|
||||
<el-upload
|
||||
ref="faviconUploadRef"
|
||||
:show-file-list="false"
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
:accept="shopFaviconAccept"
|
||||
:disabled="faviconUploadSaving"
|
||||
:on-change="onFaviconChange"
|
||||
:on-remove="onFaviconRemove"
|
||||
>
|
||||
<el-button type="default">Select image</el-button>
|
||||
</el-upload>
|
||||
|
||||
<template v-if="faviconPendingFile">
|
||||
<el-text size="small" class="flex-ellipsis" :title="faviconPendingFile.name">
|
||||
{{ faviconPendingFile.name }}
|
||||
</el-text>
|
||||
<el-button type="primary" :loading="faviconUploadSaving" @click="submitFaviconUpload">
|
||||
Upload
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="mb-24" shadow="never">
|
||||
<template #header>
|
||||
<span>Typical shipping costs</span>
|
||||
</template>
|
||||
|
||||
<div class="mb-16 flex flex-col items-start gap-4">
|
||||
<el-text tag="p" size="small" class="secondary-text w-full">
|
||||
Describe typical shipping costs by region or method. Shown to buyers on the cart when it
|
||||
contains physically delivered items. The two-step payment process is explained automatically by
|
||||
the shop.
|
||||
</el-text>
|
||||
|
||||
<el-text tag="p" size="small" class="secondary-text w-full">
|
||||
Example: Domestic: usually €5–8. EU: usually €12–20. Rest of world: usually €25–45.
|
||||
</el-text>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="shippingNoteFormRef"
|
||||
label-position="top"
|
||||
:model="shippingNoteForm"
|
||||
:rules="shippingNoteRules"
|
||||
>
|
||||
<el-form-item label="Shipping note" prop="shippingNote">
|
||||
<el-input
|
||||
v-model="shippingNoteForm.shippingNote"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
:maxlength="shippingNoteMaxLength"
|
||||
show-word-limit
|
||||
placeholder="Typical shipping costs by region…"
|
||||
@input="shippingNoteFormRef?.clearValidate('shippingNote')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-button type="primary" :loading="shippingNoteSaving" @click="submitShippingNote">
|
||||
Save shipping note
|
||||
</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<span>Simplex contact</span>
|
||||
</template>
|
||||
|
||||
<div class="mb-16">
|
||||
<el-text tag="p" size="small" class="secondary-text w-full">
|
||||
Shown to buyers in the storefront footer so they can reach you on SimpleX.
|
||||
</el-text>
|
||||
</div>
|
||||
|
||||
<el-form ref="simplexFormRef" label-position="top" :model="simplexForm" :rules="simplexRules">
|
||||
<el-form-item label="Simplex link" prop="simplexLink">
|
||||
<el-input
|
||||
v-model="simplexForm.simplexLink"
|
||||
placeholder="https://simplex.chat/contact#…"
|
||||
@input="simplexFormRef?.clearValidate('simplexLink')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-button type="primary" :loading="simplexSaving" @click="submitSimplex"
|
||||
>Save contact link</el-button
|
||||
>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { config } from '@/config';
|
||||
import { useShopSettingsStore } from '@/stores/shopSettings';
|
||||
import type { MoneroConfirmationTier } from '@/types/shopSettings/MoneroConfirmationTier';
|
||||
import { buildUploadHint } from '@/utils/upload/buildUploadHint';
|
||||
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
|
||||
import { resolveUploadPublicUrl } from '@/utils/upload/resolveUploadPublicUrl';
|
||||
import { validateUpload } from '@/utils/upload/validateUpload';
|
||||
import { ElMessage, type FormInstance, type FormRules, type UploadInstance, type UploadProps } from 'element-plus';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, onBeforeMount, reactive, ref } from 'vue';
|
||||
|
||||
const {
|
||||
shopLogo: { accept: shopLogoAccept, maxFileBytes: shopLogoMaxFileBytes },
|
||||
shopFavicon: { accept: shopFaviconAccept, maxFileBytes: shopFaviconMaxFileBytes },
|
||||
validation: { shippingNoteMinLength, shippingNoteMaxLength }
|
||||
} = config;
|
||||
|
||||
const shopSettingsStore = useShopSettingsStore();
|
||||
|
||||
const { settings } = storeToRefs(shopSettingsStore);
|
||||
const { fetchShopSettings, updateSimplexLink, updateShippingNote, uploadShopLogo, uploadShopFavicon } =
|
||||
shopSettingsStore;
|
||||
|
||||
const loading = ref(false);
|
||||
const loadError = ref(false);
|
||||
const logoUploadSaving = ref(false);
|
||||
const faviconUploadSaving = ref(false);
|
||||
const simplexSaving = ref(false);
|
||||
const shippingNoteSaving = ref(false);
|
||||
const logoPendingFile = ref<File | null>(null);
|
||||
const faviconPendingFile = ref<File | null>(null);
|
||||
const logoUploadRef = ref<UploadInstance>();
|
||||
const faviconUploadRef = ref<UploadInstance>();
|
||||
const simplexFormRef = ref<FormInstance>();
|
||||
const shippingNoteFormRef = ref<FormInstance>();
|
||||
|
||||
const simplexForm = reactive({
|
||||
simplexLink: ''
|
||||
});
|
||||
|
||||
const shippingNoteForm = reactive({
|
||||
shippingNote: ''
|
||||
});
|
||||
|
||||
const simplexRules: FormRules = {
|
||||
simplexLink: [{ required: true, message: 'Simplex link is required', trigger: 'blur' }]
|
||||
};
|
||||
|
||||
const shippingNoteRules: FormRules = {
|
||||
shippingNote: [
|
||||
{ required: true, message: 'Shipping note is required', trigger: 'blur' },
|
||||
{
|
||||
min: shippingNoteMinLength,
|
||||
max: shippingNoteMaxLength,
|
||||
message: `Between ${shippingNoteMinLength} and ${shippingNoteMaxLength} characters`,
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
onBeforeMount(async () => {
|
||||
loadError.value = false;
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const data = await fetchShopSettings();
|
||||
|
||||
simplexForm.simplexLink = data.simplexLink ?? '';
|
||||
shippingNoteForm.shippingNote = data.shippingNote ?? '';
|
||||
} catch {
|
||||
loadError.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const shopLogoHint = computed(() =>
|
||||
buildUploadHint({
|
||||
allowedMimesCsv: shopLogoAccept,
|
||||
maxFileBytes: shopLogoMaxFileBytes
|
||||
})
|
||||
);
|
||||
|
||||
const shopFaviconHint = computed(() =>
|
||||
buildUploadHint({
|
||||
allowedMimesCsv: shopFaviconAccept,
|
||||
maxFileBytes: shopFaviconMaxFileBytes
|
||||
})
|
||||
);
|
||||
|
||||
const onLogoChange: UploadProps['onChange'] = uploadFile => {
|
||||
const file = uploadFile.raw;
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = validateUpload(file, {
|
||||
allowedMimesCsv: shopLogoAccept,
|
||||
maxFileBytes: shopLogoMaxFileBytes
|
||||
});
|
||||
|
||||
if (validationError) {
|
||||
ElMessage.error(validationError);
|
||||
logoUploadRef.value?.clearFiles();
|
||||
logoPendingFile.value = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
logoPendingFile.value = file;
|
||||
};
|
||||
|
||||
const onLogoRemove: UploadProps['onRemove'] = () => {
|
||||
logoPendingFile.value = null;
|
||||
};
|
||||
|
||||
const submitLogoUpload = async (): Promise<void> => {
|
||||
if (!logoPendingFile.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
logoUploadSaving.value = true;
|
||||
|
||||
try {
|
||||
await uploadShopLogo(logoPendingFile.value);
|
||||
|
||||
ElMessage.success('Logo uploaded');
|
||||
|
||||
logoPendingFile.value = null;
|
||||
logoUploadRef.value?.clearFiles();
|
||||
} catch (e) {
|
||||
const fallback = 'Logo upload failed';
|
||||
|
||||
const message = resolveAxiosErrorMessage(e, fallback);
|
||||
|
||||
ElMessage.error(message);
|
||||
} finally {
|
||||
logoUploadSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onFaviconChange: UploadProps['onChange'] = uploadFile => {
|
||||
const file = uploadFile.raw;
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = validateUpload(file, {
|
||||
allowedMimesCsv: shopFaviconAccept,
|
||||
maxFileBytes: shopFaviconMaxFileBytes
|
||||
});
|
||||
|
||||
if (validationError) {
|
||||
ElMessage.error(validationError);
|
||||
faviconUploadRef.value?.clearFiles();
|
||||
faviconPendingFile.value = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
faviconPendingFile.value = file;
|
||||
};
|
||||
|
||||
const onFaviconRemove: UploadProps['onRemove'] = () => {
|
||||
faviconPendingFile.value = null;
|
||||
};
|
||||
|
||||
const submitFaviconUpload = async (): Promise<void> => {
|
||||
if (!faviconPendingFile.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
faviconUploadSaving.value = true;
|
||||
|
||||
try {
|
||||
await uploadShopFavicon(faviconPendingFile.value);
|
||||
|
||||
ElMessage.success('Favicon uploaded');
|
||||
|
||||
faviconPendingFile.value = null;
|
||||
faviconUploadRef.value?.clearFiles();
|
||||
} catch (e) {
|
||||
const fallback = 'Favicon upload failed';
|
||||
|
||||
const message = resolveAxiosErrorMessage(e, fallback);
|
||||
|
||||
ElMessage.error(message);
|
||||
} finally {
|
||||
faviconUploadSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitSimplex = async (): Promise<void> => {
|
||||
const form = simplexFormRef.value;
|
||||
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await form.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
simplexSaving.value = true;
|
||||
|
||||
try {
|
||||
await updateSimplexLink({ simplexLink: simplexForm.simplexLink.trim() });
|
||||
|
||||
ElMessage.success('Simplex link saved');
|
||||
} catch (e) {
|
||||
const fallback = 'Failed to save Simplex link';
|
||||
const message = resolveAxiosErrorMessage(e, fallback);
|
||||
|
||||
ElMessage.error(message);
|
||||
} finally {
|
||||
simplexSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitShippingNote = async (): Promise<void> => {
|
||||
const form = shippingNoteFormRef.value;
|
||||
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await form.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
shippingNoteSaving.value = true;
|
||||
|
||||
try {
|
||||
await updateShippingNote({ shippingNote: shippingNoteForm.shippingNote.trim() });
|
||||
|
||||
ElMessage.success('Shipping note saved');
|
||||
} catch (e) {
|
||||
const fallback = 'Failed to save shipping note';
|
||||
const message = resolveAxiosErrorMessage(e, fallback);
|
||||
|
||||
ElMessage.error(message);
|
||||
} finally {
|
||||
shippingNoteSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatMoneroConfirmationTier = (tier: MoneroConfirmationTier, currency: string): string => {
|
||||
const requirement = tier.minConfirmations === 0 ? '0 (tx-detected)' : `${tier.minConfirmations} confirmations`;
|
||||
|
||||
if (tier.upToTotalFiat === undefined) {
|
||||
return `Above previous tiers → ${requirement}`;
|
||||
}
|
||||
|
||||
return `Up to ${tier.upToTotalFiat} ${currency} → ${requirement}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.shop-logo-preview {
|
||||
width: 200px;
|
||||
height: 50px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.shop-favicon-preview {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="m-0 mb-16">Wallet</h3>
|
||||
|
||||
<monero-wallet />
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user