diff --git a/client/src/components/admin/ProgramJudgingSection.tsx b/client/src/components/admin/ProgramJudgingSection.tsx index c0bec9d..d4cd5c7 100644 --- a/client/src/components/admin/ProgramJudgingSection.tsx +++ b/client/src/components/admin/ProgramJudgingSection.tsx @@ -89,6 +89,22 @@ export function ProgramJudgingSection({ // Inline score edits made from the RESULTS view (keyed by submission id). const [resultDrafts, setResultDrafts] = useState>({}); const [savingScoreId, setSavingScoreId] = useState(null); + const [promotingId, setPromotingId] = useState(null); + + // Admin: push a winning submission into the central winners/payments panel. + const promoteWinner = async (submissionId: string) => { + setPromotingId(submissionId); + try { + const auth = await getAuth(); + const r = await api.promoteSubmission(programSlug, submissionId, auth); + toast({ title: r.data.alreadyPromoted ? "Already in the winners panel" : "Added to the winners panel" }); + await loadLeaderboard(); + } catch (e) { + toast({ title: "Couldn't promote", description: (e as Error)?.message || "Unknown error", variant: "destructive" }); + } finally { + setPromotingId(null); + } + }; const setResultField = ( id: string, @@ -951,6 +967,29 @@ export function ProgramJudgingSection({ ); })()} + {r.lumaEmail && ( +
+ ·CONTACT: {r.submitterName ? `${r.submitterName} — ` : ""} + {r.lumaEmail} +
+ )} + {canSelectWinners && r.prizeAmount != null && ( +
+ +
+ )}
·SCORES PER JUDGE
{(r.judgeScores ?? []).length === 0 ? ( No individual scores. diff --git a/client/src/components/admin/WinnersTable.tsx b/client/src/components/admin/WinnersTable.tsx index f24a8b7..659d014 100644 --- a/client/src/components/admin/WinnersTable.tsx +++ b/client/src/components/admin/WinnersTable.tsx @@ -52,7 +52,7 @@ import { api } from "@/lib/api"; interface BountyPrize { name: string; amount: number; - currency?: 'USDC' | 'DOT'; + currency?: string; hackathonWonAtId: string; txHash?: string; } @@ -126,12 +126,12 @@ export function WinnersTable({ projects, onRefresh, connectedAddress, signAdminA return true; }); - // Sort: by event date (newest first) - const sortedProjects = [...filteredProjects].sort((a, b) => { - const dateA = a.hackathon?.eventStartedAt ? new Date(a.hackathon.eventStartedAt).getTime() : 0; - const dateB = b.hackathon?.eventStartedAt ? new Date(b.hackathon.eventStartedAt).getTime() : 0; - return dateB - dateA; - }); + // Sort: by event date (newest first). Prefer the program's event date (always + // populated, incl. promoted hackathon submissions), falling back to the legacy + // flat column. So the latest event (e.g. Bitrefill 2026) sorts to the top. + const eventDate = (p: any) => + new Date(p.program?.eventStartsAt ?? p.hackathon?.eventStartedAt ?? 0).getTime() || 0; + const sortedProjects = [...filteredProjects].sort((a, b) => eventDate(b) - eventDate(a)); // Count by type for display const mainTrackCount = winnerProjects.filter(isMainTrackWinner).length; @@ -169,6 +169,8 @@ export function WinnersTable({ projects, onRefresh, connectedAddress, signAdminA // Format amount with correct currency symbol const formatAmount = (amount: number, currency?: string) => { if (currency === 'DOT') return `${amount.toLocaleString()} DOT`; + if (currency === 'EUR') return `€${amount.toLocaleString()}`; + if (currency && currency !== 'USD' && currency !== 'USDC') return `${amount.toLocaleString()} ${currency}`; return `$${amount.toLocaleString()}`; }; diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 7ac45a9..c6a10be 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -129,7 +129,7 @@ export type ApiProject = { /** Live/production website URL */ liveUrl?: string; donationAddress?: string; - bountyPrize?: { name: string; amount: number; hackathonWonAtId: string }[]; + bountyPrize?: { name: string; amount: number; currency?: string; hackathonWonAtId: string }[]; techStack?: string[]; categories?: string[]; m2Status?: "building" | "under_review" | "completed"; @@ -294,6 +294,8 @@ export type ApiLeaderboardRow = { submissionId: string; projectTitle: string; submitterName?: string; + /** Submitter contact email (admin/judge view only; stripped from public results). */ + lumaEmail?: string; githubUrl?: string; videoUrl?: string; /** False when the submitter's Luma email isn't in the signup list. */ @@ -309,6 +311,8 @@ export type ApiLeaderboardRow = { judgeScores?: { judgeEmail: string; requirements: number; techStack: number; innovation: number; total: number }[]; /** The viewing judge's own score (null = not yet scored), for inline edit + re-save. */ myScore?: { requirements: number; techStack: number; innovation: number; notes: string } | null; + /** Set once this winner has been pushed into the central winners panel. */ + promotedProjectId?: string | null; /** Current prize on this submission (null = not a winner). */ prizeAmount?: number | null; prizeCurrency?: string | null; @@ -2120,6 +2124,7 @@ export const api = { slug: string, submissionId: string, authHeader: AdminAuthArg, + bounty?: { bountyName?: string; bountyAmount?: number; bountyCurrency?: string }, ): Promise<{ status: string; data: { projectId: string; alreadyPromoted?: boolean } }> => { if (USE_MOCK_DATA) { const { mockJudging } = await import("./mockJudging"); @@ -2127,7 +2132,11 @@ export const api = { } return request( `/programs/${encodeURIComponent(slug)}/submissions/${encodeURIComponent(submissionId)}/promote`, - { method: "POST", headers: adminAuthHeaders(authHeader) }, + { + method: "POST", + headers: { ...adminAuthHeaders(authHeader), "Content-Type": "application/json" }, + body: JSON.stringify(bounty ?? {}), + }, ); }, diff --git a/server/api/controllers/submission.controller.js b/server/api/controllers/submission.controller.js index 6142d96..edf276c 100644 --- a/server/api/controllers/submission.controller.js +++ b/server/api/controllers/submission.controller.js @@ -398,7 +398,13 @@ class SubmissionController { if (!program) { return res.status(404).json({ status: 'error', message: 'Program not found' }); } - const result = await scoringService.promoteToProject(program, submissionId); + // Optional bounty override (e.g. a "Showcase" 100 EUR award); else the + // submission's own awarded prize is used. + const bounty = + req.body && (req.body.bountyName || req.body.bountyAmount != null) + ? { name: req.body.bountyName, amount: req.body.bountyAmount, currency: req.body.bountyCurrency } + : null; + const result = await scoringService.promoteToProject(program, submissionId, bounty); if (result.notFound) { return res.status(404).json({ status: 'error', message: 'Submission not found' }); } diff --git a/server/api/repositories/project.repository.js b/server/api/repositories/project.repository.js index 09a0160..7e5f2d2 100644 --- a/server/api/repositories/project.repository.js +++ b/server/api/repositories/project.repository.js @@ -84,6 +84,7 @@ const transformProject = (row) => { bountyPrize: (row.bounty_prizes || []).map(b => ({ name: b.name, amount: b.amount, + currency: b.currency || 'USD', hackathonWonAtId: b.hackathon_won_at_id })), milestones: (row.milestones || []).map(m => ({ @@ -282,6 +283,7 @@ class ProjectRepository { project_id: projectId, name: b.name, amount: b.amount, + currency: b.currency || 'USD', hackathon_won_at_id: b.hackathonWonAtId }))); if (bountyError) throw bountyError; diff --git a/server/api/services/__tests__/scoring.service.test.js b/server/api/services/__tests__/scoring.service.test.js index 1802e9e..4346946 100644 --- a/server/api/services/__tests__/scoring.service.test.js +++ b/server/api/services/__tests__/scoring.service.test.js @@ -250,14 +250,45 @@ describe('scoringService.promoteToProject', () => { projectName: 'Aurora Pay', projectRepo: 'https://gh', demoUrl: 'https://v', - hackathon: { id: 'bitrefill-2026', name: 'Bitrefill 2026' }, + hackathon: expect.objectContaining({ id: 'bitrefill-2026', name: 'Bitrefill 2026' }), program: { id: 'prog-1' }, + projectState: 'submitted', teamMembers: [expect.objectContaining({ name: 'Ada' })], + bountyPrize: [], // submission had no prize -> no bounty }), ); expect(submissionRepo.setPromotedProject).toHaveBeenCalledWith('sub-1', 'aurora-pay-ab12'); }); + it('creates a bounty_prize from the awarded prize (or an override) when promoting a winner', async () => { + submissionRepo.findById.mockResolvedValue({ + id: 'sub-2', programId: 'prog-1', projectTitle: 'VoiceBuy', submitterName: 'Bo', + lumaEmail: 'bo@x.com', videoUrl: 'https://v', githubUrl: 'https://gh', + prizeAmount: 500, prizeCurrency: 'EUR', prizeLabel: 'Bitrefill giftcard', + }); + projectService.createProject.mockResolvedValue({ id: 'voicebuy-1' }); + + // Default: uses the submission's awarded prize. + await scoringService.promoteToProject(program, 'sub-2'); + expect(projectService.createProject).toHaveBeenCalledWith( + expect.objectContaining({ + bountyPrize: [expect.objectContaining({ name: 'Bitrefill giftcard', amount: 500, currency: 'EUR' })], + }), + ); + + // Override: a Showcase 100 EUR award. + submissionRepo.findById.mockResolvedValue({ + id: 'sub-3', programId: 'prog-1', projectTitle: 'LootDrop', submitterName: 'Cy', + lumaEmail: 'cy@x.com', videoUrl: 'https://v', githubUrl: 'https://gh', + }); + await scoringService.promoteToProject(program, 'sub-3', { name: 'Showcase', amount: 100, currency: 'EUR' }); + expect(projectService.createProject).toHaveBeenLastCalledWith( + expect.objectContaining({ + bountyPrize: [expect.objectContaining({ name: 'Showcase', amount: 100, currency: 'EUR' })], + }), + ); + }); + it('is idempotent — returns the existing project without creating a second', async () => { submissionRepo.findById.mockResolvedValue({ id: 'sub-1', programId: 'prog-1', promotedProjectId: 'existing-1' }); const r = await scoringService.promoteToProject(program, 'sub-1'); diff --git a/server/api/services/scoring.service.js b/server/api/services/scoring.service.js index 55eda14..8c92071 100644 --- a/server/api/services/scoring.service.js +++ b/server/api/services/scoring.service.js @@ -206,25 +206,39 @@ class ScoringService { // Luma email + video into the description since the project/team schema has no // email field. The payout wallet is added by an admin later (no wallet is // collected at submission time). - async promoteToProject(program, submissionId) { + // `bounty` lets the caller override the prize that lands on the winners panel + // (e.g. a "Showcase" 100 EUR award); otherwise the submission's own awarded + // prize is used. A submission with no prize promotes without a bounty row. + async promoteToProject(program, submissionId, bounty = null) { const submission = await programSubmissionRepository.findById(submissionId); if (!submission || submission.programId !== program.id) return { notFound: true }; if (submission.promotedProjectId) { return { alreadyPromoted: true, projectId: submission.promotedProjectId }; } + // Bounty for the winners panel: explicit override, else the awarded prize. + const prizeName = bounty?.name || submission.prizeLabel || 'Prize'; + const prizeAmount = bounty?.amount ?? submission.prizeAmount ?? null; + const prizeCurrency = bounty?.currency || submission.prizeCurrency || 'EUR'; + const bountyPrize = + prizeAmount != null + ? [{ name: prizeName, amount: prizeAmount, currency: prizeCurrency, hackathonWonAtId: program.slug }] + : []; + const project = await projectService.createProject({ projectName: submission.projectTitle, - description: - `Hackathon submission by ${submission.submitterName} (${submission.lumaEmail}).` + - ` Video demo: ${submission.videoUrl}`, + // Do NOT embed the submitter's email here — project descriptions are + // public. Contact stays admin-only (judging view + CSV export). + description: `Hackathon submission by ${submission.submitterName}. Video demo: ${submission.videoUrl}`, projectRepo: submission.githubUrl, demoUrl: submission.videoUrl, - // hackathon_* are NOT NULL on projects; backfill from the program (same - // convention as elsewhere — hackathon_id mirrors the program slug). - hackathon: { id: program.slug, name: program.name }, + // hackathon_*/project_state are NOT NULL on projects; backfill from the + // program (hackathon_id mirrors the program slug; end date from the event). + hackathon: { id: program.slug, name: program.name, endDate: program.eventEndsAt ?? program.eventStartsAt ?? null }, program: { id: program.id }, + projectState: 'submitted', teamMembers: [{ name: submission.submitterName, github: submission.githubUrl }], + bountyPrize, }); await programSubmissionRepository.setPromotedProject(submissionId, project.id); @@ -284,6 +298,9 @@ class ScoringService { submissionId: s.id, projectTitle: s.projectTitle, submitterName: s.submitterName, + // Contact email — admin/judge view only (this endpoint is gated; + // publicResults strips it). Lets admins reach winners. + lumaEmail: s.lumaEmail, githubUrl: s.githubUrl, videoUrl: s.videoUrl, eligible: eligibleSet.has(normalizeEmail(s.lumaEmail)), @@ -311,6 +328,8 @@ class ScoringService { prizeAmount: s.prizeAmount ?? null, prizeCurrency: s.prizeCurrency ?? null, prizeLabel: s.prizeLabel ?? null, + // Whether this winner has been pushed into the central winners panel. + promotedProjectId: s.promotedProjectId ?? null, // Payout tracking, so the results table can show + toggle PAID. paid: s.paid ?? false, }; diff --git a/supabase/migrations/20260618000000_bounty_prize_currency.sql b/supabase/migrations/20260618000000_bounty_prize_currency.sql new file mode 100644 index 0000000..8c52224 --- /dev/null +++ b/supabase/migrations/20260618000000_bounty_prize_currency.sql @@ -0,0 +1,9 @@ +-- Bounty prizes were USD-only (no currency column; amounts render with "$"). +-- Hackathon submissions promoted into the winners panel carry non-USD prizes +-- (e.g. Bitrefill = EUR), so record the currency per bounty. Existing rows +-- default to USD, preserving current behavior. +-- +-- Additive + idempotent. + +ALTER TABLE bounty_prizes + ADD COLUMN IF NOT EXISTS currency TEXT NOT NULL DEFAULT 'USD';