diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf9a19b..9be6baf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,3 +41,6 @@ jobs: - name: Typecheck run: bun run typecheck + + - name: Test + run: bun run test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 532fb77..cc9299e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,7 @@ can track what's done. Use `- [x]` for completed items. | `bun run lint:fix` | Auto-fix lint issues | | `bun run format` | Auto-format code | | `bun run typecheck` | Type-check all packages | +| `bun run test` | Run API regression tests | ### Project Structure diff --git a/PLAN.md b/PLAN.md index 2d87b58..80e583f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -21,6 +21,7 @@ - [x] **All 4** — Agree on sticker storage format (base64 PNG), admin role mechanism (is_admin), map provider for mobile (react-native-leaflet-view), quest system (parameterised templates), daily quest pool (~5), push timing (8–9am), billboard limits (concurrent cap + Sydney-day posting cap) - [x] **All 4** — Document commit practice: update PLAN.md before each commit (`CONTRIBUTING.md`) +- [x] **All 4** — Pin TypeScript 5.9 across workspaces so local and CI typechecks use the same compiler > **Dependency edge:** Everything else depends on the shared schemas and DB schema. @@ -62,7 +63,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,10 +77,10 @@ - [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 and map HUD modal: level, avatar, perks, stats, and saved stickers 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 +- [x] **FE2** — Saved stickers collection picker: preserve names, browse, select, reuse stickers, and refresh quest/profile progress after saves - [x] **FE1/FE2** — Wire quest screen to the live quest API instead of `mockQuests` - [ ] **FE2** — Saved sticky notes collection screen: browse, select, reuse text notes @@ -95,6 +96,7 @@ - [x] **FE2** — Realtime refresh on map + billboard screen via WebSocket (pull-to-refresh not needed) - [x] **FE1** — Remove stale `events/index` and `events/[id]` stack declarations from `apps/app/app/_layout.tsx` - [x] **FE1** — Auth page polish: tighten Clerk footer gap between "Don't have an account? Sign up" and "Secured by Clerk" branding on `sign-in.web.tsx` / `sign-up.web.tsx` (CSS overrides in `lib/clerkAppearance.ts`) +- [x] **FE1/BE1** — Review hardening: expose profile editing, keep POI state live, normalize avatars, redact mutation logs, and serialize saved-sticker capacity checks --- diff --git a/apps/api/package.json b/apps/api/package.json index 5513376..9a0c98c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -20,7 +20,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "latest", - "typescript": "latest", + "typescript": "~5.9.2", "wrangler": "^4.4.0" } } diff --git a/apps/api/src/routes/stickers.ts b/apps/api/src/routes/stickers.ts index 6a5636b..93c4491 100644 --- a/apps/api/src/routes/stickers.ts +++ b/apps/api/src/routes/stickers.ts @@ -15,7 +15,11 @@ import { getDb } from "../db"; import { conflict, forbidden, notFound } from "../http"; import { getAuthUser, requireAuth } from "../middleware/auth"; import { jsonObject, isoDateTime } from "../serialize"; -import { getUserCapacities, incrementQuestProgress } from "../services/progression"; +import { + getUserCapacities, + incrementQuestProgress, + questProgressUpdate, +} from "../services/progression"; import { moderatePng, recordModerationLog } from "../services/moderation"; import type { AppBindings } from "../types"; @@ -47,6 +51,8 @@ type SavedStickerRow = { userId: string; }; +type DatabaseExecutor = Pick, "execute">; + export const stickersRoute = new Hono(); stickersRoute.post( @@ -116,39 +122,48 @@ stickersRoute.post( const db = getDb(c.env); const authUser = getAuthUser(c); const input = c.req.valid("json"); - const capacity = await getUserCapacities(db, authUser.id); - const countRows = await db.execute<{ count: number }>(sql` - select count(*)::int as count - from app.saved_stickers - where user_id = ${authUser.id} and deleted_at is null - `); + const response = await db.transaction(async (tx) => { + await tx.execute(sql`select id from app.users where id = ${authUser.id} for update`); - if ((countRows[0]?.count ?? 0) >= capacity.stickerSlots) { - conflict("Saved sticker capacity reached."); - } + const capacity = await getUserCapacities(tx, authUser.id); + const countRows = await tx.execute<{ count: number }>(sql` + select count(*)::int as count + from app.saved_stickers + where user_id = ${authUser.id} and deleted_at is null + `); - const rows = await db.execute<{ id: string }>(sql` - insert into app.saved_stickers (user_id, kind, sticker_asset_id, body, label) - values ( - ${authUser.id}, - ${input.kind}, - ${input.kind === "sticker" ? input.stickerAssetId : null}, - ${input.kind === "sticky_note" ? input.body : null}, - ${input.label ?? null} - ) - returning id - `); - const saved = (await loadSavedStickers(db, authUser.id)).find( - (item) => item.id === rows[0]!.id, - ); + if ((countRows[0]?.count ?? 0) >= capacity.stickerSlots) { + conflict("Saved sticker capacity reached."); + } - if (!saved) { - throw new Error("Saved sticker was inserted but could not be loaded."); - } + const rows = await tx.execute<{ id: string }>(sql` + insert into app.saved_stickers (user_id, kind, sticker_asset_id, body, label) + values ( + ${authUser.id}, + ${input.kind}, + ${input.kind === "sticker" ? input.stickerAssetId : null}, + ${input.kind === "sticky_note" ? input.body : null}, + ${input.label ?? null} + ) + returning id + `); + const saved = (await loadSavedStickers(tx, authUser.id)).find( + (item) => item.id === rows[0]!.id, + ); + + if (!saved) { + throw new Error("Saved sticker was inserted but could not be loaded."); + } + + const progress = await incrementQuestProgress(tx, authUser.id, "save_stickers"); - await incrementQuestProgress(db, authUser.id, "save_stickers"); + return createSavedStickerResponseSchema.parse({ + questProgress: progress.map(questProgressUpdate), + savedSticker: savedSticker(saved), + }); + }); - return c.json(createSavedStickerResponseSchema.parse({ savedSticker: savedSticker(saved) })); + return c.json(response); }, ); @@ -198,7 +213,7 @@ async function loadStickerAsset(db: ReturnType, id: string) { return sticker; } -async function loadSavedStickers(db: ReturnType, userId: string) { +async function loadSavedStickers(db: DatabaseExecutor, userId: string) { return db.execute(sql` select saved_stickers.id, diff --git a/apps/api/src/routes/users.ts b/apps/api/src/routes/users.ts index 40f358d..8d0ebd5 100644 --- a/apps/api/src/routes/users.ts +++ b/apps/api/src/routes/users.ts @@ -12,7 +12,7 @@ import { sql } from "drizzle-orm"; import { Hono } from "hono"; import { getDb } from "../db"; -import { forbidden, notFound } from "../http"; +import { badRequest, conflict, forbidden, notFound } from "../http"; import { getAuthUser, requireAuth } from "../middleware/auth"; import { ensureQuestProgress, @@ -68,29 +68,37 @@ usersRoute.patch( const input = c.req.valid("json"); const authUser = getAuthUser(c); const db = getDb(c.env); - const rows = await db.execute(sql` - update app.users - set - username = coalesce(${input.username ?? null}, username), - display_name = coalesce(${input.displayName ?? null}, display_name), - updated_at = now() - where id = ${authUser.id} - returning - id, - username, - display_name as "displayName", - avatar_base64 as "avatarBase64", - is_admin as "isAdmin", - level, - xp, - daily_streak as "dailyStreak", - streak_updated_on as "streakUpdatedOn", - last_daily_claimed_on as "lastDailyClaimedOn", - banned_at as "bannedAt", - deleted_at as "deletedAt", - created_at as "createdAt", - updated_at as "updatedAt" - `); + let rows: UserRow[]; + try { + rows = await db.execute(sql` + update app.users + set + username = coalesce(${input.username ?? null}, username), + display_name = coalesce(${input.displayName ?? null}, display_name), + updated_at = now() + where id = ${authUser.id} + returning + id, + username, + display_name as "displayName", + avatar_base64 as "avatarBase64", + is_admin as "isAdmin", + level, + xp, + daily_streak as "dailyStreak", + streak_updated_on as "streakUpdatedOn", + last_daily_claimed_on as "lastDailyClaimedOn", + banned_at as "bannedAt", + deleted_at as "deletedAt", + created_at as "createdAt", + updated_at as "updatedAt" + `); + } catch (error) { + if (isUniqueViolation(error)) { + conflict("Username is already taken."); + } + throw error; + } return c.json(updateCurrentUserResponseSchema.parse({ user: currentUser(rows[0]!) })); }, @@ -104,6 +112,7 @@ usersRoute.patch( const input = c.req.valid("json"); const authUser = getAuthUser(c); const db = getDb(c.env); + validateAvatarPng(input.avatarBase64); const rows = await db.execute(sql` update app.users set avatar_base64 = ${input.avatarBase64}, updated_at = now() @@ -268,6 +277,46 @@ export function requireAdmin(user: { isAdmin: boolean }) { } } +export function isUniqueViolation(error: unknown, depth = 0): boolean { + if (depth > 4 || typeof error !== "object" || error === null) { + return false; + } + if ("code" in error && error.code === "23505") { + return true; + } + return "cause" in error && isUniqueViolation(error.cause, depth + 1); +} + +export function validateAvatarPng(value: string) { + const payload = value.startsWith("data:image/png;base64,") ? value.slice(22) : value; + let header: string; + try { + header = atob(payload.slice(0, 32)); + } catch { + badRequest("Avatar must be a valid PNG."); + } + + const bytes = Uint8Array.from(header, (character) => character.charCodeAt(0)); + const isPng = + bytes.length >= 24 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 && + bytes[12] === 0x49 && + bytes[13] === 0x48 && + bytes[14] === 0x44 && + bytes[15] === 0x52; + if (!isPng) { + badRequest("Avatar must be a valid PNG."); + } + + const view = new DataView(bytes.buffer); + if (view.getUint32(16) !== 64 || view.getUint32(20) !== 64) { + badRequest("Avatar PNG must be 64x64 pixels."); + } +} + function publicUser(user: UserRow) { return { avatarBase64: user.avatarBase64, diff --git a/apps/api/src/services/progression.ts b/apps/api/src/services/progression.ts index 6f0f056..78d4fb3 100644 --- a/apps/api/src/services/progression.ts +++ b/apps/api/src/services/progression.ts @@ -3,7 +3,7 @@ import { sql } from "drizzle-orm"; import type { getDb } from "../db"; import { isoDate, isoDateTime, nullableIsoDateTime } from "../serialize"; -type Database = ReturnType; +type Database = Pick, "execute">; export type QuestTriggerType = | "leave_billboards" diff --git a/apps/api/test/users.test.ts b/apps/api/test/users.test.ts new file mode 100644 index 0000000..19d49a4 --- /dev/null +++ b/apps/api/test/users.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; + +import { isUniqueViolation, validateAvatarPng } from "../src/routes/users"; + +function pngHeader(width: number, height: number) { + const bytes = new Uint8Array(24); + bytes.set([0x89, 0x50, 0x4e, 0x47], 0); + bytes.set([0x49, 0x48, 0x44, 0x52], 12); + const view = new DataView(bytes.buffer); + view.setUint32(16, width); + view.setUint32(20, height); + return btoa(String.fromCharCode(...bytes)); +} + +describe("user route validation", () => { + test("finds a wrapped PostgreSQL unique violation", () => { + expect(isUniqueViolation({ cause: { cause: { code: "23505" } } })).toBe(true); + expect(isUniqueViolation({ cause: { code: "23503" } })).toBe(false); + }); + + test("accepts a 64x64 PNG header", () => { + expect(() => validateAvatarPng(pngHeader(64, 64))).not.toThrow(); + }); + + test("rejects other PNG dimensions", () => { + expect(() => validateAvatarPng(pngHeader(1024, 1024))).toThrow( + "Avatar PNG must be 64x64 pixels.", + ); + }); + + test("rejects a non-PNG payload", () => { + expect(() => validateAvatarPng(btoa("not a png header"))).toThrow( + "Avatar must be a valid PNG.", + ); + }); +}); diff --git a/apps/app/app/(app)/map.tsx b/apps/app/app/(app)/map.tsx index 8a876a4..d547ea6 100644 --- a/apps/app/app/(app)/map.tsx +++ b/apps/app/app/(app)/map.tsx @@ -34,12 +34,14 @@ import { useBillboards, useClaimQuest, useCreateBillboard, + useCurrentUser, usePois, useQuests, useUserProgress, + useVisitPoi, } from "@/lib/api/hooks"; import { colors } from "@/lib/theme"; -import { useUserProfile } from "@/lib/userProfile"; +import { avatarBase64ToUri, resetUserProfile, useUserProfile } from "@/lib/userProfile"; type HudModal = "profile" | "quests" | "studio"; @@ -51,6 +53,7 @@ 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(); @@ -68,6 +71,19 @@ export default function MapScreen() { })), [billboards.data], ); + const mapPois = useMemo( + () => + (pois.data ?? []).map((poi) => ({ + id: poi.id, + title: poi.title, + description: poi.description, + lat: poi.lat, + lng: poi.lng, + visited: poi.visited, + })), + [pois.data], + ); + const visitPoi = useVisitPoi({ campusId: UNSW_CAMPUS_ID }); const closeCreate = () => { setCreateOpen(false); @@ -120,27 +136,58 @@ export default function MapScreen() { setActiveBillboardId(id); }, []); + const checkInToPoi = useCallback( + (id: string) => { + const poi = pois.data?.find((candidate) => candidate.id === id); + if (!poi || visitPoi.isPending) return; + + setPoiError(null); + if (!location) { + setPoiError("Enable location and wait for a position before checking in."); + return; + } + + visitPoi.mutate( + { + id, + input: { lat: location.latitude, lng: location.longitude }, + }, + { + 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."); + }, + }, + ); + }, + [location, pois.data, visitPoi], + ); + return ( ({ - id: poi.id, - title: poi.title, - description: poi.description, - lat: poi.lat, - lng: poi.lng, - visited: poi.visited, - }))} + onPoiCheckIn={checkInToPoi} + pois={mapPois} /> {billboards.isLoading || pois.isLoading ? ( ) : null} + {poiError ? ( + + {poiError} + + ) : null} setCreateOpen(true)} onOpenProfile={() => setActiveHudModal("profile")} @@ -219,6 +266,10 @@ export default function MapScreen() { setActiveHudModal(null); setCreateOpen(true); }} + onOpenFullProfile={() => { + setActiveHudModal(null); + router.push("/(app)/profile" as any); + }} /> setIsCanvasOpen(false)} /> @@ -229,10 +280,12 @@ function HudSheetModal({ activeModal, onClose, onCreateBillboard, + onOpenFullProfile, }: { activeModal: HudModal | null; onClose: () => void; onCreateBillboard: () => void; + onOpenFullProfile: () => void; }) { const { height } = useWindowDimensions(); // The modal stays mounted through its fade-out, so keep showing the last @@ -281,7 +334,10 @@ function HudSheetModal({ {shown === "profile" ? ( - + ) : null} {shown === "quests" ? : null} @@ -292,19 +348,30 @@ function HudSheetModal({ ); } -function ProfileModalContent({ onCreateBillboard }: { onCreateBillboard: () => void }) { +function ProfileModalContent({ + onCreateBillboard, + onOpenFullProfile, +}: { + onCreateBillboard: () => void; + onOpenFullProfile: () => void; +}) { const profile = useUserProfile(); + const currentUser = useCurrentUser(); + const userProgress = useUserProgress(); const { signOut } = useAuth(); const [signingOut, setSigningOut] = useState(false); - const avatarSource = profile.avatarUri - ? { uri: profile.avatarUri } - : require("@/assets/images/avatar.png"); + const avatarUri = avatarBase64ToUri(currentUser.data?.avatarBase64) ?? profile.avatarUri; + const avatarSource = avatarUri ? { uri: avatarUri } : require("@/assets/images/avatar.png"); + const username = currentUser.data?.username ?? profile.username; + const level = userProgress.data?.level ?? currentUser.data?.level ?? 1; + const stats = userProgress.data?.stats; const handleSignOut = async () => { if (signingOut) return; setSigningOut(true); try { await signOut(); + resetUserProfile(); router.replace("/"); } catch { setSigningOut(false); @@ -318,17 +385,24 @@ function ProfileModalContent({ onCreateBillboard }: { onCreateBillboard: () => v - @{profile.username} - lv22 explorer + @{username} + lv{level} explorer - - - + + + + [styles.profileActionSecondary, pressed && styles.pressed]} + > + view and edit profile + + [styles.profileAction, pressed && styles.pressed]} @@ -489,7 +563,6 @@ function QuestSection({ title, children }: { title: string; children: React.Reac ); } - const styles = StyleSheet.create({ root: { flex: 1, @@ -507,6 +580,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", @@ -709,6 +799,19 @@ const styles = StyleSheet.create({ minHeight: 48, justifyContent: "center", }, + profileActionSecondary: { + alignItems: "center", + borderColor: colors.sageDark, + borderRadius: 10, + borderWidth: 2, + justifyContent: "center", + minHeight: 46, + }, + profileActionSecondaryText: { + color: colors.sageDark, + fontFamily: fonts.family, + fontSize: 22, + }, pressed: { opacity: 0.78, }, diff --git a/apps/app/app/(app)/profile.tsx b/apps/app/app/(app)/profile.tsx index 0979cde..5e0de6e 100644 --- a/apps/app/app/(app)/profile.tsx +++ b/apps/app/app/(app)/profile.tsx @@ -1,7 +1,8 @@ import { useAuth } from "@clerk/expo"; import { router } from "expo-router"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { + ActivityIndicator, Image, Modal, Pressable, @@ -25,7 +26,19 @@ 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, + resetUserProfile, + setUsername, + useUserProfile, +} from "@/lib/userProfile"; const AVATAR_SIZE = 132; const RING_SIZE = AVATAR_SIZE + 18; @@ -34,73 +47,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; @@ -113,19 +59,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 ? ( @@ -161,25 +117,133 @@ 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 { signOut } = useAuth(); - const xpProgress = useMemo(() => DEMO_USER.xpCurrent / DEMO_USER.xpForNext, []); 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 [signingOut, setSigningOut] = useState(false); const handleSignOut = useCallback(async () => { @@ -187,6 +251,7 @@ export default function ProfileScreen() { setSigningOut(true); try { await signOut(); + resetUserProfile(); router.replace("/"); } catch { setSigningOut(false); @@ -195,6 +260,20 @@ export default function ProfileScreen() { return ( + {currentUser.isLoading || userProgress.isLoading ? ( + + + syncing profile... + + ) : null} + {currentUser.isError ? ( + + + {(currentUser.error as Error | undefined)?.message ?? "Could not load profile."} + + + ) : null} + {/* ── Identity hero ─────────────────────────── */} @@ -217,14 +296,14 @@ export default function ProfileScreen() { - lv{DEMO_USER.level} + lv{level} - @{profile.username} + @{username} - joined {DEMO_USER.joined} + joined {joined} XP @@ -232,7 +311,7 @@ export default function ProfileScreen() { - {DEMO_USER.xpCurrent}/{DEMO_USER.xpForNext} + {levelXp.current}/{levelXp.total} @@ -260,7 +339,7 @@ export default function ProfileScreen() { - {DEMO_USER.streak}-day streak + {streak}-day streak keep it going — daily quest resets at midnight @@ -269,7 +348,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, ]} /> ))} @@ -279,7 +358,7 @@ export default function ProfileScreen() { {/* ── Stats grid ────────────────────────────── */} stats - {DEMO_STATS.map((s) => ( + {stats.map((s) => ( ))} @@ -288,32 +367,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 ────────────────────────── */} @@ -323,14 +408,16 @@ 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 ──────────────────────────────── */} @@ -363,7 +450,7 @@ export default function ProfileScreen() { autoCapitalize="none" autoCorrect={false} autoFocus - maxLength={20} + maxLength={32} onChangeText={setDraftName} onSubmitEditing={submitName} placeholder="username" @@ -374,6 +461,7 @@ export default function ProfileScreen() { value={draftName} /> + {nameError ? {nameError} : null} setNameModalOpen(false)} @@ -386,16 +474,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"} + @@ -406,6 +496,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", @@ -738,6 +853,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/(auth)/sign-up.tsx b/apps/app/app/(auth)/sign-up.tsx index fbffcdf..ff78d51 100644 --- a/apps/app/app/(auth)/sign-up.tsx +++ b/apps/app/app/(auth)/sign-up.tsx @@ -52,7 +52,7 @@ export default function SignUpScreen() { return; } await signUp.prepareEmailAddressVerification({ strategy: "email_code" }); - router.push("/(auth)/verify"); + router.push("/(auth)/verify" as any); } catch (err) { setError(clerkErrorMessage(err)); } finally { diff --git a/apps/app/app/avatar/create.tsx b/apps/app/app/avatar/create.tsx index 9455475..6ae8232 100644 --- a/apps/app/app/avatar/create.tsx +++ b/apps/app/app/avatar/create.tsx @@ -6,11 +6,46 @@ 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"; const MAX_UPLOAD_BYTES = 2 * 1024 * 1024; // 2MB before resize +const AVATAR_SIZE = 64; +const PNG_DATA_URL_PREFIX = "data:image/png;base64,"; + +function normalizeAvatarPng(dataUrl: string): Promise { + return new Promise((resolve, reject) => { + const image = document.createElement("img"); + image.onload = () => { + const canvas = document.createElement("canvas"); + canvas.width = AVATAR_SIZE; + canvas.height = AVATAR_SIZE; + const context = canvas.getContext("2d"); + if (!context || image.naturalWidth < 1 || image.naturalHeight < 1) { + reject(new Error("Could not process image.")); + return; + } + + const scale = Math.max(AVATAR_SIZE / image.naturalWidth, AVATAR_SIZE / image.naturalHeight); + const width = image.naturalWidth * scale; + const height = image.naturalHeight * scale; + context.imageSmoothingEnabled = false; + context.drawImage( + image, + (AVATAR_SIZE - width) / 2, + (AVATAR_SIZE - height) / 2, + width, + height, + ); + resolve(canvas.toDataURL("image/png")); + }; + image.onerror = () => reject(new Error("Could not decode PNG image.")); + image.src = dataUrl; + }); +} export default function CreateAvatarScreen() { const [mode, setMode] = useState("draw"); @@ -18,15 +53,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 +89,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); @@ -59,16 +111,27 @@ export default function CreateAvatarScreen() { setUploadTone("info"); setUploadStatus("Reading image…"); const reader = new FileReader(); - reader.onload = () => { + reader.onload = async () => { const result = typeof reader.result === "string" ? reader.result : null; if (!result) { setUploadTone("error"); setUploadStatus("Could not read image."); return; } - setUploadDataUrl(result); - setUploadTone("success"); - setUploadStatus(`Loaded ${file.name}`); + if (!result.startsWith(PNG_DATA_URL_PREFIX)) { + setUploadTone("error"); + setUploadStatus("Pick a PNG image."); + return; + } + try { + const normalized = await normalizeAvatarPng(result); + setUploadDataUrl(normalized); + setUploadTone("success"); + setUploadStatus(`Loaded and resized ${file.name}`); + } catch (error) { + setUploadTone("error"); + setUploadStatus(error instanceof Error ? error.message : "Could not process image."); + } }; reader.onerror = () => { setUploadTone("error"); @@ -77,12 +140,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.slice(PNG_DATA_URL_PREFIX.length), 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 +229,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 d83ff7a..eab30af 100644 --- a/apps/app/components/CreateStickerPanel.tsx +++ b/apps/app/components/CreateStickerPanel.tsx @@ -31,7 +31,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({ @@ -46,9 +46,10 @@ export function CreateStickerPanel({ const [stickerName, setStickerName] = useState(""); 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 isCompact = width < 390 || height < 720; const horizontalPanelPadding = isCompact ? 18 : 38; const reservedHeight = isAvatar ? 280 : 370; @@ -71,9 +72,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; } @@ -89,6 +100,7 @@ export function CreateStickerPanel({ await saveSticker.mutateAsync({ kind: "sticker", stickerAssetId: sticker.id, + label: stickerName.trim() || undefined, }); setSubmitTone("success"); setSubmitStatus( diff --git a/apps/app/components/map/Map.native.tsx b/apps/app/components/map/Map.native.tsx index 3f2045c..fdf2907 100644 --- a/apps/app/components/map/Map.native.tsx +++ b/apps/app/components/map/Map.native.tsx @@ -6,6 +6,9 @@ import { Camera, Map as MapLibre, Marker } from "@maplibre/maplibre-react-native import { fonts } from "@/app/theme"; import { UNSW_CENTER } from "@/constants/coordinates"; +import { useCurrentUser } from "@/lib/api/hooks"; +import { colors } from "@/lib/theme"; +import { avatarBase64ToUri, useUserProfile } from "@/lib/userProfile"; import type { MapPoi, MapProps } from "./Map.types"; @@ -98,26 +101,32 @@ function UserAvatarMarker({ coordinate, imageUrl }: UserAvatarMarkerProps) { } export const Map = forwardRef<{ invalidateSize: () => void }, MapProps>(function Map( - { billboards, onBillboardPress, pois, location }, + { billboards, isPoiCheckInPending, onBillboardPress, onPoiCheckIn, pois, location }, _ref, ) { - const [selectedPOI, setSelectedPOI] = useState(null); + const [selectedPoiId, setSelectedPoiId] = useState(null); + const currentUser = useCurrentUser(); + const localProfile = useUserProfile(); + const selectedPOI = pois.find((poi) => poi.id === selectedPoiId) ?? null; - const userAvatarUrl = Asset.fromModule(require("@/assets/images/avatar.png")).uri; + const userAvatarUrl = + avatarBase64ToUri(currentUser.data?.avatarBase64) ?? + localProfile.avatarUri ?? + Asset.fromModule(require("@/assets/images/avatar.png")).uri; const userCoord: [number, number] = location ? [location.longitude, location.latitude] : [UNSW_CENTER.lng, UNSW_CENTER.lat]; return ( - setSelectedPOI(null)}> + setSelectedPoiId(null)}> {pois.map((poi) => ( setSelectedPOI((prev) => (prev?.id === poi.id ? null : poi))} + isSelected={selectedPoiId === poi.id} + onPress={() => setSelectedPoiId((current) => (current === poi.id ? null : poi.id))} /> ))} @@ -142,13 +151,27 @@ export const Map = forwardRef<{ invalidateSize: () => void }, MapProps>(function {selectedPOI.title} setSelectedPOI(null)} + onPress={() => setSelectedPoiId(null)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} > {selectedPOI.description ?? ""} + onPoiCheckIn?.(selectedPOI.id)} + style={[styles.checkInButton, selectedPOI.visited ? styles.checkInButtonDone : null]} + > + + {selectedPOI.visited + ? "Checked in" + : isPoiCheckInPending + ? "Checking in..." + : "Check in"} + + )} @@ -341,4 +364,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: fonts.family, + fontSize: 20, + }, }); diff --git a/apps/app/components/map/Map.types.ts b/apps/app/components/map/Map.types.ts index b00270f..b4c3972 100644 --- a/apps/app/components/map/Map.types.ts +++ b/apps/app/components/map/Map.types.ts @@ -20,6 +20,8 @@ export type MapLocation = { export type MapProps = { location?: MapLocation | null; billboards: MapPoint[]; + isPoiCheckInPending?: boolean; 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 6d478fb..aa3abcc 100644 --- a/apps/app/components/map/Map.web.tsx +++ b/apps/app/components/map/Map.web.tsx @@ -4,9 +4,10 @@ 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, useUserProfile } from "@/lib/userProfile"; -import type { MapProps } from "./Map.types"; +import type { MapPoi, MapProps } from "./Map.types"; import { createBillboardIcon, createPOIIcon, createUserAvatarIcon } from "./markers"; const DRAWN_AVATAR_BG = "#faf7ef"; @@ -19,17 +20,22 @@ const TILE_ATTR = type MapHandle = { invalidateSize: () => void }; export const Map = forwardRef(function MapWeb( - { location, billboards, onBillboardPress, pois }, + { location, billboards, onBillboardPress, onPoiCheckIn, pois }, ref, ) { const containerRef = useRef(null); const mapRef = useRef(null); const userMarkerRef = useRef(null); - const { avatarUri } = useUserProfile(); - // ponytail: the init effect re-runs whenever billboards/pois/avatar change, so - // read the latest location from a ref instead of snapping back to UNSW. + const currentUser = useCurrentUser(); + const localProfile = useUserProfile(); + const avatarUri = avatarBase64ToUri(currentUser.data?.avatarBase64) ?? localProfile.avatarUri; + // Keep callbacks and location current without rebuilding Leaflet for unrelated renders. const locationRef = useRef(location); + const onBillboardPressRef = useRef(onBillboardPress); + const onPoiCheckInRef = useRef(onPoiCheckIn); locationRef.current = location; + onBillboardPressRef.current = onBillboardPress; + onPoiCheckInRef.current = onPoiCheckIn; useImperativeHandle(ref, () => ({ invalidateSize: () => mapRef.current?.invalidateSize(), @@ -66,9 +72,7 @@ export const Map = forwardRef(function MapWeb( icon: createPOIIcon(poi.title), }) .addTo(map) - .bindPopup( - `${escapeHtml(poi.title)}
${escapeHtml(poi.description ?? "")}`, - ); + .bindPopup(createPoiPopupContent(poi, (id) => onPoiCheckInRef.current?.(id))); } for (const billboard of billboards) { @@ -76,7 +80,7 @@ export const Map = forwardRef(function MapWeb( icon: createBillboardIcon(billboard.title), }) .addTo(map) - .on("click", () => onBillboardPress?.(billboard.id)); + .on("click", () => onBillboardPressRef.current?.(billboard.id)); } const fallbackUrl = Asset.fromModule(require("@/assets/images/avatar.png")).uri; @@ -92,7 +96,7 @@ export const Map = forwardRef(function MapWeb( mapRef.current = null; userMarkerRef.current = null; }; - }, [avatarUri, billboards, onBillboardPress, pois]); + }, [billboards, pois]); useEffect(() => { if (Platform.OS !== "web") return; @@ -120,21 +124,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/components/map/MapHUD.tsx b/apps/app/components/map/MapHUD.tsx index 3b95cc9..7448f89 100644 --- a/apps/app/components/map/MapHUD.tsx +++ b/apps/app/components/map/MapHUD.tsx @@ -3,8 +3,9 @@ import { useState } from "react"; import { Image, Pressable, StyleSheet, Text, View } from "react-native"; import { fonts } from "@/app/theme"; +import { useCurrentUser, useUserProgress } from "@/lib/api/hooks"; import { colors } from "@/lib/theme"; -import { useUserProfile } from "@/lib/userProfile"; +import { avatarBase64ToUri, useUserProfile } from "@/lib/userProfile"; const PROFILE_SIZE = 128; const AVATAR_SIZE = PROFILE_SIZE; @@ -15,7 +16,11 @@ const COLOR_BG = colors.pageBgSoft; const COLOR_TEXT = colors.creamText; function ProfileButton({ onOpenProfile }: { onOpenProfile: () => void }) { - const { avatarUri } = useUserProfile(); + const profile = useUserProfile(); + const currentUser = useCurrentUser(); + const userProgress = useUserProgress(); + const avatarUri = avatarBase64ToUri(currentUser.data?.avatarBase64) ?? profile.avatarUri; + const level = userProgress.data?.level ?? currentUser.data?.level ?? 1; const useDrawn = Boolean(avatarUri); const source = avatarUri ? { uri: avatarUri } : require("@/assets/images/avatar.png"); @@ -34,7 +39,7 @@ function ProfileButton({ onOpenProfile }: { onOpenProfile: () => void }) {
- lv22 + lv{level} ); diff --git a/apps/app/lib/api/client.ts b/apps/app/lib/api/client.ts index 5f3f660..46a8af6 100644 --- a/apps/app/lib/api/client.ts +++ b/apps/app/lib/api/client.ts @@ -45,7 +45,6 @@ export async function apiFetch({ } const url = `${API_BASE_URL}${path}`; - console.log("[apiFetch]", method, url, body); const res = await fetch(url, init); const json: unknown = await res.json().catch(() => ({})); diff --git a/apps/app/lib/api/hooks.ts b/apps/app/lib/api/hooks.ts index 3880be2..3332998 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(); @@ -192,8 +218,11 @@ export function useSaveSticker() { getToken: auth.getToken, schema: createSavedStickerResponseSchema, }), - onSuccess: () => { + onSuccess: (data) => { + pushQuestProgress(data.questProgress); queryClient.invalidateQueries({ queryKey: qk.savedStickers(auth.userId) }); + queryClient.invalidateQueries({ queryKey: qk.quests(auth.userId) }); + queryClient.invalidateQueries({ queryKey: qk.userProgress(auth.userId) }); }, }); } @@ -211,6 +240,7 @@ export function useDeleteSavedSticker() { }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: qk.savedStickers(auth.userId) }); + queryClient.invalidateQueries({ queryKey: qk.userProgress(auth.userId) }); }, }); } @@ -281,3 +311,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..e06f769 100644 --- a/apps/app/lib/userProfile.ts +++ b/apps/app/lib/userProfile.ts @@ -5,10 +5,11 @@ type UserProfile = { avatarUri: string | null; }; -let state: UserProfile = { - username: "fern_walker", +const initialState: UserProfile = { + username: "explorer", avatarUri: null, }; +let state = initialState; const listeners = new Set<() => void>(); @@ -43,3 +44,14 @@ export function setAvatarUri(uri: string | null) { state = { ...state, avatarUri: uri }; emit(); } + +export function resetUserProfile() { + state = initialState; + 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}`; +} diff --git a/bun.lock b/bun.lock index 8b9c580..213e2b2 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ "devDependencies": { "oxfmt": "latest", "oxlint": "latest", - "typescript": "latest", + "typescript": "~5.9.2", }, }, "apps/api": { @@ -24,7 +24,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "latest", - "typescript": "latest", + "typescript": "~5.9.2", "wrangler": "^4.4.0", }, }, @@ -93,7 +93,7 @@ }, "devDependencies": { "drizzle-kit": "^0.31.10", - "typescript": "latest", + "typescript": "~5.9.2", }, }, "packages/shared": { @@ -103,7 +103,7 @@ "zod": "latest", }, "devDependencies": { - "typescript": "latest", + "typescript": "~5.9.2", }, }, }, @@ -902,46 +902,6 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "eslint-visitor-keys": "^5.0.0" } }, "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ=="], - "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], - - "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], - - "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], - - "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], - - "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], - - "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], - - "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], - - "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], - - "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], - - "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], - - "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], - - "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], - - "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], - - "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], - - "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], - - "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], - - "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], - - "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], - - "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], - - "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], - "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="], "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.12.2", "", { "os": "android", "cpu": "arm" }, "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w=="], @@ -2298,7 +2258,7 @@ "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], - "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "ua-parser-js": ["ua-parser-js@1.0.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug=="], @@ -2530,12 +2490,8 @@ "@react-native/community-cli-plugin/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], - "@repo/app/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "@repo/app/wrangler": ["wrangler@4.113.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260721.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260721.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260721.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "cf-wrangler": "bin/cf-wrangler.js", "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-ROGzSloJv0y21It6Oc9LaruNcu1tdiQ/XzL3Jc3YkFjzXEMXzTqVhA8vQaGMTdZHTjFP0PVcwAHNgaw3gXu4wA=="], - "@repo/shared/typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], - "@scure/bip32/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], "@scure/bip39/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], diff --git a/package.json b/package.json index e436c16..62c3c2d 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "build": "bun run build:app && bun run build:api", "build:app": "bun --cwd apps/app build:web", "build:api": "bun --cwd apps/api build", + "test": "bun test apps/api/test", "typecheck": "bun run typecheck:app && bun run typecheck:api && bun run typecheck:shared && bun run typecheck:db", "typecheck:app": "bun --cwd apps/app typecheck", "typecheck:api": "bun --cwd apps/api typecheck", @@ -26,7 +27,7 @@ "devDependencies": { "oxfmt": "latest", "oxlint": "latest", - "typescript": "latest" + "typescript": "~5.9.2" }, "packageManager": "bun@1.3.14" } diff --git a/packages/db/package.json b/packages/db/package.json index 11651cd..95a71e9 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -18,6 +18,6 @@ }, "devDependencies": { "drizzle-kit": "^0.31.10", - "typescript": "latest" + "typescript": "~5.9.2" } } diff --git a/packages/shared/package.json b/packages/shared/package.json index ce0ce07..9260cf4 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -22,6 +22,6 @@ "zod": "latest" }, "devDependencies": { - "typescript": "latest" + "typescript": "~5.9.2" } } diff --git a/packages/shared/src/sticker.ts b/packages/shared/src/sticker.ts index dae313f..292852e 100644 --- a/packages/shared/src/sticker.ts +++ b/packages/shared/src/sticker.ts @@ -1,6 +1,12 @@ import { z } from "zod"; -import { base64PngSchema, contentStatusSchema, idSchema, isoDateTimeSchema } from "./common"; +import { + base64PngSchema, + contentStatusSchema, + idSchema, + isoDateTimeSchema, + questProgressUpdateSchema, +} from "./common"; export const savedStickerKindSchema = z.enum(["sticker", "sticky_note"]); @@ -55,6 +61,7 @@ export const createStickerResponseSchema = z.object({ }); export const createSavedStickerResponseSchema = z.object({ + questProgress: z.array(questProgressUpdateSchema), savedSticker: savedStickerSchema, }); diff --git a/packages/shared/src/user.ts b/packages/shared/src/user.ts index ed92082..b3f34ca 100644 --- a/packages/shared/src/user.ts +++ b/packages/shared/src/user.ts @@ -68,7 +68,7 @@ export const updateCurrentUserInputSchema = z.object({ }); export const updateAvatarInputSchema = z.object({ - avatarBase64: base64PngSchema, + avatarBase64: base64PngSchema.max(256 * 1024, "Avatar PNG payload is too large."), }); export const updateCurrentUserResponseSchema = z.object({ diff --git a/tsconfig.base.json b/tsconfig.base.json index cdd2943..c414cf5 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -8,7 +8,6 @@ "resolveJsonModule": true, "isolatedModules": true, "baseUrl": ".", - "ignoreDeprecations": "6.0", "paths": { "@repo/db": ["packages/db/src/index.ts"], "@repo/db/*": ["packages/db/src/*"],