diff --git a/package-lock.json b/package-lock.json index b9ffdf79..6caee47b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@tanstack/react-table": "^8.21.3", "@types/katex": "^0.16.7", "@unicitylabs/sphere-sdk": "0.11.8", - "@unicitylabs/sphere-ui": "^0.1.23", + "@unicitylabs/sphere-ui": "^0.1.30", "crypto-js": "^4.2.0", "elliptic": "^6.6.1", "framer-motion": "^12.23.24", @@ -629,6 +629,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -645,6 +646,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -661,6 +663,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -677,6 +680,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -693,6 +697,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -709,6 +714,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -725,6 +731,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -741,6 +748,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -757,6 +765,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -773,6 +782,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -789,6 +799,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -805,6 +816,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -821,6 +833,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -837,6 +850,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -853,6 +867,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -869,6 +884,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -885,6 +901,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -901,6 +918,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -917,6 +935,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -933,6 +952,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -949,6 +969,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -965,6 +986,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -981,6 +1003,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -997,6 +1020,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1013,6 +1037,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1029,6 +1054,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1585,6 +1611,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1598,6 +1625,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1611,6 +1639,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1624,6 +1653,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1637,6 +1667,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1650,6 +1681,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1663,6 +1695,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1676,6 +1709,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1689,6 +1723,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1702,6 +1737,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1715,6 +1751,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1728,6 +1765,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1741,6 +1779,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1754,6 +1793,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1767,6 +1807,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1780,6 +1821,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1793,6 +1835,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1806,6 +1849,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1819,6 +1863,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1832,6 +1877,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1845,6 +1891,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1858,6 +1905,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1871,6 +1919,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1884,6 +1933,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1897,6 +1947,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2487,6 +2538,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -2506,7 +2558,7 @@ "version": "24.12.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -2945,9 +2997,9 @@ } }, "node_modules/@unicitylabs/sphere-ui": { - "version": "0.1.23", - "resolved": "https://registry.npmjs.org/@unicitylabs/sphere-ui/-/sphere-ui-0.1.23.tgz", - "integrity": "sha512-BsyhrYQ2XKFsuxW4/X72hJqV/F5k/ZG5rhWPWZ9md+/mbA73wF8HVICLNQ5xK52+VQGt/OeHOxOow6L4YLoYcw==", + "version": "0.1.30", + "resolved": "https://registry.npmjs.org/@unicitylabs/sphere-ui/-/sphere-ui-0.1.30.tgz", + "integrity": "sha512-LsI8BMMlUiFZEcvGez55R7x1BqmTAFEIdAvf+zN8gAhIaQEZ2TtQclxJMjHyYxXq55LPmYC4J+lr7XFSTDeSxg==", "dependencies": { "framer-motion": "^11.18.2", "lucide-react": ">=0.400.0", @@ -4289,6 +4341,7 @@ "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -4596,6 +4649,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -4719,6 +4773,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5914,6 +5969,7 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, "funding": [ { "type": "github", @@ -6294,12 +6350,14 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -6382,6 +6440,7 @@ "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -6816,6 +6875,7 @@ "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -7248,6 +7308,7 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -7465,7 +7526,7 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -7575,6 +7636,7 @@ "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.27.0", @@ -7901,7 +7963,7 @@ "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index a408d82f..12b39092 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "@tanstack/react-table": "^8.21.3", "@types/katex": "^0.16.7", "@unicitylabs/sphere-sdk": "0.11.8", - "@unicitylabs/sphere-ui": "^0.1.23", + "@unicitylabs/sphere-ui": "^0.1.30", "crypto-js": "^4.2.0", "elliptic": "^6.6.1", "framer-motion": "^12.23.24", diff --git a/src/pages/ProjectPage.tsx b/src/pages/ProjectPage.tsx index e141cb19..ded5a42d 100644 --- a/src/pages/ProjectPage.tsx +++ b/src/pages/ProjectPage.tsx @@ -1,9 +1,10 @@ import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom'; import { motion } from 'framer-motion'; import { useState, useRef, useCallback, useEffect } from 'react'; -import { ArrowLeft, ExternalLink, Users, Target, Trophy, Globe, Check, Download, X, ChevronLeft, ChevronRight, Star } from 'lucide-react'; +import { ArrowLeft, ExternalLink, Users, Target, Trophy, Globe, Check, Download, X, ChevronDown, ChevronLeft, ChevronRight, Star } from 'lucide-react'; import { ProjectLogo } from '@unicitylabs/sphere-ui'; import { useProject, useProjectQuests, useProjectMetrics } from '../hooks/useMarketplace'; +import { groupQuestsByTrack } from '../utils/groupQuestsByTrack'; import { QuestPreviewCard } from '../components/marketplace/QuestPreviewCard'; import { ProjectReviewsSection } from '../components/marketplace/ProjectReviewsSection'; import { DiscordIcon, XIcon } from '../components/icons/SocialIcons'; @@ -223,6 +224,20 @@ export function ProjectPage() { const [lightboxIndex, setLightboxIndex] = useState(null); const { data: quests } = useProjectQuests(slug ?? ''); const { data: metrics } = useProjectMetrics(project?._id); + // Quests are heavy content — collapsed by default, and collapsed per track. + const [questsOpen, setQuestsOpen] = useState(false); + const [openTracks, setOpenTracks] = useState>(new Set()); + const questGroups = groupQuestsByTrack(quests ?? []); + const onlyGeneralGroup = questGroups.length === 1 && questGroups[0].key === 'general'; + + const toggleTrack = (key: string) => { + setOpenTracks((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; // All media items (screenshots + videos) in one list. The API returns // `caption: string | null`; coerce nulls to undefined so MediaItem (which @@ -282,9 +297,11 @@ export function ProjectPage() { {/* Logo — outside overflow-hidden container. Uses ProjectLogo for consistency with marketplace cards / desktop dock: - real uploaded logo fills the tile (no blik visible); a missing logo - falls back to a 2-letter monogram with the gradient blik visible. */} -
+ real uploaded logo renders un-cropped on the accent tile; a missing + logo falls back to a 2-letter monogram with gloss. + No frame around the tile — mirrors the border-4 removal on the + dev-portal/backoffice overview pages. */} +
{project.name}

{project.tagline}

- + {/* Neutral chip on purpose — accent-tinted text turns unreadable + with dark/low-contrast project accent colors. */} + {categoryLabels[project.category] ?? project.category} {project.tags.slice(0, 3).map((tag) => ( @@ -413,18 +428,65 @@ export function ProjectPage() {
- {/* Quests */} + {/* Quests — collapsed by default; expanded view groups quests by track, + each track collapsed as well. Only active tracks reach this list + (the API filters out quests of DRAFT/ENDED tracks). */} {quests && quests.length > 0 && (
-

- Quests ({quests.length}) -

-
- {quests.map((quest) => ( - - ))} -
+ + + {questsOpen && (onlyGeneralGroup ? ( +
+ {quests.map((quest) => ( + + ))} +
+ ) : ( +
+ {questGroups.map((group) => { + const open = openTracks.has(group.key); + return ( +
+ + {open && ( +
+ {group.quests.map((quest) => ( + + ))} +
+ )} +
+ ); + })} +
+ ))}
)} diff --git a/src/services/marketplaceApi.ts b/src/services/marketplaceApi.ts index 38f1d6ea..ae8f5dc5 100644 --- a/src/services/marketplaceApi.ts +++ b/src/services/marketplaceApi.ts @@ -46,6 +46,8 @@ export interface ProjectQuest { imageUrl: string | null; tags: string[]; questType: string; + /** Active track this quest belongs to; null for trackless quests. */ + track?: { slug: string; title: string; sortOrder: number } | null; } export interface ProjectAchievement { diff --git a/src/utils/groupQuestsByTrack.ts b/src/utils/groupQuestsByTrack.ts new file mode 100644 index 00000000..cb136bab --- /dev/null +++ b/src/utils/groupQuestsByTrack.ts @@ -0,0 +1,34 @@ +import type { ProjectQuest } from '../services/marketplaceApi'; + +export interface QuestTrackGroup { + /** Track slug, or 'general' for trackless quests. */ + key: string; + title: string; + sortOrder: number; + quests: ProjectQuest[]; +} + +/** + * Group marketplace quests by their (active) track, ordered by track + * sortOrder. Trackless quests fall into a trailing "General" group. + */ +export function groupQuestsByTrack(quests: ProjectQuest[]): QuestTrackGroup[] { + const groups = new Map(); + for (const quest of quests) { + const key = quest.track?.slug ?? 'general'; + let group = groups.get(key); + if (!group) { + group = { + key, + title: quest.track?.title ?? 'General', + sortOrder: quest.track ? quest.track.sortOrder : Number.MAX_SAFE_INTEGER, + quests: [], + }; + groups.set(key, group); + } + group.quests.push(quest); + } + return [...groups.values()].sort( + (a, b) => a.sortOrder - b.sortOrder || a.title.localeCompare(b.title), + ); +} diff --git a/tests/unit/utils/groupQuestsByTrack.test.ts b/tests/unit/utils/groupQuestsByTrack.test.ts new file mode 100644 index 00000000..7f957025 --- /dev/null +++ b/tests/unit/utils/groupQuestsByTrack.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { groupQuestsByTrack } from '../../../src/utils/groupQuestsByTrack'; +import type { ProjectQuest } from '../../../src/services/marketplaceApi'; + +function quest(id: string, track: ProjectQuest['track']): ProjectQuest { + return { + _id: id, + title: `Quest ${id}`, + description: '', + points: 10, + platform: null, + imageUrl: null, + tags: [], + questType: 'SOCIAL', + track, + }; +} + +describe('groupQuestsByTrack', () => { + it('groups quests by track slug, ordered by track sortOrder', () => { + const groups = groupQuestsByTrack([ + quest('a', { slug: 'transact', title: 'Transact', sortOrder: 2 }), + quest('b', { slug: 'account', title: 'Account', sortOrder: 1 }), + quest('c', { slug: 'transact', title: 'Transact', sortOrder: 2 }), + ]); + expect(groups.map((g) => g.key)).toEqual(['account', 'transact']); + expect(groups[1].quests.map((q) => q._id)).toEqual(['a', 'c']); + }); + + it('puts trackless quests into a trailing General group', () => { + const groups = groupQuestsByTrack([ + quest('a', null), + quest('b', { slug: 'account', title: 'Account', sortOrder: 5 }), + quest('c', undefined as unknown as ProjectQuest['track']), + ]); + expect(groups.map((g) => g.key)).toEqual(['account', 'general']); + expect(groups[1].title).toBe('General'); + expect(groups[1].quests.map((q) => q._id)).toEqual(['a', 'c']); + }); + + it('preserves quest order inside each group', () => { + const track = { slug: 't', title: 'T', sortOrder: 0 }; + const groups = groupQuestsByTrack([quest('1', track), quest('2', track), quest('3', track)]); + expect(groups[0].quests.map((q) => q._id)).toEqual(['1', '2', '3']); + }); + + it('returns an empty list for no quests', () => { + expect(groupQuestsByTrack([])).toEqual([]); + }); +});