This commit is contained in:
2026-08-28 17:31:02 +02:00
commit 2b30e8bd39
694 changed files with 49243 additions and 0 deletions
+109
View File
@@ -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>
+274
View File
@@ -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>
+118
View File
@@ -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>
+233
View File
@@ -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>
+122
View File
@@ -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>
+134
View File
@@ -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>
+296
View File
@@ -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>
+176
View File
@@ -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>
+32
View File
@@ -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>
+488
View File
@@ -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 58. EU: usually 1220. Rest of world: usually 2545.
</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>
+7
View File
@@ -0,0 +1,7 @@
<template>
<div>
<h3 class="m-0 mb-16">Wallet</h3>
<monero-wallet />
</div>
</template>