From 5041f536b4c130a84aeb1ce150934c1200b57263 Mon Sep 17 00:00:00 2001 From: Brandon Lile Date: Fri, 4 Sep 2026 06:06:10 +0000 Subject: [PATCH] M2: scan loop The first playable slice. Scanning a printed marker creates an anonymous guest session, records the discovery, awards discovery XP once per animal, detects level-ups, and celebrates. API: play models (GuestSession, Scan, Discovery, XPEvent ledger, plus QuestProgress/ChallengeAttempt for M3), X-Guest-Token authentication, POST /zoos/{zoo}/sessions, GET+PATCH /me, POST /scan with the scan resolver (unknown / wrong-zoo / inactive markers handled; repeat scans logged, no XP). Session and scan admin views. 13 new tests (33 total). Web: session bootstrap hook (single-flight creation, 401 recovery), /s/[code] marker landing, Welcome with optional team name, Success sequence (confetti, XP roll-up, fun fact, level-up card, next-mission CTA), Explorer Profile, Choose Adventure list, in-app scanner (BarcodeDetector + manual code entry), app shell with XP bar and bottom nav. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KAaivnLHszR4G5fsh1fpjg --- README.md | 2 +- apps/api/apps/play/admin.py | 62 +++- apps/api/apps/play/auth.py | 65 ++++ apps/api/apps/play/migrations/0001_initial.py | 289 ++++++++++++++++++ apps/api/apps/play/models.py | 144 ++++++++- apps/api/apps/play/serializers.py | 28 ++ apps/api/apps/play/services.py | 133 ++++++++ apps/api/apps/play/tests/conftest.py | 11 + apps/api/apps/play/tests/test_scan_loop.py | 122 ++++++++ apps/api/apps/play/urls.py | 9 + apps/api/apps/play/views.py | 105 +++++++ apps/api/config/settings/base.py | 12 +- apps/api/config/urls.py | 2 +- apps/web/src/app/s/[code]/MarkerLanding.tsx | 81 +++++ apps/web/src/app/s/[code]/page.tsx | 13 +- apps/web/src/app/z/[zoo]/Welcome.tsx | 68 +++++ apps/web/src/app/z/[zoo]/ZooFrame.tsx | 19 ++ apps/web/src/app/z/[zoo]/layout.tsx | 13 + apps/web/src/app/z/[zoo]/page.tsx | 12 +- apps/web/src/app/z/[zoo]/profile/Profile.tsx | 63 ++++ apps/web/src/app/z/[zoo]/profile/page.tsx | 12 +- apps/web/src/app/z/[zoo]/quests/Quests.tsx | 48 +++ apps/web/src/app/z/[zoo]/quests/page.tsx | 12 +- apps/web/src/app/z/[zoo]/scan/Scanner.tsx | 109 +++++++ apps/web/src/app/z/[zoo]/scan/page.tsx | 12 +- apps/web/src/app/z/[zoo]/success/Success.tsx | 103 +++++++ apps/web/src/app/z/[zoo]/success/page.tsx | 12 +- apps/web/src/components/AppShell.tsx | 62 ++++ apps/web/src/components/Confetti.tsx | 50 +++ apps/web/src/components/Loading.tsx | 22 ++ apps/web/src/components/XPBar.tsx | 36 +++ apps/web/src/components/XPCounter.tsx | 29 ++ apps/web/src/hooks/useSession.ts | 74 +++++ apps/web/src/lib/api.ts | 150 ++++++++- apps/web/src/lib/store.ts | 18 +- 35 files changed, 1936 insertions(+), 66 deletions(-) create mode 100644 apps/api/apps/play/auth.py create mode 100644 apps/api/apps/play/migrations/0001_initial.py create mode 100644 apps/api/apps/play/serializers.py create mode 100644 apps/api/apps/play/services.py create mode 100644 apps/api/apps/play/tests/conftest.py create mode 100644 apps/api/apps/play/tests/test_scan_loop.py create mode 100644 apps/api/apps/play/urls.py create mode 100644 apps/api/apps/play/views.py create mode 100644 apps/web/src/app/s/[code]/MarkerLanding.tsx create mode 100644 apps/web/src/app/z/[zoo]/Welcome.tsx create mode 100644 apps/web/src/app/z/[zoo]/ZooFrame.tsx create mode 100644 apps/web/src/app/z/[zoo]/layout.tsx create mode 100644 apps/web/src/app/z/[zoo]/profile/Profile.tsx create mode 100644 apps/web/src/app/z/[zoo]/quests/Quests.tsx create mode 100644 apps/web/src/app/z/[zoo]/scan/Scanner.tsx create mode 100644 apps/web/src/app/z/[zoo]/success/Success.tsx create mode 100644 apps/web/src/components/AppShell.tsx create mode 100644 apps/web/src/components/Confetti.tsx create mode 100644 apps/web/src/components/Loading.tsx create mode 100644 apps/web/src/components/XPBar.tsx create mode 100644 apps/web/src/components/XPCounter.tsx create mode 100644 apps/web/src/hooks/useSession.ts diff --git a/README.md b/README.md index fe65cff..9ea59d7 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Tests and lint: `make test`, `make lint`. CI runs the same on every push. - [x] **M0 Foundation** — this. Repo, settings split, four apps, health endpoint, route skeleton, CI, deploy configs. - [x] **M1 Content backbone** — models, Django Admin (Unfold), Cedar Hollow fixture, read-only endpoints, printable QR sheet. -- [ ] **M2 Scan loop** — guest sessions, `POST /scan`, discovery XP, marker landing, Success, Profile. +- [x] **M2 Scan loop** — guest sessions, `POST /scan`, discovery XP once per animal, level-ups, marker landing, Welcome, Success, Profile, Choose Adventure, in-app scanner. - [ ] **M3 Quests and challenges** — six verifiers, submit/hint, quest screens. - [ ] **M4 Progression** — levels, badge rule engine, full Success sequence, Map. - [ ] **M5 Hardening** — service worker, retries, throttling, analytics view. diff --git a/apps/api/apps/play/admin.py b/apps/api/apps/play/admin.py index ad1e938..87dad89 100644 --- a/apps/api/apps/play/admin.py +++ b/apps/api/apps/play/admin.py @@ -1 +1,61 @@ -# Admin registrations arrive in M1. +"""Read-mostly views of play data for debugging and support. Analytics proper arrives in M5.""" + +from django.contrib import admin +from unfold.admin import ModelAdmin, TabularInline + +from apps.tenants.admin import ZooScopedAdmin + +from .models import Discovery, GuestSession, Scan, XPEvent + + +class DiscoveryInline(TabularInline): + model = Discovery + fields = ["animal", "marker", "discovered_at"] + readonly_fields = fields + extra = 0 + can_delete = False + + +class XPEventInline(TabularInline): + model = XPEvent + fields = ["amount", "source_type", "source_id", "created_at"] + readonly_fields = fields + extra = 0 + can_delete = False + + +@admin.register(GuestSession) +class GuestSessionAdmin(ZooScopedAdmin): + list_display = [ + "__str__", + "zoo", + "total_xp", + "discovery_count", + "scan_count", + "created_at", + "last_seen_at", + ] + list_filter = ["zoo", "created_at"] + search_fields = ["team_name", "token"] + readonly_fields = ["token", "total_xp", "created_at", "last_seen_at", "user_agent"] + inlines = [DiscoveryInline, XPEventInline] + date_hierarchy = "created_at" + + @admin.display(description="Discovered") + def discovery_count(self, obj): + return obj.discoveries.count() + + @admin.display(description="Scans") + def scan_count(self, obj): + return obj.scans.count() + + +@admin.register(Scan) +class ScanAdmin(ModelAdmin): + list_display = ["scanned_at", "session", "marker", "result", "xp_awarded"] + list_filter = ["result", "marker__zoo", "marker__exhibit"] + readonly_fields = ["session", "marker", "result", "xp_awarded", "scanned_at"] + date_hierarchy = "scanned_at" + + def has_add_permission(self, request): + return False diff --git a/apps/api/apps/play/auth.py b/apps/api/apps/play/auth.py new file mode 100644 index 0000000..088e58b --- /dev/null +++ b/apps/api/apps/play/auth.py @@ -0,0 +1,65 @@ +""" +Guest identity. The explorer app sends `X-Guest-Token: `; we resolve it +to a GuestSession and expose it as request.guest. No Django User is involved. +""" + +import uuid + +from drf_spectacular.extensions import OpenApiAuthenticationExtension +from rest_framework import authentication, exceptions, permissions + +from .models import GuestSession + +HEADER = "HTTP_X_GUEST_TOKEN" + + +class GuestUser: + """Minimal stand-in so DRF's request.user machinery is satisfied.""" + + is_authenticated = True + is_anonymous = False + is_active = True + + def __init__(self, session): + self.session = session + self.pk = session.pk # DRF throttling keys on user.pk + + def __str__(self): + return f"guest:{self.session.pk}" + + +class GuestTokenAuthentication(authentication.BaseAuthentication): + def authenticate(self, request): + raw = request.META.get(HEADER) + if not raw: + return None + try: + token = uuid.UUID(raw.strip()) + except ValueError: + raise exceptions.AuthenticationFailed("That explorer token is not valid.") from None + session = GuestSession.objects.select_related("zoo").filter(token=token, zoo__is_active=True).first() + if session is None: + raise exceptions.AuthenticationFailed("We could not find your adventure. Start a new one.") + session.save(update_fields=["last_seen_at"]) # heartbeat for session-length analytics + request.guest = session + return (GuestUser(session), token) + + def authenticate_header(self, request): + return "X-Guest-Token" + + +class IsGuest(permissions.BasePermission): + message = "Start an adventure first." + + def has_permission(self, request, view): + return getattr(request, "guest", None) is not None + + +class GuestTokenScheme(OpenApiAuthenticationExtension): + """Tells drf-spectacular (and the generated TypeScript client) about the header.""" + + target_class = "apps.play.auth.GuestTokenAuthentication" + name = "guestToken" + + def get_security_definition(self, auto_schema): + return {"type": "apiKey", "in": "header", "name": "X-Guest-Token"} diff --git a/apps/api/apps/play/migrations/0001_initial.py b/apps/api/apps/play/migrations/0001_initial.py new file mode 100644 index 0000000..346d7b4 --- /dev/null +++ b/apps/api/apps/play/migrations/0001_initial.py @@ -0,0 +1,289 @@ +# Generated by Django 5.1.15 on 2026-09-04 05:54 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("content", "0001_initial"), + ("tenants", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="GuestSession", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "token", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ("team_name", models.CharField(blank=True, max_length=40)), + ("total_xp", models.PositiveIntegerField(default=0)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("last_seen_at", models.DateTimeField(auto_now=True)), + ("user_agent", models.CharField(blank=True, max_length=200)), + ( + "zoo", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)ss", + to="tenants.zoo", + ), + ), + ], + options={ + "ordering": ["-created_at"], + }, + ), + migrations.CreateModel( + name="QuestProgress", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("active", "Active"), + ("completed", "Completed"), + ("abandoned", "Abandoned"), + ], + default="active", + max_length=10, + ), + ), + ("started_at", models.DateTimeField(auto_now_add=True)), + ("completed_at", models.DateTimeField(blank=True, null=True)), + ( + "quest", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="progress", + to="content.quest", + ), + ), + ( + "session", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="quest_progress", + to="play.guestsession", + ), + ), + ], + options={ + "unique_together": {("session", "quest")}, + }, + ), + migrations.CreateModel( + name="Scan", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "result", + models.CharField( + choices=[ + ("discovery", "New discovery"), + ("repeat", "Already discovered"), + ("exhibit", "Exhibit marker (no animal)"), + ], + max_length=12, + ), + ), + ("xp_awarded", models.PositiveIntegerField(default=0)), + ("scanned_at", models.DateTimeField(auto_now_add=True)), + ( + "marker", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="scans", + to="content.marker", + ), + ), + ( + "session", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="scans", + to="play.guestsession", + ), + ), + ], + options={ + "ordering": ["-scanned_at"], + }, + ), + migrations.CreateModel( + name="XPEvent", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("amount", models.IntegerField()), + ( + "source_type", + models.CharField( + choices=[ + ("discovery", "Animal discovered"), + ("challenge", "Challenge completed"), + ("quest", "Quest completed"), + ("badge", "Badge earned"), + ("adjustment", "Manual adjustment"), + ], + max_length=12, + ), + ), + ("source_id", models.PositiveIntegerField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "session", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="xp_events", + to="play.guestsession", + ), + ), + ], + options={ + "ordering": ["-created_at"], + }, + ), + migrations.CreateModel( + name="Discovery", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("discovered_at", models.DateTimeField(auto_now_add=True)), + ( + "animal", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="discoveries", + to="content.animal", + ), + ), + ( + "marker", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="content.marker", + ), + ), + ( + "session", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="discoveries", + to="play.guestsession", + ), + ), + ], + options={ + "ordering": ["discovered_at"], + "unique_together": {("session", "animal")}, + }, + ), + migrations.CreateModel( + name="ChallengeAttempt", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("in_progress", "In progress"), + ("completed", "Completed"), + ], + default="in_progress", + max_length=12, + ), + ), + ("attempts", models.PositiveIntegerField(default=0)), + ("answer", models.JSONField(blank=True, null=True)), + ("xp_awarded", models.PositiveIntegerField(default=0)), + ("started_at", models.DateTimeField(auto_now_add=True)), + ("completed_at", models.DateTimeField(blank=True, null=True)), + ( + "challenge", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="attempts", + to="content.challenge", + ), + ), + ( + "session", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="attempts", + to="play.guestsession", + ), + ), + ( + "quest_progress", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="attempts", + to="play.questprogress", + ), + ), + ], + options={ + "unique_together": {("session", "challenge")}, + }, + ), + ] diff --git a/apps/api/apps/play/models.py b/apps/api/apps/play/models.py index a27e24b..3807011 100644 --- a/apps/api/apps/play/models.py +++ b/apps/api/apps/play/models.py @@ -1,3 +1,143 @@ -from django.db import models # noqa: F401 +""" +Play: what a family does. One GuestSession is one family sharing one phone +(Blueprint, Section 0 decision 5). Everything here is write-heavy, per-session, +and anonymous: no names, no emails, no device IDs beyond a random token. +""" -# Models arrive in M1 (see Blueprint, Section D). +import uuid + +from django.db import models, transaction +from django.db.models import F + +from apps.content.models import Animal, Challenge, Level, Marker, Quest +from apps.tenants.models import ZooScopedModel + + +class GuestSession(ZooScopedModel): + token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) + team_name = models.CharField(max_length=40, blank=True) + total_xp = models.PositiveIntegerField(default=0) # denormalized from XPEvent + created_at = models.DateTimeField(auto_now_add=True) + last_seen_at = models.DateTimeField(auto_now=True) + user_agent = models.CharField(max_length=200, blank=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return self.team_name or f"Explorer {str(self.token)[:8]}" + + # ---- progression ----------------------------------------------------- + def level_info(self): + current, nxt = Level.for_xp(self.zoo, self.total_xp) + return { + "number": current.number if current else 1, + "title": current.title if current else "Explorer", + "xp_required": current.xp_required if current else 0, + "next_title": nxt.title if nxt else None, + "next_at": nxt.xp_required if nxt else None, + } + + def add_xp(self, amount, source_type, source_id=None): + """ + Append to the ledger and bump the denormalized total atomically. + Returns (new_total, level_up) where level_up is the new Level or None. + """ + if amount <= 0: + return self.total_xp, None + before, _ = Level.for_xp(self.zoo, self.total_xp) + with transaction.atomic(): + XPEvent.objects.create(session=self, amount=amount, source_type=source_type, source_id=source_id) + GuestSession.objects.filter(pk=self.pk).update(total_xp=F("total_xp") + amount) + self.refresh_from_db(fields=["total_xp"]) + after, _ = Level.for_xp(self.zoo, self.total_xp) + level_up = after if after and (before is None or after.number > before.number) else None + return self.total_xp, level_up + + +class Scan(models.Model): + """Raw log: one row per scan, including repeats. Analytics reads this.""" + + class Result(models.TextChoices): + DISCOVERY = "discovery", "New discovery" + REPEAT = "repeat", "Already discovered" + EXHIBIT = "exhibit", "Exhibit marker (no animal)" + + session = models.ForeignKey(GuestSession, on_delete=models.CASCADE, related_name="scans") + marker = models.ForeignKey(Marker, on_delete=models.CASCADE, related_name="scans") + result = models.CharField(max_length=12, choices=Result.choices) + xp_awarded = models.PositiveIntegerField(default=0) + scanned_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-scanned_at"] + + +class Discovery(models.Model): + """One per animal per session. The unique constraint IS the anti-farming rule.""" + + session = models.ForeignKey(GuestSession, on_delete=models.CASCADE, related_name="discoveries") + animal = models.ForeignKey(Animal, on_delete=models.CASCADE, related_name="discoveries") + marker = models.ForeignKey(Marker, on_delete=models.SET_NULL, null=True, related_name="+") + discovered_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = [("session", "animal")] + ordering = ["discovered_at"] + + +class QuestProgress(models.Model): + class Status(models.TextChoices): + ACTIVE = "active", "Active" + COMPLETED = "completed", "Completed" + ABANDONED = "abandoned", "Abandoned" + + session = models.ForeignKey(GuestSession, on_delete=models.CASCADE, related_name="quest_progress") + quest = models.ForeignKey(Quest, on_delete=models.CASCADE, related_name="progress") + status = models.CharField(max_length=10, choices=Status.choices, default=Status.ACTIVE) + started_at = models.DateTimeField(auto_now_add=True) + completed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + unique_together = [("session", "quest")] + + +class ChallengeAttempt(models.Model): + class Status(models.TextChoices): + IN_PROGRESS = "in_progress", "In progress" + COMPLETED = "completed", "Completed" + + session = models.ForeignKey(GuestSession, on_delete=models.CASCADE, related_name="attempts") + challenge = models.ForeignKey(Challenge, on_delete=models.CASCADE, related_name="attempts") + quest_progress = models.ForeignKey( + QuestProgress, on_delete=models.SET_NULL, null=True, blank=True, related_name="attempts" + ) + status = models.CharField(max_length=12, choices=Status.choices, default=Status.IN_PROGRESS) + attempts = models.PositiveIntegerField(default=0) + answer = models.JSONField(null=True, blank=True) + xp_awarded = models.PositiveIntegerField(default=0) + started_at = models.DateTimeField(auto_now_add=True) + completed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + unique_together = [("session", "challenge")] + + +class XPEvent(models.Model): + """Append-only ledger. Every XP change has a source.""" + + class Source(models.TextChoices): + DISCOVERY = "discovery", "Animal discovered" + CHALLENGE = "challenge", "Challenge completed" + QUEST = "quest", "Quest completed" + BADGE = "badge", "Badge earned" + ADJUSTMENT = "adjustment", "Manual adjustment" + + session = models.ForeignKey(GuestSession, on_delete=models.CASCADE, related_name="xp_events") + amount = models.IntegerField() + source_type = models.CharField(max_length=12, choices=Source.choices) + source_id = models.PositiveIntegerField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] diff --git a/apps/api/apps/play/serializers.py b/apps/api/apps/play/serializers.py new file mode 100644 index 0000000..b4d77d8 --- /dev/null +++ b/apps/api/apps/play/serializers.py @@ -0,0 +1,28 @@ +from rest_framework import serializers + +from apps.content.serializers import AnimalDetailSerializer, QuestListSerializer + + +class CreateSessionSerializer(serializers.Serializer): + team_name = serializers.CharField(max_length=40, required=False, allow_blank=True, default="") + + +class UpdateSessionSerializer(serializers.Serializer): + team_name = serializers.CharField(max_length=40, allow_blank=True) + + +class ScanRequestSerializer(serializers.Serializer): + code = serializers.CharField(max_length=40) + + +class ScanResponseSerializer(serializers.Serializer): + """Documented shape of POST /scan (Blueprint, Section E).""" + + marker = serializers.DictField() + animal = AnimalDetailSerializer(allow_null=True) + discovery = serializers.DictField() + completed_challenges = serializers.ListField() + unlocked_badges = serializers.ListField() + level_up = serializers.DictField(allow_null=True) + totals = serializers.DictField() + suggested_quest = QuestListSerializer(allow_null=True) diff --git a/apps/api/apps/play/services.py b/apps/api/apps/play/services.py new file mode 100644 index 0000000..3c67f0e --- /dev/null +++ b/apps/api/apps/play/services.py @@ -0,0 +1,133 @@ +""" +Game logic for the scan loop (Blueprint, Section A "Where the game logic lives"). + +`resolve_scan` is the referee: given a session and a marker code it decides +what happened, records it, awards XP, and returns everything the Success +screen needs in one object. Challenge completion (M3) and badge rules (M4) +plug into the marked spots. +""" + +from dataclasses import dataclass, field + +from django.db import IntegrityError, transaction +from django.utils import timezone + +from apps.content.models import Marker, Quest + +from .models import Discovery, GuestSession, Scan, XPEvent + + +class ScanError(Exception): + def __init__(self, code, message, status=400): + super().__init__(message) + self.code = code + self.message = message + self.status = status + + +@dataclass +class ScanResult: + marker: Marker + result: str = "" # Scan.Result value + discovery_xp: int = 0 + completed_challenges: list = field(default_factory=list) # M3 + unlocked_badges: list = field(default_factory=list) # M4 + level_up: object = None + total_xp: int = 0 + suggested_quest: Quest | None = None + + @property + def is_new(self): + return self.result == Scan.Result.DISCOVERY + + +def create_session(zoo, team_name="", user_agent=""): + return GuestSession.objects.create(zoo=zoo, team_name=team_name[:40], user_agent=user_agent[:200]) + + +def resolve_scan(session: GuestSession, code: str) -> ScanResult: + code = (code or "").strip().upper() + marker = ( + Marker.objects.select_related("zoo", "exhibit", "animal", "animal__exhibit").filter(code=code).first() + ) + if marker is None: + raise ScanError("unknown_marker", "That marker isn't part of any quest. Try another one.", 404) + if marker.zoo_id != session.zoo_id: + raise ScanError("wrong_zoo", f"That marker belongs to {marker.zoo.name}, not your zoo.", 400) + if not marker.is_active: + raise ScanError("inactive_marker", "That marker is retired. Look for a newer sign nearby.", 410) + + result = ScanResult(marker=marker, total_xp=session.total_xp) + + with transaction.atomic(): + if marker.animal_id is None: + result.result = Scan.Result.EXHIBIT + else: + try: + with transaction.atomic(): + Discovery.objects.create(session=session, animal=marker.animal, marker=marker) + is_new = True + except IntegrityError: + is_new = False + if is_new: + result.result = Scan.Result.DISCOVERY + result.discovery_xp = int(session.zoo.setting("discovery_xp", 0)) + result.total_xp, result.level_up = session.add_xp( + result.discovery_xp, XPEvent.Source.DISCOVERY, marker.animal_id + ) + else: + result.result = Scan.Result.REPEAT + + Scan.objects.create( + session=session, marker=marker, result=result.result, xp_awarded=result.discovery_xp + ) + + # M3 hook: complete any active-quest challenge that accepts this marker. + # M4 hook: evaluate badge rules after XP changes. + + result.suggested_quest = suggest_quest_for(marker) + return result + + +def suggest_quest_for(marker): + """The first live quest that includes a challenge this marker completes.""" + now = timezone.now() + return ( + Quest.objects.for_zoo(marker.zoo) + .active() + .filter(steps__challenge__accept_markers=marker) + .exclude(starts_at__gt=now) + .exclude(ends_at__lt=now) + .order_by("-is_featured", "sort_order") + .first() + ) + + +def profile_for(session: GuestSession) -> dict: + discoveries = session.discoveries.select_related("animal", "animal__exhibit").order_by("discovered_at") + return { + "token": str(session.token), + "zoo": {"slug": session.zoo.slug, "name": session.zoo.name}, + "team_name": session.team_name, + "total_xp": session.total_xp, + "level": session.level_info(), + "stats": { + "animals_discovered": discoveries.count(), + "animals_total": session.zoo.animals.filter(is_active=True).count(), + "scans": session.scans.count(), + "challenges_completed": session.attempts.filter(status="completed").count(), + "badges": 0, # M4 + }, + "discoveries": [ + { + "slug": d.animal.slug, + "name": d.animal.name, + "emoji": d.animal.emoji, + "image": d.animal.image.url if d.animal.image else None, + "exhibit": d.animal.exhibit.name, + "discovered_at": d.discovered_at, + } + for d in discoveries + ], + "created_at": session.created_at, + } diff --git a/apps/api/apps/play/tests/conftest.py b/apps/api/apps/play/tests/conftest.py new file mode 100644 index 0000000..b093c0e --- /dev/null +++ b/apps/api/apps/play/tests/conftest.py @@ -0,0 +1,11 @@ +import pytest +from django.core.management import call_command + +from apps.tenants.models import Zoo + + +@pytest.fixture +def cedar_hollow(db): + """The fictional test zoo, loaded from the shipped fixture. Every content test starts here.""" + call_command("loaddata", "fixtures/cedar_hollow_seed.json", verbosity=0) + return Zoo.objects.get(slug="cedar-hollow") diff --git a/apps/api/apps/play/tests/test_scan_loop.py b/apps/api/apps/play/tests/test_scan_loop.py new file mode 100644 index 0000000..c94a8c0 --- /dev/null +++ b/apps/api/apps/play/tests/test_scan_loop.py @@ -0,0 +1,122 @@ +import pytest +from django.urls import reverse + +from apps.play.models import Discovery, GuestSession, Scan, XPEvent + + +def start(client, zoo="cedar-hollow", team=""): + r = client.post( + reverse("v1:session-create", args=[zoo]), {"team_name": team}, content_type="application/json" + ) + assert r.status_code == 201, r.content + return r.json() + + +def scan(client, token, code): + return client.post( + reverse("v1:scan"), {"code": code}, content_type="application/json", HTTP_X_GUEST_TOKEN=token + ) + + +@pytest.mark.django_db +class TestSessions: + def test_start_returns_profile_with_token(self, client, cedar_hollow): + p = start(client, team="Team Giraffe") + assert p["team_name"] == "Team Giraffe" + assert p["total_xp"] == 0 + assert p["level"]["title"] == "Zoo Rookie" and p["level"]["next_at"] == 300 + assert p["stats"]["animals_total"] == 10 + assert GuestSession.objects.get(token=p["token"]).zoo == cedar_hollow + + def test_me_requires_token(self, client, cedar_hollow): + assert client.get(reverse("v1:me")).status_code == 401 + + def test_me_rejects_garbage_token(self, client, cedar_hollow): + r = client.get(reverse("v1:me"), HTTP_X_GUEST_TOKEN="not-a-uuid") + assert r.status_code == 401 + + def test_patch_team_name(self, client, cedar_hollow): + token = start(client)["token"] + r = client.patch( + reverse("v1:me"), + {"team_name": " The Explorers "}, + content_type="application/json", + HTTP_X_GUEST_TOKEN=token, + ) + assert r.status_code == 200 and r.json()["team_name"] == "The Explorers" + + +@pytest.mark.django_db +class TestScan: + def test_first_scan_discovers_and_awards_xp(self, client, cedar_hollow): + token = start(client)["token"] + r = scan(client, token, "chz-giraffe-001") # lowercase on purpose + assert r.status_code == 200, r.content + body = r.json() + assert body["discovery"] == {"result": "discovery", "is_new": True, "xp": 100} + assert body["animal"]["slug"] == "giraffe" + assert len(body["animal"]["fun_facts"]) == 3 + assert body["totals"]["xp"] == 100 + assert body["level_up"] is None + assert body["suggested_quest"]["slug"] == "savanna-safari" + assert body["suggested_quest"]["mission_count"] == 6 + assert Discovery.objects.count() == 1 + assert XPEvent.objects.get().amount == 100 + + def test_repeat_scan_gives_no_xp_but_is_logged(self, client, cedar_hollow): + token = start(client)["token"] + scan(client, token, "CHZ-GIRAFFE-001") + r = scan(client, token, "CHZ-GIRAFFE-001") + assert r.status_code == 200 + assert r.json()["discovery"] == {"result": "repeat", "is_new": False, "xp": 0} + assert r.json()["totals"]["xp"] == 100 + assert Scan.objects.count() == 2 + assert Discovery.objects.count() == 1 + + def test_exhibit_marker_without_animal(self, client, cedar_hollow): + token = start(client)["token"] + r = scan(client, token, "CHZ-BASECAMP-001") + assert r.status_code == 200 + assert r.json()["discovery"]["result"] == "exhibit" + assert r.json()["animal"] is None + assert r.json()["marker"]["exhibit"]["name"] == "Base Camp" + + def test_level_up_is_reported_once(self, client, cedar_hollow): + token = start(client)["token"] + codes = ["CHZ-GIRAFFE-001", "CHZ-ELEPHANT-001", "CHZ-ZEBRA-001"] # 300 XP = level 2 + results = [scan(client, token, c).json() for c in codes] + assert results[0]["level_up"] is None and results[1]["level_up"] is None + assert results[2]["level_up"]["title"] == "Animal Explorer" + assert results[2]["totals"]["level"]["number"] == 2 + + def test_unknown_marker(self, client, cedar_hollow): + token = start(client)["token"] + r = scan(client, token, "CHZ-DRAGON-001") + assert r.status_code == 404 and r.json()["error"] == "unknown_marker" + + def test_inactive_marker(self, client, cedar_hollow): + cedar_hollow.markers.filter(code="CHZ-LION-001").update(is_active=False) + token = start(client)["token"] + r = scan(client, token, "CHZ-LION-001") + assert r.status_code == 410 and r.json()["error"] == "inactive_marker" + + def test_marker_from_another_zoo_is_refused(self, client, cedar_hollow): + from apps.tenants.models import Zoo + + other = Zoo.objects.create(name="Other Zoo", slug="other") + token = start(client, zoo="other")["token"] + r = scan(client, token, "CHZ-GIRAFFE-001") + assert r.status_code == 400 and r.json()["error"] == "wrong_zoo" + assert other.guestsessions.count() == 1 + + def test_scan_requires_token(self, client, cedar_hollow): + assert scan(client, "", "CHZ-GIRAFFE-001").status_code in (401, 403) + + def test_profile_reflects_discoveries(self, client, cedar_hollow): + token = start(client)["token"] + scan(client, token, "CHZ-SLOTH-001") + scan(client, token, "CHZ-TORTOISE-001") + p = client.get(reverse("v1:me"), HTTP_X_GUEST_TOKEN=token).json() + assert p["total_xp"] == 200 + assert p["stats"]["animals_discovered"] == 2 and p["stats"]["scans"] == 2 + assert [d["slug"] for d in p["discoveries"]] == ["sloth", "tortoise"] diff --git a/apps/api/apps/play/urls.py b/apps/api/apps/play/urls.py new file mode 100644 index 0000000..a90fac7 --- /dev/null +++ b/apps/api/apps/play/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("zoos//sessions/", views.SessionCreateView.as_view(), name="session-create"), + path("me/", views.MeView.as_view(), name="me"), + path("scan/", views.ScanView.as_view(), name="scan"), +] diff --git a/apps/api/apps/play/views.py b/apps/api/apps/play/views.py new file mode 100644 index 0000000..305037f --- /dev/null +++ b/apps/api/apps/play/views.py @@ -0,0 +1,105 @@ +from django.db.models import Count +from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.response import Response +from rest_framework.throttling import ScopedRateThrottle +from rest_framework.views import APIView + +from apps.content.serializers import AnimalDetailSerializer, QuestListSerializer +from apps.tenants.models import Zoo + +from .auth import IsGuest +from .serializers import ( + CreateSessionSerializer, + ScanRequestSerializer, + ScanResponseSerializer, + UpdateSessionSerializer, +) +from .services import ScanError, create_session, profile_for, resolve_scan + + +class SessionCreateView(APIView): + """POST /zoos/{zoo}/sessions/ — start an anonymous adventure. Returns the token to keep.""" + + authentication_classes = [] + permission_classes = [] + + @extend_schema(request=CreateSessionSerializer, responses={201: dict}, tags=["play"]) + def post(self, request, zoo_slug): + zoo = get_object_or_404(Zoo, slug=zoo_slug, is_active=True) + data = CreateSessionSerializer(data=request.data or {}) + data.is_valid(raise_exception=True) + session = create_session( + zoo, + team_name=data.validated_data["team_name"], + user_agent=request.META.get("HTTP_USER_AGENT", ""), + ) + return Response(profile_for(session), status=status.HTTP_201_CREATED) + + +class MeView(APIView): + """GET /me/ — the explorer profile. PATCH /me/ — change the team name.""" + + permission_classes = [IsGuest] + + @extend_schema(responses={200: dict}, tags=["play"]) + def get(self, request): + return Response(profile_for(request.guest)) + + @extend_schema(request=UpdateSessionSerializer, responses={200: dict}, tags=["play"]) + def patch(self, request): + data = UpdateSessionSerializer(data=request.data) + data.is_valid(raise_exception=True) + request.guest.team_name = data.validated_data["team_name"].strip() + request.guest.save(update_fields=["team_name"]) + return Response(profile_for(request.guest)) + + +class ScanView(APIView): + """POST /scan/ {code} — the main verb. One response renders the Success screen.""" + + permission_classes = [IsGuest] + throttle_classes = [ScopedRateThrottle] + throttle_scope = "scan" + + @extend_schema(request=ScanRequestSerializer, responses={200: ScanResponseSerializer}, tags=["play"]) + def post(self, request): + data = ScanRequestSerializer(data=request.data) + data.is_valid(raise_exception=True) + try: + r = resolve_scan(request.guest, data.validated_data["code"]) + except ScanError as e: + return Response({"error": e.code, "detail": e.message}, status=e.status) + + session = request.guest + m = r.marker + payload = { + "marker": { + "code": m.code, + "label": m.label, + "exhibit": {"slug": m.exhibit.slug, "name": m.exhibit.name}, + }, + "animal": AnimalDetailSerializer(m.animal).data if m.animal else None, + "discovery": {"result": r.result, "is_new": r.is_new, "xp": r.discovery_xp}, + "completed_challenges": r.completed_challenges, + "unlocked_badges": r.unlocked_badges, + "level_up": ( + { + "number": r.level_up.number, + "title": r.level_up.title, + "xp_required": r.level_up.xp_required, + } + if r.level_up + else None + ), + "totals": {"xp": r.total_xp, "level": session.level_info()}, + "suggested_quest": QuestListSerializer(_with_mission_count(r.suggested_quest)).data + if r.suggested_quest + else None, + } + return Response(payload) + + +def _with_mission_count(quest): + return type(quest).objects.filter(pk=quest.pk).annotate(mission_count=Count("steps")).first() diff --git a/apps/api/config/settings/base.py b/apps/api/config/settings/base.py index b505a90..12f3edd 100644 --- a/apps/api/config/settings/base.py +++ b/apps/api/config/settings/base.py @@ -128,7 +128,10 @@ REST_FRAMEWORK = { "DEFAULT_RENDERER_CLASSES": ["rest_framework.renderers.JSONRenderer"], "DEFAULT_PARSER_CLASSES": ["rest_framework.parsers.JSONParser"], - "DEFAULT_AUTHENTICATION_CLASSES": ["rest_framework.authentication.SessionAuthentication"], + "DEFAULT_AUTHENTICATION_CLASSES": [ + "apps.play.auth.GuestTokenAuthentication", + "rest_framework.authentication.SessionAuthentication", + ], "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.AllowAny"], "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_THROTTLE_CLASSES": ["rest_framework.throttling.AnonRateThrottle"], @@ -202,6 +205,13 @@ {"title": "Badges", "icon": "military_tech", "link": "/admin/content/badge/"}, ], }, + { + "title": "Visitors", + "items": [ + {"title": "Sessions", "icon": "groups", "link": "/admin/play/guestsession/"}, + {"title": "Scans", "icon": "qr_code_scanner", "link": "/admin/play/scan/"}, + ], + }, ], }, } diff --git a/apps/api/config/urls.py b/apps/api/config/urls.py index 0033ab5..8394f69 100644 --- a/apps/api/config/urls.py +++ b/apps/api/config/urls.py @@ -16,7 +16,7 @@ api_v1 = [ path("", include("apps.core.urls")), path("", include("apps.content.urls")), - # M2: path("", include("apps.play.urls")), + path("", include("apps.play.urls")), # M5: path("", include("apps.analytics.urls")), ] diff --git a/apps/web/src/app/s/[code]/MarkerLanding.tsx b/apps/web/src/app/s/[code]/MarkerLanding.tsx new file mode 100644 index 0000000..abe7e1d --- /dev/null +++ b/apps/web/src/app/s/[code]/MarkerLanding.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; + +import { ErrorNote, Loading } from "@/components/Loading"; +import { useSession } from "@/hooks/useSession"; +import { ApiError, lookupMarker, scanMarker } from "@/lib/api"; +import { useStore } from "@/lib/store"; + +/** + * 1. Resolve the printed code to a zoo (public, no session needed). + * 2. Make sure we have a session for that zoo (creates one on first visit). + * 3. POST /scan once, stash the result, go celebrate. + */ +export function MarkerLanding({ code }: { code: string }) { + const router = useRouter(); + const setLastResult = useStore((s) => s.setLastResult); + const marker = useQuery({ queryKey: ["marker", code], queryFn: () => lookupMarker(code), retry: 1 }); + const zoo = marker.data?.zoo.slug ?? null; + const session = useSession(zoo); + const fired = useRef(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!marker.data || !session.ready || fired.current) return; + fired.current = true; + scanMarker(code) + .then((result) => { + setLastResult(result); + router.replace(`/z/${zoo}/success`); + }) + .catch((e: ApiError) => { + fired.current = false; + setError(e); + }); + }, [marker.data, session.ready, code, zoo, router, setLastResult]); + + if (marker.isError) { + return ( + + + + ); + } + if (error) { + return ( + + setError(null)} /> + + ); + } + if (session.error) { + return ( + + + + ); + } + return ( + +

Quest marker

+

+ {marker.data ? marker.data.exhibit_name : "Scanning…"} +

+ {marker.data?.animal_name &&

{marker.data.animal_name}

} + +
+ ); +} + +function Shell({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/apps/web/src/app/s/[code]/page.tsx b/apps/web/src/app/s/[code]/page.tsx index b5c7917..cce38f6 100644 --- a/apps/web/src/app/s/[code]/page.tsx +++ b/apps/web/src/app/s/[code]/page.tsx @@ -1,10 +1,7 @@ -import { Placeholder } from "@/components/Placeholder"; +import { MarkerLanding } from "./MarkerLanding"; -export default async function Page({ params }: { params: Promise> }) { - const p = await params; - return ( - -
{JSON.stringify(p, null, 2)}
-
- ); +/** The QR entry point. Everything interesting happens client-side once storage is readable. */ +export default async function Page({ params }: { params: Promise<{ code: string }> }) { + const { code } = await params; + return ; } diff --git a/apps/web/src/app/z/[zoo]/Welcome.tsx b/apps/web/src/app/z/[zoo]/Welcome.tsx new file mode 100644 index 0000000..2b5b43f --- /dev/null +++ b/apps/web/src/app/z/[zoo]/Welcome.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { ErrorNote, Loading } from "@/components/Loading"; +import { useSession } from "@/hooks/useSession"; +import { getZoo, updateTeamName } from "@/lib/api"; + +export function Welcome({ zoo }: { zoo: string }) { + const router = useRouter(); + const info = useQuery({ queryKey: ["zoo", zoo], queryFn: () => getZoo(zoo) }); + const session = useSession(zoo); + const [team, setTeam] = useState(""); + const save = useMutation({ + mutationFn: (name: string) => updateTeamName(name), + onSettled: () => router.push(`/z/${zoo}/quests`), + }); + + if (info.isError) return ; + if (!info.data || session.isLoading) return ; + + const returning = (session.profile?.stats.scans ?? 0) > 0; + const start = () => { + const name = team.trim(); + if (name && name !== session.profile?.team_name) save.mutate(name); + else router.push(`/z/${zoo}/quests`); + }; + + return ( +
+
+
+

{info.data.name}

+

Zoo Quest

+

+ {returning + ? `Welcome back${session.profile?.team_name ? `, ${session.profile.team_name}` : ""}. Your adventure is saved.` + : `Your adventure starts here. ${info.data.animal_count} animals to discover, ${info.data.quest_count} quests to finish.`} +

+ + {!returning && ( + + )} + + +

+ No account needed. Progress stays on this phone. +

+
+
+ ); +} diff --git a/apps/web/src/app/z/[zoo]/ZooFrame.tsx b/apps/web/src/app/z/[zoo]/ZooFrame.tsx new file mode 100644 index 0000000..ffe53ea --- /dev/null +++ b/apps/web/src/app/z/[zoo]/ZooFrame.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { usePathname } from "next/navigation"; + +import { AppShell } from "@/components/AppShell"; +import { useSession } from "@/hooks/useSession"; + +const FULLSCREEN = [/^\/z\/[^/]+\/?$/, /\/success$/]; + +export function ZooFrame({ zoo, children }: { zoo: string; children: React.ReactNode }) { + const pathname = usePathname(); + const session = useSession(zoo); + if (FULLSCREEN.some((re) => re.test(pathname))) return <>{children}; + return ( + + {children} + + ); +} diff --git a/apps/web/src/app/z/[zoo]/layout.tsx b/apps/web/src/app/z/[zoo]/layout.tsx new file mode 100644 index 0000000..8c71796 --- /dev/null +++ b/apps/web/src/app/z/[zoo]/layout.tsx @@ -0,0 +1,13 @@ +import { ZooFrame } from "./ZooFrame"; + +/** Every /z/[zoo]/* screen except Welcome and Success sits inside the app shell. */ +export default async function Layout({ + params, + children, +}: { + params: Promise<{ zoo: string }>; + children: React.ReactNode; +}) { + const { zoo } = await params; + return {children}; +} diff --git a/apps/web/src/app/z/[zoo]/page.tsx b/apps/web/src/app/z/[zoo]/page.tsx index 6480977..a5a6d51 100644 --- a/apps/web/src/app/z/[zoo]/page.tsx +++ b/apps/web/src/app/z/[zoo]/page.tsx @@ -1,10 +1,6 @@ -import { Placeholder } from "@/components/Placeholder"; +import { Welcome } from "./Welcome"; -export default async function Page({ params }: { params: Promise> }) { - const p = await params; - return ( - -
{JSON.stringify(p, null, 2)}
-
- ); +export default async function Page({ params }: { params: Promise<{ zoo: string }> }) { + const { zoo } = await params; + return ; } diff --git a/apps/web/src/app/z/[zoo]/profile/Profile.tsx b/apps/web/src/app/z/[zoo]/profile/Profile.tsx new file mode 100644 index 0000000..efdbf48 --- /dev/null +++ b/apps/web/src/app/z/[zoo]/profile/Profile.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { ErrorNote, Loading } from "@/components/Loading"; +import { useSession } from "@/hooks/useSession"; + +export function Profile({ zoo }: { zoo: string }) { + const s = useSession(zoo); + if (s.error) return s.refetch()} />; + if (!s.profile) return ; + const p = s.profile; + const remaining = p.stats.animals_total - p.stats.animals_discovered; + + return ( +
+

Explorer profile

+

{p.team_name || "Your team"}

+

+ Level {p.level.number} · {p.level.title} + {p.level.next_at && ` · ${(p.level.next_at - p.total_xp).toLocaleString()} XP to ${p.level.next_title}`} +

+ +
+ + + +
+ +

Discovered

+ {p.discoveries.length === 0 ? ( +

+ Nothing yet. Find a Zoo Quest sign and scan it with your camera. +

+ ) : ( +
    + {p.discoveries.map((d) => ( +
  • +
    {d.emoji || "🐾"}
    +
    {d.name}
    +
    {d.exhibit}
    +
  • + ))} +
+ )} + {remaining > 0 && p.discoveries.length > 0 && ( +

{remaining} more to find.

+ )} + +

Badges

+

+ Badges unlock in the next update. Keep scanning; nothing you earn now is lost. +

+
+ ); +} + +function Stat({ label, value }: { label: string; value: number | string }) { + return ( +
+
{label}
+
{typeof value === "number" ? value.toLocaleString() : value}
+
+ ); +} diff --git a/apps/web/src/app/z/[zoo]/profile/page.tsx b/apps/web/src/app/z/[zoo]/profile/page.tsx index bc9ca84..b3e6c59 100644 --- a/apps/web/src/app/z/[zoo]/profile/page.tsx +++ b/apps/web/src/app/z/[zoo]/profile/page.tsx @@ -1,10 +1,6 @@ -import { Placeholder } from "@/components/Placeholder"; +import { Profile } from "./Profile"; -export default async function Page({ params }: { params: Promise> }) { - const p = await params; - return ( - -
{JSON.stringify(p, null, 2)}
-
- ); +export default async function Page({ params }: { params: Promise<{ zoo: string }> }) { + const { zoo } = await params; + return ; } diff --git a/apps/web/src/app/z/[zoo]/quests/Quests.tsx b/apps/web/src/app/z/[zoo]/quests/Quests.tsx new file mode 100644 index 0000000..17e9ed3 --- /dev/null +++ b/apps/web/src/app/z/[zoo]/quests/Quests.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import Link from "next/link"; + +import { ErrorNote, Loading } from "@/components/Loading"; +import { getQuests } from "@/lib/api"; + +/** Choose Adventure. Lists quests (not mechanics: Blueprint, Section 0 decision 2). Starting one is M3. */ +export function Quests({ zoo }: { zoo: string }) { + const q = useQuery({ queryKey: ["quests", zoo], queryFn: () => getQuests(zoo) }); + if (q.isError) return q.refetch()} />; + if (!q.data) return ; + + return ( +
+

Choose adventure

+

What's your mission?

+
    + {q.data.map((quest) => ( +
  • + +
    +

    {quest.name}

    + {quest.is_featured && ( + + Featured + + )} +
    +

    {quest.description}

    +

    + {quest.mission_count} missions · about {quest.estimated_minutes} min · +{quest.xp_reward} XP + {quest.badge && ` · ${quest.badge.icon} ${quest.badge.name}`} +

    + +
  • + ))} +
+

+ Or just explore: scan any Zoo Quest sign to discover animals and earn XP. +

+
+ ); +} diff --git a/apps/web/src/app/z/[zoo]/quests/page.tsx b/apps/web/src/app/z/[zoo]/quests/page.tsx index 7945325..bdbabae 100644 --- a/apps/web/src/app/z/[zoo]/quests/page.tsx +++ b/apps/web/src/app/z/[zoo]/quests/page.tsx @@ -1,10 +1,6 @@ -import { Placeholder } from "@/components/Placeholder"; +import { Quests } from "./Quests"; -export default async function Page({ params }: { params: Promise> }) { - const p = await params; - return ( - -
{JSON.stringify(p, null, 2)}
-
- ); +export default async function Page({ params }: { params: Promise<{ zoo: string }> }) { + const { zoo } = await params; + return ; } diff --git a/apps/web/src/app/z/[zoo]/scan/Scanner.tsx b/apps/web/src/app/z/[zoo]/scan/Scanner.tsx new file mode 100644 index 0000000..e83798b --- /dev/null +++ b/apps/web/src/app/z/[zoo]/scan/Scanner.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; + +/** + * In-app scanner (the secondary path; the phone's camera app is the primary one). + * Uses the native BarcodeDetector when the browser has it, otherwise offers + * manual code entry. No scanner library in the MVP. + */ +export function Scanner({ zoo }: { zoo: string }) { + const router = useRouter(); + const videoRef = useRef(null); + const [supported] = useState(() => typeof window !== "undefined" && "BarcodeDetector" in window); + const [camera, setCamera] = useState<"idle" | "on" | "denied">("idle"); + const [code, setCode] = useState(""); + + useEffect(() => { + if (camera !== "on" || !supported) return; + let stream: MediaStream | null = null; + let raf = 0; + let stopped = false; + const Detector = (window as unknown as { BarcodeDetector: new (o: { formats: string[] }) => { detect: (v: HTMLVideoElement) => Promise<{ rawValue: string }[]> } }).BarcodeDetector; + const detector = new Detector({ formats: ["qr_code"] }); + (async () => { + try { + stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } }); + const v = videoRef.current!; + v.srcObject = stream; + await v.play(); + const loop = async () => { + if (stopped) return; + try { + const codes = await detector.detect(v); + const hit = codes.find((c) => /\/s\/([A-Z0-9-]+)/i.test(c.rawValue) || /^[A-Z0-9-]{6,40}$/i.test(c.rawValue)); + if (hit) { + const m = hit.rawValue.match(/\/s\/([A-Z0-9-]+)/i); + stopped = true; + router.push(`/s/${(m ? m[1] : hit.rawValue).toUpperCase()}`); + return; + } + } catch { + /* keep scanning */ + } + raf = requestAnimationFrame(loop); + }; + loop(); + } catch { + setCamera("denied"); + } + })(); + return () => { + stopped = true; + cancelAnimationFrame(raf); + stream?.getTracks().forEach((t) => t.stop()); + }; + }, [camera, supported, router]); + + const submit = (e: React.FormEvent) => { + e.preventDefault(); + const c = code.trim().toUpperCase(); + if (c) router.push(`/s/${c}`); + }; + + return ( +
+

Scanner

+

Scan the quest marker

+

Tip: your phone's regular camera app reads these signs too.

+ + {supported && camera !== "denied" && ( +
+ {camera === "on" ? ( +
+ )} + {camera === "denied" && ( +

+ Camera access was blocked. Type the code printed under the QR square instead. +

+ )} + +
+ +
+ setCode(e.target.value)} + placeholder="CHZ-GIRAFFE-001" + autoCapitalize="characters" + autoComplete="off" + className="min-h-14 flex-1 rounded-2xl border border-line bg-canopy px-4 font-mono text-lg uppercase text-sand placeholder:text-sand-dim/50 focus:border-ember focus:outline-none" + /> + +
+
+

{zoo.replace(/-/g, " ")}

+
+ ); +} diff --git a/apps/web/src/app/z/[zoo]/scan/page.tsx b/apps/web/src/app/z/[zoo]/scan/page.tsx index b53b3dd..0df4167 100644 --- a/apps/web/src/app/z/[zoo]/scan/page.tsx +++ b/apps/web/src/app/z/[zoo]/scan/page.tsx @@ -1,10 +1,6 @@ -import { Placeholder } from "@/components/Placeholder"; +import { Scanner } from "./Scanner"; -export default async function Page({ params }: { params: Promise> }) { - const p = await params; - return ( - -
{JSON.stringify(p, null, 2)}
-
- ); +export default async function Page({ params }: { params: Promise<{ zoo: string }> }) { + const { zoo } = await params; + return ; } diff --git a/apps/web/src/app/z/[zoo]/success/Success.tsx b/apps/web/src/app/z/[zoo]/success/Success.tsx new file mode 100644 index 0000000..e9c1ae5 --- /dev/null +++ b/apps/web/src/app/z/[zoo]/success/Success.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + +import { Confetti } from "@/components/Confetti"; +import { XPCounter } from "@/components/XPCounter"; +import { useStore } from "@/lib/store"; + +/** + * The celebration. Reads the last scan result from the store; if there is + * none (deep link, refresh) it sends the family back to their quest. + * Sequence: headline → XP roll → fact → level-up → what next. + */ +export function Success({ zoo }: { zoo: string }) { + const router = useRouter(); + const qc = useQueryClient(); + const result = useStore((s) => s.lastResult); + const token = useStore((s) => s.guestToken); + const [stage, setStage] = useState(0); + + useEffect(() => { + if (!result) { + router.replace(`/z/${zoo}/quests`); + return; + } + qc.invalidateQueries({ queryKey: ["me", token] }); + const timers = [400, 1300, 2000].map((ms, i) => setTimeout(() => setStage(i + 1), ms)); + return () => timers.forEach(clearTimeout); + }, [result, router, zoo, qc, token]); + + if (!result) return null; + + const { animal, discovery, level_up, totals, suggested_quest, marker } = result; + const isNew = discovery.is_new; + const headline = isNew ? "Discovered!" : discovery.result === "repeat" ? "Already found" : "Checkpoint"; + const fact = animal?.fun_facts?.[0]; + + return ( +
+ +
+ +

+ {isNew ? "Mission update" : marker.exhibit.name} +

+

{headline}

+ +
+
+ {animal?.emoji ?? "📍"} +
+
+

{animal ? `You found a ${animal.name.toLowerCase()}!` : marker.exhibit.name}

+

{animal ? animal.exhibit_name : marker.label}

+
+
+ +
= 1 ? "opacity-100" : "opacity-0"}`}> + {isNew ? ( +
+ + XP +
+ ) : ( +

+ {discovery.result === "repeat" ? "You've already discovered this one. No extra XP, but nice to see you again." : "Every checkpoint counts. Keep going."} +

+ )} +
+ + {fact && ( +
= 2 ? "translate-y-0 opacity-100" : "translate-y-3 opacity-0"}`}> +

Did you know?

+

{fact}

+
+ )} + + {level_up && ( +
= 3 ? "scale-100 opacity-100" : "scale-95 opacity-0"}`}> +

Level up

+

{level_up.title}

+
+ )} + +
= 3 ? "opacity-100" : "opacity-0"}`}> + {suggested_quest && ( + + {isNew ? "Next mission" : "Back to quest"} → + + )} + + {totals.xp.toLocaleString()} XP · See my profile + +
+
+ ); +} diff --git a/apps/web/src/app/z/[zoo]/success/page.tsx b/apps/web/src/app/z/[zoo]/success/page.tsx index f557d7e..36e1200 100644 --- a/apps/web/src/app/z/[zoo]/success/page.tsx +++ b/apps/web/src/app/z/[zoo]/success/page.tsx @@ -1,10 +1,6 @@ -import { Placeholder } from "@/components/Placeholder"; +import { Success } from "./Success"; -export default async function Page({ params }: { params: Promise> }) { - const p = await params; - return ( - -
{JSON.stringify(p, null, 2)}
-
- ); +export default async function Page({ params }: { params: Promise<{ zoo: string }> }) { + const { zoo } = await params; + return ; } diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx new file mode 100644 index 0000000..e40fba0 --- /dev/null +++ b/apps/web/src/components/AppShell.tsx @@ -0,0 +1,62 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +import type { Profile } from "@/lib/api"; +import { XPBar } from "./XPBar"; + +const tabs = [ + { key: "quests", label: "Quest", icon: "🧭" }, + { key: "scan", label: "Scan", icon: "▣" }, + { key: "map", label: "Map", icon: "🗺️" }, + { key: "profile", label: "You", icon: "🎒" }, +]; + +/** Persistent frame: XP bar on top, four-tab nav on the bottom, content between. */ +export function AppShell({ + zoo, + profile, + children, +}: { + zoo: string; + profile: Profile | null; + children: React.ReactNode; +}) { + const pathname = usePathname(); + return ( +
+
+ +
+
{children}
+ +
+ ); +} diff --git a/apps/web/src/components/Confetti.tsx b/apps/web/src/components/Confetti.tsx new file mode 100644 index 0000000..0cc5f3c --- /dev/null +++ b/apps/web/src/components/Confetti.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +/** One-shot canvas burst in the brand colors. Skips entirely under reduced motion. */ +export function Confetti({ fire }: { fire: boolean }) { + const ref = useRef(null); + useEffect(() => { + if (!fire) return; + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + const canvas = ref.current!; + const ctx = canvas.getContext("2d")!; + const dpr = window.devicePixelRatio || 1; + const w = (canvas.width = canvas.offsetWidth * dpr); + const h = (canvas.height = canvas.offsetHeight * dpr); + const colors = ["#f0963a", "#3f8a63", "#f2ede2", "#c96f16"]; + const parts = Array.from({ length: 90 }, () => ({ + x: w / 2, + y: h * 0.35, + vx: (Math.random() - 0.5) * 14 * dpr, + vy: (Math.random() - 1.1) * 12 * dpr, + s: (4 + Math.random() * 5) * dpr, + c: colors[Math.floor(Math.random() * colors.length)], + r: Math.random() * Math.PI, + })); + let frame = 0; + let raf = 0; + const draw = () => { + ctx.clearRect(0, 0, w, h); + for (const p of parts) { + p.x += p.vx; + p.y += p.vy; + p.vy += 0.35 * dpr; + p.r += 0.1; + ctx.save(); + ctx.translate(p.x, p.y); + ctx.rotate(p.r); + ctx.fillStyle = p.c; + ctx.globalAlpha = Math.max(0, 1 - frame / 80); + ctx.fillRect(-p.s / 2, -p.s / 2, p.s, p.s * 0.6); + ctx.restore(); + } + if (frame++ < 85) raf = requestAnimationFrame(draw); + else ctx.clearRect(0, 0, w, h); + }; + raf = requestAnimationFrame(draw); + return () => cancelAnimationFrame(raf); + }, [fire]); + return ; +} diff --git a/apps/web/src/components/Loading.tsx b/apps/web/src/components/Loading.tsx new file mode 100644 index 0000000..d753135 --- /dev/null +++ b/apps/web/src/components/Loading.tsx @@ -0,0 +1,22 @@ +export function Loading({ label = "Loading your adventure…" }: { label?: string }) { + return ( +
+ + {label} +
+ ); +} + +export function ErrorNote({ message, onRetry }: { message: string; onRetry?: () => void }) { + return ( +
+

Hmm, that didn't work.

+

{message}

+ {onRetry && ( + + )} +
+ ); +} diff --git a/apps/web/src/components/XPBar.tsx b/apps/web/src/components/XPBar.tsx new file mode 100644 index 0000000..82be9d0 --- /dev/null +++ b/apps/web/src/components/XPBar.tsx @@ -0,0 +1,36 @@ +"use client"; + +import type { Profile } from "@/lib/api"; + +/** Level title, XP total and progress to the next level. Reads only from the profile. */ +export function XPBar({ profile }: { profile: Profile | null }) { + if (!profile) { + return
; + } + const { level, total_xp } = profile; + const span = level.next_at ? level.next_at - level.xp_required : 1; + const pct = level.next_at ? Math.min(100, ((total_xp - level.xp_required) / span) * 100) : 100; + return ( +
+
+ + Lv {level.number} · {level.title} + + + {total_xp.toLocaleString()} XP + {level.next_at && / {level.next_at.toLocaleString()}} + +
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/XPCounter.tsx b/apps/web/src/components/XPCounter.tsx new file mode 100644 index 0000000..b45be39 --- /dev/null +++ b/apps/web/src/components/XPCounter.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** Rolls from 0 to `value` over ~900ms. Respects reduced motion by jumping straight there. */ +export function XPCounter({ value, prefix = "+" }: { value: number; prefix?: string }) { + const [shown, setShown] = useState(0); + useEffect(() => { + const reduce = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const dur = reduce || value === 0 ? 0 : 900; + let start = 0; + let raf = 0; + const tick = (t: number) => { + if (!start) start = t; + const p = dur === 0 ? 1 : Math.min(1, (t - start) / dur); + const eased = 1 - Math.pow(1 - p, 3); + setShown(Math.round(value * eased)); + if (p < 1) raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [value]); + return ( + + {prefix} + {shown} + + ); +} diff --git a/apps/web/src/hooks/useSession.ts b/apps/web/src/hooks/useSession.ts new file mode 100644 index 0000000..3214b88 --- /dev/null +++ b/apps/web/src/hooks/useSession.ts @@ -0,0 +1,74 @@ +"use client"; + +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; + +import { ApiError, createSession, getMe, type Profile } from "@/lib/api"; +import { useStore } from "@/lib/store"; + +/** + * Only one session may be created at a time, no matter how many components + * call useSession() in the same render (the app frame and the page both do). + */ +let inflight: { zoo: string; promise: Promise } | null = null; + +function createOnce(zoo: string): Promise { + if (inflight?.zoo === zoo) return inflight.promise; + const promise = createSession(zoo).finally(() => { + if (inflight?.promise === promise) inflight = null; + }); + inflight = { zoo, promise }; + return promise; +} + +/** + * Guarantees a guest session for `zooSlug` and exposes the profile. + * - No token yet → creates one (once). + * - Token for a different zoo → replaces it (a family at a new zoo is a new adventure). + * - Token rejected by the API (401) → clears it and starts over. + */ +export function useSession(zooSlug: string | null) { + const { guestToken, zooSlug: storedZoo, hydrated, setSession, clearSession } = useStore(); + const qc = useQueryClient(); + const [createError, setCreateError] = useState(null); + + const needsNew = hydrated && !!zooSlug && (!guestToken || storedZoo !== zooSlug); + + useEffect(() => { + if (!needsNew || !zooSlug || createError) return; + let cancelled = false; + createOnce(zooSlug) + .then((profile) => { + if (cancelled) return; + setSession(profile.token, profile.zoo.slug); + qc.setQueryData(["me", profile.token], profile); + }) + .catch((e: ApiError) => { + if (!cancelled) setCreateError(e); + }); + return () => { + cancelled = true; + }; + }, [needsNew, zooSlug, createError, setSession, qc]); + + const me = useQuery({ + queryKey: ["me", guestToken], + queryFn: getMe, + enabled: hydrated && !!guestToken && !needsNew, + retry: (count, err) => err.status !== 401 && count < 2, + }); + + useEffect(() => { + if (me.error?.status === 401) clearSession(); + }, [me.error, clearSession]); + + return { + ready: hydrated && !!guestToken && !needsNew && !!me.data, + profile: me.data ?? null, + isLoading: !hydrated || needsNew || me.isPending, + error: createError ?? (me.error?.status === 401 ? null : me.error) ?? null, + refetch: me.refetch, + retryCreate: () => setCreateError(null), + token: guestToken, + }; +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 4fdce4d..9ba0a50 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -4,9 +4,6 @@ * - Attaches X-Guest-Token (Blueprint, Section E). * - Normalizes errors into ApiError. * - Retries idempotent POSTs (scan, start, submit) a few times for spotty signal. - * - * Typed request/response shapes are generated from the OpenAPI schema in M2 - * (`npm run api:types`); until then endpoints are typed by hand at the call site. */ import { getGuestToken } from "./store"; @@ -17,15 +14,25 @@ export class ApiError extends Error { public status: number, public detail: unknown, ) { - super(typeof detail === "string" ? detail : `API error ${status}`); + super( + typeof detail === "string" + ? detail + : detail && typeof detail === "object" && "detail" in detail + ? String((detail as { detail: unknown }).detail) + : `Something went wrong (${status}).`, + ); + } + get code(): string | undefined { + const d = this.detail as { error?: string } | null; + return d?.error; } } -type Options = RequestInit & { retries?: number }; +type Options = RequestInit & { retries?: number; token?: string | null }; export async function api(path: string, options: Options = {}): Promise { - const { retries = 0, headers, ...rest } = options; - const token = getGuestToken(); + const { retries = 0, headers, token: explicitToken, ...rest } = options; + const token = explicitToken === undefined ? getGuestToken() : explicitToken; const attempt = async (remaining: number): Promise => { let response: Response; @@ -57,6 +64,133 @@ export async function api(path: string, options: Options = {}): Promise { const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); -// ---- endpoints that exist today ------------------------------------------ +// ---------------------------------------------------------------- types +// Hand-typed for now; generated from the OpenAPI schema in M3. + export type Health = { status: "ok" | "degraded"; database: string; version: string }; + +export type LevelInfo = { + number: number; + title: string; + xp_required: number; + next_title: string | null; + next_at: number | null; +}; + +export type DiscoveryCard = { + slug: string; + name: string; + emoji: string; + image: string | null; + exhibit: string; + discovered_at: string; +}; + +export type Profile = { + token: string; + zoo: { slug: string; name: string }; + team_name: string; + total_xp: number; + level: LevelInfo; + stats: { + animals_discovered: number; + animals_total: number; + scans: number; + challenges_completed: number; + badges: number; + }; + discoveries: DiscoveryCard[]; + created_at: string; +}; + +export type ZooSummary = { + slug: string; + name: string; + logo: string | null; + primary_color: string; + quest_count: number; + animal_count: number; + levels: { number: number; title: string; xp_required: number }[]; +}; + +export type Badge = { + id: number; + slug: string; + name: string; + description: string; + icon: string; + image: string | null; + xp_reward: number; + is_secret: boolean; + requirement: string; +}; + +export type QuestCard = { + id: number; + slug: string; + name: string; + description: string; + cover_image: string | null; + xp_reward: number; + badge: Badge | null; + estimated_minutes: number | null; + is_featured: boolean; + mission_count: number; +}; + +export type AnimalDetail = { + id: number; + slug: string; + name: string; + emoji: string; + image: string | null; + exhibit: string; + exhibit_name: string; + conservation_status: string; + conservation_status_label: string; + species: string; + scientific_name: string; + description: string; + fun_facts: string[]; + conservation_info: string; + tags: string[]; +}; + +export type MarkerLookup = { + code: string; + exhibit: string; + animal: string | null; + label: string; + zoo: { slug: string; name: string }; + exhibit_name: string; + animal_name: string | null; +}; + +export type ScanResult = { + marker: { code: string; label: string; exhibit: { slug: string; name: string } }; + animal: AnimalDetail | null; + discovery: { result: "discovery" | "repeat" | "exhibit"; is_new: boolean; xp: number }; + completed_challenges: unknown[]; + unlocked_badges: unknown[]; + level_up: { number: number; title: string; xp_required: number } | null; + totals: { xp: number; level: LevelInfo }; + suggested_quest: QuestCard | null; +}; + +// ---------------------------------------------------------------- endpoints export const getHealth = () => api("/health/"); +export const getZoo = (zoo: string) => api(`/zoos/${zoo}/`); +export const getQuests = (zoo: string) => api(`/zoos/${zoo}/quests/`); +export const lookupMarker = (code: string) => api(`/markers/${encodeURIComponent(code)}/`); +export const createSession = (zoo: string, team_name = "") => + api(`/zoos/${zoo}/sessions/`, { + method: "POST", + body: JSON.stringify({ team_name }), + token: null, + retries: 2, + }); +export const getMe = () => api("/me/"); +export const updateTeamName = (team_name: string) => + api("/me/", { method: "PATCH", body: JSON.stringify({ team_name }) }); +export const scanMarker = (code: string) => + api("/scan/", { method: "POST", body: JSON.stringify({ code }), retries: 3 }); diff --git a/apps/web/src/lib/store.ts b/apps/web/src/lib/store.ts index 731d337..42ae588 100644 --- a/apps/web/src/lib/store.ts +++ b/apps/web/src/lib/store.ts @@ -5,13 +5,19 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; +import type { ScanResult } from "./api"; + type State = { guestToken: string | null; zooSlug: string | null; - /** The last scan/submit result, handed to the Success screen to celebrate. */ - lastResult: unknown | null; + /** True once the persisted values have been read back from storage. */ + hydrated: boolean; + /** The last scan result, handed to the Success screen to celebrate. Not persisted. */ + lastResult: ScanResult | null; setSession: (token: string, zooSlug: string) => void; - setLastResult: (result: unknown | null) => void; + clearSession: () => void; + setLastResult: (result: ScanResult | null) => void; + setHydrated: () => void; }; export const useStore = create()( @@ -19,13 +25,17 @@ export const useStore = create()( (set) => ({ guestToken: null, zooSlug: null, + hydrated: false, lastResult: null, setSession: (guestToken, zooSlug) => set({ guestToken, zooSlug }), + clearSession: () => set({ guestToken: null, zooSlug: null, lastResult: null }), setLastResult: (lastResult) => set({ lastResult }), + setHydrated: () => set({ hydrated: true }), }), { name: "zooquest", - partialize: (s) => ({ guestToken: s.guestToken, zooSlug: s.zooSlug }), // lastResult is per-visit + partialize: (s) => ({ guestToken: s.guestToken, zooSlug: s.zooSlug }), + onRehydrateStorage: () => (state) => state?.setHydrated(), }, ), );