Compare commits
No commits in common. "b39fe0f2cd26829403fc69f9bbf87995c0ade6de" and "6c02f9b4c15a73d342099856a5a6e0d3d9bb1714" have entirely different histories.
b39fe0f2cd
...
6c02f9b4c1
13 changed files with 28 additions and 548 deletions
|
|
@ -17,36 +17,26 @@ export default class extends Controller {
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
}).addTo(this.map);
|
}).addTo(this.map);
|
||||||
|
|
||||||
this.currentUserId = parseInt(this.element.dataset.userId);
|
|
||||||
this.panel = document.getElementById('rating-panel');
|
|
||||||
this.popup = null;
|
this.popup = null;
|
||||||
this.markerLayer = L.layerGroup().addTo(this.map);
|
|
||||||
|
|
||||||
this.loadSubjects();
|
this.loadSubjects();
|
||||||
|
|
||||||
this.map.on('click', (e) => this.openPopup(e.latlng));
|
this.map.on('click', (e) => this.openPopup(e.latlng));
|
||||||
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape') this.closePanel();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loadSubjects() {
|
loadSubjects() {
|
||||||
fetch('/api/subjects')
|
fetch('/api/subjects')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
this.markerLayer.clearLayers();
|
|
||||||
data.payload.subjects.forEach(s => {
|
data.payload.subjects.forEach(s => {
|
||||||
if (s.latitude && s.longitude) {
|
if (s.latitude && s.longitude) {
|
||||||
this.addMarker(s);
|
this.addMarker(s.latitude, s.longitude, s.name);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
openPopup(latlng) {
|
openPopup(latlng) {
|
||||||
this.closePanel();
|
|
||||||
|
|
||||||
if (this.popup) {
|
if (this.popup) {
|
||||||
this.popup.remove();
|
this.popup.remove();
|
||||||
}
|
}
|
||||||
|
|
@ -58,6 +48,7 @@ export default class extends Controller {
|
||||||
.setContent(content)
|
.setContent(content)
|
||||||
.openOn(this.map);
|
.openOn(this.map);
|
||||||
|
|
||||||
|
// Autofocus after Leaflet inserts the DOM
|
||||||
this.popup.once('add', () => {
|
this.popup.once('add', () => {
|
||||||
const input = this.popup.getElement()?.querySelector('.add-place-input');
|
const input = this.popup.getElement()?.querySelector('.add-place-input');
|
||||||
if (input) input.focus();
|
if (input) input.focus();
|
||||||
|
|
@ -90,7 +81,10 @@ export default class extends Controller {
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
const name = input.value.trim();
|
const name = input.value.trim();
|
||||||
if (!name) { input.focus(); return; }
|
if (!name) {
|
||||||
|
input.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.createSubject(name, latlng);
|
this.createSubject(name, latlng);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -108,177 +102,20 @@ export default class extends Controller {
|
||||||
fetch('/api/subjects', {
|
fetch('/api/subjects', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, latitude: latlng.lat, longitude: latlng.lng }),
|
body: JSON.stringify({
|
||||||
|
name: name,
|
||||||
|
latitude: latlng.lat,
|
||||||
|
longitude: latlng.lng,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// Reload subjects to get the new one with its ID
|
this.addMarker(latlng.lat, latlng.lng, name);
|
||||||
this.popup.remove();
|
this.popup.remove();
|
||||||
this.loadSubjects();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
addMarker(subject) {
|
addMarker(lat, lng, name) {
|
||||||
const marker = L.marker([subject.latitude, subject.longitude]).addTo(this.markerLayer);
|
L.marker([lat, lng]).addTo(this.map).bindPopup(name);
|
||||||
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,105 +54,3 @@ html, body { height: 100%; margin: 0; }
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-top: 1rem;
|
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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auto-generated Migration: Please modify to your needs!
|
|
||||||
*/
|
|
||||||
final class Version20260605221350 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
// this up() migration is auto-generated, please modify it to your needs
|
|
||||||
$this->addSql('ALTER TABLE rating ADD user_id INT NOT NULL');
|
|
||||||
$this->addSql('ALTER TABLE rating ADD CONSTRAINT FK_D8892622A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE');
|
|
||||||
$this->addSql('CREATE INDEX IDX_D8892622A76ED395 ON rating (user_id)');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
// this down() migration is auto-generated, please modify it to your needs
|
|
||||||
$this->addSql('ALTER TABLE rating DROP CONSTRAINT FK_D8892622A76ED395');
|
|
||||||
$this->addSql('DROP INDEX IDX_D8892622A76ED395');
|
|
||||||
$this->addSql('ALTER TABLE rating DROP user_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auto-generated Migration: Please modify to your needs!
|
|
||||||
*/
|
|
||||||
final class Version20260605222943 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
// this up() migration is auto-generated, please modify it to your needs
|
|
||||||
$this->addSql('ALTER TABLE rating ADD created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
// this down() migration is auto-generated, please modify it to your needs
|
|
||||||
$this->addSql('ALTER TABLE rating DROP created_at');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,8 +3,6 @@
|
||||||
namespace App\Controller\Api;
|
namespace App\Controller\Api;
|
||||||
|
|
||||||
use App\Entity\Rating;
|
use App\Entity\Rating;
|
||||||
use App\Entity\User;
|
|
||||||
use App\Presenter\RatingPresenter;
|
|
||||||
use App\Repository\RatingRepository;
|
use App\Repository\RatingRepository;
|
||||||
use App\Repository\SubjectRepository;
|
use App\Repository\SubjectRepository;
|
||||||
use App\Services\OutputService;
|
use App\Services\OutputService;
|
||||||
|
|
@ -13,7 +11,6 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
|
||||||
|
|
||||||
class RatingController extends AbstractController
|
class RatingController extends AbstractController
|
||||||
{
|
{
|
||||||
|
|
@ -21,10 +18,19 @@ class RatingController extends AbstractController
|
||||||
public function index(
|
public function index(
|
||||||
RatingRepository $repo,
|
RatingRepository $repo,
|
||||||
OutputService $output,
|
OutputService $output,
|
||||||
RatingPresenter $ratingPresenter,
|
|
||||||
): JsonResponse {
|
): JsonResponse {
|
||||||
return $this->json($output->success([
|
return $this->json($output->success([
|
||||||
'ratings' => $ratingPresenter->presentMany($repo->findAll()),
|
'ratings' => array_map(
|
||||||
|
fn(Rating $rating) => [
|
||||||
|
'id' => $rating->getId(),
|
||||||
|
'score' => $rating->getScore(),
|
||||||
|
'subject' => [
|
||||||
|
'id' => $rating->getSubject()->getId(),
|
||||||
|
'name' => $rating->getSubject()->getName(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
$repo->findAll()
|
||||||
|
),
|
||||||
]), 200);
|
]), 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,8 +40,6 @@ class RatingController extends AbstractController
|
||||||
EntityManagerInterface $em,
|
EntityManagerInterface $em,
|
||||||
OutputService $output,
|
OutputService $output,
|
||||||
SubjectRepository $subjectRepository,
|
SubjectRepository $subjectRepository,
|
||||||
#[CurrentUser]
|
|
||||||
User $user
|
|
||||||
): JsonResponse {
|
): JsonResponse {
|
||||||
$subject = $subjectRepository->find($request->getPayload()->get('subject_id'));
|
$subject = $subjectRepository->find($request->getPayload()->get('subject_id'));
|
||||||
|
|
||||||
|
|
@ -47,7 +51,6 @@ class RatingController extends AbstractController
|
||||||
|
|
||||||
$rating = new Rating();
|
$rating = new Rating();
|
||||||
$rating->setSubject($subject);
|
$rating->setSubject($subject);
|
||||||
$rating->setUser($user);
|
|
||||||
$rating->setScore($request->getPayload()->get('score'));
|
$rating->setScore($request->getPayload()->get('score'));
|
||||||
|
|
||||||
$em->persist($rating);
|
$em->persist($rating);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ namespace App\Controller\Api;
|
||||||
|
|
||||||
use App\Entity\Restaurant;
|
use App\Entity\Restaurant;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Presenter\SubjectPresenter;
|
|
||||||
use App\Repository\SubjectRepository;
|
use App\Repository\SubjectRepository;
|
||||||
use App\Services\OutputService;
|
use App\Services\OutputService;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
@ -20,11 +19,8 @@ class SubjectController extends AbstractController
|
||||||
public function index(
|
public function index(
|
||||||
SubjectRepository $repo,
|
SubjectRepository $repo,
|
||||||
OutputService $output,
|
OutputService $output,
|
||||||
SubjectPresenter $subjectPresenter,
|
|
||||||
): JsonResponse {
|
): JsonResponse {
|
||||||
return $this->json($output->success([
|
return $this->json($output->success(['subjects' => $repo->findAll()]));
|
||||||
'subjects' => $subjectPresenter->presentMany($repo->findAll()),
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/api/subjects', methods: ['POST'])]
|
#[Route('/api/subjects', methods: ['POST'])]
|
||||||
|
|
@ -54,22 +50,6 @@ class SubjectController extends AbstractController
|
||||||
return $this->json(null, 201);
|
return $this->json(null, 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/api/subjects/{id}', methods: ['GET'])]
|
|
||||||
public function show(
|
|
||||||
int $id,
|
|
||||||
SubjectRepository $repository,
|
|
||||||
SubjectPresenter $presenter,
|
|
||||||
OutputService $output,
|
|
||||||
): JsonResponse {
|
|
||||||
$subject = $repository->find($id);
|
|
||||||
|
|
||||||
if ($subject === null) {
|
|
||||||
return $this->json($output->error(['UNKNOWN_SUBJECT']), 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->json($output->success(['subject' => $presenter->present($subject)]));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/api/subjects/{id}', methods: ['PATCH'])]
|
#[Route('/api/subjects/{id}', methods: ['PATCH'])]
|
||||||
public function update(
|
public function update(
|
||||||
int $id,
|
int $id,
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,10 @@
|
||||||
namespace App\Entity;
|
namespace App\Entity;
|
||||||
|
|
||||||
use App\Repository\RatingRepository;
|
use App\Repository\RatingRepository;
|
||||||
use DateTimeImmutable;
|
|
||||||
use Doctrine\DBAL\Types\Types;
|
use Doctrine\DBAL\Types\Types;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
#[ORM\Entity(repositoryClass: RatingRepository::class)]
|
#[ORM\Entity(repositoryClass: RatingRepository::class)]
|
||||||
#[ORM\HasLifecycleCallbacks]
|
|
||||||
class Rating
|
class Rating
|
||||||
{
|
{
|
||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
|
|
@ -16,10 +14,6 @@ class Rating
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne]
|
|
||||||
#[ORM\JoinColumn(nullable: false)]
|
|
||||||
private User $user;
|
|
||||||
|
|
||||||
#[ORM\ManyToOne(inversedBy: 'ratings')]
|
#[ORM\ManyToOne(inversedBy: 'ratings')]
|
||||||
#[ORM\JoinColumn(nullable: false)]
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
private Subject $subject;
|
private Subject $subject;
|
||||||
|
|
@ -30,9 +24,6 @@ class Rating
|
||||||
#[ORM\Column(type: Types::TEXT, options: ['default' => ''])]
|
#[ORM\Column(type: Types::TEXT, options: ['default' => ''])]
|
||||||
private string $note = '';
|
private string $note = '';
|
||||||
|
|
||||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
|
||||||
private DateTimeImmutable $createdAt;
|
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
return $this->id;
|
return $this->id;
|
||||||
|
|
@ -50,18 +41,6 @@ class Rating
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getUser(): User
|
|
||||||
{
|
|
||||||
return $this->user;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setUser(User $user): static
|
|
||||||
{
|
|
||||||
$this->user = $user;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getScore(): ?int
|
public function getScore(): ?int
|
||||||
{
|
{
|
||||||
return $this->score;
|
return $this->score;
|
||||||
|
|
@ -85,15 +64,4 @@ class Rating
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ORM\PrePersist]
|
|
||||||
public function setCreatedAtValue(): void
|
|
||||||
{
|
|
||||||
$this->createdAt = new \DateTimeImmutable();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCreatedAt(): ?\DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->createdAt;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,7 @@ final class RatingFactory extends PersistentObjectFactory
|
||||||
protected function defaults(): array|callable
|
protected function defaults(): array|callable
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'score' => self::faker()->numberBetween(1, 5),
|
|
||||||
'note' => self::faker()->optional()->sentence(),
|
|
||||||
'subject' => RestaurantFactory::new(),
|
'subject' => RestaurantFactory::new(),
|
||||||
'user' => UserFactory::new(),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Presenter;
|
|
||||||
|
|
||||||
use App\Entity\Rating;
|
|
||||||
|
|
||||||
class RatingPresenter
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function present(Rating $rating): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $rating->getId(),
|
|
||||||
'score' => $rating->getScore(),
|
|
||||||
'note' => $rating->getNote(),
|
|
||||||
'createdAt' => $rating->getCreatedAt()?->format(\DateTimeInterface::ATOM),
|
|
||||||
'user' => [
|
|
||||||
'id' => $rating->getUser()->getId(),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param iterable<Rating> $ratings
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public function presentMany(iterable $ratings): array
|
|
||||||
{
|
|
||||||
return array_map(fn(Rating $r) => $this->present($r), [...$ratings]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Presenter;
|
|
||||||
|
|
||||||
use App\Entity\Subject;
|
|
||||||
|
|
||||||
class SubjectPresenter
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private RatingPresenter $ratingPresenter,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function present(Subject $subject): array
|
|
||||||
{
|
|
||||||
$ratings = $subject->getRatings()->toArray();
|
|
||||||
|
|
||||||
usort($ratings, fn($a, $b) => $b->getCreatedAt() <=> $a->getCreatedAt());
|
|
||||||
|
|
||||||
return [
|
|
||||||
'id' => $subject->getId(),
|
|
||||||
'name' => $subject->getName(),
|
|
||||||
'latitude' => $subject->getLatitude(),
|
|
||||||
'longitude' => $subject->getLongitude(),
|
|
||||||
'createdBy' => [
|
|
||||||
'id' => $subject->getCreatedBy()->getId(),
|
|
||||||
'email' => $subject->getCreatedBy()->getEmail(),
|
|
||||||
],
|
|
||||||
'ratingCount' => count($ratings),
|
|
||||||
'averageScore' => $this->computeAverage($ratings),
|
|
||||||
'ratings' => array_map(
|
|
||||||
fn($r) => $this->ratingPresenter->present($r),
|
|
||||||
$ratings
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<\App\Entity\Rating> $ratings
|
|
||||||
*/
|
|
||||||
private function computeAverage(array $ratings): ?float
|
|
||||||
{
|
|
||||||
$scores = array_filter(
|
|
||||||
array_map(fn($r) => $r->getScore(), $ratings),
|
|
||||||
fn($s) => $s !== null
|
|
||||||
);
|
|
||||||
|
|
||||||
if (count($scores) === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return round(array_sum($scores) / count($scores), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param iterable<Subject> $subjects
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public function presentMany(iterable $subjects): array
|
|
||||||
{
|
|
||||||
return array_map(fn($s) => $this->present($s), [...$subjects]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
{% extends 'base.html.twig' %}
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div {{ stimulus_controller('map') }} class="map-container" data-user-id="{{ app.user.id }}"></div>
|
<div {{ stimulus_controller('map') }} class="map-container"></div>
|
||||||
<div id="rating-panel" class="rating-panel"></div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -73,10 +73,7 @@ class RatingControllerTest extends WebTestCase
|
||||||
|
|
||||||
$this->assertNotNull($ratings);
|
$this->assertNotNull($ratings);
|
||||||
$this->assertCount(1, $ratings);
|
$this->assertCount(1, $ratings);
|
||||||
|
$this->assertInstanceOf(Rating::class, $ratings[0]);
|
||||||
$rating = $ratings[0];
|
|
||||||
$this->assertInstanceOf(Rating::class, $rating);
|
|
||||||
$this->assertSame($user->getId(), $rating->getUser()->getId());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testUpdate(): void
|
public function testUpdate(): void
|
||||||
|
|
@ -84,7 +81,6 @@ class RatingControllerTest extends WebTestCase
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
|
|
||||||
$user = UserFactory::createOne();
|
$user = UserFactory::createOne();
|
||||||
|
|
||||||
$client->loginUser($user);
|
$client->loginUser($user);
|
||||||
|
|
||||||
$subject = RestaurantFactory::createOne([
|
$subject = RestaurantFactory::createOne([
|
||||||
|
|
@ -93,7 +89,6 @@ class RatingControllerTest extends WebTestCase
|
||||||
|
|
||||||
$rating = RatingFactory::createOne([
|
$rating = RatingFactory::createOne([
|
||||||
'subject' => $subject,
|
'subject' => $subject,
|
||||||
'user' => $user,
|
|
||||||
'score' => 0,
|
'score' => 0,
|
||||||
'note' => 'first note',
|
'note' => 'first note',
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ namespace App\Tests\Application\Controller\Api;
|
||||||
|
|
||||||
use App\Entity\Restaurant;
|
use App\Entity\Restaurant;
|
||||||
use App\Entity\Subject;
|
use App\Entity\Subject;
|
||||||
use App\Factory\RatingFactory;
|
|
||||||
use App\Factory\RestaurantFactory;
|
use App\Factory\RestaurantFactory;
|
||||||
use App\Factory\UserFactory;
|
use App\Factory\UserFactory;
|
||||||
use App\Repository\SubjectRepository;
|
use App\Repository\SubjectRepository;
|
||||||
|
|
@ -40,38 +39,6 @@ class SubjectControllerTest extends WebTestCase
|
||||||
$this->assertNotNull($subject['longitude']);
|
$this->assertNotNull($subject['longitude']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testShow(): void
|
|
||||||
{
|
|
||||||
$client = static::createClient();
|
|
||||||
|
|
||||||
$user = UserFactory::createOne();
|
|
||||||
$client->loginUser($user);
|
|
||||||
|
|
||||||
$subject = RestaurantFactory::createOne(['createdBy' => $user]);
|
|
||||||
|
|
||||||
RatingFactory::createOne(['subject' => $subject, 'score' => 4, 'user' => $user]);
|
|
||||||
RatingFactory::createOne(['subject' => $subject, 'score' => 2, 'user' => UserFactory::createOne()]);
|
|
||||||
|
|
||||||
$client->request('GET', '/api/subjects/' . $subject->getId(), server: ['CONTENT_TYPE' => 'application/json']);
|
|
||||||
|
|
||||||
$data = json_decode($client->getResponse()->getContent(), true);
|
|
||||||
|
|
||||||
$this->assertResponseIsSuccessful();
|
|
||||||
$this->assertSame($subject->getId(), $data['payload']['subject']['id']);
|
|
||||||
$this->assertSame($subject->getName(), $data['payload']['subject']['name']);
|
|
||||||
$this->assertCount(2, $data['payload']['subject']['ratings']);
|
|
||||||
$this->assertSame(2, $data['payload']['subject']['ratingCount']);
|
|
||||||
$this->assertEquals(3.0, $data['payload']['subject']['averageScore']);
|
|
||||||
|
|
||||||
$rating = $data['payload']['subject']['ratings'][0];
|
|
||||||
$this->assertArrayHasKey('id', $rating);
|
|
||||||
$this->assertArrayHasKey('score', $rating);
|
|
||||||
$this->assertArrayHasKey('note', $rating);
|
|
||||||
$this->assertArrayHasKey('createdAt', $rating);
|
|
||||||
$this->assertArrayHasKey('user', $rating);
|
|
||||||
$this->assertArrayHasKey('id', $rating['user']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCreate(): void
|
public function testCreate(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue