4 - Rating panel: visit log display + log-a-visit form
This commit is contained in:
parent
4c54a95f50
commit
b39fe0f2cd
3 changed files with 280 additions and 15 deletions
|
|
@ -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 = `
|
||||
<div class="rating-panel-header">
|
||||
<span class="rating-panel-title">${subject.name}</span>
|
||||
<button class="btn btn-ghost btn-sm rating-panel-close">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="rating-section">
|
||||
<p class="rating-section-label">Overall</p>
|
||||
${avg !== null
|
||||
? `<div class="rating-stars">${this.renderStars(avg)}</div>
|
||||
<p class="rating-meta">based on ${count} ${count === 1 ? 'rating' : 'ratings'}</p>`
|
||||
: `<p class="rating-meta">No ratings yet.</p>`
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="rating-section">
|
||||
<p class="rating-section-label">Your visits</p>
|
||||
${myRatings.length > 0
|
||||
? myRatings.map(r => `
|
||||
<div class="visit-row">
|
||||
<span class="rating-stars">${this.renderStars(r.score)}</span>
|
||||
${r.note ? `<span class="visit-note">${r.note}</span>` : ''}
|
||||
<span class="visit-date">${this.formatDate(r.createdAt)}</span>
|
||||
</div>`).join('')
|
||||
: `<p class="rating-meta">No visits logged yet.</p>`
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="rating-log-action"></div>
|
||||
`;
|
||||
|
||||
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 += `
|
||||
<fieldset class="fieldset mt-3">
|
||||
<legend class="fieldset-legend">Note <span class="opacity-50">(optional)</span></legend>
|
||||
<textarea class="textarea w-full log-note" rows="2" placeholder="How was it?"></textarea>
|
||||
</fieldset>
|
||||
<div class="log-visit-actions">
|
||||
<button type="button" class="btn btn-ghost btn-sm log-cancel">Cancel</button>
|
||||
<button type="button" class="btn btn-primary btn-sm log-save">Save</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 += '<span class="star-filled">★</span>';
|
||||
} else if (score >= i - 0.5) {
|
||||
html += '<span class="star-half">★</span>';
|
||||
} else {
|
||||
html += '<span class="star-empty">☆</span>';
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
formatDate(iso) {
|
||||
return new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
.format(new Date(iso));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
|
|
|
|||
Loading…
Reference in a new issue