diff --git a/app/(landing)/hackathons/preview/[orgId]/[draftId]/page.tsx b/app/(landing)/hackathons/preview/[orgId]/[draftId]/page.tsx index 4a13e5e0..06add0d5 100644 --- a/app/(landing)/hackathons/preview/[orgId]/[draftId]/page.tsx +++ b/app/(landing)/hackathons/preview/[orgId]/[draftId]/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useMemo } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { ArrowLeft } from 'lucide-react'; import { getDraft, PrizeTier, VenueType } from '@/lib/api/hackathons'; +import { getOrganization } from '@/lib/api/organization'; import { HackathonBanner } from '@/components/hackathons/hackathonBanner'; import { HackathonNavTabs } from '@/components/hackathons/hackathonNavTabs'; import { HackathonOverview } from '@/components/hackathons/overview/hackathonOverview'; @@ -93,6 +94,18 @@ export default function DraftPreviewPage({ params }: PreviewPageProps) { resolvedParams.orgId, resolvedParams.draftId ); + let organizationData = { name: '', logo: '' }; + try { + const orgRes = await getOrganization(resolvedParams.orgId); + if (orgRes) { + organizationData = { + name: orgRes.name, + logo: orgRes.logo || '', + }; + } + } catch (orgErr) { + console.error('Failed to fetch organization for preview:', orgErr); + } if (response.success && response.data) { const draft = response.data; @@ -111,8 +124,8 @@ export default function DraftPreviewPage({ params }: PreviewPageProps) { organizationId: resolvedParams.orgId, organization: { id: resolvedParams.orgId, - name: '', // We don't have organizer name from draft - logo: '', + name: organizationData.name, + logo: organizationData.logo, }, status: 'DRAFT', @@ -144,6 +157,12 @@ export default function DraftPreviewPage({ params }: PreviewPageProps) { submissionDeadline: draft.data.timeline?.submissionDeadline || '', registrationDeadline: draft.data.participation?.registrationDeadline || '', + judgingStart: draft.data.timeline?.judgingStart || '', + judgingEnd: draft.data.timeline?.judgingEnd || '', + winnersAnnouncedAt: + draft.data.timeline?.winnersAnnouncedAt || + draft.data.timeline?.winnerAnnouncementDate || + '', customRegistrationDeadline: draft.data.participation?.registrationDeadline || null, diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/rewards/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/rewards/page.tsx index ca45e636..e97849a2 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/rewards/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/rewards/page.tsx @@ -7,6 +7,7 @@ import PublishWinnersWizard from '@/components/organization/hackathons/rewards/P import { RewardsPageHeader } from '@/components/organization/hackathons/rewards/RewardsPageHeader'; import { RewardsPageContent } from '@/components/organization/hackathons/rewards/RewardsPageContent'; import { useHackathonRewards } from '@/hooks/use-hackathon-rewards'; +import { useRewardDistributionStatus } from '@/hooks/use-reward-distribution-status'; import { useRankAssignment } from '@/hooks/use-rank-assignment'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { AuthGuard } from '@/components/auth'; @@ -27,11 +28,21 @@ export default function RewardsPage() { isLoadingSubmissions, error, refreshEscrow, + refetchHackathon, + resultsPublished, + hackathon, } = useHackathonRewards(organizationId, hackathonId); const { handleRankChange } = useRankAssignment(); const [isPublishWizardOpen, setIsPublishWizardOpen] = useState(false); + const { + distributionStatus, + isLoading: isLoadingDistributionStatus, + error: distributionError, + refetch: refetchDistributionStatus, + } = useRewardDistributionStatus(organizationId, hackathonId); + const maxRank = useMemo(() => prizeTiers.length, [prizeTiers.length]); const winners = useMemo( () => submissions.filter(s => s.rank && s.rank <= maxRank), @@ -56,6 +67,8 @@ export default function RewardsPage() { const handlePublishSuccess = () => { refreshEscrow(); + refetchDistributionStatus(); + refetchHackathon(); }; return ( @@ -85,6 +98,14 @@ export default function RewardsPage() { )} + {!isLoading && distributionError && ( + + + Distribution Status Error + {distributionError} + + )} + {!isLoading && !error && ( setIsPublishWizardOpen(true)} onRankChange={handleRankChangeWrapper} + distributionStatus={distributionStatus} + isLoadingDistributionStatus={isLoadingDistributionStatus} + onRefreshDistributionStatus={refetchDistributionStatus} + resultsPublished={resultsPublished} + escrowAddress={hackathon?.escrowAddress || hackathon?.contractId} /> )} diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/settings/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/settings/page.tsx index 2ba98388..5713c51a 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/settings/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/settings/page.tsx @@ -16,6 +16,8 @@ import { } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/lib/api/api'; +import { getHackathon, Hackathon } from '@/lib/api/hackathons'; +import { useEffect } from 'react'; import GeneralSettingsTab from '@/components/organization/hackathons/settings/GeneralSettingsTab'; import TimelineSettingsTab from '@/components/organization/hackathons/settings/TimelineSettingsTab'; import ParticipantSettingsTab from '@/components/organization/hackathons/settings/ParticipantSettingsTab'; @@ -32,101 +34,155 @@ export default function SettingsPage() { const hackathonId = params.hackathonId as string; const [isSaving, setIsSaving] = useState(false); + const [hackathon, setHackathon] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const fetchHackathon = async () => { + try { + const res = await getHackathon(hackathonId); + setHackathon(res.data); + } catch { + toast.error('Failed to load hackathon data'); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (hackathonId) { + fetchHackathon(); + } + }, [hackathonId]); const tabTriggerClassName = 'data-[state=active]:border-b-primary rounded-none border-b-2 border-transparent bg-transparent px-0 pt-4 pb-3 text-sm font-medium text-gray-400 transition-all data-[state=active]:text-white data-[state=active]:shadow-none flex items-center gap-2'; - const mockHackathonData = { - info: { - name: 'Web3 Innovation Hackathon', - banner: 'https://example.com/banner.jpg', - description: '

Join us for an exciting hackathon...

', - category: ['DeFi' as const], - venueType: 'virtual' as const, - country: '', - state: '', - city: '', - venueName: '', - venueAddress: '', - }, - timeline: { - startDate: new Date('2025-01-15'), - endDate: new Date('2025-01-20'), - registrationDeadline: new Date('2025-01-10'), - submissionDeadline: new Date('2025-01-18'), - timezone: 'UTC', - phases: [], - }, - participant: { - participantType: 'team_or_individual' as const, - teamMin: 2, - teamMax: 5, - about: '', - require_github: true, - require_demo_video: true, - require_other_links: false, - details_tab: true, - schedule_tab: true, - rules_tab: true, - reward_tab: true, - announcements_tab: true, - partners_tab: true, - join_a_team_tab: true, - projects_tab: true, - participants_tab: true, - }, - rewards: { - prizeTiers: [ - { - id: '1', - place: '1st', - prizeAmount: '10000', - currency: 'USDC', - description: '', - passMark: 80, - }, - { - id: '2', - place: '2nd', - prizeAmount: '5000', - currency: 'USDC', - description: '', - passMark: 70, - }, - { - id: '3', - place: '3rd', - prizeAmount: '3000', - currency: 'USDC', - description: '', - passMark: 60, - }, - ], - }, - collaboration: { - contactEmail: 'contact@example.com', - telegram: '', - discord: '', - socialLinks: [], - sponsorsPartners: [], - }, + // Mapping functions to convert Hackathon to tab data types + const getGeneralData = (h: Hackathon | null) => { + if (!h) return undefined; + return { + name: h.name, + tagline: h.tagline, + slug: h.slug, + banner: h.banner, + description: h.description, + categories: h.categories, + venueType: h.venueType.toLowerCase() as any, + country: h.country, + state: h.state, + city: h.city, + venueName: h.venueName, + venueAddress: h.venueAddress, + }; + }; + + const getTimelineData = (h: Hackathon | null) => { + if (!h) return undefined; + return { + startDate: h.startDate ? new Date(h.startDate) : undefined, + endDate: h.endDate ? new Date(h.endDate) : undefined, + submissionDeadline: h.submissionDeadline + ? new Date(h.submissionDeadline) + : undefined, + judgingStart: h.judgingStart ? new Date(h.judgingStart) : undefined, + judgingEnd: h.judgingEnd ? new Date(h.judgingEnd) : undefined, + winnersAnnouncedAt: h.winnersAnnouncedAt + ? new Date(h.winnersAnnouncedAt) + : undefined, + timezone: h.timezone || 'UTC', + phases: h.phases?.map(p => ({ + ...p, + startDate: p.startDate ? new Date(p.startDate) : undefined, + endDate: p.endDate ? new Date(p.endDate) : undefined, + })) as any, + }; + }; + + const getParticipantData = (h: Hackathon | null) => { + if (!h) return undefined; + return { + participantType: h.participantType.toLowerCase() as any, + teamMin: h.teamMin, + teamMax: h.teamMax, + require_github: h.requireGithub, + require_demo_video: h.requireDemoVideo, + require_other_links: h.requireOtherLinks, + registrationDeadlinePolicy: + h.registrationDeadlinePolicy?.toLowerCase() as any, + registrationDeadline: h.customRegistrationDeadline || undefined, + detailsTab: h.enabledTabs.includes('detailsTab'), + participantsTab: h.enabledTabs.includes('participantsTab'), + resourcesTab: h.enabledTabs.includes('resourcesTab'), + submissionTab: h.enabledTabs.includes('submissionTab'), + announcementsTab: h.enabledTabs.includes('announcementsTab'), + discussionTab: h.enabledTabs.includes('discussionTab'), + winnersTab: h.enabledTabs.includes('winnersTab'), + sponsorsTab: h.enabledTabs.includes('sponsorsTab'), + joinATeamTab: h.enabledTabs.includes('joinATeamTab'), + rulesTab: h.enabledTabs.includes('rulesTab'), + }; + }; + + const getAdvancedData = (h: Hackathon | null) => { + if (!h) return undefined; + const adv = h.metadata?.advancedSettings; + return { + isPublic: adv?.isPublic ?? true, + allowLateRegistration: adv?.allowLateRegistration ?? false, + requireApproval: adv?.requireApproval ?? false, + maxParticipants: adv?.maxParticipants, + customDomain: adv?.customDomain || '', + enableDiscord: adv?.enableDiscord ?? !!h.discord, + discordInviteLink: adv?.discordInviteLink || h.discord || '', + enableTelegram: adv?.enableTelegram ?? !!h.telegram, + telegramInviteLink: adv?.telegramInviteLink || h.telegram || '', + }; }; const handleSave = async (section: string, data: unknown) => { setIsSaving(true); try { - await api.patch( - `/organizations/${organizationId}/hackathons/${hackathonId}/settings/${section.toLowerCase()}`, - data - ); + if (section === 'General') { + await api.patch( + `/organizations/${organizationId}/hackathons/${hackathonId}/content`, + { information: data } + ); + } else if (section === 'Collaboration') { + await api.patch( + `/organizations/${organizationId}/hackathons/${hackathonId}/content`, + { collaboration: data } + ); + } else if (section === 'Participants') { + await api.patch( + `/organizations/${organizationId}/hackathons/${hackathonId}/schedule`, + { participation: data } + ); + } else if (section === 'Rewards') { + await api.patch( + `/organizations/${organizationId}/hackathons/${hackathonId}/financial`, + { rewards: data } + ); + } else { + await api.patch( + `/organizations/${organizationId}/hackathons/${hackathonId}/settings/${section.toLowerCase()}`, + data + ); + } toast.success(`${section} settings saved successfully!`); - } catch { - toast.error(`Failed to save ${section} settings`); + await fetchHackathon(); // Call fetchHackathon after successful save + } catch (error: any) { + const message = + error.response?.data?.message || `Failed to save ${section} settings`; + const errorMessage = Array.isArray(message) ? message[0] : message; + toast.error(errorMessage); + throw error; // Re-throw to let callers know it failed } finally { setIsSaving(false); } }; + if (isLoading) return ; + return ( }>
@@ -208,9 +264,12 @@ export default function SettingsPage() { handleSave('General', data)} + initialData={getGeneralData(hackathon) as any} + onSave={async data => { + await handleSave('General', data); + }} isLoading={isSaving} + isPublished={hackathon?.status === 'PUBLISHED'} /> @@ -218,9 +277,8 @@ export default function SettingsPage() { handleSave('Timeline', data)} - isLoading={isSaving} + initialData={getTimelineData(hackathon)} + onSaveSuccess={fetchHackathon} /> @@ -228,8 +286,15 @@ export default function SettingsPage() { handleSave('Participants', data)} + initialData={ + hackathon ? getParticipantData(hackathon) : undefined + } + isRegistrationClosed={ + hackathon ? !hackathon.registrationOpen : false + } + onSave={async data => { + await handleSave('Participants', data); + }} isLoading={isSaving} /> @@ -238,8 +303,10 @@ export default function SettingsPage() { handleSave('Rewards', data)} + initialData={{ prizeTiers: hackathon?.prizeTiers || [] } as any} + onSave={async data => { + await handleSave('Rewards', data); + }} isLoading={isSaving} /> @@ -248,8 +315,18 @@ export default function SettingsPage() { handleSave('Collaboration', data)} + initialData={ + { + contactEmail: hackathon?.contactEmail || '', + telegram: hackathon?.telegram || '', + discord: hackathon?.discord || '', + socialLinks: hackathon?.socialLinks || [], + sponsorsPartners: hackathon?.sponsorsPartners || [], + } as any + } + onSave={async data => { + await handleSave('Collaboration', data); + }} isLoading={isSaving} /> @@ -258,8 +335,8 @@ export default function SettingsPage() { handleSave('Advanced', data)} - isLoading={isSaving} + initialData={getAdvancedData(hackathon)} + onSaveSuccess={fetchHackathon} /> diff --git a/components/hackathons/hackathonStickyCard.tsx b/components/hackathons/hackathonStickyCard.tsx index b152ca14..25435e45 100644 --- a/components/hackathons/hackathonStickyCard.tsx +++ b/components/hackathons/hackathonStickyCard.tsx @@ -237,7 +237,15 @@ export function HackathonStickyCard(props: HackathonStickyCardProps) { {/* Action Buttons */}
{/* Register / Leave Button */} - {!isRegistered ? ( + {status === 'ended' ? ( + + ) : !isRegistered ? ( canRegister ? (
)} @@ -264,6 +275,7 @@ const SubmissionTabContent: React.FC = ({ submissionId={mySubmission.id} isPinned={true} isMySubmission={true} + isDeadlinePassed={isDeadlinePassed} onViewClick={() => handleViewSubmission(mySubmission.id)} onEditClick={expand} onDeleteClick={() => handleDeleteClick(mySubmission.id)} diff --git a/components/landing-page/Hero2.tsx b/components/landing-page/Hero2.tsx index 69df7149..2c15b465 100644 --- a/components/landing-page/Hero2.tsx +++ b/components/landing-page/Hero2.tsx @@ -143,16 +143,19 @@ export default function Hero2() { state: 'CA', country: 'USA', timezone: 'UTC', - startDate: '2024-01-01', - endDate: '2024-01-01', - submissionDeadline: '2024-01-01', - registrationDeadline: '2024-01-01', - customRegistrationDeadline: '2024-01-01', + startDate: '2024-05-01T09:00:00Z', + endDate: '2024-06-15T18:00:00Z', + submissionDeadline: '2024-06-01T23:59:59Z', + registrationDeadline: '2024-05-15T23:59:59Z', + judgingStart: '2024-06-02T09:00:00Z', + judgingEnd: '2024-06-10T18:00:00Z', + winnersAnnouncedAt: '2024-06-15T12:00:00Z', + customRegistrationDeadline: '2024-05-15T23:59:59Z', registrationOpen: true, registrationDeadlinePolicy: 'BEFORE_SUBMISSION_DEADLINE' as const, - daysUntilStart: 10, - daysUntilEnd: 10, + daysUntilStart: 0, + daysUntilEnd: 0, participantType: 'INDIVIDUAL' as const, teamMin: 1, teamMax: 4, @@ -303,16 +306,19 @@ export default function Hero2() { state: 'California', country: 'United States', timezone: 'America/Los_Angeles', - startDate: '2026-01-01', - endDate: '2026-01-01', - submissionDeadline: '2026-01-01', - registrationDeadline: '2026-01-01', - customRegistrationDeadline: '2026-01-01', + startDate: '2026-06-01T10:00:00Z', + endDate: '2026-06-30T17:00:00Z', + submissionDeadline: '2026-06-25T23:59:59Z', + registrationDeadline: '2026-05-25T23:59:59Z', + judgingStart: '2026-06-26T09:00:00Z', + judgingEnd: '2026-06-28T18:00:00Z', + winnersAnnouncedAt: '2026-06-30T12:00:00Z', + customRegistrationDeadline: '2026-05-25T23:59:59Z', registrationOpen: true, registrationDeadlinePolicy: 'BEFORE_SUBMISSION_DEADLINE' as const, - daysUntilStart: 10, - daysUntilEnd: 10, + daysUntilStart: 100, + daysUntilEnd: 130, participantType: 'INDIVIDUAL' as const, teamMin: 1, teamMax: 4, @@ -349,8 +355,8 @@ export default function Hero2() { { id: '1', name: 'Phase 1', - startDate: '2026-01-01', - endDate: '2026-01-01', + startDate: '2026-06-01T10:00:00Z', + endDate: '2026-06-30T17:00:00Z', }, ], resources: [], diff --git a/components/organization/hackathons/new/tabs/ParticipantTab.tsx b/components/organization/hackathons/new/tabs/ParticipantTab.tsx index a2400857..a5ee7f40 100644 --- a/components/organization/hackathons/new/tabs/ParticipantTab.tsx +++ b/components/organization/hackathons/new/tabs/ParticipantTab.tsx @@ -39,6 +39,7 @@ interface ParticipantTabProps { onSave?: (data: ParticipantFormData) => Promise; initialData?: ParticipantFormData; isLoading?: boolean; + isRegistrationClosed?: boolean; } const participantTypes = [ @@ -105,6 +106,7 @@ export default function ParticipantTab({ onSave, initialData, isLoading = false, + isRegistrationClosed = false, }: ParticipantTabProps) { const form = useForm({ resolver: zodResolver(participantSchema), @@ -132,14 +134,23 @@ export default function ParticipantTab({ const participantType = form.watch('participantType'); + React.useEffect(() => { + if (initialData) { + form.reset(initialData); + } + }, [initialData, form]); + const onSubmit = async (data: ParticipantFormData) => { try { if (onSave) { await onSave(data); - toast.success('Participation settings saved successfully'); } - } catch { - toast.error('Failed to save participation settings. Please try again.'); + } catch (error: any) { + const message = error.response?.data?.message || error.message; + const errorMessage = Array.isArray(message) ? message[0] : message; + toast.error( + errorMessage || 'Failed to save participant settings. Please try again.' + ); } }; @@ -147,29 +158,44 @@ export default function ParticipantTab({ value: number; onIncrement: () => void; onDecrement: () => void; + disabled?: boolean; } const NumberInput = ({ value, onIncrement, onDecrement, + disabled = false, }: NumberInputProps) => (
-
+
{value}
@@ -204,12 +230,17 @@ export default function ParticipantTab({
-
+
{announcement ? ( <>
500 - ? announcementContent.content.substring(0, 500) + '...' - : announcementContent.content, - }} + className='markdown-content text-gray-400' + dangerouslySetInnerHTML={sanitizedContent} /> - {announcementContent.content.length > 500 && ( + {isLongAnnouncement && ( )} ) : ( -

No announcement added yet.

+

No announcement added yet.

)}
diff --git a/components/organization/hackathons/rewards/AnnouncementStep.tsx b/components/organization/hackathons/rewards/AnnouncementStep.tsx index 4b0ca8c6..10d9b90e 100644 --- a/components/organization/hackathons/rewards/AnnouncementStep.tsx +++ b/components/organization/hackathons/rewards/AnnouncementStep.tsx @@ -14,17 +14,19 @@ export const AnnouncementStep: React.FC = ({ onAnnouncementChange, }) => { return ( -
+
- + -

- This message will be displayed publicly with the winners announcement. +

+ Displayed publicly with the winners announcement.

diff --git a/components/organization/hackathons/rewards/CreateMilestonesButton.tsx b/components/organization/hackathons/rewards/CreateMilestonesButton.tsx deleted file mode 100644 index 416e6321..00000000 --- a/components/organization/hackathons/rewards/CreateMilestonesButton.tsx +++ /dev/null @@ -1,97 +0,0 @@ -'use client'; - -import React, { useState, useMemo } from 'react'; -import { BoundlessButton } from '@/components/buttons'; -import { Trophy } from 'lucide-react'; -import { Submission } from './types'; -import { HackathonEscrowData } from '@/lib/api/hackathons'; -import { PrizeTier } from '@/components/organization/hackathons/new/tabs/schemas/rewardsSchema'; -import { useWalletAddresses } from '@/hooks/use-wallet-addresses'; -import { useMilestoneCreation } from '@/hooks/use-milestone-creation'; -import { CreateMilestonesDialog } from './CreateMilestonesDialog'; - -interface CreateMilestonesButtonProps { - submissions: Submission[]; - prizeTiers: PrizeTier[]; - escrow: HackathonEscrowData | null; - organizationId: string; - hackathonId: string; - onSuccess?: () => void; -} - -export default function CreateMilestonesButton({ - submissions, - prizeTiers, - escrow, - organizationId, - hackathonId, - onSuccess, -}: CreateMilestonesButtonProps) { - const [isOpen, setIsOpen] = useState(false); - - const winners = useMemo( - () => submissions.filter(s => s.rank !== undefined && s.rank !== null), - [submissions] - ); - - const hasWinners = winners.length > 0; - const canCreateMilestones = escrow?.isFunded && hasWinners; - - const { walletAddresses, errors, setErrors, handleWalletAddressChange } = - useWalletAddresses({ - isOpen, - winners, - }); - - const { isLoading, createMilestones } = useMilestoneCreation({ - winners, - prizeTiers, - escrow, - organizationId, - hackathonId, - walletAddresses, - setErrors, - onSuccess, - }); - - const handleCreateMilestones = async () => { - try { - await createMilestones(); - setIsOpen(false); - } catch { - // Error is handled in the hook - } - }; - - if (!hasWinners) { - return null; - } - - return ( - <> - setIsOpen(true)} - disabled={!canCreateMilestones} - className='gap-2' - > - - Create Milestones - - - - - ); -} diff --git a/components/organization/hackathons/rewards/PodiumCard.tsx b/components/organization/hackathons/rewards/PodiumCard.tsx index 1c2f7ac6..e6c1374f 100644 --- a/components/organization/hackathons/rewards/PodiumCard.tsx +++ b/components/organization/hackathons/rewards/PodiumCard.tsx @@ -108,11 +108,9 @@ export default function PodiumCard({ rank, submission }: PodiumCardProps) { > {submission ? ( <> - + - {submission.name.charAt(0) || 'U'} + {submission.name?.charAt(0) || 'U'} ) : ( diff --git a/components/organization/hackathons/rewards/PodiumSection.tsx b/components/organization/hackathons/rewards/PodiumSection.tsx index f51bdd33..b618fb9b 100644 --- a/components/organization/hackathons/rewards/PodiumSection.tsx +++ b/components/organization/hackathons/rewards/PodiumSection.tsx @@ -35,10 +35,10 @@ export default function PodiumSection({ const thirdPlace = winners.find(s => s.rank === 3); return ( -
- - - +
+ {secondPlace && } + {firstPlace && } + {thirdPlace && }
); } @@ -47,13 +47,16 @@ export default function PodiumSection({
- {Array.from({ length: maxRank }, (_, i) => i + 1).map(rank => { - const winner = winners.find(s => s.rank === rank); - return ; - })} + {winners.map(winner => ( + + ))}
); } diff --git a/components/organization/hackathons/rewards/PreviewStep.tsx b/components/organization/hackathons/rewards/PreviewStep.tsx index c999d3da..0cac8ebf 100644 --- a/components/organization/hackathons/rewards/PreviewStep.tsx +++ b/components/organization/hackathons/rewards/PreviewStep.tsx @@ -14,7 +14,11 @@ interface PreviewStepProps { }>; announcement: string; onEditAnnouncement: () => void; - getPrizeForRank: (rank: number) => string; + getPrizeForRank: (rank: number) => { + amount: string; + currency: string; + label: string; + }; } export const PreviewStep: React.FC = ({ @@ -25,11 +29,10 @@ export const PreviewStep: React.FC = ({ getPrizeForRank, }) => { return ( -
-
-

Preview

-

- Review winners and announcement before publishing +

+
+

+ Review winners and announcement before triggering distribution.

diff --git a/components/organization/hackathons/rewards/PublishWinnersWizard.tsx b/components/organization/hackathons/rewards/PublishWinnersWizard.tsx index 3dea661f..d364a6d6 100644 --- a/components/organization/hackathons/rewards/PublishWinnersWizard.tsx +++ b/components/organization/hackathons/rewards/PublishWinnersWizard.tsx @@ -13,9 +13,7 @@ import { Submission } from './types'; import { HackathonEscrowData } from '@/lib/api/hackathons'; import { PrizeTier } from '@/components/organization/hackathons/new/tabs/schemas/rewardsSchema'; import { useWizardSteps } from '@/hooks/use-wizard-steps'; -import { useWalletAddresses } from '@/hooks/use-wallet-addresses'; import { usePublishWinners } from '@/hooks/use-publish-winners'; -import { WalletsStep } from './WalletsStep'; import { AnnouncementStep } from './AnnouncementStep'; import { PreviewStep } from './PreviewStep'; import { WizardStepIndicator } from './WizardStepIndicator'; @@ -53,7 +51,6 @@ export default function PublishWinnersWizard({ ); const [announcement, setAnnouncement] = useState(''); - const [milestonesCreated, setMilestonesCreated] = useState(false); const { currentStep, @@ -64,21 +61,13 @@ export default function PublishWinnersWizard({ handleBack, } = useWizardSteps({ open, escrow }); - const { walletAddresses, handleWalletAddressChange } = useWalletAddresses({ - isOpen: open, - winners, - }); - const { isPublishing, publishWinners } = usePublishWinners({ winners, prizeTiers, escrow, organizationId, hackathonId, - walletAddresses, announcement, - milestonesCreated, - setMilestonesCreated, onSuccess: () => { onOpenChange(false); if (onSuccess) { @@ -99,8 +88,8 @@ export default function PublishWinnersWizard({ const mappedPrizeTiers = useMemo( () => - prizeTiers.map((tier, index) => ({ - rank: index + 1, + prizeTiers.map(tier => ({ + rank: tier.rank, prizeAmount: tier.prizeAmount, currency: tier.currency, })), @@ -110,28 +99,29 @@ export default function PublishWinnersWizard({ const getPrizeForRank = (rank: number) => { const tier = mappedPrizeTiers.find(t => t.rank === rank); if (tier) { - const amount = parseFloat(tier.prizeAmount).toLocaleString('en-US'); - return `${amount} ${tier.currency}`; + const amount = parseFloat(tier.prizeAmount || '0').toLocaleString( + 'en-US' + ); + const currency = tier.currency || 'USDC'; + return { amount, currency, label: `${amount} ${currency}` }; } - return rank === 1 - ? '10,000 USDC' - : rank === 2 - ? '5,000 USDC' - : '8,000 USDC'; + return { amount: '0', currency: 'USDC', label: 'No prize configured' }; }; return ( - - - Publish Winners + + + + Reward Winners + @@ -142,18 +132,7 @@ export default function PublishWinnersWizard({ currentStepIndex={currentStepIndex} /> -
- {currentStep === 'wallets' && ( - - )} - +
{currentStep === 'announcement' && ( void; +} + +/** + * Stellar stroop precision: 7 decimals + */ +const STROOP_FACTOR = 1e7; + +const STATUS_CONFIG: Record< + RewardDistributionStatusEnum, + { + icon: React.ReactNode; + label: string; + bgClass: string; + borderClass: string; + textClass: string; + iconClass: string; + } +> = { + NOT_TRIGGERED: { + icon: , + label: 'Not Triggered', + bgClass: 'bg-gray-900/60', + borderClass: 'border-gray-800', + textClass: 'text-gray-300', + iconClass: 'text-gray-400', + }, + PENDING_ADMIN_REVIEW: { + icon: , + label: 'Pending Admin Review', + bgClass: 'bg-amber-950/40', + borderClass: 'border-amber-800/60', + textClass: 'text-amber-300', + iconClass: 'text-amber-400', + }, + APPROVED: { + icon: , + label: 'Approved', + bgClass: 'bg-green-950/40', + borderClass: 'border-green-800/60', + textClass: 'text-green-300', + iconClass: 'text-green-400', + }, + REJECTED: { + icon: , + label: 'Rejected', + bgClass: 'bg-red-950/40', + borderClass: 'border-red-800/60', + textClass: 'text-red-300', + iconClass: 'text-red-400', + }, + EXECUTING: { + icon: , + label: 'Executing', + bgClass: 'bg-blue-950/40', + borderClass: 'border-blue-800/60', + textClass: 'text-blue-300', + iconClass: 'text-blue-400', + }, + COMPLETED: { + icon: , + label: 'Completed', + bgClass: 'bg-green-950/40', + borderClass: 'border-green-800/60', + textClass: 'text-green-300', + iconClass: 'text-green-400', + }, + FAILED: { + icon: , + label: 'Failed', + bgClass: 'bg-red-950/50', + borderClass: 'border-red-700/60', + textClass: 'text-red-300', + iconClass: 'text-red-400', + }, + PARTIAL_SUCCESS: { + icon: , + label: 'Partial Success', + bgClass: 'bg-orange-950/40', + borderClass: 'border-orange-800/60', + textClass: 'text-orange-300', + iconClass: 'text-orange-400', + }, +}; + +const formatDate = (val: string | null | undefined): string | null => { + if (!val) return null; + try { + return formatInTimeZone( + new Date(val), + 'UTC', + "MMM d, yyyy 'at' HH:mm 'UTC'" + ); + } catch { + return val; + } +}; + +export const RewardDistributionStatusBanner: React.FC< + RewardDistributionStatusBannerProps +> = ({ distributionStatus, isLoading, onRefresh }) => { + const { status } = distributionStatus; + const cfg = STATUS_CONFIG[status]; + + if (!cfg) return null; + + const triggeredAt = formatDate(distributionStatus.triggeredAt); + const adminDecisionAt = formatDate(distributionStatus.adminDecisionAt); + const updatedAt = formatDate(distributionStatus.updatedAt); + + return ( +
+
+
+

+ Distribution Status +

+

+ Current reward distribution state for this hackathon +

+
+ {onRefresh && ( + + )} +
+ +
+ {/* Status Header */} +
+ {cfg.icon} +
+

+ {cfg.label} +

+ {triggeredAt && ( +

+ Triggered: {triggeredAt} +

+ )} +
+
+ + {/* Detail Fields */} +
+ {distributionStatus.distributionId && ( +
+ Distribution ID +

+ {distributionStatus.distributionId} +

+
+ )} + + {adminDecisionAt && ( +
+ Admin Decision +

{adminDecisionAt}

+
+ )} + + {updatedAt && ( +
+ Last Updated +

{updatedAt}

+
+ )} + + {distributionStatus.snapshot?.totalPrizePool != null && + distributionStatus.snapshot.totalPrizePool > 0 && ( +
+ + Total Prize Pool + +

+ {( + distributionStatus.snapshot.totalPrizePool / STROOP_FACTOR + ).toLocaleString('en-US', { maximumFractionDigits: 2 })}{' '} + {distributionStatus.snapshot.currency} +

+
+ )} + + {distributionStatus.snapshot?.winners?.length > 0 && ( +
+ Winners +

+ {distributionStatus.snapshot.winners.length} recipient(s) +

+
+ )} +
+ + {/* Rejection reason */} + {distributionStatus.rejectionReason && ( +
+

+ Rejection Reason +

+

+ {distributionStatus.rejectionReason} +

+
+ )} + + {/* Admin note */} + {distributionStatus.adminNote && ( +
+

Admin Note

+

+ {distributionStatus.adminNote} +

+
+ )} +
+
+ ); +}; diff --git a/components/organization/hackathons/rewards/RewardsPageContent.tsx b/components/organization/hackathons/rewards/RewardsPageContent.tsx index 09501163..045feac2 100644 --- a/components/organization/hackathons/rewards/RewardsPageContent.tsx +++ b/components/organization/hackathons/rewards/RewardsPageContent.tsx @@ -1,13 +1,29 @@ 'use client'; -import React from 'react'; -import { Megaphone } from 'lucide-react'; +import React, { useState } from 'react'; +import { + Megaphone, + CheckCircle2, + XCircle, + Clock, + Loader2, + AlertTriangle, + Info, + Ban, + ChevronRight, +} from 'lucide-react'; import { BoundlessButton } from '@/components/buttons'; import PodiumSection from '@/components/organization/hackathons/rewards/PodiumSection'; import SubmissionsList from '@/components/organization/hackathons/rewards/SubmissionsList'; import EscrowStatusCard from '@/components/organization/hackathons/rewards/EscrowStatusCard'; +import { RewardDistributionStatusBanner } from '@/components/organization/hackathons/rewards/RewardDistributionStatusBanner'; +import BoundlessSheet from '@/components/sheet/boundless-sheet'; import { Submission } from '@/components/organization/hackathons/rewards/types'; -import type { HackathonEscrowData } from '@/lib/api/hackathons'; +import type { + HackathonEscrowData, + RewardDistributionStatusResponse, + RewardDistributionStatusEnum, +} from '@/lib/api/hackathons'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { AlertCircle } from 'lucide-react'; @@ -20,6 +36,11 @@ interface RewardsPageContentProps { hasWinners: boolean; onPublishClick: () => void; onRankChange: (submissionId: string, newRank: number | null) => Promise; + distributionStatus?: RewardDistributionStatusResponse | null; + isLoadingDistributionStatus?: boolean; + onRefreshDistributionStatus?: () => void; + resultsPublished?: boolean; + escrowAddress?: string; } export const RewardsPageContent: React.FC = ({ @@ -31,20 +52,143 @@ export const RewardsPageContent: React.FC = ({ hasWinners, onPublishClick, onRankChange, + distributionStatus, + isLoadingDistributionStatus, + onRefreshDistributionStatus, + resultsPublished, + escrowAddress, }) => { + const [isStatusSheetOpen, setIsStatusSheetOpen] = useState(false); + + const status = distributionStatus?.status; + const isNotTriggered = !distributionStatus || status === 'NOT_TRIGGERED'; + const isRejected = status === 'REJECTED' || status === 'FAILED'; + const isLocked = + status && + ['PENDING_ADMIN_REVIEW', 'APPROVED', 'EXECUTING'].includes(status); + + const showTriggerButton = (isNotTriggered || isRejected) && !isLocked; + const canTrigger = resultsPublished && !!escrowAddress; + + // Status color/icon mapping for the compact badge + const STATUS_BADGE: Record< + string, + { color: string; icon: React.ReactNode; label: string } + > = { + NOT_TRIGGERED: { + color: 'text-gray-400 border-gray-700 bg-gray-900/60', + icon: , + label: 'Not Triggered', + }, + PENDING_ADMIN_REVIEW: { + color: 'text-amber-400 border-amber-800/60 bg-amber-950/30', + icon: , + label: 'Pending Review', + }, + APPROVED: { + color: 'text-green-400 border-green-800/60 bg-green-950/30', + icon: , + label: 'Approved', + }, + REJECTED: { + color: 'text-red-400 border-red-800/60 bg-red-950/30', + icon: , + label: 'Rejected', + }, + EXECUTING: { + color: 'text-blue-400 border-blue-800/60 bg-blue-950/30', + icon: , + label: 'Executing', + }, + COMPLETED: { + color: 'text-green-400 border-green-800/60 bg-green-950/30', + icon: , + label: 'Completed', + }, + FAILED: { + color: 'text-red-400 border-red-800/60 bg-red-950/30', + icon: , + label: 'Failed', + }, + PARTIAL_SUCCESS: { + color: 'text-orange-400 border-orange-800/60 bg-orange-950/30', + icon: , + label: 'Partial Success', + }, + }; + const badge = status ? STATUS_BADGE[status] : null; + return (
- {hasWinners && ( -
- + + + - - Publish Winners - +
+ +
+ + + )} + + {/* 2. Trigger Control Section / Requirements Alert */} + {showTriggerButton && ( +
+ {!canTrigger && ( + + + Distribution Requirements + + To trigger reward distribution, you must: +
    + {!resultsPublished && ( +
  • Publish the judging results in the Judging tab.
  • + )} + {!escrowAddress && ( +
  • + Configure a valid Stellar Escrow address in Settings. +
  • + )} +
+
+
+ )} + +
+ + + {isRejected + ? 'Re-trigger Reward Distribution' + : 'Trigger Reward Distribution'} + +
)} diff --git a/components/organization/hackathons/rewards/WalletsStep.tsx b/components/organization/hackathons/rewards/WalletsStep.tsx deleted file mode 100644 index 7f583f6e..00000000 --- a/components/organization/hackathons/rewards/WalletsStep.tsx +++ /dev/null @@ -1,74 +0,0 @@ -'use client'; - -import React from 'react'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { AlertCircle, Info } from 'lucide-react'; -import { WinnerFormItem } from './WinnerFormItem'; -import { PrizeTierValidationAlert } from './PrizeTierValidationAlert'; -import type { Submission } from './types'; -import type { PrizeTier } from '@/components/organization/hackathons/new/tabs/schemas/rewardsSchema'; -import type { HackathonEscrowData } from '@/lib/api/hackathons'; - -interface WalletsStepProps { - winners: Submission[]; - prizeTiers: PrizeTier[]; - escrow: HackathonEscrowData | null; - walletAddresses: Record; - isPublishing: boolean; - onWalletAddressChange: (submissionId: string, address: string) => void; -} - -export const WalletsStep: React.FC = ({ - winners, - prizeTiers, - escrow, - walletAddresses, - isPublishing, - onWalletAddressChange, -}) => { - return ( -
- - - Mandatory Step - - Creating milestones is required before announcing - winners. Please provide wallet addresses for all winners to proceed. - - - - - - {!escrow?.isFunded && ( - - - Cannot Create Milestones - - Escrow is not funded. Please fund the escrow first. - - - )} - - {winners.map(winner => ( - - ))} - - - - Important - - After publishing, you'll need to approve each winner milestone and - then release funds to distribute prizes. - - -
- ); -}; diff --git a/components/organization/hackathons/rewards/WinnerCard.tsx b/components/organization/hackathons/rewards/WinnerCard.tsx index 2a12ddfd..7d22eabb 100644 --- a/components/organization/hackathons/rewards/WinnerCard.tsx +++ b/components/organization/hackathons/rewards/WinnerCard.tsx @@ -18,8 +18,9 @@ import { getRibbonColors, getRibbonText } from './winnersUtils'; interface WinnerCardProps { rank: number; winner?: Submission; - prizeAmount: string; - currency: string; + prizeAmount?: string; + currency?: string; + prizeLabel?: string; maxRank: number; } @@ -28,6 +29,7 @@ export default function WinnerCard({ winner, prizeAmount, currency, + prizeLabel, maxRank, }: WinnerCardProps) { const getScaleClass = () => { @@ -43,25 +45,27 @@ export default function WinnerCard({ return (
-
+
Trophy - - ${prizeAmount} {currency} + + {prizeAmount != null && currency && prizeAmount !== '0' + ? `$${prizeAmount} ${currency}` + : prizeLabel || 'No prize configured'}
-
- +
+ {winner ? ( <> ) : ( - + ? )}
-
+
-
-

{winner?.name || '?'}

+
+

+ {winner?.name || '?'} +

{winner && ( @@ -116,15 +122,20 @@ export default function WinnerCard({

{winner.projectName}

- - Category + + {winner.category || 'General'}
-
- {winner.score} Votes -
- 1k+ Comments - +
+ + {winner.averageScore + ? winner.averageScore.toFixed(1) + : winner.score || 0}{' '} + Score + +
+ {winner.commentCount || 0} Comments +
diff --git a/components/organization/hackathons/rewards/WinnersGrid.tsx b/components/organization/hackathons/rewards/WinnersGrid.tsx index c4fa9e79..6632d77e 100644 --- a/components/organization/hackathons/rewards/WinnersGrid.tsx +++ b/components/organization/hackathons/rewards/WinnersGrid.tsx @@ -1,6 +1,6 @@ 'use client'; -import React from 'react'; +import React, { useMemo } from 'react'; import { cn } from '@/lib/utils'; import { Submission } from './types'; import WinnerCard from './WinnerCard'; @@ -12,7 +12,11 @@ interface WinnersGridProps { currency: string; }>; winners: Submission[]; - getPrizeForRank: (rank: number) => string; + getPrizeForRank: (rank: number) => { + amount: string; + currency: string; + label: string; + }; } export default function WinnersGrid({ @@ -20,54 +24,69 @@ export default function WinnersGrid({ winners, getPrizeForRank, }: WinnersGridProps) { - const maxRank = prizeTiers.length; + const totalTiers = prizeTiers.length; - const getGridCols = () => { - if (maxRank === 1) return 'grid-cols-1'; - if (maxRank === 2) return 'grid-cols-1 md:grid-cols-2'; - if (maxRank === 3) return 'grid-cols-1 md:grid-cols-3'; - if (maxRank === 4) return 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3'; + // Filter only tiers that have an assigned winner + const tiersWithWinners = useMemo(() => { + return prizeTiers.filter(tier => winners.some(w => w.rank === tier.rank)); + }, [prizeTiers, winners]); + + const getGridCols = (count: number) => { + if (count === 1) return 'grid-cols-1'; + if (count === 2) return 'grid-cols-1 md:grid-cols-2'; + if (count === 3) return 'grid-cols-1 md:grid-cols-3'; return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'; }; - const getTierOrder = () => { - const sortedTiers = [...prizeTiers].sort((a, b) => a.rank - b.rank); + const getTierOrder = (availableTiers: typeof prizeTiers) => { + const sortedTiers = [...availableTiers].sort((a, b) => a.rank - b.rank); - if (maxRank <= 3) { - const secondTier = sortedTiers.find(t => t.rank === 2); - const firstTier = sortedTiers.find(t => t.rank === 1); - const thirdTier = sortedTiers.find(t => t.rank === 3); + if (sortedTiers.length === 3) { + const secondTier = sortedTiers.find(t => t.rank === 2) || sortedTiers[1]; + const firstTier = sortedTiers.find(t => t.rank === 1) || sortedTiers[0]; + const thirdTier = sortedTiers.find(t => t.rank === 3) || sortedTiers[2]; return [secondTier, firstTier, thirdTier].filter(Boolean); } return sortedTiers; }; - const tiers = getTierOrder(); + const tiersToDisplay = getTierOrder(tiersWithWinners); return ( -
- {tiers.map(tier => { - if (!tier) return null; +
+
+ + {winners.length}/{totalTiers} Winners Assigned + +
+ +
+ {tiersToDisplay.map(tier => { + if (!tier) return null; - const rank = tier.rank; - const winner = winners.find(s => s.rank === rank); - const prize = getPrizeForRank(rank); - const parts = prize.split(' '); - const amount = parts.slice(0, -1).join(' '); - const currency = parts[parts.length - 1]; + const rank = tier.rank; + const winner = winners.find(s => s.rank === rank); + const prize = getPrizeForRank(rank); + const amount = prize.amount || '0'; + const currency = prize.currency || 'USDC'; + const label = prize.label; - return ( - - ); - })} + return ( + + ); + })} +
); } diff --git a/components/organization/hackathons/rewards/WinnersPreviewPage.tsx b/components/organization/hackathons/rewards/WinnersPreviewPage.tsx index 1c42b6d5..1142d51e 100644 --- a/components/organization/hackathons/rewards/WinnersPreviewPage.tsx +++ b/components/organization/hackathons/rewards/WinnersPreviewPage.tsx @@ -37,13 +37,13 @@ export default function WinnersPreviewPage({ const tier = prizeTiers.find(t => t.rank === rank); if (tier) { const amount = parseFloat(tier.prizeAmount).toLocaleString('en-US'); - return `${amount} ${tier.currency}`; + return { + amount, + currency: tier.currency, + label: `${amount} ${tier.currency}`, + }; } - return rank === 1 - ? '10,000 USDC' - : rank === 2 - ? '5,000 USDC' - : '8,000 USDC'; + return { amount: '0', currency: 'USDC', label: 'No prize configured' }; }; return ( diff --git a/components/organization/hackathons/rewards/WizardFooter.tsx b/components/organization/hackathons/rewards/WizardFooter.tsx index 50bd33b1..32bd66a9 100644 --- a/components/organization/hackathons/rewards/WizardFooter.tsx +++ b/components/organization/hackathons/rewards/WizardFooter.tsx @@ -26,7 +26,7 @@ export const WizardFooter: React.FC = ({ onPublish, }) => { return ( -
+
= ({ ) : ( <> - Publish Winners + Reward Winners )} diff --git a/components/organization/hackathons/rewards/types.ts b/components/organization/hackathons/rewards/types.ts index 064eba5b..8f040687 100644 --- a/components/organization/hackathons/rewards/types.ts +++ b/components/organization/hackathons/rewards/types.ts @@ -11,4 +11,6 @@ export interface Submission { walletAddress?: string; averageScore?: number; judgeCount?: number; + category?: string; + commentCount?: number; } diff --git a/components/organization/hackathons/settings/AdvancedSettingsTab.tsx b/components/organization/hackathons/settings/AdvancedSettingsTab.tsx index 7b92abe1..dc859858 100644 --- a/components/organization/hackathons/settings/AdvancedSettingsTab.tsx +++ b/components/organization/hackathons/settings/AdvancedSettingsTab.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { Form, @@ -32,6 +32,7 @@ import { Button } from '@/components/ui/button'; import { Trash2 } from 'lucide-react'; import { deleteHackathon } from '@/lib/api/hackathons'; import { toast } from 'sonner'; +import { api } from '@/lib/api/api'; const advancedSettingsSchema = z.object({ isPublic: z.boolean(), @@ -50,19 +51,20 @@ type AdvancedSettingsFormData = z.infer; interface AdvancedSettingsTabProps { organizationId: string; hackathonId: string; - onSave?: (data: AdvancedSettingsFormData) => Promise; - isLoading?: boolean; + initialData?: AdvancedSettingsFormData; + onSaveSuccess?: () => Promise; } export default function AdvancedSettingsTab({ organizationId, hackathonId, - onSave, - isLoading = false, + initialData, + onSaveSuccess, }: AdvancedSettingsTabProps) { const router = useRouter(); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [isDeleting, setIsDeleting] = useState(false); + const [isSaving, setIsSaving] = useState(false); const form = useForm({ resolver: zodResolver(advancedSettingsSchema), @@ -79,9 +81,33 @@ export default function AdvancedSettingsTab({ }, }); + useEffect(() => { + if (initialData) { + form.reset(initialData); + } + }, [initialData, form]); + const onSubmit = async (data: AdvancedSettingsFormData) => { - if (onSave) { - await onSave(data); + setIsSaving(true); + try { + await api.patch( + `/organizations/${organizationId}/hackathons/${hackathonId}/advanced-settings`, + { advancedSettings: data } + ); + toast.success('Advanced settings saved successfully!'); + // Update form state with new values to maintain "clean" status + form.reset(data); + if (onSaveSuccess) { + await onSaveSuccess(); + } + } catch (error: any) { + const message = error.response?.data?.message || error.message; + const errorMessage = Array.isArray(message) ? message[0] : message; + toast.error( + errorMessage || 'Failed to save advanced settings. Please try again.' + ); + } finally { + setIsSaving(false); } }; @@ -366,10 +392,10 @@ export default function AdvancedSettingsTab({ type='submit' variant='default' size='lg' - disabled={isLoading} + disabled={isSaving} className='min-w-[120px]' > - {isLoading ? 'Saving...' : 'Save Changes'} + {isSaving ? 'Saving...' : 'Save Changes'}
diff --git a/components/organization/hackathons/settings/GeneralSettingsTab.tsx b/components/organization/hackathons/settings/GeneralSettingsTab.tsx index 835958ce..2dd139d3 100644 --- a/components/organization/hackathons/settings/GeneralSettingsTab.tsx +++ b/components/organization/hackathons/settings/GeneralSettingsTab.tsx @@ -8,8 +8,11 @@ import { FormItem, FormLabel, FormMessage, + FormDescription, } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; +import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'; +import { AlertCircle } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { @@ -50,12 +53,16 @@ interface GeneralSettingsTabProps { initialData?: Partial; onSave?: (data: InfoFormData) => Promise; isLoading?: boolean; + isPublished?: boolean; } export default function GeneralSettingsTab({ + organizationId, + hackathonId, initialData, onSave, isLoading = false, + isPublished = false, }: GeneralSettingsTabProps) { const [countries, setCountries] = useState([]); const [states, setStates] = useState([]); @@ -76,6 +83,8 @@ export default function GeneralSettingsTab({ resolver: zodResolver(infoSchema), defaultValues: { name: initialData?.name || '', + tagline: initialData?.tagline || '', + slug: initialData?.slug || '', banner: initialData?.banner || '', description: initialData?.description || '', categories: Array.isArray(initialData?.categories) @@ -178,6 +187,62 @@ export default function GeneralSettingsTab({ )} /> + {isPublished && ( + + + Slug cannot be changed + + The URL slug cannot be modified once a hackathon has been + published to avoid breaking existing links. + + + )} + + ( + + URL Slug + + + + + Lowercase letters, numbers, and hyphens only. + + + + )} + /> + + ( + + + Tagline * + + + + + + + )} + /> + Promise; isLoading?: boolean; + isRegistrationClosed?: boolean; } export default function ParticipantSettingsTab({ initialData, onSave, isLoading = false, + isRegistrationClosed = false, }: ParticipantSettingsTabProps) { return (
@@ -33,6 +35,7 @@ export default function ParticipantSettingsTab({ initialData={initialData} onSave={onSave} isLoading={isLoading} + isRegistrationClosed={isRegistrationClosed} />
); diff --git a/components/organization/hackathons/settings/TimelineSettingsTab.tsx b/components/organization/hackathons/settings/TimelineSettingsTab.tsx index 6e2cd260..f5482ec1 100644 --- a/components/organization/hackathons/settings/TimelineSettingsTab.tsx +++ b/components/organization/hackathons/settings/TimelineSettingsTab.tsx @@ -37,19 +37,24 @@ import { } from '@/components/ui/select'; import { TIMEZONES } from '@/components/organization/hackathons/new/tabs/components/timeline/timelineConstants'; +import { api } from '@/lib/api/api'; +import { toast } from 'sonner'; +import { useState, useEffect } from 'react'; + interface TimelineSettingsTabProps { - organizationId?: string; - hackathonId?: string; + organizationId: string; + hackathonId: string; initialData?: Partial; - onSave?: (data: TimelineFormData) => Promise; - isLoading?: boolean; + onSaveSuccess?: () => Promise; } export default function TimelineSettingsTab({ + organizationId, + hackathonId, initialData, - onSave, - isLoading = false, + onSaveSuccess, }: TimelineSettingsTabProps) { + const [isSaving, setIsSaving] = useState(false); const form = useForm({ resolver: zodResolver(timelineSchema), defaultValues: { @@ -64,6 +69,21 @@ export default function TimelineSettingsTab({ }, }); + useEffect(() => { + if (initialData) { + form.reset({ + startDate: initialData.startDate || undefined, + submissionDeadline: initialData.submissionDeadline || undefined, + judgingStart: initialData.judgingStart || undefined, + endDate: initialData.endDate || undefined, + judgingEnd: initialData.judgingEnd || undefined, + winnersAnnouncedAt: initialData.winnersAnnouncedAt || undefined, + timezone: initialData.timezone || 'UTC', + phases: initialData.phases || [], + }); + } + }, [initialData, form]); + const hasJudgingEnd = !!form.watch('judgingEnd'); const hasWinnersAnnouncedAt = !!form.watch('winnersAnnouncedAt'); @@ -82,8 +102,50 @@ export default function TimelineSettingsTab({ }; const onSubmit = async (data: TimelineFormData) => { - if (onSave) { - await onSave(data); + setIsSaving(true); + try { + const formatDate = (date?: Date | string | null) => { + if (!date) return undefined; + const d = new Date(date); + return isNaN(d.getTime()) ? undefined : d.toISOString(); + }; + + const payload = { + timeline: { + startDate: formatDate(data.startDate), + submissionDeadline: formatDate(data.submissionDeadline), + judgingStart: formatDate(data.judgingStart), + endDate: formatDate(data.endDate), + judgingEnd: formatDate(data.judgingEnd), + winnersAnnouncedAt: formatDate(data.winnersAnnouncedAt), + timezone: data.timezone, + phases: data.phases?.map(phase => ({ + name: phase.name, + description: phase.description, + startDate: formatDate(phase.startDate), + endDate: formatDate(phase.endDate), + })), + }, + }; + + await api.patch( + `/organizations/${organizationId}/hackathons/${hackathonId}/schedule`, + payload + ); + toast.success('Timeline settings saved successfully!'); + // Reset form with current data to clear dirty state + form.reset(data); + if (onSaveSuccess) { + await onSaveSuccess(); + } + } catch (error: any) { + const message = error.response?.data?.message || error.message; + const errorMessage = Array.isArray(message) ? message[0] : message; + toast.error( + errorMessage || 'Failed to save timeline settings. Please try again.' + ); + } finally { + setIsSaving(false); } }; @@ -548,10 +610,10 @@ export default function TimelineSettingsTab({ type='submit' variant='default' size='lg' - disabled={isLoading} + disabled={isSaving} className='min-w-[120px]' > - {isLoading ? 'Saving...' : 'Save Changes'} + {isSaving ? 'Saving...' : 'Save Changes'}
diff --git a/hooks/use-hackathon-rewards.ts b/hooks/use-hackathon-rewards.ts index b0940a05..ede80046 100644 --- a/hooks/use-hackathon-rewards.ts +++ b/hooks/use-hackathon-rewards.ts @@ -1,4 +1,5 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; +import pLimit from 'p-limit'; import { useGetEscrowFromIndexerByContractIds } from '@trustless-work/escrow/hooks'; import type { GetEscrowFromIndexerByContractIdsParams, @@ -11,6 +12,12 @@ import { type Hackathon, type HackathonEscrowData, } from '@/lib/api/hackathons'; +import { + getJudgingResults, + type JudgingResult, +} from '@/lib/api/hackathons/judging'; +import { getSubmissionDetails } from '@/lib/api/hackathons/participants'; +import { getCrowdfundingProject } from '@/features/projects/api'; import { mapJudgingSubmissionsToRewardSubmissions } from '@/lib/utils/rewards-data-mapper'; import { Submission } from '@/components/organization/hackathons/rewards/types'; import { PrizeTier } from '@/components/organization/hackathons/new/tabs/schemas/rewardsSchema'; @@ -47,12 +54,28 @@ const mapEscrowToHackathonEscrowData = ( }; }; +const getOrdinalSuffix = (i: number) => { + const j = i % 10, + k = i % 100; + if (j === 1 && k !== 11) { + return i + 'st'; + } + if (j === 2 && k !== 12) { + return i + 'nd'; + } + if (j === 3 && k !== 13) { + return i + 'rd'; + } + return i + 'th'; +}; + const getDefaultPrizeTiers = (): PrizeTier[] => [ { id: 'tier-1', place: '1st Place', prizeAmount: '0', currency: 'USDC', + rank: 1, passMark: 0, }, { @@ -60,6 +83,7 @@ const getDefaultPrizeTiers = (): PrizeTier[] => [ place: '2nd Place', prizeAmount: '0', currency: 'USDC', + rank: 2, passMark: 0, }, { @@ -67,6 +91,7 @@ const getDefaultPrizeTiers = (): PrizeTier[] => [ place: '3rd Place', prizeAmount: '0', currency: 'USDC', + rank: 3, passMark: 0, }, ]; @@ -82,6 +107,9 @@ interface UseHackathonRewardsReturn { isLoadingSubmissions: boolean; error: string | null; refreshEscrow: () => Promise; + refetchHackathon: () => Promise; + resultsPublished: boolean; + hackathon: Hackathon | null; } export const useHackathonRewards = ( @@ -98,6 +126,7 @@ export const useHackathonRewards = ( const [isLoadingEscrow, setIsLoadingEscrow] = useState(true); const [isLoadingSubmissions, setIsLoadingSubmissions] = useState(true); const [error, setError] = useState(null); + const [hackathon, setHackathon] = useState(null); const isFetchingEscrowRef = useRef(false); const lastFetchedContractIdRef = useRef(null); @@ -159,42 +188,62 @@ export const useHackathonRewards = ( await fetchEscrowData(contractId); }, [contractId, fetchEscrowData]); - useEffect(() => { - const fetchHackathon = async () => { - try { - const response = await getHackathon(hackathonId); - if (response.success) { - const hackathon: Hackathon = response.data; - - if (hackathon.prizeTiers) { - const tiers: PrizeTier[] = hackathon.prizeTiers.map( - (tier: any, index: number) => ({ - id: tier.position || `tier-${index}`, - place: tier.position || `${index + 1}st Place`, - prizeAmount: tier.amount?.toString() || '0', + const fetchHackathon = useCallback(async () => { + try { + const response = await getHackathon(hackathonId); + if (response.success) { + const fetchedHackathon: Hackathon = response.data; + setHackathon(fetchedHackathon); + + if (fetchedHackathon.prizeTiers) { + // Sort tiers by amount descending or use parsed numeric rank from place if available + const sortedTiers = [...fetchedHackathon.prizeTiers].sort( + (a: any, b: any) => { + const rankA = parseInt(a.place?.match(/\d+/)?.[0] || '999'); + const rankB = parseInt(b.place?.match(/\d+/)?.[0] || '999'); + if (rankA !== rankB) return rankA - rankB; + + const amountA = parseFloat(a.prizeAmount || '0'); + const amountB = parseFloat(b.prizeAmount || '0'); + return amountB - amountA; + } + ); + + const tiers: PrizeTier[] = sortedTiers.map( + (tier: any, index: number) => { + const parsedRank = parseInt( + tier.place?.match(/\d+/)?.[0] || String(index + 1) + ); + return { + id: tier.id || `tier-${index + 1}`, + place: tier.place || `${getOrdinalSuffix(index + 1)} Place`, + prizeAmount: tier.prizeAmount?.toString() || '0', currency: tier.currency || 'USDC', passMark: tier.passMark || 0, description: tier.description, - }) - ); - setPrizeTiers(tiers); - } + rank: parsedRank, + }; + } + ); + setPrizeTiers(tiers); + } - const hackathonContractId = - hackathon.contractId || hackathon.escrowAddress || null; - if (hackathonContractId) { - setContractId(hackathonContractId); - } + const hackathonContractId = + fetchedHackathon.contractId || fetchedHackathon.escrowAddress || null; + if (hackathonContractId) { + setContractId(hackathonContractId); } - } catch { - setPrizeTiers(getDefaultPrizeTiers()); } - }; + } catch { + setPrizeTiers(getDefaultPrizeTiers()); + } + }, [hackathonId]); + useEffect(() => { if (organizationId && hackathonId) { fetchHackathon(); } - }, [organizationId, hackathonId]); + }, [organizationId, hackathonId, fetchHackathon]); useEffect(() => { if (contractId) { @@ -218,12 +267,166 @@ export const useHackathonRewards = ( organizationId, hackathonId, 1, - 100 + 100, + 'all' ); if (response.success) { - const mappedSubmissions = mapJudgingSubmissionsToRewardSubmissions( - response.data || [] + // response.data may be a plain array or a paginated object { submissions: [...] } + const rawData = response.data as any; + const submissionsArray = Array.isArray(rawData) + ? rawData + : Array.isArray(rawData?.submissions) + ? rawData.submissions + : []; + + const limit = pLimit(5); + const detailsPromises = submissionsArray.map((sub: any) => + limit(async () => { + try { + // Standard path for judging submission data + const subData = sub.submission || sub; + const partData = sub.participant || sub; + + // Get current profile data safely + const profile = + partData.user?.profile || partData.submitterProfile || {}; + const name = + partData.name || + profile.firstName || + profile.name || + (partData as any)?.username; + const avatar = + profile.avatar || + profile.image || + (partData as any)?.image || + (partData as any)?.avatar; + + const isGenericName = + !name || name === 'Unknown' || name === 'anonymous'; + const isGenericAvatar = + !avatar || avatar.includes('github.com/shadcn.png'); + + // If we already have good data, don't re-fetch + if (!isGenericName && !isGenericAvatar) { + return sub; + } + + // 1. Try resolving via Project ID (best for creator info) + const pId = sub.projectId || subData.projectId || subData.id; + if (pId) { + try { + const project = await getCrowdfundingProject(pId); + if (project && project.project && project.project.creator) { + const creator = project.project.creator; + return { + ...sub, + participant: { + ...partData, + name: creator.name || partData.name, + username: creator.username || partData.username, + image: creator.image, + email: creator.email, + user: { + ...partData.user, + name: creator.name, + username: creator.username, + image: creator.image, + email: creator.email, + profile: { + ...partData.user?.profile, + firstName: creator.name?.split(' ')[0] || '', + lastName: + creator.name?.split(' ').slice(1).join(' ') || + '', + username: creator.username, + avatar: creator.image, + image: creator.image, + }, + }, + }, + }; + } + } catch (pErr) { + // silent fail for project fetch + } + } + + // 2. Fallback to Submission Details (fetches participant/user object directly) + const sId = sub.id || subData.id || sub.submissionId; + if (sId) { + try { + const detailsRes = await getSubmissionDetails(sId); + if (detailsRes.success && detailsRes.data) { + const details = detailsRes.data as any; + return { + ...sub, + participant: { + ...partData, + ...details.participant, + user: details.participant?.user || partData.user, + }, + }; + } + } catch (sErr) { + // silent fail + } + } + + return sub; + } catch (err) { + console.error(`Failed to enrich submission detail:`, err); + return sub; + } + }) ); + + const enrichedSubmissions = await Promise.all(detailsPromises); + + // 3. Fetch Judging Results to get actual rankings + let mappedSubmissions = + mapJudgingSubmissionsToRewardSubmissions(enrichedSubmissions); + + try { + const resultsRes = await getJudgingResults( + organizationId, + hackathonId + ); + if (resultsRes.success && resultsRes.data) { + const resultsList = resultsRes.data.results || []; + + // Merge rank and scores from results into submissions + mappedSubmissions = mappedSubmissions.map(sub => { + const s = sub as any; + // Try to find result by participantId or submissionId matching various sub IDs + const result = resultsList.find( + r => + r.participantId === sub.id || + r.submissionId === sub.id || + r.submissionId === s.submissionId || + r.participantId === s.participantId + ); + + if (result) { + return { + ...sub, + rank: result.rank, + score: Math.round(Number(result.averageScore || 0)), + maxScore: 100, + averageScore: Number(result.averageScore || 0), + projectName: result.projectName || sub.projectName, + submissionTitle: result.projectName || sub.submissionTitle, + }; + } + return sub; + }); + } + } catch (resultsErr) { + console.error( + 'Failed to fetch judging results for rewards page:', + resultsErr + ); + } + setSubmissions(mappedSubmissions); } else { throw new Error('Failed to fetch submissions'); @@ -255,5 +458,8 @@ export const useHackathonRewards = ( isLoadingSubmissions, error, refreshEscrow, + refetchHackathon: fetchHackathon, + resultsPublished: !!hackathon?.resultsPublished, + hackathon, }; }; diff --git a/hooks/use-milestone-creation.ts b/hooks/use-milestone-creation.ts deleted file mode 100644 index 3b0980cb..00000000 --- a/hooks/use-milestone-creation.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { useState } from 'react'; -import { toast } from 'sonner'; -import { createWinnerMilestones } from '@/lib/api/hackathons'; -import { extractRankFromPosition } from '@/lib/utils/prize-tier-matcher'; -import { validateStellarAddress } from '@/lib/utils/stellar-address-validation'; -import type { Submission } from '@/components/organization/hackathons/rewards/types'; -import type { PrizeTier } from '@/components/organization/hackathons/new/tabs/schemas/rewardsSchema'; -import type { HackathonEscrowData } from '@/lib/api/hackathons'; - -interface UseMilestoneCreationProps { - winners: Submission[]; - prizeTiers: PrizeTier[]; - escrow: HackathonEscrowData | null; - organizationId: string; - hackathonId: string; - walletAddresses: Record; - setErrors: React.Dispatch>>; - onSuccess?: () => void; -} - -export const useMilestoneCreation = ({ - winners, - prizeTiers, - escrow, - organizationId, - hackathonId, - walletAddresses, - setErrors, - onSuccess, -}: UseMilestoneCreationProps) => { - const [isLoading, setIsLoading] = useState(false); - - const validatePrizeTiers = (): { - valid: boolean; - missingRanks: number[]; - } => { - const missingRanks: number[] = []; - - winners.forEach(winner => { - if (!winner.rank) return; - - const hasTier = prizeTiers.some(tier => { - const tierRank = extractRankFromPosition(tier.place); - return tierRank === winner.rank; - }); - - if (!hasTier) { - missingRanks.push(winner.rank); - } - }); - - return { - valid: missingRanks.length === 0, - missingRanks: [...new Set(missingRanks)].sort((a, b) => a - b), - }; - }; - - const validateBeforeSubmit = (): boolean => { - const newErrors: Record = {}; - - const tierValidation = validatePrizeTiers(); - if (!tierValidation.valid) { - const ranksStr = tierValidation.missingRanks - .map( - r => `${r}${r === 1 ? 'st' : r === 2 ? 'nd' : r === 3 ? 'rd' : 'th'}` - ) - .join(', '); - - toast.error( - `No prize tier found for rank${tierValidation.missingRanks.length > 1 ? 's' : ''} ${ranksStr}. ` + - `Please configure prize tiers in the Rewards tab before creating milestones.` - ); - return false; - } - - winners.forEach(winner => { - const address = walletAddresses[winner.id]?.trim(); - if (!address) { - newErrors[winner.id] = 'Wallet address is required'; - } else if (!validateStellarAddress(address)) { - newErrors[winner.id] = 'Invalid Stellar address format'; - } - }); - - setErrors(newErrors); - return Object.keys(newErrors).length === 0; - }; - - const createMilestones = async () => { - if (!validateBeforeSubmit()) { - return; - } - - if (!escrow) { - toast.error('Escrow not found'); - return; - } - - setIsLoading(true); - - try { - const winnersData = winners - .filter(winner => winner.rank !== undefined && winner.rank !== null) - .map(winner => { - const participantId = winner.participantId || winner.id; - if (!participantId || participantId.trim() === '') { - throw new Error(`Participant ID missing for ${winner.name}`); - } - - const rank = winner.rank!; - if (rank < 1) { - throw new Error(`Invalid rank for ${winner.name}`); - } - - const walletAddress = walletAddresses[winner.id]?.trim(); - if (!walletAddress || walletAddress === '') { - throw new Error(`Wallet address missing for ${winner.name}`); - } - - if (!validateStellarAddress(walletAddress)) { - throw new Error( - `Invalid Stellar wallet address format for ${winner.name}. Address must be 56 characters and start with 'G'.` - ); - } - - const prizeTier = prizeTiers.find(tier => { - const tierRank = extractRankFromPosition(tier.place); - return tierRank === rank; - }); - if (!prizeTier) { - throw new Error( - `No prize tier found for rank ${rank} (${winner.name}). Please ensure a prize tier with position matching rank ${rank} exists (e.g., "${rank}${rank === 1 ? 'st' : rank === 2 ? 'nd' : rank === 3 ? 'rd' : 'th'} Place").` - ); - } - - const prizeAmount = prizeTier.prizeAmount - ? parseFloat(prizeTier.prizeAmount) - : 0; - - if (prizeAmount <= 0) { - throw new Error( - `Invalid prize amount for rank ${rank} (${winner.name}). Prize amount must be greater than 0.` - ); - } - - const currency = prizeTier.currency || 'USDC'; - - return { - participantId: participantId.trim(), - rank: rank, - walletAddress: walletAddress.trim(), - amount: prizeAmount, - currency: currency, - }; - }); - - if (winnersData.length === 0) { - throw new Error( - 'No winners selected. Please assign ranks to submissions first.' - ); - } - - const response = await createWinnerMilestones( - organizationId, - hackathonId, - { - winners: winnersData, - } - ); - - if (response.success) { - toast.success( - `Successfully created ${response.data.milestonesCreated} milestone(s)` - ); - if (onSuccess) { - onSuccess(); - } - return true; - } else { - const errorMessage = response.message || 'Failed to create milestones'; - if ( - errorMessage.includes('prize tier') || - errorMessage.includes('rank') - ) { - throw new Error( - `${errorMessage} Please go to the hackathon edit page and add prize tiers in the Rewards tab. ` + - `The position field in prize tiers must match the ranks you're assigning (e.g., "1" for rank 1).` - ); - } - throw new Error(errorMessage); - } - } catch (error) { - const errorMessage = - error instanceof Error - ? error.message - : 'Failed to create milestones. Please try again.'; - toast.error(errorMessage); - throw error; - } finally { - setIsLoading(false); - } - }; - - return { - isLoading, - createMilestones, - }; -}; diff --git a/hooks/use-publish-winners.ts b/hooks/use-publish-winners.ts index 45bf8bd2..953f9bef 100644 --- a/hooks/use-publish-winners.ts +++ b/hooks/use-publish-winners.ts @@ -1,6 +1,7 @@ -import { useState } from 'react'; +import { useState, useRef } from 'react'; import { toast } from 'sonner'; -import { createWinnerMilestones } from '@/lib/api/hackathons'; +import { v4 as uuidv4 } from 'uuid'; +import { triggerRewardDistribution } from '@/lib/api/hackathons'; import { validateStellarAddress } from '@/lib/utils/stellar-address-validation'; import { validatePrizeTiers } from '@/lib/utils/prize-tier-validation'; import { extractRankFromPosition } from '@/lib/utils/hackathon-escrow'; @@ -14,10 +15,7 @@ interface UsePublishWinnersProps { escrow: HackathonEscrowData | null; organizationId: string; hackathonId: string; - walletAddresses: Record; announcement: string; - milestonesCreated: boolean; - setMilestonesCreated: (value: boolean) => void; onSuccess?: () => void; } @@ -27,18 +25,33 @@ export const usePublishWinners = ({ escrow, organizationId, hackathonId, - walletAddresses, announcement, - milestonesCreated, - setMilestonesCreated, onSuccess, }: UsePublishWinnersProps) => { const [isPublishing, setIsPublishing] = useState(false); + // Keep the idempotency key consistent across retries even if the page reloads + const idempotencyKeyRef = useRef(null); + + if (!idempotencyKeyRef.current) { + if (typeof window !== 'undefined') { + const storageKey = `publish-idempotency-${hackathonId}`; + let key = sessionStorage.getItem(storageKey); + if (!key) { + key = uuidv4(); + sessionStorage.setItem(storageKey, key); + } + idempotencyKeyRef.current = key; + } else { + idempotencyKeyRef.current = uuidv4(); + } + } + const publishWinners = async () => { setIsPublishing(true); try { + // 1. Initial Local Validations const tierValidation = validatePrizeTiers(winners, prizeTiers); if (!tierValidation.valid) { const ranksStr = tierValidation.missingRanks @@ -50,126 +63,29 @@ export const usePublishWinners = ({ throw new Error( `No prize tier found for rank${tierValidation.missingRanks.length > 1 ? 's' : ''} ${ranksStr}. ` + - `Please configure prize tiers in the Rewards tab before creating milestones. ` + - `Go to the hackathon edit page and add prize tiers with positions matching the ranks you're assigning.` + `Please configure prize tiers in the Rewards tab before publishing winners.` ); } - const shouldCreateMilestones = escrow?.canUpdate && escrow?.isFunded; - - if (shouldCreateMilestones && !milestonesCreated) { - if (!escrow) { - toast.error('Escrow not found'); - setIsPublishing(false); - return; - } - - const winnersData = winners - .filter(winner => winner.rank !== undefined && winner.rank !== null) - .map(winner => { - const participantId = winner.participantId || winner.id; - if (!participantId || participantId.trim() === '') { - throw new Error(`Participant ID missing for ${winner.name}`); - } - - const rank = winner.rank!; - if (rank < 1) { - throw new Error(`Invalid rank for ${winner.name}`); - } - - const walletAddress = walletAddresses[winner.id]?.trim(); - if (!walletAddress || walletAddress === '') { - throw new Error(`Wallet address missing for ${winner.name}`); - } - - if (!validateStellarAddress(walletAddress)) { - throw new Error( - `Invalid Stellar wallet address format for ${winner.name}. Address must be 56 characters and start with 'G'.` - ); - } - - const prizeTier = prizeTiers.find(tier => { - const tierRank = extractRankFromPosition(tier.place); - return tierRank === rank; - }); - if (!prizeTier) { - throw new Error( - `No prize tier found for rank ${rank} (${winner.name}). Please ensure a prize tier with position matching rank ${rank} exists (e.g., "${rank}${rank === 1 ? 'st' : rank === 2 ? 'nd' : rank === 3 ? 'rd' : 'th'} Place").` - ); - } - - const prizeAmount = prizeTier.prizeAmount - ? parseFloat(prizeTier.prizeAmount) - : 0; - - if (prizeAmount <= 0) { - throw new Error( - `Invalid prize amount for rank ${rank} (${winner.name}). Prize amount must be greater than 0.` - ); - } - - const currency = prizeTier.currency || 'USDC'; - - return { - participantId: participantId.trim(), - rank: rank, - walletAddress: walletAddress.trim(), - amount: prizeAmount, - currency: currency, - }; - }); - - if (winnersData.length === 0) { - throw new Error( - 'No winners selected. Please assign ranks to submissions first.' - ); - } + if (!escrow?.isFunded) { + throw new Error('Escrow is not funded. Please fund the escrow first.'); + } - const milestonesResponse = await createWinnerMilestones( - organizationId, - hackathonId, - { winners: winnersData } - ); + // 2. Trigger Reward Distribution + await triggerRewardDistribution(organizationId, hackathonId, { + idempotencyKey: idempotencyKeyRef.current || uuidv4(), + organizerNote: announcement || undefined, + }); - if (!milestonesResponse.success) { - const errorMessage = - milestonesResponse.message || 'Failed to create milestones'; - if ( - errorMessage.includes('prize tier') || - errorMessage.includes('rank') - ) { - throw new Error( - `${errorMessage} Please go to the hackathon edit page and add prize tiers in the Rewards tab. ` + - `The position field in prize tiers must match the ranks you're assigning (e.g., "1" for rank 1).` - ); - } - throw new Error(errorMessage); - } + toast.success( + 'Reward distribution successfully triggered! Pending admin review.' + ); - setMilestonesCreated(true); - toast.success( - `Successfully created ${milestonesResponse.data.milestonesCreated} milestone(s)` - ); - } else if (shouldCreateMilestones && milestonesCreated) { - toast.info('Milestones already created, proceeding to announcement...'); - } else { - if (!escrow?.isFunded) { - throw new Error( - 'Escrow is not funded. Please fund the escrow first.' - ); - } + // Cleanup idempotency key since distribution succeeded + if (typeof window !== 'undefined') { + sessionStorage.removeItem(`publish-idempotency-${hackathonId}`); } - const { api } = await import('@/lib/api/api'); - await api.post( - `/organizations/${organizationId}/hackathons/${hackathonId}/winners/announce`, - { - winners: winners.map(w => ({ submissionId: w.id, rank: w.rank })), - announcement, - } - ); - - toast.success('Winners published successfully!'); if (onSuccess) { onSuccess(); } diff --git a/hooks/use-reward-distribution-status.ts b/hooks/use-reward-distribution-status.ts new file mode 100644 index 00000000..8bdd2f87 --- /dev/null +++ b/hooks/use-reward-distribution-status.ts @@ -0,0 +1,79 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + getRewardDistributionStatus, + type RewardDistributionStatusResponse, +} from '@/lib/api/hackathons'; + +interface UseRewardDistributionStatusReturn { + distributionStatus: RewardDistributionStatusResponse | null; + isLoading: boolean; + error: string | null; + refetch: () => Promise; +} + +export const useRewardDistributionStatus = ( + organizationId: string, + hackathonId: string +): UseRewardDistributionStatusReturn => { + const [distributionStatus, setDistributionStatus] = + useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchStatus = useCallback(async () => { + if (!organizationId || !hackathonId) { + setIsLoading(false); + setError(null); + return; + } + setIsLoading(true); + setError(null); + try { + const data = await getRewardDistributionStatus( + organizationId, + hackathonId + ); + setDistributionStatus(data); + } catch (err: any) { + // 404 means "no active distribution" – treat as NOT_TRIGGERED silently + if (err?.response?.status === 404 || err?.status === 404) { + setDistributionStatus({ + distributionId: null, + status: 'NOT_TRIGGERED', + snapshot: { + idempotencyKey: '', + winners: [], + totalPrizePool: 0, + platformFee: 0, + totalRequired: 0, + currency: 'USDC', + escrowAddress: '', + winnersChecksum: '', + snapshotAt: '', + organizerNote: null, + }, + triggeredAt: '', + adminDecisionAt: null, + adminNote: null, + adminUserId: null, + rejectionReason: null, + updatedAt: '', + }); + } else { + setError( + err instanceof Error + ? err.message + : 'Failed to fetch distribution status' + ); + } + } finally { + setIsLoading(false); + } + }, [organizationId, hackathonId]); + + useEffect(() => { + fetchStatus(); + }, [fetchStatus]); + + return { distributionStatus, isLoading, error, refetch: fetchStatus }; +}; diff --git a/hooks/use-wallet-addresses.ts b/hooks/use-wallet-addresses.ts deleted file mode 100644 index 11773714..00000000 --- a/hooks/use-wallet-addresses.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useState, useEffect } from 'react'; -import { validateStellarAddress } from '@/lib/utils/stellar-address-validation'; -import type { Submission } from '@/components/organization/hackathons/rewards/types'; - -interface UseWalletAddressesProps { - isOpen: boolean; - winners: Submission[]; -} - -export const useWalletAddresses = ({ - isOpen, - winners, -}: UseWalletAddressesProps) => { - const [walletAddresses, setWalletAddresses] = useState< - Record - >({}); - const [errors, setErrors] = useState>({}); - - useEffect(() => { - if (isOpen) { - const addresses: Record = {}; - winners.forEach(winner => { - if (winner.walletAddress) { - addresses[winner.id] = winner.walletAddress; - } - }); - setWalletAddresses(addresses); - setErrors({}); - } - }, [isOpen, winners]); - - const handleWalletAddressChange = (submissionId: string, address: string) => { - setWalletAddresses(prev => ({ - ...prev, - [submissionId]: address, - })); - - if (errors[submissionId]) { - setErrors(prev => { - const newErrors = { ...prev }; - delete newErrors[submissionId]; - return newErrors; - }); - } - - if (address && !validateStellarAddress(address)) { - setErrors(prev => ({ - ...prev, - [submissionId]: 'Invalid Stellar address format', - })); - } - }; - - return { - walletAddresses, - errors, - setErrors, - handleWalletAddressChange, - }; -}; diff --git a/hooks/use-wizard-steps.ts b/hooks/use-wizard-steps.ts index a07fdd29..63729b5e 100644 --- a/hooks/use-wizard-steps.ts +++ b/hooks/use-wizard-steps.ts @@ -1,14 +1,9 @@ import { useState, useEffect, useMemo, useRef } from 'react'; import type { HackathonEscrowData } from '@/lib/api/hackathons'; -export type WizardStep = 'wallets' | 'announcement' | 'preview'; +export type WizardStep = 'announcement' | 'preview'; const STEPS: Array<{ id: WizardStep; name: string; description: string }> = [ - { - id: 'wallets', - name: 'Wallet Addresses', - description: 'Enter Stellar wallet addresses for each winner', - }, { id: 'announcement', name: 'Announcement', @@ -27,20 +22,9 @@ interface UseWizardStepsProps { } export const useWizardSteps = ({ open, escrow }: UseWizardStepsProps) => { - const [currentStep, setCurrentStep] = useState('wallets'); + const [currentStep, setCurrentStep] = useState('announcement'); const initializedRef = useRef(false); - const needsMilestones = useMemo( - () => escrow?.canUpdate && escrow?.isFunded, - [escrow?.canUpdate, escrow?.isFunded] - ); - - const stepsToShow = useMemo( - () => - needsMilestones ? STEPS : STEPS.filter(step => step.id !== 'wallets'), - [needsMilestones] - ); - useEffect(() => { if (!open) { initializedRef.current = false; @@ -52,16 +36,14 @@ export const useWizardSteps = ({ open, escrow }: UseWizardStepsProps) => { } initializedRef.current = true; - const currentNeedsMilestones = escrow?.canUpdate && escrow?.isFunded; - setCurrentStep(currentNeedsMilestones ? 'wallets' : 'announcement'); - }, [open, escrow?.canUpdate, escrow?.isFunded]); + setCurrentStep('announcement'); + }, [open]); + const stepsToShow = STEPS; const currentStepIndex = stepsToShow.findIndex(s => s.id === currentStep); const handleNext = () => { - if (currentStep === 'wallets') { - setCurrentStep('announcement'); - } else if (currentStep === 'announcement') { + if (currentStep === 'announcement') { setCurrentStep('preview'); } }; @@ -69,10 +51,6 @@ export const useWizardSteps = ({ open, escrow }: UseWizardStepsProps) => { const handleBack = () => { if (currentStep === 'preview') { setCurrentStep('announcement'); - } else if (currentStep === 'announcement') { - if (needsMilestones) { - setCurrentStep('wallets'); - } } }; @@ -81,7 +59,6 @@ export const useWizardSteps = ({ open, escrow }: UseWizardStepsProps) => { setCurrentStep, stepsToShow, currentStepIndex, - needsMilestones, handleNext, handleBack, }; diff --git a/lib/api/hackathons.ts b/lib/api/hackathons.ts index 5cb0cd89..c7a1af79 100644 --- a/lib/api/hackathons.ts +++ b/lib/api/hackathons.ts @@ -351,6 +351,9 @@ export type Hackathon = { endDate: string; // ISO date submissionDeadline: string; // ISO date registrationDeadline: string; // ISO date + judgingStart: string; // ISO date + judgingEnd?: string; // ISO date + winnersAnnouncedAt?: string; // ISO date customRegistrationDeadline: string | null; registrationOpen: boolean; @@ -447,9 +450,24 @@ export type Hackathon = { contractId?: string; escrowAddress?: string; + resultsPublished?: boolean; transactionHash?: string | null; message?: string; escrowDetails?: object; + metadata?: { + advancedSettings?: { + isPublic: boolean; + allowLateRegistration: boolean; + requireApproval: boolean; + maxParticipants?: number; + customDomain?: string; + enableDiscord: boolean; + discordInviteLink?: string; + enableTelegram: boolean; + telegramInviteLink?: string; + }; + [key: string]: unknown; + }; }; // Request Types @@ -1417,14 +1435,19 @@ export const getJudgingSubmissions = async ( organizationId: string, hackathonId: string, page = 1, - limit = 10 + limit = 10, + status?: string ): Promise => { const params = new URLSearchParams({ page: page.toString(), limit: limit.toString(), - status: 'SHORTLISTED', }); + const filterStatus = status === 'all' ? undefined : status || 'SHORTLISTED'; + if (filterStatus) { + params.append('status', filterStatus); + } + const res = await api.get( `/hackathons/${hackathonId}/submissions?${params.toString()}` ); @@ -1476,9 +1499,87 @@ export const assignRanks = async ( return res.data; }; +// ─── Reward Distribution Status Types ───────────────────────────────────────── + +export type RewardDistributionStatusEnum = + | 'NOT_TRIGGERED' + | 'PENDING_ADMIN_REVIEW' + | 'APPROVED' + | 'REJECTED' + | 'EXECUTING' + | 'COMPLETED' + | 'FAILED' + | 'PARTIAL_SUCCESS'; + +export interface WinnerSnapshot { + submissionId: string; + rank: number; + submissionTitle: string; + prizeTierName: string; + prizeAmount: number; + walletAddresses: string; +} + +export interface RewardDistributionSnapshot { + idempotencyKey: string; + winners: WinnerSnapshot[]; + totalPrizePool: number; + platformFee: number; + totalRequired: number; + currency: string; + escrowAddress: string; + winnersChecksum: string; + snapshotAt: string; + organizerNote: string | null; +} + +export interface RewardDistributionStatusResponse { + distributionId: string | null; + status: RewardDistributionStatusEnum; + snapshot: RewardDistributionSnapshot; + triggeredAt: string; + adminDecisionAt: string | null; + adminNote: string | null; + adminUserId: string | null; + rejectionReason: string | null; + updatedAt: string; +} + /** - * Get hackathon escrow details + * Get reward distribution status (organizer) + * Returns the latest distribution status: PENDING_ADMIN_REVIEW, APPROVED, REJECTED, EXECUTING, etc. */ +export const getRewardDistributionStatus = async ( + organizationId: string, + hackathonId: string +): Promise => { + const res = await api.get( + `/organizations/${organizationId}/hackathons/${hackathonId}/rewards/status` + ); + return res.data?.data ?? res.data; +}; + +export interface TriggerRewardDistributionRequest { + idempotencyKey: string; + organizerNote?: string; +} + +/** + * Trigger a new reward distribution for a hackathon. + * Moves the snapshot into PENDING_ADMIN_REVIEW. Requires published results, funds in escrow. + */ +export const triggerRewardDistribution = async ( + organizationId: string, + hackathonId: string, + data: TriggerRewardDistributionRequest +): Promise => { + const res = await api.post( + `/organizations/${organizationId}/hackathons/${hackathonId}/rewards/trigger`, + data + ); + return res.data?.data ?? res.data; +}; + export const getHackathonEscrow = async ( organizationId: string, hackathonId: string diff --git a/lib/api/hackathons/judging.ts b/lib/api/hackathons/judging.ts index 3b8951ac..ef7614e2 100644 --- a/lib/api/hackathons/judging.ts +++ b/lib/api/hackathons/judging.ts @@ -356,20 +356,26 @@ export const disqualifySubmission = async ( }; /** - * Get shortlisted submissions for judging + * Get shortlisted submissions for judging (or all if status is undefined) */ export const getJudgingSubmissions = async ( organizationId: string, hackathonId: string, page = 1, - limit = 10 + limit = 10, + status?: string ): Promise => { const params = new URLSearchParams({ page: page.toString(), limit: limit.toString(), - status: 'SHORTLISTED', }); + // Default to SHORTLISTED for backwards-compatibility, passing 'all' bypasses it + const filterStatus = status === 'all' ? undefined : status || 'SHORTLISTED'; + if (filterStatus) { + params.append('status', filterStatus); + } + const res = await api.get( `/hackathons/${hackathonId}/submissions?${params.toString()}` ); diff --git a/lib/utils/renderHtml.ts b/lib/utils/renderHtml.ts index a5c9c9a6..e97a10c8 100644 --- a/lib/utils/renderHtml.ts +++ b/lib/utils/renderHtml.ts @@ -44,6 +44,6 @@ export function sanitizeHtml(html: string | undefined | null) { }); return { __html: clean }; } catch { - return { __html: dirty }; + return { __html: '' }; } } diff --git a/lib/utils/rewards-data-mapper.ts b/lib/utils/rewards-data-mapper.ts index df928a3e..a423640e 100644 --- a/lib/utils/rewards-data-mapper.ts +++ b/lib/utils/rewards-data-mapper.ts @@ -7,38 +7,74 @@ import { Submission } from '@/components/organization/hackathons/rewards/types'; export const mapJudgingSubmissionToRewardSubmission = ( judgingSubmission: JudgingSubmission ): Submission => { - const participant = judgingSubmission.participant; - const submission = judgingSubmission.submission; - const user = participant.user; + const sub = judgingSubmission as any; + const participant = sub.participant || sub; + const submissionData = sub.submission || sub; + const userProfile = + participant.user?.profile || participant.submitterProfile || {}; - // Get participant name - const name = - participant.participationType === 'team' && participant.teamName - ? participant.teamName - : `${user.profile.firstName} ${user.profile.lastName}`.trim() || - user.profile.username; + // Get participant name - exhaustively like JudgingParticipant.tsx + let pName = 'Unknown'; + if (participant.name && participant.name !== submissionData.projectName) { + pName = participant.name; + } else if ( + participant.user?.name && + participant.user.name !== submissionData.projectName + ) { + pName = participant.user.name; + } else if (userProfile.firstName || userProfile.lastName) { + pName = + `${userProfile.firstName || ''} ${userProfile.lastName || ''}`.trim(); + } else if ( + participant.submitterName && + participant.submitterName !== submissionData.projectName + ) { + pName = participant.submitterName; + } else if (participant.name) { + pName = participant.name; // Use it as last resort if no other info + } else if ( + userProfile.username || + participant.username || + (participant.user as any)?.username + ) { + pName = + userProfile.username || + participant.username || + (participant.user as any)?.username; + } + const name = pName || 'Unknown'; - // Get submission title (use projectName as fallback) - const submissionTitle = submission.projectName || 'Untitled Project'; + // Get avatar - exhaustively + const avatar = + participant.image || + participant.user?.image || + userProfile.avatar || + userProfile.image || + participant.submitterAvatar || + ''; - // Map average score (0-100 scale) to score and maxScore - const averageScore = judgingSubmission.averageScore ?? 0; - const score = Math.round(averageScore); // Round to integer - const maxScore = 100; // Judging uses 0-100 scale + // Get submission title + const submissionTitle = submissionData.projectName || 'Untitled Project'; + + // Map average score + const averageScore = sub.averageScore ?? 0; + const score = Math.round(Number(averageScore)); + const maxScore = 100; return { - id: participant.id, - participantId: participant.id, + id: participant.id || sub.id || '', + participantId: participant.id || sub.id || '', name, - projectName: submission.projectName, - avatar: user.profile.avatar, + projectName: submissionData.projectName || '', + avatar, score, maxScore, - averageScore, - judgeCount: judgingSubmission.judgeCount, - rank: submission.rank, // Use rank from submission object + averageScore: Number(averageScore), + judgeCount: sub.judgeCount, + rank: submissionData.rank, submissionTitle, - walletAddress: undefined, // Will be collected when creating milestones + category: submissionData.category || sub.category || 'General', + commentCount: sub.commentCount || 0, }; }; @@ -48,5 +84,6 @@ export const mapJudgingSubmissionToRewardSubmission = ( export const mapJudgingSubmissionsToRewardSubmissions = ( judgingSubmissions: JudgingSubmission[] ): Submission[] => { + if (!Array.isArray(judgingSubmissions)) return []; return judgingSubmissions.map(mapJudgingSubmissionToRewardSubmission); }; diff --git a/package-lock.json b/package-lock.json index de59a5f5..3cbf34b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -88,6 +88,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", "dompurify": "^3.3.0", "embla-carousel-autoplay": "^8.6.0", "embla-carousel-react": "^8.6.0", @@ -103,6 +104,7 @@ "next-auth": "^5.0.0-beta.29", "next-themes": "^0.4.6", "nextjs-toploader": "^3.9.17", + "p-limit": "^7.3.0", "qrcode.react": "^4.2.0", "react": "19.2.1", "react-day-picker": "^9.11.1", @@ -118,6 +120,7 @@ "tailwind-merge": "^3.3.1", "three": "^0.180.0", "tw-animate-css": "^1.3.6", + "uuid": "^13.0.0", "vaul": "^1.1.2", "zod": "^4.3.5", "zustand": "^5.0.6" @@ -125,8 +128,10 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@types/node": "^20", + "@types/p-limit": "^2.1.0", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.38.0", "@typescript-eslint/parser": "^8.38.0", "eslint": "^9.39.2", @@ -771,9 +776,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", "dev": true, "license": "MIT", "engines": { @@ -6918,6 +6923,13 @@ "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", "license": "MIT" }, + "node_modules/@types/p-limit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/p-limit/-/p-limit-2.1.0.tgz", + "integrity": "sha512-qorhClbttP1axgmbMP3rozj6WF6TPAOeKmW3qyC3I7hAgPMvH76Avt/F3seqEciacgLWk8pQxP9Rtde4BnBEpA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/prismjs": { "version": "1.26.6", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", @@ -7000,9 +7012,10 @@ "license": "MIT" }, "node_modules/@types/uuid": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", - "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, "license": "MIT" }, "node_modules/@types/w3c-web-usb": { @@ -7220,24 +7233,37 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz", + "integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -8041,9 +8067,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -8281,9 +8307,9 @@ } }, "node_modules/asn1.js/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "license": "MIT", "peer": true }, @@ -8705,9 +8731,9 @@ "license": "MIT" }, "node_modules/bn.js": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz", - "integrity": "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", + "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", "license": "MIT" }, "node_modules/boolbase": { @@ -9478,9 +9504,9 @@ } }, "node_modules/create-ecdh/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "license": "MIT", "peer": true }, @@ -9812,6 +9838,15 @@ "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", "license": "MIT" }, + "node_modules/date-fns-tz": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -10046,9 +10081,9 @@ } }, "node_modules/diffie-hellman/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "license": "MIT", "peer": true }, @@ -10167,9 +10202,9 @@ } }, "node_modules/elliptic/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "license": "MIT" }, "node_modules/embla-carousel": { @@ -10522,9 +10557,9 @@ } }, "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", "dependencies": { @@ -10534,7 +10569,7 @@ "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", + "@eslint/js": "9.39.3", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -12710,6 +12745,15 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, + "node_modules/jayson/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/jayson/node_modules/ws": { "version": "7.5.10", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", @@ -14367,9 +14411,9 @@ } }, "node_modules/miller-rabin/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "license": "MIT", "peer": true }, @@ -14420,9 +14464,9 @@ "license": "MIT" }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", + "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", "dev": true, "license": "ISC", "dependencies": { @@ -15079,16 +15123,15 @@ } }, "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz", + "integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==", "license": "MIT", "dependencies": { - "yocto-queue": "^0.1.0" + "yocto-queue": "^1.2.1" }, "engines": { - "node": ">=10" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -15110,6 +15153,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -15803,9 +15875,9 @@ } }, "node_modules/public-encrypt/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "license": "MIT", "peer": true }, @@ -16736,6 +16808,12 @@ "utf-8-validate": "^5.0.2" } }, + "node_modules/rpc-websockets/node_modules/@types/uuid": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", + "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==", + "license": "MIT" + }, "node_modules/rpc-websockets/node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -16745,6 +16823,15 @@ "@types/node": "*" } }, + "node_modules/rpc-websockets/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -17754,9 +17841,9 @@ } }, "node_modules/tiny-secp256k1/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "license": "MIT" }, "node_modules/tinyglobby": { @@ -18572,12 +18659,16 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/uuid4": { @@ -19147,13 +19238,12 @@ } }, "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", "license": "MIT", "engines": { - "node": ">=10" + "node": ">=12.20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" diff --git a/package.json b/package.json index 29cfc970..a181672f 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", "dompurify": "^3.3.0", "embla-carousel-autoplay": "^8.6.0", "embla-carousel-react": "^8.6.0", @@ -118,6 +119,7 @@ "next-auth": "^5.0.0-beta.29", "next-themes": "^0.4.6", "nextjs-toploader": "^3.9.17", + "p-limit": "^7.3.0", "qrcode.react": "^4.2.0", "react": "19.2.1", "react-day-picker": "^9.11.1", @@ -133,6 +135,7 @@ "tailwind-merge": "^3.3.1", "three": "^0.180.0", "tw-animate-css": "^1.3.6", + "uuid": "^13.0.0", "vaul": "^1.1.2", "zod": "^4.3.5", "zustand": "^5.0.6" @@ -140,8 +143,10 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@types/node": "^20", + "@types/p-limit": "^2.1.0", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.38.0", "@typescript-eslint/parser": "^8.38.0", "eslint": "^9.39.2",