Files
nullcart/cms/src/components/variantDetail/VariantDetailDigitalStockSection.vue
T
nobswebdev e5eb773404 Use descriptive required-field validation messages in CMS forms.
Replace generic "Required" copy with field-specific messages for product, variant, and digital stock forms.
2026-09-16 16:58:13 +02:00

764 lines
26 KiB
Vue

<template>
<el-card shadow="never">
<template #header>
<span>Digital stock</span>
</template>
<el-text tag="p" size="small" class="secondary-text w-full m-0 mb-16">
Content along with all attachments is delivered automatically after purchase. Content and attachments are
encrypted at rest on the server.
</el-text>
<p class="mb-16">Currently available: {{ currentVariant?.stockAvailable }}</p>
<el-form
ref="stockAddFormRef"
label-position="top"
:model="stockAddForm"
:rules="stockContentRules"
class="mb-24"
>
<el-form-item label="Add content" prop="content">
<div class="flex gap-12 w-full">
<el-input
v-model="stockAddForm.content"
type="textarea"
:rows="3"
class="flex-1"
:disabled="stockRemoving"
@input="stockAddFormRef?.clearValidate('content')"
/>
<el-button type="primary" :loading="stockAdding" :disabled="stockRemoving" @click="submitAddStock">
Add
</el-button>
</div>
</el-form-item>
<el-form-item label="Attachments (optional)">
<div class="stock-attachments-field flex flex-col gap-12 w-full">
<el-text tag="p" size="small" class="secondary-text w-full">
{{ stockAttachmentsHint }}
</el-text>
<div class="stock-attachment-upload-row flex items-center gap-12 min-w-0">
<el-upload
ref="stockAddUploadRef"
:show-file-list="false"
:auto-upload="false"
multiple
:accept="digitalStockAttachmentAccept"
:disabled="
stockAdding ||
stockRemoving ||
stockAddPendingFiles.length >= validationDigitalStockAttachmentsMax
"
:on-change="onStockAddFileChange"
>
<el-button
type="default"
:disabled="stockAddPendingFiles.length >= validationDigitalStockAttachmentsMax"
>
Select files
</el-button>
</el-upload>
</div>
<ul v-if="stockAddPendingFiles.length" class="stock-pending-files m-0 p-0">
<li
v-for="(file, index) in stockAddPendingFiles"
:key="`${file.name}-${file.size}-${index}`"
class="stock-pending-files__item flex items-center justify-between gap-12 py-4 min-w-0"
>
<el-text size="small" class="flex-ellipsis" :title="file.name">
{{ file.name }}
</el-text>
<el-button
link
type="danger"
class="flex-shrink-0"
:disabled="stockAdding || stockRemoving"
@click="removeStockAddPendingFile(index)"
>
Remove
</el-button>
</li>
</ul>
</div>
</el-form-item>
</el-form>
<div class="flex items-center gap-12 mb-16">
<el-switch v-model="hideSold" active-text="Hide sold" @change="() => loadStockList({ page: 1 })" />
</div>
<div class="cms-table-scroll">
<el-table
v-loading="stockListLoading || stockRemoving"
:element-loading-text="stockRemoving ? 'Removing stock…' : 'Loading stock…'"
:data="items"
stripe
empty-text="No stock lines yet"
>
<el-table-column prop="content" label="Content" min-width="200" show-overflow-tooltip />
<el-table-column label="Attachments" width="120">
<template #default="{ row: item }">
<el-tooltip
v-if="item.attachments?.length"
:content="formatAttachmentNames(item.attachments)"
placement="top"
>
<span
>{{ item.attachments.length }} file{{ item.attachments.length === 1 ? '' : 's' }}</span
>
</el-tooltip>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column prop="isSold" label="Sold" width="80" />
<el-table-column label="Updated" width="180">
<template #default="{ row: item }">
{{ formatDate(item.updatedAt) }}
</template>
</el-table-column>
<el-table-column width="180" fixed="right">
<template #default="{ row: item }">
<el-button
link
type="primary"
:disabled="item.isSold || stockRemoving"
@click="openEditStock(item)"
>
Edit
</el-button>
<el-button
link
type="danger"
:disabled="item.isSold || stockRemoving"
@click="confirmRemoveStock(item)"
>
Delete
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<cms-list-pagination v-model:page="page" v-model:limit="limit" :total="total" @change="() => loadStockList()" />
</el-card>
<el-dialog
v-model="stockEditVisible"
title="Edit stock item"
width="520px"
destroy-on-close
@closed="stockEditItem = null"
>
<el-form ref="stockEditFormRef" label-position="top" :model="stockEditForm" :rules="stockContentRules">
<el-form-item label="Content" prop="content">
<el-input
v-model="stockEditForm.content"
type="textarea"
:rows="6"
@input="stockEditFormRef?.clearValidate('content')"
/>
</el-form-item>
<el-form-item label="Attachments">
<div class="stock-attachments-field flex flex-col gap-12 w-full">
<el-text tag="p" size="small" class="secondary-text w-full">
{{ stockAttachmentsHint }}
</el-text>
<ul v-if="stockEditAttachments.length" class="stock-edit-attachments m-0 p-0 secondary-text">
<li
v-for="attachment in stockEditAttachments"
:key="attachment.id"
class="stock-edit-attachments__item flex items-center justify-between gap-12 py-4 min-w-0"
>
<div class="stock-edit-attachments__meta flex items-start gap-8 min-h-0 flex-1 min-w-0">
<span
class="flex-ellipsis"
:title="attachment.originalFilename"
>
{{ attachment.originalFilename }}
</span>
<span class="secondary-text flex-shrink-0">
{{ formatFileSize(attachment.sizeBytes) }}
</span>
</div>
<div class="stock-edit-attachments__actions flex gap-4">
<el-button
link
type="primary"
:loading="stockEditDownloadingId === attachment.id"
:disabled="stockEditAttachmentBusy"
@click="downloadStockAttachment(attachment)"
>
Download
</el-button>
<el-button
link
type="danger"
:loading="stockEditRemovingAttachmentId === attachment.id"
:disabled="stockEditItem?.isSold || stockEditAttachmentBusy"
@click="confirmRemoveStockAttachment(attachment.id)"
>
Remove
</el-button>
</div>
</li>
</ul>
<el-text v-else tag="p" size="small" class="secondary-text w-full m-0">No attachments yet.</el-text>
<div class="stock-attachment-upload-row flex items-center gap-12 min-w-0">
<el-upload
ref="stockEditUploadRef"
:show-file-list="false"
:auto-upload="false"
:limit="1"
:accept="digitalStockAttachmentAccept"
:disabled="
stockEditItem?.isSold ||
stockEditAttachmentBusy ||
stockEditAttachments.length >= validationDigitalStockAttachmentsMax
"
:on-change="onStockEditFileChange"
:on-remove="onStockEditFileRemove"
>
<el-button
type="default"
:disabled="
stockEditItem?.isSold ||
stockEditAttachments.length >= validationDigitalStockAttachmentsMax
"
>
Select file
</el-button>
</el-upload>
<template v-if="stockEditPendingFile">
<el-text
size="small"
class="flex-ellipsis"
:title="stockEditPendingFile.name"
>
{{ stockEditPendingFile.name }}
</el-text>
<el-button
type="primary"
:loading="stockEditUploading"
:disabled="stockEditItem?.isSold || stockEditAttachmentBusy"
@click="submitStockEditUpload"
>
Upload
</el-button>
</template>
</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="stockEditVisible = false">Cancel</el-button>
<el-button type="primary" :loading="stockEditSaving" @click="submitEditStock">Save</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { config } from '@/config';
import { useDigitalStockStore } from '@/stores/digitalStock';
import { useProductsStore } from '@/stores/products';
import { getPaginationLastPage } from '@/utils/getPaginationLastPage';
import type { DigitalStockAttachment } from '@/types/product/DigitalStockAttachment';
import type { DigitalStockItem } from '@/types/product/DigitalStockItem';
import type { DigitalStockListQuery } from '@/types/product/DigitalStockListQuery';
import { buildUploadHint } from '@/utils/upload/buildUploadHint';
import { formatDate } from '@/utils/formatDate';
import { formatFileSize } from '@/utils/formatFileSize';
import { validateUpload } from '@/utils/upload/validateUpload';
import {
ElMessage,
ElMessageBox,
type FormInstance,
type FormRules,
type UploadInstance,
type UploadProps
} from 'element-plus';
import { storeToRefs } from 'pinia';
import { computed, nextTick, onBeforeMount, reactive, ref } from 'vue';
const {
digitalStockAttachment: { accept: digitalStockAttachmentAccept, maxFileBytes: digitalStockAttachmentMaxFileBytes },
validation: { digitalStockAttachmentsMax: validationDigitalStockAttachmentsMax }
} = config;
const productsStore = useProductsStore();
const digitalStockStore = useDigitalStockStore();
const { currentVariant, currentProductId, currentVariantId } = storeToRefs(productsStore);
const { items } = storeToRefs(digitalStockStore);
const page = ref(1);
const limit = ref(20);
const total = ref(0);
const hideSold = ref(true);
const stockAddFormRef = ref<FormInstance>();
const stockAddForm = reactive({ content: '' });
const stockAdding = ref(false);
const stockRemoving = ref(false);
const stockListLoading = ref(false);
const stockContentRules: FormRules = {
content: [{ required: true, message: 'Content is required', trigger: 'blur' }]
};
const stockEditVisible = ref(false);
const stockEditItem = ref<DigitalStockItem | null>(null);
const stockEditFormRef = ref<FormInstance>();
const stockEditForm = reactive({ content: '' });
const stockEditSaving = ref(false);
const stockAddUploadRef = ref<UploadInstance>();
const stockAddPendingFiles = ref<File[]>([]);
const stockEditUploadRef = ref<UploadInstance>();
const stockEditPendingFile = ref<File | null>(null);
const stockEditUploading = ref(false);
const stockEditDownloadingId = ref<string | null>(null);
const stockEditRemovingAttachmentId = ref<string | null>(null);
let suppressPaginationChange = false;
onBeforeMount(() => {
digitalStockStore.resetList();
loadStockList();
});
const stockAttachmentsHint = computed(() =>
buildUploadHint({
allowedMimesCsv: digitalStockAttachmentAccept,
maxFileBytes: digitalStockAttachmentMaxFileBytes,
maxFiles: validationDigitalStockAttachmentsMax,
maxFilesLabel: 'attachments per stock item',
encryptedAtRest: true
})
);
const stockEditAttachments = computed(() => {
const item = stockEditItem.value;
if (!item) {
return [];
}
const current = items.value.find(stockItem => stockItem.id === item.id);
return current?.attachments ?? item.attachments ?? [];
});
const stockEditAttachmentBusy = computed(
() =>
stockEditUploading.value ||
stockEditDownloadingId.value !== null ||
stockEditRemovingAttachmentId.value !== null
);
/**
* Fetches the paginated digital stock list for the current variant.
*
* Query params are built from component refs (`page`, `limit`, `hideSold`), with optional
* `overrides` for programmatic jumps (e.g. reset to page 1 on filter change, go to last
* page after add). Server response reconciles `total`, `page`, and `limit`.
*
* `el-pagination` emits `@change` after programmatic v-model updates (flush: post), not only
* on user clicks. Reconcile after fetch would otherwise trigger a duplicate fetch via
* `@change="loadStockList()"`. We set `suppressPaginationChange` while assigning refs and
* clear it after `nextTick()` so the echo call is ignored at the top of this function.
*
* @param overrides - Partial query merged with current refs; omitted keys fall back to refs.
*/
const loadStockList = async (overrides: Partial<DigitalStockListQuery> = {}): Promise<void> => {
if (suppressPaginationChange) {
return;
}
if (!currentProductId.value || !currentVariantId.value) {
return;
}
const query: DigitalStockListQuery = {
page: overrides.page ?? page.value,
limit: overrides.limit ?? limit.value,
hideSold: overrides.hideSold ?? hideSold.value
};
stockListLoading.value = true;
try {
const data = await digitalStockStore.fetchList(currentProductId.value, currentVariantId.value, query);
suppressPaginationChange = true;
total.value = data.total;
page.value = data.page;
limit.value = data.limit;
await nextTick();
suppressPaginationChange = false;
} finally {
stockListLoading.value = false;
}
};
const formatAttachmentNames = (attachments: DigitalStockAttachment[]): string =>
attachments.map(attachment => attachment.originalFilename).join(', ');
const clearStockAddForm = (): void => {
stockAddForm.content = '';
stockAddPendingFiles.value = [];
stockAddUploadRef.value?.clearFiles();
stockAddFormRef.value?.clearValidate();
};
const submitAddStock = async (): Promise<void> => {
const form = stockAddFormRef.value;
if (!form || !currentProductId.value || !currentVariantId.value) {
return;
}
try {
await form.validate();
} catch {
return;
}
for (const file of stockAddPendingFiles.value) {
const err = validateUpload(file, {
allowedMimesCsv: digitalStockAttachmentAccept,
maxFileBytes: digitalStockAttachmentMaxFileBytes
});
if (err) {
ElMessage.error(`${file.name}: ${err}`);
return;
}
}
if (stockAddPendingFiles.value.length > validationDigitalStockAttachmentsMax) {
ElMessage.error(`A stock item can have at most ${validationDigitalStockAttachmentsMax} attachments`);
return;
}
stockAdding.value = true;
const pendingFiles = [...stockAddPendingFiles.value];
try {
const newItem = await digitalStockStore.addItem(
currentProductId.value,
currentVariantId.value,
stockAddForm.content.trim()
);
const newLastPage = getPaginationLastPage(total.value + 1, limit.value);
await loadStockList({ page: newLastPage });
clearStockAddForm();
if (pendingFiles.length === 0) {
ElMessage.success('Stock item added');
return;
}
const results = await Promise.allSettled(
pendingFiles.map(file =>
digitalStockStore.uploadAttachment(currentProductId.value!, currentVariantId.value!, newItem.id, file)
)
);
const failedCount = results.filter(result => result.status === 'rejected').length;
if (failedCount === 0) {
ElMessage.success('Stock item added and attachments uploaded');
} else if (failedCount === pendingFiles.length) {
ElMessage.warning('Stock item added, but attachments could not be uploaded');
} else {
ElMessage.warning(
`Stock item added, but ${failedCount} of ${pendingFiles.length} attachments could not be uploaded`
);
}
} catch {
ElMessage.error('Could not add stock');
} finally {
stockAdding.value = false;
}
};
const onStockAddFileChange: UploadProps['onChange'] = uploadFile => {
const raw = uploadFile.raw;
if (!raw) {
return;
}
if (stockAddPendingFiles.value.length >= validationDigitalStockAttachmentsMax) {
ElMessage.error(`A stock item can have at most ${validationDigitalStockAttachmentsMax} attachments`);
stockAddUploadRef.value?.clearFiles();
return;
}
const err = validateUpload(raw, {
allowedMimesCsv: digitalStockAttachmentAccept,
maxFileBytes: digitalStockAttachmentMaxFileBytes
});
if (err) {
ElMessage.error(err);
stockAddUploadRef.value?.clearFiles();
return;
}
stockAddPendingFiles.value = [...stockAddPendingFiles.value, raw];
stockAddUploadRef.value?.clearFiles();
};
const removeStockAddPendingFile = (index: number): void => {
stockAddPendingFiles.value = stockAddPendingFiles.value.filter((_, i) => i !== index);
};
const openEditStock = (item: DigitalStockItem): void => {
stockEditItem.value = item;
stockEditForm.content = item.content;
stockEditPendingFile.value = null;
stockEditUploadRef.value?.clearFiles();
stockEditVisible.value = true;
};
const onStockEditFileChange: UploadProps['onChange'] = uploadFile => {
const raw = uploadFile.raw;
if (raw) {
const err = validateUpload(raw, {
allowedMimesCsv: digitalStockAttachmentAccept,
maxFileBytes: digitalStockAttachmentMaxFileBytes
});
if (err) {
ElMessage.error(err);
stockEditUploadRef.value?.clearFiles();
stockEditPendingFile.value = null;
return;
}
}
stockEditPendingFile.value = raw ?? null;
};
const onStockEditFileRemove: UploadProps['onRemove'] = () => {
stockEditPendingFile.value = null;
};
const submitStockEditUpload = async (): Promise<void> => {
const item = stockEditItem.value;
const file = stockEditPendingFile.value;
if (!file || !item || !currentProductId.value || !currentVariantId.value) {
return;
}
const err = validateUpload(file, {
allowedMimesCsv: digitalStockAttachmentAccept,
maxFileBytes: digitalStockAttachmentMaxFileBytes
});
if (err) {
ElMessage.error(err);
return;
}
stockEditUploading.value = true;
try {
await digitalStockStore.uploadAttachment(currentProductId.value, currentVariantId.value, item.id, file);
stockEditPendingFile.value = null;
stockEditUploadRef.value?.clearFiles();
ElMessage.success('Attachment uploaded');
} catch {
ElMessage.error('Could not upload attachment');
} finally {
stockEditUploading.value = false;
}
};
const downloadStockAttachment = async (attachment: DigitalStockAttachment): Promise<void> => {
const item = stockEditItem.value;
if (!item || !currentProductId.value || !currentVariantId.value) {
return;
}
stockEditDownloadingId.value = attachment.id;
try {
await digitalStockStore.downloadAttachment(
currentProductId.value,
currentVariantId.value,
item.id,
attachment.id,
attachment.originalFilename
);
} catch {
ElMessage.error('Could not download attachment');
} finally {
stockEditDownloadingId.value = null;
}
};
const confirmRemoveStockAttachment = async (attachmentId: string): Promise<void> => {
const item = stockEditItem.value;
if (!item || !currentProductId.value || !currentVariantId.value) {
return;
}
try {
await ElMessageBox.confirm('Remove this attachment?', 'Delete attachment', {
type: 'warning',
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel'
});
} catch {
return;
}
stockEditRemovingAttachmentId.value = attachmentId;
try {
await digitalStockStore.removeAttachment(currentProductId.value, currentVariantId.value, item.id, attachmentId);
ElMessage.success('Attachment removed');
} catch {
ElMessage.error('Could not remove attachment');
} finally {
stockEditRemovingAttachmentId.value = null;
}
};
const submitEditStock = async (): Promise<void> => {
const form = stockEditFormRef.value;
const item = stockEditItem.value;
if (!form || !item || !currentProductId.value || !currentVariantId.value) {
return;
}
try {
await form.validate();
} catch {
return;
}
stockEditSaving.value = true;
try {
await digitalStockStore.updateItem(
currentProductId.value,
currentVariantId.value,
item.id,
stockEditForm.content.trim()
);
stockEditVisible.value = false;
ElMessage.success('Stock item updated');
} catch {
ElMessage.error('Could not update stock');
} finally {
stockEditSaving.value = false;
}
};
const confirmRemoveStock = async (item: DigitalStockItem): Promise<void> => {
if (!currentProductId.value || !currentVariantId.value) {
return;
}
try {
await ElMessageBox.confirm('Remove this stock line?', 'Delete stock item', {
type: 'warning',
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel'
});
} catch {
return;
}
stockRemoving.value = true;
try {
await digitalStockStore.removeItem(currentProductId.value, currentVariantId.value, item.id, item.isSold);
const nextTotal = Math.max(0, total.value - 1);
const nextPage = Math.min(page.value, getPaginationLastPage(nextTotal, limit.value));
await loadStockList({ page: nextPage });
ElMessage.success('Stock item removed');
} catch {
ElMessage.error('Could not remove stock');
} finally {
stockRemoving.value = false;
}
};
</script>
<style scoped lang="scss">
.stock-attachments-field {
line-height: 1.45;
:deep(.el-text) {
line-height: inherit;
}
}
.stock-attachment-upload-row {
flex-wrap: wrap;
}
.stock-pending-files,
.stock-edit-attachments {
list-style: none;
}
.stock-edit-attachments__item {
border-bottom: 1px solid var(--el-border-color-lighter);
&:last-child {
border-bottom: none;
}
}
.stock-edit-attachments__actions {
flex-shrink: 0;
:deep(.el-button + .el-button) {
margin-left: 0;
}
}
</style>