diff --git a/CHANGELOG.md b/CHANGELOG.md index 606f9b7..45d589c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * [PR-19](https://github.com/itk-dev/itk-project-database/pull/19) Auto-upload files with a progress bar and image preview, view images in an in-page lightbox, and refresh the media field styling. +* [PR-17](https://github.com/itk-dev/itk-project-database/pull/17) + Fix the mascot nudges that never appeared, nudge users to finish incomplete + contacts with a link to their edit page. * [PR-16](https://github.com/itk-dev/itk-project-database/pull/16) Turn the strategies and tags fields into a searchable, shared tag pool where new entries are capitalised and reused as suggestions. diff --git a/assets/controllers/mascot_controller.js b/assets/controllers/mascot_controller.js index e2d1d6f..c5c288b 100644 --- a/assets/controllers/mascot_controller.js +++ b/assets/controllers/mascot_controller.js @@ -8,7 +8,14 @@ import { Controller } from "@hotwired/stimulus"; * pointer until it tags it back. Messages arrive already translated. */ export default class extends Controller { - static targets = ["bubble", "text", "cta", "play", "finish"]; + static targets = [ + "bubble", + "text", + "cta", + "play", + "finish", + "finishContact", + ]; static values = { messages: { type: Array, default: [] }, @@ -20,6 +27,7 @@ export default class extends Controller { giveup: String, escaped: String, finishTexts: { type: Array, default: [] }, + finishContactTexts: { type: Array, default: [] }, enabled: { type: Boolean, default: true }, farewell: String, welcome: String, @@ -193,13 +201,16 @@ export default class extends Controller { this.scheduleNext(this.intervalValue); } - // Clicking the avatar only does something during the play-catch game; a plain - // idle click is intentionally inert (no message, no animation). + // Clicking the avatar plays catch mid-game; an idle click pops a fresh + // message and resets the cadence so the next auto-message isn't right behind. poke() { if ("invited" === this.mode) { this.startFlee(); } else if ("flee" === this.mode) { this.caught(); + } else if ("idle" === this.mode) { + this.speak(); + this.scheduleNext(this.intervalValue); } } @@ -212,13 +223,29 @@ export default class extends Controller { return; } + // A contact created on the fly (name only) gets a gentle reminder to + // finish it, linking straight to its edit page. + const contactTexts = this.finishContactTextsValue; + if ( + this.hasFinishContactTarget && + contactTexts.length > 0 && + Math.random() < 0.15 + ) { + this.say( + contactTexts[Math.floor(Math.random() * contactTexts.length)], + "finishContact", + ); + + return; + } + // Now and then, nudge the user to finish their least-complete initiative, // picking one of the finish lines at random for variety. const finishTexts = this.finishTextsValue; if ( this.hasFinishTarget && finishTexts.length > 0 && - Math.random() < 0.4 + Math.random() < 0.15 ) { this.say( finishTexts[Math.floor(Math.random() * finishTexts.length)], @@ -388,6 +415,9 @@ export default class extends Controller { if (this.hasFinishTarget) { this.finishTarget.hidden = "finish" !== action; } + if (this.hasFinishContactTarget) { + this.finishContactTarget.hidden = "finishContact" !== action; + } this.bubbleTarget.hidden = false; window.requestAnimationFrame(() => { this.bubbleTarget.classList.add("is-visible"); diff --git a/assets/styles/app.css b/assets/styles/app.css index 8180910..cfc9177 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -2050,6 +2050,33 @@ body.is-lightbox-open { overflow: visible; } +.mascot__gold-stop { + animation: mascot-gold 9s ease-in-out infinite; +} + +.mascot__gold-stop:nth-child(2) { + animation-delay: -3s; +} + +.mascot__gold-stop:nth-child(3) { + animation-delay: -6s; +} + +@keyframes mascot-gold { + 0% { + stop-color: #ffe488; + } + 33% { + stop-color: #fcc24a; + } + 66% { + stop-color: #eaa326; + } + 100% { + stop-color: #ffe488; + } +} + @keyframes mascot-bob { 0%, 100% { diff --git a/src/Repository/ContactRepository.php b/src/Repository/ContactRepository.php index 9d3e8d5..a7cd004 100644 --- a/src/Repository/ContactRepository.php +++ b/src/Repository/ContactRepository.php @@ -5,6 +5,7 @@ namespace App\Repository; use App\Entity\Contact; +use App\Entity\User; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -55,4 +56,24 @@ public function findOrCreate(string $name): Contact return $contact; } + + /** + * The user's most recent contact that still lacks an email — typically one + * they created on the fly from an initiative's contact picker (name only). + * Used by the mascot to nudge them to fill in the rest. + */ + public function findIncompleteByCreator(User $user): ?Contact + { + // createdBy is a ManyToOne to the UserInterface (resolved to User via + // resolve_target_entities); binding the entity to a ULID FK doesn't match, + // so compare the raw FK against the user's id with the ulid type applied. + return $this->createQueryBuilder('c') + ->andWhere('IDENTITY(c.createdBy) = :user') + ->andWhere("(c.email IS NULL OR c.email = '')") + ->setParameter('user', $user->getId(), 'ulid') + ->orderBy('c.createdAt', 'DESC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } } diff --git a/src/Repository/InitiativeRepository.php b/src/Repository/InitiativeRepository.php index 9cfa46a..af4636e 100644 --- a/src/Repository/InitiativeRepository.php +++ b/src/Repository/InitiativeRepository.php @@ -5,6 +5,7 @@ namespace App\Repository; use App\Entity\Initiative; +use App\Entity\User; use App\Enum\EndorsementAuthor; use App\Enum\Funding; use App\Enum\InitiativeType; @@ -14,7 +15,6 @@ use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; -use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Contracts\Translation\TranslatorInterface; /** @@ -165,12 +165,15 @@ public function countAll(): int ->getSingleScalarResult(); } - public function countByCreator(UserInterface $user): int + public function countByCreator(User $user): int { + // createdBy is a ManyToOne to the UserInterface (resolved to User via + // resolve_target_entities); binding the entity to a ULID FK doesn't match, + // so compare the raw FK against the user's id with the ulid type applied. return (int) $this->createQueryBuilder('i') ->select('COUNT(i.id)') - ->andWhere('i.createdBy = :user') - ->setParameter('user', $user) + ->andWhere('IDENTITY(i.createdBy) = :user') + ->setParameter('user', $user->getId(), 'ulid') ->getQuery() ->getSingleScalarResult(); } @@ -180,11 +183,12 @@ public function countByCreator(UserInterface $user): int * null if none are outstanding. Completion is computed in PHP (not a stored * column), so this scans only the creator's 50 most recent initiatives. */ - public function findUnfinishedByCreator(UserInterface $user): ?Initiative + public function findUnfinishedByCreator(User $user): ?Initiative { + // See countByCreator: match the raw ULID FK, not the entity. $initiatives = $this->createQueryBuilder('i') - ->andWhere('i.createdBy = :user') - ->setParameter('user', $user) + ->andWhere('IDENTITY(i.createdBy) = :user') + ->setParameter('user', $user->getId(), 'ulid') ->orderBy('i.createdAt', 'DESC') ->setMaxResults(50) ->getQuery() diff --git a/src/Twig/MascotExtension.php b/src/Twig/MascotExtension.php index 8037634..0ae8aab 100644 --- a/src/Twig/MascotExtension.php +++ b/src/Twig/MascotExtension.php @@ -4,8 +4,10 @@ namespace App\Twig; +use App\Entity\Contact; use App\Entity\Initiative; use App\Entity\User; +use App\Repository\ContactRepository; use App\Repository\InitiativeRepository; use Symfony\Bundle\SecurityBundle\Security; use Twig\Extension\AbstractExtension; @@ -21,6 +23,7 @@ class MascotExtension extends AbstractExtension public function __construct( private readonly Security $security, private readonly InitiativeRepository $initiatives, + private readonly ContactRepository $contacts, ) { } @@ -32,18 +35,19 @@ public function getFunctions(): array } /** - * @return array{count: int, unfinished: Initiative|null} + * @return array{count: int, unfinished: Initiative|null, incompleteContact: Contact|null} */ public function context(): array { $user = $this->security->getUser(); if (!$user instanceof User) { - return ['count' => 0, 'unfinished' => null]; + return ['count' => 0, 'unfinished' => null, 'incompleteContact' => null]; } return [ 'count' => $this->initiatives->countByCreator($user), 'unfinished' => $this->initiatives->findUnfinishedByCreator($user), + 'incompleteContact' => $this->contacts->findIncompleteByCreator($user), ]; } } diff --git a/templates/base.html.twig b/templates/base.html.twig index d0200a9..177618c 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -109,6 +109,15 @@ 'mascot.finish.strong'|trans(finishParams), ] %} {% endif %} + {% set contactMessages = [] %} + {% if ctx.incompleteContact %} + {% set contactParams = {'%name%': ctx.incompleteContact.name} %} + {% set contactMessages = [ + 'mascot.contact.details'|trans(contactParams), + 'mascot.contact.quick'|trans(contactParams), + 'mascot.contact.name_only'|trans(contactParams), + ] %} + {% endif %}