From b39fe0f2cd26829403fc69f9bbf87995c0ade6de Mon Sep 17 00:00:00 2001 From: myrmidex Date: Fri, 5 Jun 2026 23:10:11 +0000 Subject: [PATCH] 4 - Rating panel: visit log display + log-a-visit form --- assets/controllers/map_controller.js | 191 +++++++++++++++++++++++++-- assets/styles/app.css | 102 ++++++++++++++ src/Factory/RatingFactory.php | 2 +- 3 files changed, 280 insertions(+), 15 deletions(-) diff --git a/assets/controllers/map_controller.js b/assets/controllers/map_controller.js index ca36d82..aff9211 100644 --- a/assets/controllers/map_controller.js +++ b/assets/controllers/map_controller.js @@ -17,26 +17,36 @@ export default class extends Controller { maxZoom: 19, }).addTo(this.map); + this.currentUserId = parseInt(this.element.dataset.userId); + this.panel = document.getElementById('rating-panel'); this.popup = null; + this.markerLayer = L.layerGroup().addTo(this.map); this.loadSubjects(); this.map.on('click', (e) => this.openPopup(e.latlng)); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') this.closePanel(); + }); } loadSubjects() { fetch('/api/subjects') .then(r => r.json()) .then(data => { + this.markerLayer.clearLayers(); data.payload.subjects.forEach(s => { if (s.latitude && s.longitude) { - this.addMarker(s.latitude, s.longitude, s.name); + this.addMarker(s); } }); }); } openPopup(latlng) { + this.closePanel(); + if (this.popup) { this.popup.remove(); } @@ -48,7 +58,6 @@ export default class extends Controller { .setContent(content) .openOn(this.map); - // Autofocus after Leaflet inserts the DOM this.popup.once('add', () => { const input = this.popup.getElement()?.querySelector('.add-place-input'); if (input) input.focus(); @@ -81,10 +90,7 @@ export default class extends Controller { const submit = () => { const name = input.value.trim(); - if (!name) { - input.focus(); - return; - } + if (!name) { input.focus(); return; } this.createSubject(name, latlng); }; @@ -102,20 +108,177 @@ export default class extends Controller { fetch('/api/subjects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: name, - latitude: latlng.lat, - longitude: latlng.lng, - }), + body: JSON.stringify({ name, latitude: latlng.lat, longitude: latlng.lng }), }) .then(r => r.json()) .then(() => { - this.addMarker(latlng.lat, latlng.lng, name); + // Reload subjects to get the new one with its ID this.popup.remove(); + this.loadSubjects(); }); } - addMarker(lat, lng, name) { - L.marker([lat, lng]).addTo(this.map).bindPopup(name); + addMarker(subject) { + const marker = L.marker([subject.latitude, subject.longitude]).addTo(this.markerLayer); + marker.on('click', (e) => { + L.DomEvent.stopPropagation(e); + if (this.popup) this.popup.remove(); + this.openPanel(subject); + }); + } + + openPanel(subject) { + fetch(`/api/subjects/${subject.id}`) + .then(r => r.json()) + .then(data => this.renderPanel(data.payload.subject)); + } + + closePanel() { + this.panel.classList.remove('open'); + this.panel.innerHTML = ''; + } + + renderPanel(subject) { + this.panel.innerHTML = ''; + + const myRatings = subject.ratings.filter(r => r.user.id === this.currentUserId); + const avg = subject.averageScore; + const count = subject.ratingCount; + + const el = document.createElement('div'); + el.innerHTML = ` +
+ ${subject.name} + +
+ +
+ + ${avg !== null + ? `
${this.renderStars(avg)}
+

based on ${count} ${count === 1 ? 'rating' : 'ratings'}

` + : `

No ratings yet.

` + } +
+ +
+ + ${myRatings.length > 0 + ? myRatings.map(r => ` +
+ ${this.renderStars(r.score)} + ${r.note ? `${r.note}` : ''} + ${this.formatDate(r.createdAt)} +
`).join('') + : `

No visits logged yet.

` + } +
+ +
+ `; + + el.querySelector('.rating-panel-close').addEventListener('click', () => this.closePanel()); + + const actionArea = el.querySelector('.rating-log-action'); + this.renderLogButton(actionArea, subject); + + this.panel.appendChild(el); + this.panel.classList.add('open'); + } + + renderLogButton(container, subject) { + container.innerHTML = ''; + const btn = document.createElement('button'); + btn.className = 'btn btn-primary w-full mt-2'; + btn.textContent = 'Log a visit'; + btn.addEventListener('click', () => this.renderLogForm(container, subject)); + container.appendChild(btn); + } + + renderLogForm(container, subject) { + container.innerHTML = ''; + + const form = document.createElement('div'); + form.className = 'log-visit-form'; + + const picker = this.buildStarPicker(); + form.appendChild(picker.el); + + form.innerHTML += ` +
+ Note (optional) + +
+
+ + +
+ `; + + // Re-insert picker before the fieldset (innerHTML nuked it) + form.insertBefore(picker.el, form.firstChild); + + form.querySelector('.log-cancel').addEventListener('click', () => this.renderLogButton(container, subject)); + + form.querySelector('.log-save').addEventListener('click', () => { + const score = picker.getScore(); + if (!score) { picker.el.classList.add('shake'); return; } + const note = form.querySelector('.log-note').value.trim(); + this.saveVisit(subject, score, note, container); + }); + + container.appendChild(form); + } + + saveVisit(subject, score, note, container) { + fetch('/api/ratings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ subject_id: subject.id, score, note }), + }) + .then(r => { if (!r.ok) throw new Error(); return r; }) + .then(() => fetch(`/api/subjects/${subject.id}`)) + .then(r => r.json()) + .then(data => this.renderPanel(data.payload.subject)); + } + + buildStarPicker() { + const el = document.createElement('div'); + el.className = 'star-picker'; + let selected = 0; + + const buttons = Array.from({ length: 5 }, (_, i) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = '★'; + btn.dataset.score = i + 1; + btn.addEventListener('click', () => { + selected = i + 1; + buttons.forEach((b, j) => b.classList.toggle('active', j < selected)); + }); + el.appendChild(btn); + return btn; + }); + + return { el, getScore: () => selected }; + } + + renderStars(score, max = 5) { + let html = ''; + for (let i = 1; i <= max; i++) { + if (score >= i) { + html += ''; + } else if (score >= i - 0.5) { + html += ''; + } else { + html += ''; + } + } + return html; + } + + formatDate(iso) { + return new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) + .format(new Date(iso)); } } diff --git a/assets/styles/app.css b/assets/styles/app.css index a91872c..d142bf3 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -54,3 +54,105 @@ html, body { height: 100%; margin: 0; } gap: 0.5rem; margin-top: 1rem; } + +/* Rating panel — bottom sheet on mobile, side panel on desktop */ +.rating-panel { + display: none; + position: fixed; + bottom: 0; left: 0; right: 0; + height: 55vh; + z-index: 1000; + background: var(--color-base-100); + color: var(--color-base-content); + border-radius: 1rem 1rem 0 0; + overflow-y: auto; + padding: 1.25rem; + box-shadow: 0 -4px 24px oklch(0% 0 0 / 0.4); +} +.rating-panel.open { display: block; } + +@media (min-width: 768px) { + .rating-panel { + top: 4rem; + right: 0; bottom: 0; left: auto; + width: 320px; height: auto; + border-radius: 0; + box-shadow: -4px 0 24px oklch(0% 0 0 / 0.3); + } +} + +.rating-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} +.rating-panel-title { + font-size: 1.1rem; + font-weight: 700; + color: var(--color-primary); +} +.rating-section { + margin-bottom: 1.25rem; +} +.rating-section-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + opacity: 0.5; + margin-bottom: 0.4rem; +} +.rating-meta { + font-size: 0.8rem; + opacity: 0.5; + margin-top: 0.2rem; +} +.visit-row { + display: flex; + align-items: baseline; + gap: 0.5rem; + margin-bottom: 0.4rem; + font-size: 0.9rem; +} +.visit-note { + flex: 1; + opacity: 0.8; + font-style: italic; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.visit-date { + font-size: 0.75rem; + opacity: 0.5; + white-space: nowrap; +} + +/* Stars */ +.rating-stars { display: inline-flex; gap: 1px; } +.star-filled { color: var(--color-primary); } +.star-half { color: var(--color-primary); opacity: 0.45; } +.star-empty { opacity: 0.2; } + +/* Star picker */ +.star-picker { display: flex; gap: 2px; margin-bottom: 0.25rem; } +.star-picker button { + font-size: 1.6rem; + background: none; border: none; + cursor: pointer; padding: 0 1px; + color: var(--color-base-content); + opacity: 0.2; + transition: opacity 0.1s; + line-height: 1; +} +.star-picker button.active { + opacity: 1; + color: var(--color-primary); +} + +.log-visit-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.75rem; +} diff --git a/src/Factory/RatingFactory.php b/src/Factory/RatingFactory.php index 499c687..390fb4d 100644 --- a/src/Factory/RatingFactory.php +++ b/src/Factory/RatingFactory.php @@ -33,7 +33,7 @@ final class RatingFactory extends PersistentObjectFactory { return [ 'score' => self::faker()->numberBetween(1, 5), - 'note' => self::faker()->optional()->sentence() ?? '', + 'note' => self::faker()->optional()->sentence(), 'subject' => RestaurantFactory::new(), 'user' => UserFactory::new(), ];