881 lines
29 KiB
Vue
881 lines
29 KiB
Vue
<script setup>
|
|
import { usePageTitle } from '../composables/Core/usePageTitle';
|
|
usePageTitle('Create Product');
|
|
|
|
import { ref, computed, onMounted, watch } from 'vue';
|
|
import axios from 'axios';
|
|
import { useNavigate } from '../composables/Core/useNavigate';
|
|
import { useModal } from '../composables/Core/useModal';
|
|
import { useFileUpload } from '../composables/useFileUpload.js';
|
|
import { useProductStore } from '../stores/product';
|
|
|
|
import CardSimple from '../Components/Core/CardSimple.vue';
|
|
import Dropzone from '../Components/Core/Dropzone.vue';
|
|
import FileImage from '../Components/Core/FileImage.vue';
|
|
import StockPhotoPicker from '../Components/Core/StockPhotoPicker.vue';
|
|
import LoadingSpinner from '../Components/LoadingSpinner.vue';
|
|
|
|
const { navigate } = useNavigate();
|
|
const modal = useModal();
|
|
const productStore = useProductStore();
|
|
|
|
const { uploadFile, removeHash, photoHashes, isUploading: isFileUploading, uploadError } = useFileUpload({
|
|
category: 'ProductMarket',
|
|
maxSizeMB: 10,
|
|
});
|
|
|
|
const STEP = {
|
|
PICK: 1,
|
|
NEW_GLOBAL: 2,
|
|
DESCRIPTION: 3,
|
|
ASSIGN_STORES: 4,
|
|
PER_STORE: 5,
|
|
};
|
|
|
|
const step = ref(STEP.PICK);
|
|
const isLoading = ref(false);
|
|
const isSubmitting = ref(false);
|
|
const error = ref(null);
|
|
|
|
// Stores
|
|
const selectableStores = ref([]);
|
|
const noStoresChecked = ref(false);
|
|
|
|
// Flow mode
|
|
const mode = ref('existing'); // 'existing' | 'new'
|
|
|
|
// Selected existing global product
|
|
const pickedProduct = ref(null);
|
|
|
|
// Search
|
|
const searchTerm = ref('');
|
|
const searchResults = ref([]);
|
|
const isSearching = ref(false);
|
|
let searchDebounce = null;
|
|
|
|
// New global product form
|
|
const newProduct = ref({
|
|
name: '',
|
|
description: '',
|
|
category: '',
|
|
subcategory: '',
|
|
price: 1,
|
|
unitname: '',
|
|
available: 1,
|
|
barcode: '',
|
|
});
|
|
const categoryList = ref([]);
|
|
const subcategoryList = ref([]);
|
|
const dropzoneRef = ref(null);
|
|
const dropzoneFiles = ref([]);
|
|
|
|
// Stock photo picker
|
|
const showPhotoPicker = ref(false);
|
|
const onStockPhotoSelected = ({ hashkey, url }) => {
|
|
// Mirror Dropzone's entry shape: preview drives the thumbnail, hashkey is
|
|
// what the submit handler filters on for the photourl payload.
|
|
dropzoneFiles.value.push({ file: null, name: 'stock-photo.jpg', preview: url, hashkey, uploading: false, progress: 100, error: null });
|
|
};
|
|
|
|
// Description override (existing path)
|
|
const overrideDescription = ref('');
|
|
|
|
// Store selection + per-store overrides
|
|
const assignedStoreHashes = ref([]); // array of store hashkeys
|
|
const perStoreOverrides = ref({}); // { [storeHash]: { price, available } }
|
|
|
|
// ---------------- Bootstrap ----------------
|
|
onMounted(async () => {
|
|
isLoading.value = true;
|
|
await fetchSelectableStores();
|
|
isLoading.value = false;
|
|
noStoresChecked.value = true;
|
|
|
|
if (selectableStores.value.length === 0) {
|
|
modal.yesNoModal({
|
|
title: 'No store found',
|
|
body: 'You need to create a store before you can add a product. Create one now?',
|
|
yesText: 'Create Store',
|
|
onYes: () => navigate({ page: 'CreateStore' }),
|
|
noText: 'Cancel',
|
|
onNo: () => navigate({ page: 'Home' }),
|
|
});
|
|
} else {
|
|
loadCategories();
|
|
}
|
|
});
|
|
|
|
const fetchSelectableStores = async () => {
|
|
try {
|
|
const { data } = await axios.post('/Admin/Stores/Selectable');
|
|
if (data && data.success) selectableStores.value = data.data || [];
|
|
} catch (e) {
|
|
console.error('Failed to load stores', e);
|
|
}
|
|
};
|
|
|
|
const loadCategories = async () => {
|
|
try {
|
|
const { data } = await axios.post('/Products/New/Category/Datalist', {});
|
|
if (Array.isArray(data)) {
|
|
categoryList.value = data.map((item) => ({
|
|
value: typeof item === 'string' ? item : item[0],
|
|
label: typeof item === 'string' ? item : item[1] || item[0],
|
|
}));
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load categories', e);
|
|
}
|
|
};
|
|
|
|
const loadSubcategories = async () => {
|
|
if (!newProduct.value.category) {
|
|
subcategoryList.value = [];
|
|
return;
|
|
}
|
|
try {
|
|
const { data } = await axios.post('/Products/New/SubCategory/Datalist', {
|
|
category: newProduct.value.category,
|
|
});
|
|
if (Array.isArray(data)) {
|
|
subcategoryList.value = data.map((item) => ({
|
|
value: typeof item === 'string' ? item : item[0],
|
|
label: typeof item === 'string' ? item : item[1] || item[0],
|
|
}));
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load subcategories', e);
|
|
}
|
|
};
|
|
|
|
watch(() => newProduct.value.category, loadSubcategories);
|
|
|
|
// ---------------- Search ----------------
|
|
watch(searchTerm, (val) => {
|
|
clearTimeout(searchDebounce);
|
|
if (!val || val.trim().length < 2) {
|
|
searchResults.value = [];
|
|
return;
|
|
}
|
|
searchDebounce = setTimeout(runSearch, 300);
|
|
});
|
|
|
|
const runSearch = async () => {
|
|
if (!searchTerm.value || searchTerm.value.trim().length < 2) return;
|
|
isSearching.value = true;
|
|
try {
|
|
const { data } = await axios.post('/Products/Admin/FuzzySearch', {
|
|
name: searchTerm.value.trim(),
|
|
});
|
|
searchResults.value = data && data.success && Array.isArray(data.data) ? data.data : [];
|
|
} catch (e) {
|
|
console.error('Search failed', e);
|
|
searchResults.value = [];
|
|
} finally {
|
|
isSearching.value = false;
|
|
}
|
|
};
|
|
|
|
// ---------------- Dropzone ----------------
|
|
watch(
|
|
() => dropzoneFiles.value,
|
|
async (newFiles) => {
|
|
const filesToUpload = newFiles.filter((f) => !f.uploading && !f.hashkey && !f.error);
|
|
for (const fileObj of filesToUpload) {
|
|
const idx = newFiles.indexOf(fileObj);
|
|
if (idx === -1) continue;
|
|
dropzoneRef.value.setFileStatus(idx, { uploading: true, progress: 30 });
|
|
const result = await uploadFile(fileObj.file);
|
|
if (result && result.hashkey) {
|
|
dropzoneRef.value.setFileStatus(idx, { uploading: false, progress: 100, hashkey: result.hashkey });
|
|
if (error.value && error.value.startsWith('Photo upload failed:')) {
|
|
error.value = null;
|
|
}
|
|
} else {
|
|
const msg = uploadError.value || 'Upload failed';
|
|
dropzoneRef.value.setFileStatus(idx, { uploading: false, progress: 0, error: msg });
|
|
error.value = `Photo upload failed: ${msg}`;
|
|
}
|
|
}
|
|
},
|
|
{ deep: true }
|
|
);
|
|
|
|
const handlePhotoRemoved = (hashkey) => {
|
|
if (hashkey) removeHash(hashkey);
|
|
};
|
|
|
|
// ---------------- Step transitions ----------------
|
|
const selectExistingProduct = (product) => {
|
|
pickedProduct.value = product;
|
|
mode.value = 'existing';
|
|
overrideDescription.value = product.description || '';
|
|
step.value = STEP.DESCRIPTION;
|
|
error.value = null;
|
|
};
|
|
|
|
const startNewProduct = () => {
|
|
mode.value = 'new';
|
|
pickedProduct.value = null;
|
|
step.value = STEP.NEW_GLOBAL;
|
|
error.value = null;
|
|
};
|
|
|
|
const validateNewProduct = () => {
|
|
const p = newProduct.value;
|
|
if (!p.name) return 'Product name is required';
|
|
if (!p.description) return 'Description is required';
|
|
if (!p.category) return 'Category is required';
|
|
if (!p.subcategory) return 'Subcategory is required';
|
|
if (!p.price || parseFloat(p.price) <= 0) return 'Valid price is required';
|
|
if (!p.unitname) return 'Unit name is required';
|
|
const hasPhoto = dropzoneFiles.value.some((f) => !!f.hashkey);
|
|
if (!hasPhoto) return 'At least one photo is required';
|
|
if (p.barcode && !/^\d{12}$/.test(p.barcode)) return 'Barcode must be exactly 12 digits';
|
|
return null;
|
|
};
|
|
|
|
const advanceFromNewGlobal = () => {
|
|
const err = validateNewProduct();
|
|
if (err) {
|
|
error.value = err;
|
|
return;
|
|
}
|
|
error.value = null;
|
|
step.value = STEP.ASSIGN_STORES;
|
|
};
|
|
|
|
const advanceFromDescription = () => {
|
|
if (!overrideDescription.value || !overrideDescription.value.trim()) {
|
|
error.value = 'Description is required';
|
|
return;
|
|
}
|
|
error.value = null;
|
|
step.value = STEP.ASSIGN_STORES;
|
|
};
|
|
|
|
const toggleStore = (hash) => {
|
|
const i = assignedStoreHashes.value.indexOf(hash);
|
|
if (i >= 0) {
|
|
assignedStoreHashes.value.splice(i, 1);
|
|
delete perStoreOverrides.value[hash];
|
|
} else {
|
|
assignedStoreHashes.value.push(hash);
|
|
}
|
|
};
|
|
|
|
const advanceFromStores = () => {
|
|
if (assignedStoreHashes.value.length === 0) {
|
|
error.value = 'Select at least one store.';
|
|
return;
|
|
}
|
|
// Seed per-store defaults from the global product.
|
|
const defaultPrice = mode.value === 'new'
|
|
? parseFloat(newProduct.value.price) || 0
|
|
: parseFloat(pickedProduct.value?.price) || 0;
|
|
const defaultAvailable = mode.value === 'new'
|
|
? parseInt(newProduct.value.available) || 1
|
|
: 1;
|
|
|
|
for (const hash of assignedStoreHashes.value) {
|
|
if (!perStoreOverrides.value[hash]) {
|
|
perStoreOverrides.value[hash] = {
|
|
price: defaultPrice,
|
|
available: defaultAvailable,
|
|
};
|
|
}
|
|
}
|
|
error.value = null;
|
|
step.value = STEP.PER_STORE;
|
|
};
|
|
|
|
const goBack = () => {
|
|
error.value = null;
|
|
if (step.value === STEP.PER_STORE) {
|
|
step.value = STEP.ASSIGN_STORES;
|
|
} else if (step.value === STEP.ASSIGN_STORES) {
|
|
step.value = mode.value === 'new' ? STEP.NEW_GLOBAL : STEP.DESCRIPTION;
|
|
} else if (step.value === STEP.DESCRIPTION || step.value === STEP.NEW_GLOBAL) {
|
|
step.value = STEP.PICK;
|
|
}
|
|
};
|
|
|
|
// ---------------- Submit ----------------
|
|
const submit = async () => {
|
|
if (isSubmitting.value) return;
|
|
|
|
for (const hash of assignedStoreHashes.value) {
|
|
const ov = perStoreOverrides.value[hash];
|
|
if (!ov || !ov.price || parseFloat(ov.price) <= 0) {
|
|
error.value = 'Each assigned store needs a valid price.';
|
|
return;
|
|
}
|
|
if (ov.available === '' || ov.available === null || parseInt(ov.available) < 0) {
|
|
error.value = 'Each assigned store needs a valid availability.';
|
|
return;
|
|
}
|
|
}
|
|
|
|
isSubmitting.value = true;
|
|
error.value = null;
|
|
|
|
try {
|
|
let productHash = pickedProduct.value?.hashkey;
|
|
let storesToAssign = [...assignedStoreHashes.value];
|
|
|
|
if (mode.value === 'new') {
|
|
const firstStore = storesToAssign[0];
|
|
const photoHashList = dropzoneFiles.value.filter((f) => f.hashkey).map((f) => f.hashkey);
|
|
const { data } = await axios.post('/Products/Admin/New/', {
|
|
NewProductName: newProduct.value.name,
|
|
NewProductDescription: newProduct.value.description,
|
|
NewProductCategory: newProduct.value.category,
|
|
NewProductSubCategory: newProduct.value.subcategory,
|
|
NewProductPrice: parseFloat(newProduct.value.price),
|
|
NewProductUnitName: newProduct.value.unitname,
|
|
NewProductAvailable: parseInt(newProduct.value.available),
|
|
NewProductBarcode: newProduct.value.barcode,
|
|
TargetStore: firstStore,
|
|
photourl: photoHashList,
|
|
});
|
|
if (!data || !data.success) {
|
|
error.value = data?.message || 'Failed to create global product.';
|
|
isSubmitting.value = false;
|
|
return;
|
|
}
|
|
productHash = data.data?.hashkey || data.hashkey;
|
|
}
|
|
|
|
const descriptionOverride =
|
|
mode.value === 'new' ? newProduct.value.description : overrideDescription.value;
|
|
|
|
const failures = [];
|
|
for (const hash of storesToAssign) {
|
|
const ov = perStoreOverrides.value[hash];
|
|
try {
|
|
await axios.post('/Products/AssignToStore/', {
|
|
target: productHash,
|
|
TargetStore: hash,
|
|
price: parseFloat(ov.price),
|
|
available: parseInt(ov.available),
|
|
description: descriptionOverride,
|
|
});
|
|
} catch (e) {
|
|
const storeName = selectableStores.value.find((s) => s.hashkey === hash)?.name || hash;
|
|
failures.push(storeName);
|
|
}
|
|
}
|
|
|
|
if (failures.length === storesToAssign.length) {
|
|
error.value = 'Failed to assign product to any selected store.';
|
|
isSubmitting.value = false;
|
|
return;
|
|
}
|
|
|
|
productStore.fetchProducts();
|
|
|
|
modal.quickDismiss({
|
|
title: 'Product Listed',
|
|
body:
|
|
failures.length > 0
|
|
? `Listed in ${storesToAssign.length - failures.length} store(s). Failed for: ${failures.join(', ')}.`
|
|
: `Your product is now listed in ${storesToAssign.length} store(s).`,
|
|
onShown: () => {
|
|
setTimeout(() => navigate({ page: 'ManageProductsAdmin' }), 1200);
|
|
},
|
|
});
|
|
} catch (e) {
|
|
console.error('Submit failed', e);
|
|
error.value = e.response?.data?.message || 'Failed to create product.';
|
|
} finally {
|
|
isSubmitting.value = false;
|
|
}
|
|
};
|
|
|
|
// ---------------- Helpers ----------------
|
|
const stepTitle = computed(() => {
|
|
switch (step.value) {
|
|
case STEP.PICK:
|
|
return 'Find your product';
|
|
case STEP.NEW_GLOBAL:
|
|
return 'Create new product';
|
|
case STEP.DESCRIPTION:
|
|
return 'Describe your product';
|
|
case STEP.ASSIGN_STORES:
|
|
return 'Assign to stores';
|
|
case STEP.PER_STORE:
|
|
return 'Price & availability per store';
|
|
default:
|
|
return '';
|
|
}
|
|
});
|
|
|
|
const stepNumber = computed(() => {
|
|
if (step.value === STEP.PICK) return 1;
|
|
if (step.value === STEP.NEW_GLOBAL || step.value === STEP.DESCRIPTION) return 2;
|
|
if (step.value === STEP.ASSIGN_STORES) return 3;
|
|
if (step.value === STEP.PER_STORE) return 4;
|
|
return 1;
|
|
});
|
|
|
|
const storeName = (hash) =>
|
|
selectableStores.value.find((s) => s.hashkey === hash)?.name || hash;
|
|
|
|
const globalDefaultPrice = computed(() =>
|
|
mode.value === 'new'
|
|
? parseFloat(newProduct.value.price) || 0
|
|
: parseFloat(pickedProduct.value?.price) || 0
|
|
);
|
|
</script>
|
|
|
|
<template>
|
|
<div class="csop-page">
|
|
<div class="tf-container mt-4 mb-3 text-center">
|
|
<h1 class="fw_8 page-title">Add a Product to Your Store</h1>
|
|
<p class="text-muted small mb-0">Step {{ stepNumber }} of 4 — {{ stepTitle }}</p>
|
|
</div>
|
|
|
|
<div v-if="error" class="tf-container mb-3">
|
|
<div class="glass-alert alert-danger">
|
|
<i class="fas fa-exclamation-triangle me-2"></i>
|
|
{{ error }}
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="isLoading" class="tf-container text-center py-5">
|
|
<LoadingSpinner />
|
|
</div>
|
|
|
|
<div v-else-if="noStoresChecked && selectableStores.length === 0" class="tf-container text-center py-5">
|
|
<p class="text-muted">You need a store before adding products.</p>
|
|
<button class="btn btn-primary rounded-pill px-4 mt-2" @click="navigate({ page: 'CreateStore' })">
|
|
Create a Store
|
|
</button>
|
|
</div>
|
|
|
|
<div v-else class="tf-container">
|
|
<!-- STEP 1: Pick existing or new -->
|
|
<div v-if="step === STEP.PICK">
|
|
<CardSimple title="Search for your product" cardStyle="height: auto">
|
|
<p class="text-muted small">
|
|
Many products are already in our system. Type a product name to see if yours exists.
|
|
</p>
|
|
<div class="premium-input-group mb-3">
|
|
<input
|
|
type="text"
|
|
v-model="searchTerm"
|
|
class="premium-input"
|
|
placeholder="e.g., Premium Rice"
|
|
autofocus
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="isSearching" class="text-center py-3">
|
|
<LoadingSpinner size="small" />
|
|
</div>
|
|
|
|
<div v-else-if="searchResults.length > 0" class="results-list">
|
|
<div
|
|
v-for="m in searchResults"
|
|
:key="m.hashkey"
|
|
class="result-row"
|
|
@click="selectExistingProduct(m)"
|
|
>
|
|
<FileImage
|
|
:src="m.photourl && m.photourl[0] ? m.photourl[0] : ''"
|
|
:alt="m.name"
|
|
class="result-thumb"
|
|
fallback="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/146710fe9ece.bin"
|
|
/>
|
|
<div class="result-info">
|
|
<div class="fw_6">{{ m.name }}</div>
|
|
<div class="text-muted smallest">
|
|
<span v-if="m.category">{{ m.category }}<span v-if="m.subcategory"> · {{ m.subcategory }}</span> · </span>
|
|
<span>₱{{ m.price }} / {{ m.unitname }}</span>
|
|
</div>
|
|
</div>
|
|
<i class="fas fa-chevron-right text-muted"></i>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-else-if="searchTerm && searchTerm.length >= 2"
|
|
class="text-muted smallest text-center py-3"
|
|
>
|
|
No matches yet. You can create a new product below.
|
|
</div>
|
|
</CardSimple>
|
|
|
|
<div class="text-center mt-4">
|
|
<button class="btn btn-outline-primary rounded-pill px-4" @click="startNewProduct">
|
|
<i class="fas fa-plus me-2"></i> My product is not listed — Create new
|
|
</button>
|
|
</div>
|
|
|
|
<div class="text-center mt-3">
|
|
<button class="btn-text" @click="navigate({ page: 'Home' })">
|
|
<i class="fas fa-chevron-left me-2"></i> Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- STEP 2 (NEW): Full new global product form -->
|
|
<div v-else-if="step === STEP.NEW_GLOBAL">
|
|
<CardSimple title="New product details">
|
|
<div class="premium-input-group mb-3">
|
|
<label class="form-label">Product Name <span class="required">*</span></label>
|
|
<input type="text" v-model="newProduct.name" class="premium-input" placeholder="e.g., Premium Rice" />
|
|
</div>
|
|
<div class="premium-input-group mb-3">
|
|
<label class="form-label">Description <span class="required">*</span></label>
|
|
<textarea v-model="newProduct.description" class="premium-input" rows="3" placeholder="Describe your product..."></textarea>
|
|
</div>
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<div class="premium-input-group mb-3">
|
|
<label class="form-label">Category <span class="required">*</span></label>
|
|
<select v-model="newProduct.category" class="premium-select">
|
|
<option value="" disabled>Select Category</option>
|
|
<option v-for="cat in categoryList" :key="cat.value" :value="cat.value">{{ cat.label }}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div class="premium-input-group mb-3">
|
|
<label class="form-label">Subcategory <span class="required">*</span></label>
|
|
<select v-model="newProduct.subcategory" class="premium-select" :disabled="subcategoryList.length === 0">
|
|
<option value="" disabled>Select Subcategory</option>
|
|
<option v-for="sub in subcategoryList" :key="sub.value" :value="sub.value">{{ sub.label }}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="row">
|
|
<div class="col-md-4">
|
|
<div class="premium-input-group mb-3">
|
|
<label class="form-label">Base Price (PHP) <span class="required">*</span></label>
|
|
<input type="number" v-model="newProduct.price" class="premium-input" min="1" step="0.01" />
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<div class="premium-input-group mb-3">
|
|
<label class="form-label">Unit <span class="required">*</span></label>
|
|
<input type="text" v-model="newProduct.unitname" class="premium-input" placeholder="e.g., 25kg" />
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<div class="premium-input-group mb-3">
|
|
<label class="form-label">Default Available <span class="required">*</span></label>
|
|
<input type="number" v-model="newProduct.available" class="premium-input" min="1" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="premium-input-group mb-3">
|
|
<label class="form-label">Barcode (12 digits)</label>
|
|
<input type="text" v-model="newProduct.barcode" class="premium-input" maxlength="12" placeholder="Optional" />
|
|
</div>
|
|
<div class="premium-input-group">
|
|
<label class="form-label">Product Photos <span class="required">*</span></label>
|
|
<button type="button" class="btn btn-outline-secondary btn-sm rounded-pill mb-2"
|
|
@click="showPhotoPicker = true">
|
|
<i class="fas fa-images me-1"></i> Search Stock Photos
|
|
</button>
|
|
<Dropzone ref="dropzoneRef" v-model:files="dropzoneFiles" @removed="handlePhotoRemoved" />
|
|
<StockPhotoPicker v-model="showPhotoPicker" :product-name="newProduct.name"
|
|
@photo-selected="onStockPhotoSelected" />
|
|
</div>
|
|
</CardSimple>
|
|
|
|
<div class="nav-bar mt-4">
|
|
<button class="btn-text" @click="goBack">
|
|
<i class="fas fa-chevron-left me-2"></i> Back
|
|
</button>
|
|
<button
|
|
class="btn btn-primary rounded-pill px-4"
|
|
:disabled="isFileUploading"
|
|
@click="advanceFromNewGlobal"
|
|
>
|
|
Next <i class="fas fa-chevron-right ms-2"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- STEP 2 (EXISTING): Description override -->
|
|
<div v-else-if="step === STEP.DESCRIPTION">
|
|
<CardSimple :title="pickedProduct?.name || 'Selected product'">
|
|
<div class="picked-preview mb-3">
|
|
<FileImage
|
|
:src="pickedProduct?.photourl && pickedProduct.photourl[0] ? pickedProduct.photourl[0] : ''"
|
|
:alt="pickedProduct?.name"
|
|
class="picked-thumb"
|
|
fallback="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/146710fe9ece.bin"
|
|
/>
|
|
<div class="text-muted small">
|
|
<span v-if="pickedProduct?.category">{{ pickedProduct.category }}<span v-if="pickedProduct.subcategory"> · {{ pickedProduct.subcategory }}</span> · </span>
|
|
<span>₱{{ pickedProduct?.price }} / {{ pickedProduct?.unitname }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="premium-input-group">
|
|
<label class="form-label">Description for your listing <span class="required">*</span></label>
|
|
<textarea
|
|
v-model="overrideDescription"
|
|
class="premium-input"
|
|
rows="5"
|
|
placeholder="Describe how this product appears in your store..."
|
|
></textarea>
|
|
<p class="smallest text-muted mt-2">
|
|
<i class="fas fa-info-circle me-1"></i>
|
|
This description will be shown for this product across the stores you assign it to.
|
|
</p>
|
|
</div>
|
|
</CardSimple>
|
|
|
|
<div class="nav-bar mt-4">
|
|
<button class="btn-text" @click="goBack">
|
|
<i class="fas fa-chevron-left me-2"></i> Back
|
|
</button>
|
|
<button class="btn btn-primary rounded-pill px-4" @click="advanceFromDescription">
|
|
Next <i class="fas fa-chevron-right ms-2"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- STEP 3: Assign to stores -->
|
|
<div v-else-if="step === STEP.ASSIGN_STORES">
|
|
<CardSimple title="Which of your stores should sell this?">
|
|
<p class="text-muted small">Pick one or more stores.</p>
|
|
<div class="store-list">
|
|
<label
|
|
v-for="s in selectableStores"
|
|
:key="s.hashkey"
|
|
class="store-row"
|
|
:class="{ 'is-selected': assignedStoreHashes.includes(s.hashkey) }"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
:checked="assignedStoreHashes.includes(s.hashkey)"
|
|
@change="toggleStore(s.hashkey)"
|
|
/>
|
|
<div>
|
|
<div class="fw_6">{{ s.name }}</div>
|
|
<div class="text-muted smallest">{{ s.role }}<span v-if="s.category"> · {{ s.category }}</span></div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</CardSimple>
|
|
|
|
<div class="nav-bar mt-4">
|
|
<button class="btn-text" @click="goBack">
|
|
<i class="fas fa-chevron-left me-2"></i> Back
|
|
</button>
|
|
<button class="btn btn-primary rounded-pill px-4" @click="advanceFromStores">
|
|
Next <i class="fas fa-chevron-right ms-2"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- STEP 4: Per-store overrides -->
|
|
<div v-else-if="step === STEP.PER_STORE">
|
|
<CardSimple title="Set price and stock for each store">
|
|
<p class="text-muted small">
|
|
Defaults come from the product's base price (₱{{ globalDefaultPrice }}). Adjust per store as needed.
|
|
</p>
|
|
<div class="per-store-list">
|
|
<div
|
|
v-for="hash in assignedStoreHashes"
|
|
:key="hash"
|
|
class="per-store-row"
|
|
>
|
|
<div class="per-store-name">
|
|
<i class="fas fa-store me-2 text-muted"></i>
|
|
<span class="fw_6">{{ storeName(hash) }}</span>
|
|
</div>
|
|
<div class="per-store-fields">
|
|
<div class="premium-input-group">
|
|
<label class="form-label smallest">Price (PHP)</label>
|
|
<input
|
|
type="number"
|
|
class="premium-input"
|
|
min="1"
|
|
step="0.01"
|
|
v-model="perStoreOverrides[hash].price"
|
|
/>
|
|
</div>
|
|
<div class="premium-input-group">
|
|
<label class="form-label smallest">Available</label>
|
|
<input
|
|
type="number"
|
|
class="premium-input"
|
|
min="0"
|
|
v-model="perStoreOverrides[hash].available"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardSimple>
|
|
|
|
<div class="nav-bar mt-4">
|
|
<button class="btn-text" @click="goBack">
|
|
<i class="fas fa-chevron-left me-2"></i> Back
|
|
</button>
|
|
<AnimatedButton
|
|
@click="submit"
|
|
:disabled="isSubmitting"
|
|
:loading="isSubmitting"
|
|
btnClass="btn-premium-launch"
|
|
>
|
|
List Product
|
|
</AnimatedButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.csop-page {
|
|
padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px));
|
|
}
|
|
|
|
.page-title {
|
|
font-family: 'Outfit', sans-serif;
|
|
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
|
|
-webkit-background-clip: text;
|
|
background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
letter-spacing: -0.02em;
|
|
}
|
|
|
|
.premium-input-group { display: flex; flex-direction: column; }
|
|
.form-label { font-weight: 600; font-size: 0.9rem; color: #475569; margin-bottom: 6px; }
|
|
.required { color: #ef4444; margin-left: 4px; }
|
|
|
|
.premium-input, .premium-select {
|
|
padding: 12px 16px;
|
|
border-radius: 12px;
|
|
border: 1px solid #e2e8f0;
|
|
background: #fff;
|
|
font-size: 0.95rem;
|
|
outline: none;
|
|
transition: all 0.2s;
|
|
}
|
|
.premium-input:focus, .premium-select:focus {
|
|
border-color: #3b82f6;
|
|
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
|
|
}
|
|
.premium-select {
|
|
appearance: none;
|
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2364748b'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
|
|
background-repeat: no-repeat;
|
|
background-position: right 12px center;
|
|
background-size: 16px;
|
|
}
|
|
|
|
.glass-alert {
|
|
padding: 14px 18px;
|
|
border-radius: 14px;
|
|
font-weight: 500;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
.alert-danger {
|
|
background: rgba(239, 68, 68, 0.1);
|
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
|
color: #b91c1c;
|
|
}
|
|
|
|
.results-list { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
|
|
.result-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 10px 12px;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 12px;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
.result-row:hover { border-color: #93c5fd; background: rgba(59, 130, 246, 0.04); }
|
|
.result-thumb {
|
|
width: 48px; height: 48px; border-radius: 10px;
|
|
object-fit: cover; flex-shrink: 0;
|
|
}
|
|
.result-info { flex: 1; min-width: 0; }
|
|
|
|
.picked-preview { display: flex; align-items: center; gap: 12px; }
|
|
.picked-thumb { width: 72px; height: 72px; border-radius: 12px; object-fit: cover; }
|
|
|
|
.store-list { display: flex; flex-direction: column; gap: 8px; }
|
|
.store-row {
|
|
display: flex; align-items: center; gap: 12px;
|
|
padding: 12px 14px;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 12px;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
.store-row:hover { border-color: #93c5fd; }
|
|
.store-row.is-selected { border-color: #2563eb; background: rgba(37, 99, 235, 0.06); }
|
|
|
|
.per-store-list { display: flex; flex-direction: column; gap: 12px; }
|
|
.per-store-row {
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 14px;
|
|
padding: 12px 14px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
}
|
|
.per-store-name { display: flex; align-items: center; }
|
|
.per-store-fields {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 10px;
|
|
}
|
|
|
|
.nav-bar {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.btn-text {
|
|
background: transparent;
|
|
border: none;
|
|
color: #64748b;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
}
|
|
.btn-text:hover { color: #1e293b; }
|
|
|
|
.btn-premium-launch {
|
|
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
|
color: white;
|
|
border: none;
|
|
padding: 12px 32px;
|
|
border-radius: 14px;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
box-shadow: 0 10px 15px -3px rgba(37, 99, 235, 0.3);
|
|
}
|
|
.btn-premium-launch:disabled { background: #cbd5e1; cursor: not-allowed; box-shadow: none; }
|
|
|
|
:global(.dark-mode) .premium-input,
|
|
:global(.dark-mode) .premium-select {
|
|
background: #1e293b;
|
|
border-color: #334155;
|
|
color: #f8fafc;
|
|
}
|
|
:global(.dark-mode) .form-label { color: #94a3b8; }
|
|
:global(.dark-mode) .result-row,
|
|
:global(.dark-mode) .store-row,
|
|
:global(.dark-mode) .per-store-row { border-color: #334155; }
|
|
:global(.dark-mode) .page-title {
|
|
background: linear-gradient(135deg, #f8fafc 0%, #cbd5e1 100%);
|
|
-webkit-background-clip: text;
|
|
background-clip: text;
|
|
}
|
|
</style>
|