fix: add location selectors (country/city/district) to product add/edit form

The admin product form now has cascading dropdowns for Country → City → District
that filter categories by location. Previously there was no way to select location
when adding a product — only a static tag display on edit.

- catalogProduct.js: replaced static location tags with 3 cascading selects
- catalog.js: pass locations data, init JS for cascading selects + category filtering
- catalog route: pass locations array to renderCatalog
- style.css: added .pf-location-selects styling for the dropdown row
This commit is contained in:
NW
2026-06-24 22:57:38 +01:00
parent 5a9155613e
commit bbf49ec546
4 changed files with 118 additions and 20 deletions

View File

@@ -444,6 +444,22 @@ pre { font-size: 0.8rem; white-space: pre-wrap; word-break: break-all; max-width
flex-wrap: wrap;
}
.pf-location-selects {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.pf-location-selects select {
flex: 1;
min-width: 120px;
padding: 0.4rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.85rem;
background: #fff;
}
.pf-loc-tag {
display: inline-block;
padding: 0.2rem 0.5rem;

View File

@@ -29,7 +29,7 @@ router.get('/', async (req, res) => {
id: l.id, cats: (cl[l.id]||[]).map(c=>({...c, subs: sc[c.id]||[]}))
};
}
res.send(renderCatalog(tree, products, { loc, cat, sub }, categories, subcategories, msg||'', msg_type||'info'));
res.send(renderCatalog(tree, products, { loc, cat, sub }, categories, subcategories, locations, msg||'', msg_type||'info'));
});
router.post('/locations', async (req, res) => {

View File

@@ -1,13 +1,14 @@
import { layout, flash } from './layout.js';
import { renderProductEditForm } from './catalogProduct.js';
export function renderCatalog(tree, products, filter, categories, subcategories, msg, msgType) {
export function renderCatalog(tree, products, filter, categories, subcategories, locations, msg, msgType) {
const { loc, cat, sub } = filter;
const catOptions = categories.map(c => `<option value="${c.id}">${esc(c.name)}</option>`).join('');
const subcatJson = JSON.stringify(subcategories.map(s => ({ id: s.id, name: s.name, category_id: s.category_id })));
const addFormHtml = renderProductEditForm('/catalog/products', catOptions, subcatJson)
const locJson = JSON.stringify(locations || []);
const addFormHtml = renderProductEditForm('/catalog/products', catOptions, subcatJson, locations)
.replace(/`/g, '\\`').replace(/\$/g, '\\$');
const editFormHtml = renderProductEditForm('/catalog/products/__ID__/edit', catOptions, subcatJson)
const editFormHtml = renderProductEditForm('/catalog/products/__ID__/edit', catOptions, subcatJson, locations)
.replace(/`/g, '\\`').replace(/\$/g, '\\$');
let treeHtml = '<div class="tree-node"><div class="tree-toggle" data-all="1"><span class="arrow">▶</span> <strong>All Products</strong><span class="tree-count">(' + products.length + ')</span></div></div>';
@@ -67,15 +68,93 @@ export function renderCatalog(tree, products, filter, categories, subcategories,
<div id="product-modal" class="modal" style="display:none"><div class="modal-content" id="modal-body"></div></div>
<script>
const subcats = ${subcatJson};
const allLocations = ${locJson};
const addFormTpl = \`${addFormHtml}\`;
const editFormTpl = \`${editFormHtml}\`;
function initLocationSelects(selectedLocId) {
const cs = document.getElementById('loc-country');
const ci = document.getElementById('loc-city');
const di = document.getElementById('loc-district');
if (!cs) return;
const countries = [...new Set(allLocations.map(l => l.country))].sort();
cs.innerHTML = '<option value="">-- Country --</option>' + countries.map(c => '<option value="'+escHtml(c)+'">'+escHtml(c)+'</option>').join('');
ci.innerHTML = '<option value="">-- City --</option>'; ci.disabled = true;
di.innerHTML = '<option value="">-- District --</option>';
if (selectedLocId) {
const sel = allLocations.find(l => l.id == selectedLocId);
if (sel) {
cs.value = sel.country;
locOnCountryChange();
ci.value = sel.city;
locOnCityChange();
di.value = sel.id;
}
}
}
function locOnCountryChange() {
const cs = document.getElementById('loc-country');
const ci = document.getElementById('loc-city');
const di = document.getElementById('loc-district');
const country = cs.value;
ci.innerHTML = '<option value="">-- City --</option>';
di.innerHTML = '<option value="">-- District --</option>';
if (!country) { ci.disabled = true; return; }
ci.disabled = false;
const cities = [...new Set(allLocations.filter(l => l.country === country).map(l => l.city))].sort();
ci.innerHTML = '<option value="">-- City --</option>' + cities.map(c => '<option value="'+escHtml(c)+'">'+escHtml(c)+'</option>').join('');
filterCategoriesByLocation();
}
function locOnCityChange() {
const cs = document.getElementById('loc-country');
const ci = document.getElementById('loc-city');
const di = document.getElementById('loc-district');
const country = cs.value;
const city = ci.value;
di.innerHTML = '<option value="">-- District --</option>';
if (!city) { filterCategoriesByLocation(); return; }
const locs = allLocations.filter(l => l.country === country && l.city === city);
di.innerHTML = '<option value="">-- District --</option>' + locs.map(l => '<option value="'+l.id+'">'+escHtml(l.district || l.city)+'</option>').join('');
filterCategoriesByLocation();
}
function locOnDistrictChange() {
filterCategoriesByLocation();
}
function filterCategoriesByLocation() {
const di = document.getElementById('loc-district');
const catSel = document.getElementById('pf-category');
if (!catSel) return;
const locId = di ? di.value : '';
const allCats = catSel.querySelectorAll('option');
if (!locId) {
allCats.forEach(o => o.style.display = '');
return;
}
const loc = allLocations.find(l => l.id == locId);
allCats.forEach(o => {
if (!o.value) { o.style.display = ''; return; }
const cat = ${JSON.stringify(categories)}.find(c => c.id == o.value);
o.style.display = (cat && cat.location_id == locId) ? '' : 'none';
});
catSel.value = '';
updateSubcats('');
}
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
document.querySelectorAll('.tree-toggle').forEach(el=>{el.addEventListener('click',()=>{
const ch=el.nextElementSibling; if(!ch||!ch.classList.contains('tree-children')) return;
ch.classList.toggle('open'); el.querySelector('.arrow').classList.toggle('open');
const loc=el.dataset.loc, cat=el.dataset.cat, sub=el.dataset.sub, all=el.dataset.all;
if(loc||cat||sub||all){ let u='/catalog?'; if(loc) u+='loc='+loc; if(cat) u+='cat='+cat; if(sub) u+='sub='+sub; location.href=u; }
})});
function openAdd(){ document.getElementById('modal-body').innerHTML=addFormTpl; const loc=document.getElementById('pf-location'); if(loc)loc.style.display='none'; document.getElementById('product-modal').style.display='flex'; }
function openAdd(){ document.getElementById('modal-body').innerHTML=addFormTpl; initLocationSelects(null); document.getElementById('product-modal').style.display='flex'; }
async function openEdit(id){ const r=await fetch('/catalog/products/'+id+'/json'); const p=await r.json();
document.getElementById('modal-body').innerHTML=editFormTpl.replace('/__ID__/','/'+p.id+'/'); fillEditForm(p); document.getElementById('product-modal').style.display='flex'; }
function fillEditForm(p){ const f=document.getElementById('product-modal').querySelector('form'); if(!f)return;
@@ -83,13 +162,9 @@ function fillEditForm(p){ const f=document.getElementById('product-modal').query
f.querySelector('[name=quantity_in_stock]').value=p.quantity_in_stock||''; f.querySelector('[name=description]').value=p.description||'';
f.querySelector('[name=photo_url]').value=p.photo_url||''; f.querySelector('[name=hidden_photo_url]').value=p.hidden_photo_url||'';
f.querySelector('[name=hidden_coordinates]').value=p.hidden_coordinates||''; f.querySelector('[name=hidden_description]').value=p.hidden_description||'';
f.querySelector('[name=private_data]').value=p.private_data||''; f.querySelector('[name=category_id]').value=p.category_id||'';
updateSubcats(p.category_id,p.subcategory_id);
const loc=document.getElementById('pf-location');
if(loc&&(p.country||p.city||p.district)){
loc.style.display=''; document.getElementById('pf-country').textContent=p.country||'';
document.getElementById('pf-city').textContent=p.city||''; document.getElementById('pf-district').textContent=p.district||'';
} else if(loc){loc.style.display='none';}
f.querySelector('[name=private_data]').value=p.private_data||'';
if(p.category_id) { f.querySelector('[name=category_id]').value=p.category_id; updateSubcats(p.category_id, p.subcategory_id); }
initLocationSelects(p.location_id);
}
function updateSubcats(catId,selSub){ const ss=document.getElementById('product-modal').querySelector('[name=subcategory_id]'); if(!ss)return;
ss.innerHTML='<option value="">-- Subcategory --</option>'; subcats.forEach(s=>{if(s.category_id==catId){const o=document.createElement('option');o.value=s.id;o.textContent=s.name;if(s.id==selSub)o.selected=true;ss.appendChild(o)}}); }
@@ -99,4 +174,4 @@ document.addEventListener('change',e=>{if(e.target.name==='category_id'&&e.targe
return layout('Catalog', content, 'catalog');
}
function esc(str) { return String(str||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function esc(str) { return String(str||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }

View File

@@ -1,14 +1,21 @@
export function renderProductEditForm(action, catOptions, subcatJson) {
export function renderProductEditForm(action, catOptions, subcatJson, locations) {
const isEdit = action.includes('/edit');
const title = isEdit ? 'Edit Product' : 'Add Product';
const locJson = JSON.stringify(locations || []);
return `<h2>${title}</h2>
<form method="POST" action="${action}" enctype="multipart/form-data" class="product-form">
<div class="pf-group pf-location" id="pf-location" style="display:none">
<div class="pf-group">
<label>Location</label>
<div class="pf-location-row">
<span class="pf-loc-tag" id="pf-country"></span>
<span class="pf-loc-tag" id="pf-city"></span>
<span class="pf-loc-tag" id="pf-district"></span>
<div class="pf-location-selects">
<select name="location_country" id="loc-country" onchange="locOnCountryChange()">
<option value="">-- Country --</option>
</select>
<select name="location_city" id="loc-city" onchange="locOnCityChange()" disabled>
<option value="">-- City --</option>
</select>
<select name="location_id" id="loc-district" onchange="locOnDistrictChange()">
<option value="">-- District --</option>
</select>
</div>
</div>
<div class="pf-group">
@@ -28,7 +35,7 @@ export function renderProductEditForm(action, catOptions, subcatJson) {
<div class="pf-row">
<div class="pf-group">
<label>Category</label>
<select name="category_id" required onchange="updateSubcats(this.value)">
<select name="category_id" id="pf-category" required onchange="updateSubcats(this.value)">
<option value="">-- Select --</option>${catOptions}
</select>
</div>