diff --git a/PLAN.md b/PLAN.md index 2d87b58..9934561 100644 --- a/PLAN.md +++ b/PLAN.md @@ -62,7 +62,7 @@ - [x] **FE1** — Map: show POI markers with distinct glowing style - [X] **FE1** — Map: show billboard markers with note icon style - [x] **FE1** — Map: show user's current location as their 64×64 avatar (instead of a standard dot) -- [ ] **FE1** — POI discovery UX: toast when entering geofence + quest progress trigger (quest-progress toast plumbing exists for API mutations; geofence trigger still pending) +- [ ] **FE1** — POI discovery UX: toast when entering geofence + quest progress trigger (manual POI check-in now calls the live visit API; automatic geofence trigger still pending) - [X] **FE2** — Billboard expanded view (~60vh overlay): text + username pill + all placements (z-ordered) - [X] **FE2** — Pixel art sticker editor: 64×64 grid, 8-colour palette, tap-to-fill, save to collection - [X] **FE2** — Sticky note composer: text input, preview as sticky note, post to billboard @@ -76,7 +76,7 @@ - [x] **BE2** — Durable Object: WebSocket handler, Postgres connection, broadcast on mutations - [ ] **BE2** — Expo Push Notification integration: register token, send on reply + daily reminder - [x] **FE1** — Quest screen: main quest tiers + daily quest + streak counter + progress bars (currently backed by mock quest data) -- [x] **FE1** — Profile screen: level, perks unlocked, stats (notes placed, stickers saved, POIs visited) +- [x] **FE1** — Profile screen: level, perks unlocked, stats (notes placed, stickers saved, POIs visited), backed by the live user/progress API - [x] **FE1** — Level-up celebration animation/overlay - [x] **FE2** — WebSocket connection in app: connect to DO, listen for updates, refresh displayed data - [x] **FE2** — Saved stickers collection picker: browse, select, reuse stickers inside the billboard placement flow diff --git a/apps/app/app/(app)/map.tsx b/apps/app/app/(app)/map.tsx index 8639158..afd35e9 100644 --- a/apps/app/app/(app)/map.tsx +++ b/apps/app/app/(app)/map.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useCallback, useState } from "react"; import { ActivityIndicator, Modal, @@ -15,7 +15,7 @@ import { Map } from "@/components/map/Map"; import { MapHUD } from "@/components/map/MapHUD"; import { UNSW_CAMPUS_ID, UNSW_CENTER } from "@/constants/coordinates"; import { ApiError } from "@/lib/api/client"; -import { useBillboards, useCreateBillboard, usePois } from "@/lib/api/hooks"; +import { useBillboards, useCreateBillboard, usePois, useVisitPoi } from "@/lib/api/hooks"; import { colors } from "@/lib/theme"; export default function MapScreen() { @@ -23,9 +23,11 @@ export default function MapScreen() { const [createOpen, setCreateOpen] = useState(false); const [body, setBody] = useState(""); const [createError, setCreateError] = useState(null); + const [poiError, setPoiError] = useState(null); const billboards = useBillboards({ campusId: UNSW_CAMPUS_ID }); const pois = usePois({ campusId: UNSW_CAMPUS_ID }); const createBillboard = useCreateBillboard(); + const visitPoi = useVisitPoi({ campusId: UNSW_CAMPUS_ID }); const closeCreate = () => { setCreateOpen(false); @@ -65,6 +67,35 @@ export default function MapScreen() { ); }; + const checkInToPoi = useCallback( + (id: string) => { + const poi = pois.data?.find((candidate) => candidate.id === id); + if (!poi || visitPoi.isPending) return; + + setPoiError(null); + void resolveCheckInPosition({ lat: poi.lat, lng: poi.lng }) + .then((input) => { + visitPoi.mutate( + { id, input }, + { + onSuccess: (data) => { + if (!data.withinRadius) { + setPoiError("Move closer to this POI to check in."); + } + }, + onError: (err) => { + setPoiError(err instanceof ApiError ? err.message : "Could not check in here."); + }, + }, + ); + }) + .catch(() => { + setPoiError("Allow location access to check in."); + }); + }, + [pois.data, visitPoi], + ); + return ( ({ id: poi.id, title: poi.title, @@ -89,6 +121,11 @@ export default function MapScreen() { ) : null} + {poiError ? ( + + {poiError} + + ) : null} setCreateOpen(true)} /> ((resolve, reject) => { + navigator.geolocation.getCurrentPosition( + (position) => { + resolve({ + lat: position.coords.latitude, + lng: position.coords.longitude, + }); + }, + reject, + { enableHighAccuracy: true, maximumAge: 30_000, timeout: 5_000 }, + ); + }); +} + const styles = StyleSheet.create({ root: { flex: 1, @@ -174,6 +230,23 @@ const styles = StyleSheet.create({ top: 18, width: 42, }, + poiError: { + alignSelf: "center", + backgroundColor: "#F6D7CE", + borderColor: colors.pinRedDark, + borderRadius: 12, + borderWidth: 2, + maxWidth: 360, + paddingHorizontal: 14, + paddingVertical: 10, + position: "absolute", + top: 18, + }, + poiErrorText: { + color: colors.pinRedDark, + fontSize: 16, + textAlign: "center", + }, modalRoot: { flex: 1, justifyContent: "center", diff --git a/apps/app/app/(app)/profile.tsx b/apps/app/app/(app)/profile.tsx index 2ee0408..58bdbc4 100644 --- a/apps/app/app/(app)/profile.tsx +++ b/apps/app/app/(app)/profile.tsx @@ -1,6 +1,8 @@ import { router } from "expo-router"; -import { useCallback, useMemo, useState } from "react"; +import { useAuth } from "@clerk/expo"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { + ActivityIndicator, Image, Modal, Pressable, @@ -24,7 +26,14 @@ import { import { Screen } from "@/components/Screen"; import { colors, fonts, pixelBorder, stickerPalette } from "@/app/theme"; -import { setUsername, useUserProfile } from "@/lib/userProfile"; +import { + useCurrentUser, + useQuests, + useSavedStickers, + useUpdateCurrentUser, + useUserProgress, +} from "@/lib/api/hooks"; +import { avatarBase64ToUri, setUsername, useUserProfile } from "@/lib/userProfile"; const AVATAR_SIZE = 132; const RING_SIZE = AVATAR_SIZE + 18; @@ -33,73 +42,6 @@ const XP_COLOR = "#4A90D9"; const XP_TRACK = "rgba(45,45,45,0.12)"; const WEB_NO_OUTLINE = { outlineStyle: "none" } as unknown as { outlineStyle: undefined }; -const DEMO_USER = { - level: 6, - xpCurrent: 720, - xpForNext: 1000, - streak: 7, - joined: "Mar 2026", -}; - -const DEMO_STATS = [ - { key: "notes", label: "Notes placed", value: 12, Icon: MessageSquare, tint: colors.accent }, - { - key: "stickers", - label: "Stickers saved", - value: 24, - Icon: Sticker, - tint: colors.stickerPurple, - }, - { key: "pois", label: "POIs visited", value: 8, Icon: MapPin, tint: colors.primary }, - { - key: "replies", - label: "Replies received", - value: 36, - Icon: Sparkles, - tint: colors.stickerOrange, - }, -]; - -const PERKS = [ - { level: 1, label: "3 concurrent billboards · 4/day", unlocked: true }, - { level: 1, label: "10 sticker slots", unlocked: true }, - { level: 2, label: "+1 concurrent billboard (4 total)", unlocked: true }, - { level: 3, label: "+2 sticker slots (12 total)", unlocked: true }, - { level: 4, label: "Cosmetic: signature on notes", unlocked: true }, - { level: 5, label: "+1 concurrent billboard (5 total)", unlocked: true }, - { level: 6, label: "Note border flair", unlocked: true }, - { level: 7, label: "+2 sticker slots (14 total)", unlocked: false }, - { level: 8, label: "+1 concurrent billboard (6 total)", unlocked: false }, - { level: 9, label: "Sticker colour palette expansion", unlocked: false }, - { level: 10, label: "Maxed: 10 billboards · 20 sticker slots", unlocked: false }, -]; - -// 8x8 hand-authored mini stickers. Each entry is a row string of palette indices (0-7) or '.' for transparent. -// Palette index maps to stickerPalette: 0=Red 1=Blue 2=Green 3=Yellow 4=Purple 5=Orange 6=Pink 7=Cyan -const SAVED_STICKERS: string[][] = [ - // tiny heart - ["........", ".00..00.", ".000000.", ".000000.", "..0000..", "...00...", "........", "........"], - // mushroom - ["..3333..", ".333333.", "33533533", "33333333", ".322223.", "..2222..", "..2222..", "..2222.."], - // star - ["...33...", "...33...", "33333333", ".333333.", "..3333..", ".33..33.", "33....33", "........"], - // smiley - ["..3333..", ".333333.", "33033033", "33333333", "33033033", "33300333", ".333333.", "..3333.."], - // leaf - [".....22.", "....222.", "...2222.", "..22222.", ".222222.", ".22222..", ".2222...", "22......"], - // pixel cat face - [ - ".44..44.", - "444444.", - "44044044", - "44444444", - "44400444", - ".444444.", - "..4444..", - "........", - ].map((r) => r.padEnd(8, ".")), -]; - function makeXpRingUri(progress: number): string { const r = (RING_SIZE - RING_STROKE) / 2; const circ = 2 * Math.PI * r; @@ -112,19 +54,29 @@ function makeXpRingUri(progress: number): string { return `data:image/svg+xml,${encodeURIComponent(svg)}`; } -function MiniSticker({ pattern, locked }: { pattern: string[]; locked?: boolean }) { - const cell = 6; +function MiniSticker({ uri, locked }: { uri?: string; locked?: boolean }) { return ( - {pattern.map((row, ri) => ( - - {row.split("").map((ch, ci) => { - const idx = Number.parseInt(ch, 10); - const bg = Number.isNaN(idx) ? "transparent" : stickerPalette[idx]; - return ; - })} + {uri ? ( + + ) : ( + + {Array.from({ length: 8 }).map((_, row) => ( + + {Array.from({ length: 8 }).map((__, col) => ( + + ))} + + ))} - ))} + )} {locked ? ( @@ -160,27 +112,154 @@ function SectionLabel({ children }: { children: string }) { return {children}; } +function formatJoined(createdAt: string | undefined): string { + if (!createdAt) return "recently"; + const date = new Date(createdAt); + if (Number.isNaN(date.getTime())) return "recently"; + + return new Intl.DateTimeFormat(undefined, { month: "short", year: "numeric" }).format(date); +} + export default function ProfileScreen() { - const xpProgress = useMemo(() => DEMO_USER.xpCurrent / DEMO_USER.xpForNext, []); + const auth = useAuth(); const profile = useUserProfile(); - const unlockedCount = PERKS.filter((p) => p.unlocked).length; - const avatarSource = profile.avatarUri - ? { uri: profile.avatarUri } - : require("@/assets/images/avatar.png"); + const currentUser = useCurrentUser(); + const userProgress = useUserProgress(); + const quests = useQuests(); + const savedStickers = useSavedStickers(); + const updateCurrentUser = useUpdateCurrentUser(); + + const liveUser = currentUser.data; + const progress = userProgress.data; + const username = liveUser?.username ?? profile.username; + const level = progress?.level ?? liveUser?.level ?? 1; + const streak = progress?.dailyStreak ?? liveUser?.dailyStreak ?? 0; + const joined = formatJoined(liveUser?.createdAt); + const avatarUri = avatarBase64ToUri(liveUser?.avatarBase64) ?? profile.avatarUri; + const avatarSource = avatarUri ? { uri: avatarUri } : require("@/assets/images/avatar.png"); + const stickerCapacity = savedStickers.data?.capacity ?? progress?.capacities.stickerSlots ?? 0; + const liveStickers = savedStickers.data?.stickers ?? []; + + const levelXp = useMemo(() => { + const levelQuests = quests.data?.levelQuests ?? []; + const total = levelQuests.reduce((sum, quest) => sum + quest.quest.xpReward, 0); + const current = levelQuests.reduce( + (sum, quest) => sum + (quest.claimedAt ? (quest.claimedXp ?? quest.quest.xpReward) : 0), + 0, + ); + + if (total > 0) { + return { current, total }; + } + + const xp = progress?.xp ?? liveUser?.xp ?? 0; + return { current: xp, total: Math.max(xp, 1) }; + }, [liveUser?.xp, progress?.xp, quests.data?.levelQuests]); + const xpProgress = Math.min(1, levelXp.current / Math.max(levelXp.total, 1)); + + const stats = useMemo( + () => [ + { + key: "pois", + label: "POIs visited", + value: progress?.stats.poisVisited ?? 0, + Icon: MapPin, + tint: colors.primary, + }, + { + key: "placements", + label: "Pins placed", + value: progress?.stats.placementsCreated ?? 0, + Icon: MessageSquare, + tint: colors.accent, + }, + { + key: "stickers", + label: "Stickers saved", + value: progress?.stats.stickersSaved ?? liveStickers.length, + Icon: Sticker, + tint: colors.stickerPurple, + }, + { + key: "replies", + label: "Replies received", + value: progress?.stats.repliesReceived ?? 0, + Icon: Sparkles, + tint: colors.stickerOrange, + }, + ], + [liveStickers.length, progress?.stats], + ); + + const perks = useMemo(() => { + const unlocked = + progress?.unlockedPerks.map((perk) => ({ + key: perk.levelPerkId, + label: `${perk.perk.name}: ${perk.perk.description}`, + level: perk.sourceLevel, + unlocked: true, + })) ?? []; + const next = + progress?.nextPerks.map((perk) => ({ + key: perk.id, + label: `${perk.perk.name}: ${perk.perk.description}`, + level: perk.level, + unlocked: false, + })) ?? []; + + return [...unlocked, ...next]; + }, [progress?.nextPerks, progress?.unlockedPerks]); + const unlockedCount = progress?.unlockedPerks.length ?? 0; const [nameModalOpen, setNameModalOpen] = useState(false); - const [draftName, setDraftName] = useState(profile.username); + const [draftName, setDraftName] = useState(username); + const [nameError, setNameError] = useState(null); + + useEffect(() => { + if (!nameModalOpen) { + setDraftName(username); + } + }, [nameModalOpen, username]); + const openNameModal = useCallback(() => { - setDraftName(profile.username); + setDraftName(username); + setNameError(null); setNameModalOpen(true); - }, [profile.username]); - const submitName = useCallback(() => { - setUsername(draftName); - setNameModalOpen(false); - }, [draftName]); + }, [username]); + const submitName = useCallback(async () => { + const nextName = draftName.trim(); + if (!nextName) return; + + setNameError(null); + try { + await updateCurrentUser.mutateAsync({ username: nextName }); + setUsername(nextName); + setNameModalOpen(false); + } catch (err) { + setNameError(err instanceof Error ? err.message : "Could not save username."); + } + }, [draftName, updateCurrentUser]); + + const signOut = useCallback(() => { + void auth.signOut().then(() => router.replace("/(auth)/sign-in" as any)); + }, [auth]); return ( + {currentUser.isLoading || userProgress.isLoading ? ( + + + syncing profile... + + ) : null} + {currentUser.isError ? ( + + + {(currentUser.error as Error | undefined)?.message ?? "Could not load profile."} + + + ) : null} + {/* ── Identity hero ─────────────────────────── */} @@ -203,14 +282,14 @@ export default function ProfileScreen() { - lv{DEMO_USER.level} + lv{level} - @{profile.username} + @{username} - joined {DEMO_USER.joined} + joined {joined} XP @@ -218,7 +297,7 @@ export default function ProfileScreen() { - {DEMO_USER.xpCurrent}/{DEMO_USER.xpForNext} + {levelXp.current}/{levelXp.total} @@ -246,7 +325,7 @@ export default function ProfileScreen() { - {DEMO_USER.streak}-day streak + {streak}-day streak keep it going — daily quest resets at midnight @@ -255,7 +334,7 @@ export default function ProfileScreen() { key={i} style={[ styles.streakDot, - i < DEMO_USER.streak ? styles.streakDotOn : styles.streakDotOff, + i < Math.min(streak, 7) ? styles.streakDotOn : styles.streakDotOff, ]} /> ))} @@ -265,7 +344,7 @@ export default function ProfileScreen() { {/* ── Stats grid ────────────────────────────── */} stats - {DEMO_STATS.map((s) => ( + {stats.map((s) => ( ))} @@ -274,32 +353,38 @@ export default function ProfileScreen() { perks - {unlockedCount}/{PERKS.length} unlocked + {unlockedCount}/{Math.max(perks.length, unlockedCount)} unlocked - {PERKS.map((perk, i) => ( - - - - {perk.level} + {perks.length > 0 ? ( + perks.map((perk, i) => ( + + + + {perk.level} + + + + {perk.label} + {perk.unlocked ? ( + + + + ) : ( + + )} - - {perk.label} - - {perk.unlocked ? ( - - - - ) : ( - - )} + )) + ) : ( + + Perks will appear after your profile syncs. - ))} + )} {/* ── Saved stickers ────────────────────────── */} @@ -309,18 +394,23 @@ export default function ProfileScreen() { showsHorizontalScrollIndicator={false} contentContainerStyle={styles.stickerRow} > - {SAVED_STICKERS.map((p, i) => ( - + {liveStickers.map((sticker) => ( + ))} - {/* one locked slot to telegraph progression */} - + {liveStickers.length < stickerCapacity ? : null} - {SAVED_STICKERS.length}/12 slots used · unlock more at level 7 + {liveStickers.length}/{stickerCapacity} slots used {/* ── Sign out ──────────────────────────────── */} - [styles.signOutBtn, pressed && styles.pressed]}> + [styles.signOutBtn, pressed && styles.pressed]} + > sign out @@ -345,7 +435,7 @@ export default function ProfileScreen() { autoCapitalize="none" autoCorrect={false} autoFocus - maxLength={20} + maxLength={32} onChangeText={setDraftName} onSubmitEditing={submitName} placeholder="username" @@ -356,6 +446,7 @@ export default function ProfileScreen() { value={draftName} /> + {nameError ? {nameError} : null} setNameModalOpen(false)} @@ -368,16 +459,18 @@ export default function ProfileScreen() { cancel [ styles.modalBtn, styles.modalBtnPrimary, - !draftName.trim() && styles.saveBtnDisabled, + (!draftName.trim() || updateCurrentUser.isPending) && styles.saveBtnDisabled, pressed && styles.pressed, ]} > - save + + {updateCurrentUser.isPending ? "saving..." : "save"} + @@ -388,6 +481,31 @@ export default function ProfileScreen() { } const styles = StyleSheet.create({ + liveStatus: { + alignItems: "center", + backgroundColor: colors.card, + borderColor: colors.borderDark, + borderWidth: 2, + flexDirection: "row", + gap: 8, + padding: 10, + }, + liveStatusText: { + color: colors.primaryDark, + fontFamily: fonts.family, + fontSize: 16, + }, + errorBanner: { + backgroundColor: "#F6D7CE", + borderColor: colors.danger, + borderWidth: 2, + padding: 10, + }, + errorText: { + color: colors.danger, + fontFamily: fonts.family, + fontSize: 16, + }, // ── HERO ────────────────────────────── hero: { alignItems: "center", @@ -720,6 +838,14 @@ const styles = StyleSheet.create({ padding: 4, position: "relative", }, + miniStickerFallback: { + height: 48, + width: 48, + }, + miniStickerImage: { + height: 48, + width: 48, + }, miniStickerLocked: { opacity: 0.4, }, diff --git a/apps/app/app/avatar/create.tsx b/apps/app/app/avatar/create.tsx index 9455475..bef61ca 100644 --- a/apps/app/app/avatar/create.tsx +++ b/apps/app/app/avatar/create.tsx @@ -6,6 +6,8 @@ import { ChevronLeft, ImageUp, Palette } from "lucide-react-native"; import { CreateStickerPanel } from "@/components/CreateStickerPanel"; import { Screen } from "@/components/Screen"; import { colors, fonts, pixelBorder } from "@/app/theme"; +import { ApiError } from "@/lib/api/client"; +import { useUpdateAvatar } from "@/lib/api/hooks"; import { setAvatarUri } from "@/lib/userProfile"; type Mode = "draw" | "upload"; @@ -18,15 +20,32 @@ export default function CreateAvatarScreen() { const [uploadStatus, setUploadStatus] = useState(null); const [uploadTone, setUploadTone] = useState<"info" | "error" | "success">("info"); const fileInputRef = useRef(null); + const updateAvatar = useUpdateAvatar(); - const handleAvatarDrawn = useCallback(({ dataUrl }: { dataUrl: string; base64: string }) => { - setAvatarUri(dataUrl); - // small delay so the success message is briefly visible - setTimeout(() => { - if (router.canGoBack()) router.back(); - else router.replace("/(app)/profile" as any); - }, 350); - }, []); + const saveAvatar = useCallback( + async (avatarBase64: string, dataUrl: string) => { + try { + await updateAvatar.mutateAsync({ avatarBase64 }); + setAvatarUri(dataUrl); + } catch (err) { + if (err instanceof ApiError) { + throw new Error(err.message); + } + throw err; + } + + setTimeout(() => { + if (router.canGoBack()) router.back(); + else router.replace("/(app)/profile" as any); + }, 350); + }, + [updateAvatar], + ); + + const handleAvatarDrawn = useCallback( + ({ dataUrl, base64 }: { dataUrl: string; base64: string }) => saveAvatar(base64, dataUrl), + [saveAvatar], + ); const openFilePicker = useCallback(() => { if (typeof document === "undefined") { @@ -37,7 +56,7 @@ export default function CreateAvatarScreen() { if (!fileInputRef.current) { const input = document.createElement("input"); input.type = "file"; - input.accept = "image/*"; + input.accept = "image/png"; input.style.display = "none"; input.addEventListener("change", handleFileChange as unknown as EventListener); document.body.appendChild(input); @@ -66,6 +85,11 @@ export default function CreateAvatarScreen() { setUploadStatus("Could not read image."); return; } + if (!result.startsWith("data:image/png;base64,")) { + setUploadTone("error"); + setUploadStatus("Pick a PNG image."); + return; + } setUploadDataUrl(result); setUploadTone("success"); setUploadStatus(`Loaded ${file.name}`); @@ -77,12 +101,19 @@ export default function CreateAvatarScreen() { reader.readAsDataURL(file); }, []); - const handleUseUploaded = useCallback(() => { + const handleUseUploaded = useCallback(async () => { if (!uploadDataUrl) return; - setAvatarUri(uploadDataUrl); - if (router.canGoBack()) router.back(); - else router.replace("/(app)/profile" as any); - }, [uploadDataUrl]); + setUploadTone("info"); + setUploadStatus("Saving avatar..."); + try { + await saveAvatar(uploadDataUrl, uploadDataUrl); + setUploadTone("success"); + setUploadStatus("Avatar saved!"); + } catch (err) { + setUploadTone("error"); + setUploadStatus(err instanceof Error ? err.message : "Could not save avatar."); + } + }, [saveAvatar, uploadDataUrl]); return ( @@ -159,15 +190,17 @@ export default function CreateAvatarScreen() { ) : null} [ styles.saveBtn, - !uploadDataUrl && styles.saveBtnDisabled, + (!uploadDataUrl || updateAvatar.isPending) && styles.saveBtnDisabled, pressed && styles.pressed, ]} > - set as avatar + + {updateAvatar.isPending ? "saving..." : "set as avatar"} + )} diff --git a/apps/app/components/CreateStickerPanel.tsx b/apps/app/components/CreateStickerPanel.tsx index b9bd047..41ebf22 100644 --- a/apps/app/components/CreateStickerPanel.tsx +++ b/apps/app/components/CreateStickerPanel.tsx @@ -23,7 +23,7 @@ type CreateStickerPanelVariant = "sticker" | "avatar"; type CreateStickerPanelProps = { onClose?: () => void; variant?: CreateStickerPanelVariant; - onAvatarSaved?: (payload: { dataUrl: string; base64: string }) => void; + onAvatarSaved?: (payload: { dataUrl: string; base64: string }) => Promise | void; }; export function CreateStickerPanel({ @@ -36,9 +36,10 @@ export function CreateStickerPanel({ const [brushColor, setBrushColor] = useState("#111827"); const [submitStatus, setSubmitStatus] = useState(null); const [submitTone, setSubmitTone] = useState<"info" | "success" | "error">("info"); + const [avatarSubmitting, setAvatarSubmitting] = useState(false); const createAsset = useCreateStickerAsset(); const saveSticker = useSaveSticker(); - const isSubmitting = !isAvatar && (createAsset.isPending || saveSticker.isPending); + const isSubmitting = isAvatar ? avatarSubmitting : createAsset.isPending || saveSticker.isPending; const handleDownload = useCallback(() => { void ref.current?.exportAsBase64().then((payload) => { @@ -65,9 +66,19 @@ export function CreateStickerPanel({ } if (isAvatar) { - onAvatarSaved?.({ dataUrl: payload.dataUrl, base64: payload.base64 }); - setSubmitTone("success"); - setSubmitStatus("Avatar saved!"); + setAvatarSubmitting(true); + setSubmitTone("info"); + setSubmitStatus("Saving avatar..."); + try { + await onAvatarSaved?.({ dataUrl: payload.dataUrl, base64: payload.base64 }); + setSubmitTone("success"); + setSubmitStatus("Avatar saved!"); + } catch (err) { + setSubmitTone("error"); + setSubmitStatus(err instanceof Error ? err.message : "Could not save avatar."); + } finally { + setAvatarSubmitting(false); + } return; } diff --git a/apps/app/components/map/Map.native.tsx b/apps/app/components/map/Map.native.tsx index 8a90443..8f1585c 100644 --- a/apps/app/components/map/Map.native.tsx +++ b/apps/app/components/map/Map.native.tsx @@ -5,6 +5,9 @@ import { StyleSheet, Text, TouchableOpacity, View, Image } from "react-native"; import { Camera, Map as MapLibre, Marker } from "@maplibre/maplibre-react-native"; import { UNSW_CENTER } from "@/constants/coordinates"; +import { useCurrentUser } from "@/lib/api/hooks"; +import { colors } from "@/lib/theme"; +import { avatarBase64ToUri } from "@/lib/userProfile"; import type { MapPoi, MapProps } from "./Map"; @@ -109,12 +112,15 @@ function UserAvatarMarker({ coordinate, imageUrl }: UserAvatarMarkerProps) { } export default forwardRef<{ invalidateSize: () => void }, MapProps>(function Map( - { billboards, onBillboardPress, pois }, + { billboards, onBillboardPress, onPoiCheckIn, pois }, _ref, ) { const [selectedPOI, setSelectedPOI] = useState(null); + const currentUser = useCurrentUser(); - const userAvatarUrl = Asset.fromModule(require("@/assets/images/avatar.png")).uri; + const userAvatarUrl = + avatarBase64ToUri(currentUser.data?.avatarBase64) ?? + Asset.fromModule(require("@/assets/images/avatar.png")).uri; return ( @@ -166,6 +172,16 @@ export default forwardRef<{ invalidateSize: () => void }, MapProps>(function Map {selectedPOI.description ?? ""} + onPoiCheckIn?.(selectedPOI.id)} + style={[styles.checkInButton, selectedPOI.visited ? styles.checkInButtonDone : null]} + > + + {selectedPOI.visited ? "Checked in" : "Check in"} + + )} @@ -343,4 +359,24 @@ const styles = StyleSheet.create({ marginTop: 8, lineHeight: 20, }, + checkInButton: { + alignItems: "center", + backgroundColor: colors.sageDark, + borderColor: colors.sageDarker, + borderRadius: 10, + borderWidth: 2, + justifyContent: "center", + marginTop: 12, + minHeight: 44, + paddingHorizontal: 14, + }, + checkInButtonDone: { + backgroundColor: colors.sageLight, + opacity: 0.72, + }, + checkInText: { + color: colors.creamText, + fontFamily: "Jersey10", + fontSize: 20, + }, }); diff --git a/apps/app/components/map/Map.tsx b/apps/app/components/map/Map.tsx index ce8a3cf..bc00495 100644 --- a/apps/app/components/map/Map.tsx +++ b/apps/app/components/map/Map.tsx @@ -20,6 +20,7 @@ export type MapPoi = MapPoint & { export type MapProps = { billboards: MapPoint[]; onBillboardPress?: (id: string) => void; + onPoiCheckIn?: (id: string) => void; pois: MapPoi[]; }; diff --git a/apps/app/components/map/Map.web.tsx b/apps/app/components/map/Map.web.tsx index 39ec287..6b87f9b 100644 --- a/apps/app/components/map/Map.web.tsx +++ b/apps/app/components/map/Map.web.tsx @@ -4,7 +4,8 @@ import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; import { Platform, StyleSheet, View } from "react-native"; import { UNSW_CENTER } from "@/constants/coordinates"; -import { useUserProfile } from "@/lib/userProfile"; +import { useCurrentUser } from "@/lib/api/hooks"; +import { avatarBase64ToUri } from "@/lib/userProfile"; import type { MapPoint, MapPoi } from "./Map"; import { createBillboardIcon, createPOIIcon, createUserAvatarIcon } from "./markers"; @@ -21,17 +22,19 @@ type MapHandle = { invalidateSize: () => void }; type MapProps = { billboards: MapPoint[]; onBillboardPress?: (id: string) => void; + onPoiCheckIn?: (id: string) => void; pois: MapPoi[]; }; export const Map = forwardRef(function MapWeb( - { billboards, onBillboardPress, pois }, + { billboards, onBillboardPress, onPoiCheckIn, pois }, ref, ) { const containerRef = useRef(null); const mapRef = useRef(null); const userMarkerRef = useRef(null); - const { avatarUri } = useUserProfile(); + const currentUser = useCurrentUser(); + const avatarUri = avatarBase64ToUri(currentUser.data?.avatarBase64); useImperativeHandle(ref, () => ({ invalidateSize: () => { @@ -65,9 +68,7 @@ export const Map = forwardRef(function MapWeb( icon: createPOIIcon(poi.title), }) .addTo(map) - .bindPopup( - `${escapeHtml(poi.title)}
${escapeHtml(poi.description ?? "")}`, - ); + .bindPopup(createPoiPopupContent(poi, onPoiCheckIn)); } for (const billboard of billboards) { @@ -91,7 +92,7 @@ export const Map = forwardRef(function MapWeb( mapRef.current = null; userMarkerRef.current = null; }; - }, [avatarUri, billboards, onBillboardPress, pois]); + }, [avatarUri, billboards, onBillboardPress, onPoiCheckIn, pois]); useEffect(() => { if (Platform.OS !== "web") return; @@ -109,21 +110,48 @@ export const Map = forwardRef(function MapWeb( export default Map; -function escapeHtml(value: string): string { - return value.replace(/[&<>"']/g, (char) => { - switch (char) { - case "&": - return "&"; - case "<": - return "<"; - case ">": - return ">"; - case '"': - return """; - default: - return "'"; - } +function createPoiPopupContent(poi: MapPoi, onPoiCheckIn?: (id: string) => void): HTMLElement { + const root = document.createElement("div"); + root.style.minWidth = "180px"; + root.style.color = "#3E3528"; + root.style.fontFamily = "Jersey10_400Regular, Jersey10, sans-serif"; + + const title = document.createElement("strong"); + title.textContent = poi.title; + title.style.display = "block"; + title.style.fontSize = "18px"; + root.appendChild(title); + + if (poi.description) { + const description = document.createElement("p"); + description.textContent = poi.description; + description.style.fontSize = "15px"; + description.style.lineHeight = "1.15"; + description.style.margin = "6px 0 10px"; + root.appendChild(description); + } + + const button = document.createElement("button"); + button.type = "button"; + button.disabled = Boolean(poi.visited); + button.textContent = poi.visited ? "Checked in" : "Check in"; + button.style.background = poi.visited ? "#9FB287" : "#4D5E40"; + button.style.border = "2px solid #384730"; + button.style.borderRadius = "10px"; + button.style.color = "#F2EAD3"; + button.style.cursor = poi.visited ? "default" : "pointer"; + button.style.fontFamily = "inherit"; + button.style.fontSize = "17px"; + button.style.opacity = poi.visited ? "0.72" : "1"; + button.style.padding = "8px 12px"; + button.style.width = "100%"; + button.addEventListener("click", () => { + if (poi.visited) return; + onPoiCheckIn?.(poi.id); }); + + root.appendChild(button); + return root; } const styles = StyleSheet.create({ diff --git a/apps/app/lib/api/hooks.ts b/apps/app/lib/api/hooks.ts index 3880be2..7d2b5b4 100644 --- a/apps/app/lib/api/hooks.ts +++ b/apps/app/lib/api/hooks.ts @@ -13,10 +13,15 @@ import { listPoisResponseSchema, listQuestsResponseSchema, listSavedStickersResponseSchema, + updateCurrentUserResponseSchema, + visitPoiResponseSchema, type CreateBillboardInput, type CreatePlacementInput, type CreateSavedStickerInput, type CreateStickerInput, + type UpdateAvatarInput, + type UpdateCurrentUserInput, + type VisitPoiInput, } from "@repo/shared"; import { useAuth } from "@clerk/expo"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -89,6 +94,27 @@ export function usePois(filter?: { campusId?: string }) { }); } +export function useVisitPoi(filter?: { campusId?: string }) { + const auth = useApiAuth(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, input }: { id: string; input: VisitPoiInput }) => + apiFetch({ + method: "POST", + path: `/api/pois/${id}/visit`, + body: input, + getToken: auth.getToken, + schema: visitPoiResponseSchema, + }), + onSuccess: (data) => { + pushQuestProgress(data.questProgress); + queryClient.invalidateQueries({ queryKey: qk.pois(filter) }); + queryClient.invalidateQueries({ queryKey: qk.quests(auth.userId) }); + queryClient.invalidateQueries({ queryKey: qk.userProgress(auth.userId) }); + }, + }); +} + export function useCreateBillboard() { const auth = useApiAuth(); const queryClient = useQueryClient(); @@ -281,3 +307,39 @@ export function useCurrentUser() { select: (data) => data.user, }); } + +export function useUpdateCurrentUser() { + const auth = useApiAuth(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: UpdateCurrentUserInput) => + apiFetch({ + method: "PATCH", + path: "/api/users/me", + body: input, + getToken: auth.getToken, + schema: updateCurrentUserResponseSchema, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: qk.currentUser(auth.userId) }); + }, + }); +} + +export function useUpdateAvatar() { + const auth = useApiAuth(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: UpdateAvatarInput) => + apiFetch({ + method: "PATCH", + path: "/api/users/me/avatar", + body: input, + getToken: auth.getToken, + schema: updateCurrentUserResponseSchema, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: qk.currentUser(auth.userId) }); + }, + }); +} diff --git a/apps/app/lib/userProfile.ts b/apps/app/lib/userProfile.ts index 76cf420..ffe71b2 100644 --- a/apps/app/lib/userProfile.ts +++ b/apps/app/lib/userProfile.ts @@ -43,3 +43,9 @@ export function setAvatarUri(uri: string | null) { state = { ...state, avatarUri: uri }; emit(); } + +export function avatarBase64ToUri(value: string | null | undefined): string | null { + if (!value) return null; + if (value.startsWith("data:image/")) return value; + return `data:image/png;base64,${value}`; +}