From 076ee4e37a34a5378ed78397f05d477dd8240a05 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Wed, 4 Mar 2026 20:10:07 +0100 Subject: [PATCH 01/18] fix: improve timeline input , ui improvement and fixes for participation tab --- .../hackathons/new/tabs/ParticipantTab.tsx | 3 ++- .../new/tabs/components/timeline/DateTimeInput.tsx | 8 ++++---- .../new/tabs/schemas/participantSchema.ts | 13 +++++++++++++ 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/components/organization/hackathons/new/tabs/ParticipantTab.tsx b/components/organization/hackathons/new/tabs/ParticipantTab.tsx index 730d227c..5a149547 100644 --- a/components/organization/hackathons/new/tabs/ParticipantTab.tsx +++ b/components/organization/hackathons/new/tabs/ParticipantTab.tsx @@ -300,7 +300,8 @@ export default function ParticipantTab({ /> {/* Team Size Settings */} - {participantType === 'team' && ( + {(participantType === 'team' || + participantType === 'team_or_individual') && (

Team Size

diff --git a/components/organization/hackathons/new/tabs/components/timeline/DateTimeInput.tsx b/components/organization/hackathons/new/tabs/components/timeline/DateTimeInput.tsx index 9f592e5f..5dbe3b70 100644 --- a/components/organization/hackathons/new/tabs/components/timeline/DateTimeInput.tsx +++ b/components/organization/hackathons/new/tabs/components/timeline/DateTimeInput.tsx @@ -25,9 +25,9 @@ interface DateTimeInputProps { const formatTimeValue = (date?: Date): string => { if (!date) return ''; - const hours = `${date.getHours()}`.padStart(2, '0'); - const minutes = `${date.getMinutes()}`.padStart(2, '0'); - const seconds = `${date.getSeconds()}`.padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); return `${hours}:${minutes}:${seconds}`; }; @@ -100,7 +100,7 @@ export default function DateTimeInput({ { const timeValue = event.target.value; diff --git a/components/organization/hackathons/new/tabs/schemas/participantSchema.ts b/components/organization/hackathons/new/tabs/schemas/participantSchema.ts index a3df1016..5b6c558f 100644 --- a/components/organization/hackathons/new/tabs/schemas/participantSchema.ts +++ b/components/organization/hackathons/new/tabs/schemas/participantSchema.ts @@ -52,6 +52,19 @@ export const participantSchema = z }); } } + + // New validation: At least one submission requirement must be selected + if ( + !data.require_github && + !data.require_demo_video && + !data.require_other_links + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'At least one submission requirement must be selected', + path: ['require_github'], + }); + } }); export type ParticipantFormData = z.input; From f66087510d7978bc1adf727623bc67085edf60e8 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Thu, 5 Mar 2026 06:29:18 +0100 Subject: [PATCH 02/18] fix: implement 2fa for email and password login --- app/me/layout.tsx | 12 +- app/me/settings/SettingsContent.tsx | 23 +- components/auth/LoginWrapper.tsx | 113 +++-- components/auth/TwoFactorVerify.tsx | 196 +++++++++ components/profile/update/SecurityTab.tsx | 166 +++++++ components/profile/update/TwoFactorTab.tsx | 480 +++++++++++++++++++++ lib/api/auth.ts | 109 +++++ 7 files changed, 1051 insertions(+), 48 deletions(-) create mode 100644 components/auth/TwoFactorVerify.tsx create mode 100644 components/profile/update/SecurityTab.tsx create mode 100644 components/profile/update/TwoFactorTab.tsx diff --git a/app/me/layout.tsx b/app/me/layout.tsx index a2ad6e87..0560658f 100644 --- a/app/me/layout.tsx +++ b/app/me/layout.tsx @@ -4,7 +4,7 @@ import { AppSidebar } from '@/components/app-sidebar'; import { SiteHeader } from '@/components/site-header'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; import { useAuthStatus } from '@/hooks/use-auth'; -import React, { useMemo } from 'react'; +import React, { useMemo, useRef, useEffect } from 'react'; import LoadingSpinner from '@/components/LoadingSpinner'; interface MeLayoutProps { @@ -33,6 +33,13 @@ const getId = (item: ProfileItemWithId): string | undefined => const MeLayout = ({ children }: MeLayoutProps): React.ReactElement => { const { user, isLoading } = useAuthStatus(); + // Track whether we've completed the very first load. + // This prevents children from unmounting during background session refetches + // (e.g. on window focus), which would destroy component state like 2FA steps. + const hasLoadedOnce = useRef(false); + useEffect(() => { + if (!isLoading) hasLoadedOnce.current = true; + }, [isLoading]); const { name = '', email = '', profile, image: userImage = '' } = user || {}; const typedProfile = profile as MeLayoutProfile | null | undefined; @@ -62,7 +69,8 @@ const MeLayout = ({ children }: MeLayoutProps): React.ReactElement => { }).length; }, [typedProfile]); - if (isLoading) { + // Only show full-screen spinner on first load, not on background refetches + if (isLoading && !hasLoadedOnce.current) { return (

diff --git a/app/me/settings/SettingsContent.tsx b/app/me/settings/SettingsContent.tsx index 2f0ad701..ee21702c 100644 --- a/app/me/settings/SettingsContent.tsx +++ b/app/me/settings/SettingsContent.tsx @@ -8,14 +8,19 @@ import { getMe } from '@/lib/api/auth'; import { GetMeResponse } from '@/lib/api/types'; import { Skeleton } from '@/components/ui/skeleton'; import Settings from '@/components/profile/update/Settings'; +import TwoFactorTab from '@/components/profile/update/TwoFactorTab'; +import SecurityTab from '@/components/profile/update/SecurityTab'; import { IdentityVerificationSection } from '@/components/didit/IdentityVerificationSection'; import { invalidateAuthProfileCache } from '@/hooks/use-auth'; +import { useRef } from 'react'; const SettingsContent = () => { const searchParams = useSearchParams(); const fromVerification = searchParams.get('verification') === 'complete'; const [userData, setUserData] = useState(null); const [isLoading, setIsLoading] = useState(true); + // Prevent unmounting tabs on background refetches (e.g. after 2FA enable) + const hasLoadedOnce = useRef(false); const fetchUserData = useCallback(async () => { try { @@ -25,11 +30,15 @@ const SettingsContent = () => { setUserData(null); } finally { setIsLoading(false); + hasLoadedOnce.current = true; } }, []); useEffect(() => { - setIsLoading(true); + // Only set isLoading true on the very first fetch + if (!hasLoadedOnce.current) { + setIsLoading(true); + } fetchUserData(); }, [fetchUserData]); @@ -38,7 +47,8 @@ const SettingsContent = () => { invalidateAuthProfileCache(); }, [fetchUserData]); - if (isLoading) { + // Only show skeleton on first load — not on background refetches + if (isLoading && !hasLoadedOnce.current) { return (
@@ -117,6 +127,15 @@ const SettingsContent = () => { + + + + + + { const [showPassword, setShowPassword] = useState(false); const [isLoading, setIsLoading] = useState(false); const [lastMethod, setLastMethod] = useState(null); + const [twoFactorRequired, setTwoFactorRequired] = useState(false); const callbackUrl = searchParams.get('callbackUrl') ? decodeURIComponent(searchParams.get('callbackUrl')!) @@ -42,16 +44,6 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => { setLastMethod(method); }, []); - useEffect(() => { - const method = authClient.getLastUsedLoginMethod(); - setLastMethod(method); - }, []); - - useEffect(() => { - const method = authClient.getLastUsedLoginMethod(); - setLastMethod(method); - }, []); - const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { @@ -89,6 +81,17 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => { const errorStatus = error.status; const errorCode = error.code; + // Check for 2FA requirement + if ( + errorStatus === 403 && + (errorMessage === 'two_factor_required' || + errorMessage?.includes('two_factor') || + errorCode === 'TWO_FACTOR_REQUIRED') + ) { + setTwoFactorRequired(true); + return; + } + if (errorStatus === 403 || errorCode === 'FORBIDDEN') { const message = 'Please verify your email before signing in. Check your inbox for a verification link.'; @@ -171,8 +174,36 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => { setIsLoading(true); setLoadingState(true); + const syncSession = async () => { + const session = await authClient.getSession(); + if (session && typeof session === 'object' && 'user' in session) { + const sessionUser = session.user as + | { + id: string; + email: string; + name?: string | null; + image?: string | null; + } + | null + | undefined; + + if (sessionUser && sessionUser.id && sessionUser.email) { + const authStore = useAuthStore.getState(); + await authStore.syncWithSession({ + id: sessionUser.id, + email: sessionUser.email, + name: sessionUser.name || undefined, + image: sessionUser.image || undefined, + role: 'USER', + username: undefined, + accessToken: undefined, + }); + } + } + }; + try { - const { error } = await authClient.signIn.email( + const { data, error } = await authClient.signIn.email( { email: values.email, password: values.password, @@ -184,42 +215,20 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => { setIsLoading(true); setLoadingState(true); }, - onSuccess: async () => { - await new Promise(resolve => setTimeout(resolve, 200)); - - const session = await authClient.getSession(); - - if (session && typeof session === 'object' && 'user' in session) { - const sessionUser = session.user as - | { - id: string; - email: string; - name?: string | null; - image?: string | null; - } - | null - | undefined; - - if (sessionUser && sessionUser.id && sessionUser.email) { - const authStore = useAuthStore.getState(); - await authStore.syncWithSession({ - id: sessionUser.id, - email: sessionUser.email, - name: sessionUser.name || undefined, - image: sessionUser.image || undefined, - role: 'USER', - username: undefined, - accessToken: undefined, - }); - } + onSuccess: async ctx => { + const resData = ctx?.data as any; + if (resData?.twoFactorRequired || resData?.twoFactorRedirect) { + setTwoFactorRequired(true); + setIsLoading(false); + setLoadingState(false); + return; } - // Keep loading state active during redirect - // The page will unmount when redirecting, so no need to set false + await new Promise(resolve => setTimeout(resolve, 200)); + await syncSession(); window.location.href = callbackUrl; }, onError: ctx => { - // Handle error from Better Auth callback const errorObj = ctx.error || ctx; handleAuthError( typeof errorObj === 'object' @@ -233,14 +242,19 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => { } ); - // Handle error from return value if (error) { handleAuthError(error, values); setIsLoading(false); setLoadingState(false); + } else if ( + (data as any)?.twoFactorRequired || + (data as any)?.twoFactorRedirect + ) { + setTwoFactorRequired(true); + setIsLoading(false); + setLoadingState(false); } } catch (error) { - // Handle unexpected errors const errorObj = error instanceof Error ? { message: error.message, status: undefined, code: undefined } @@ -254,6 +268,17 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => { [handleAuthError, setLoadingState, callbackUrl] ); + if (twoFactorRequired) { + return ( + { + window.location.href = callbackUrl; + }} + onCancel={() => setTwoFactorRequired(false)} + /> + ); + } + return ( Promise; + onCancel: () => void; +} + +const TwoFactorVerify = ({ onSuccess, onCancel }: TwoFactorVerifyProps) => { + const [code, setCode] = useState(''); + const [backupCode, setBackupCode] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isBackupMode, setIsBackupMode] = useState(false); + + const handleVerify = async (codeValue: string) => { + if (codeValue.length < 6) return; + + setIsLoading(true); + try { + const { data, error } = await authClient.twoFactor.verifyTotp({ + code: codeValue, + }); + + if (error) { + toast.error(error.message || 'Verification failed'); + setIsLoading(false); + setCode(''); // Clear on error to let user try again + return; + } + + if (data) { + toast.success('Verification successful'); + await onSuccess(); + } + } catch (err) { + toast.error('An unexpected error occurred during verification'); + setIsLoading(false); + } + }; + + const handleVerifyBackupCode = async (e?: React.FormEvent) => { + e?.preventDefault(); + if (!backupCode) { + toast.error('Please enter a backup code'); + return; + } + + setIsLoading(true); + try { + const { data, error } = await authClient.twoFactor.verifyBackupCode({ + code: backupCode.trim(), + }); + + if (error) { + toast.error(error.message || 'Verification failed'); + setIsLoading(false); + setBackupCode(''); + return; + } + + if (data) { + toast.success('Recovery successful'); + await onSuccess(); + } + } catch (err) { + toast.error('An unexpected error occurred during verification'); + setIsLoading(false); + } + }; + + return ( +
+
+
+
+ +
+
+

+ {isBackupMode ? 'Account Recovery' : 'Two-Factor Authentication'} +

+

+ {isBackupMode + ? 'Enter a one-time backup code to access your account.' + : 'Enter the 6-digit verification code from your authenticator app.'} +

+
+ +
+ {!isBackupMode ? ( +
+
+ { + setCode(val); + if (val.length === 6) { + handleVerify(val); + } + }} + disabled={isLoading} + autoFocus + > + + + + + + + + + +
+ + +
+ ) : ( +
+
+
+ setBackupCode(e.target.value)} + className='h-14 w-full rounded-lg border border-zinc-800 bg-zinc-900/50 text-center text-xl tracking-widest text-white focus:border-[#a7f950]/50 focus:outline-none' + autoFocus + /> +
+ +
+ + +
+ )} + +
+ +
+
+
+ ); +}; + +export default TwoFactorVerify; diff --git a/components/profile/update/SecurityTab.tsx b/components/profile/update/SecurityTab.tsx new file mode 100644 index 00000000..fbe98f3f --- /dev/null +++ b/components/profile/update/SecurityTab.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { toast } from 'sonner'; +import { authClient } from '@/lib/auth-client'; +import { BoundlessButton } from '@/components/buttons'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { ShieldCheck, KeyIcon, LockIcon } from 'lucide-react'; +import { User } from '@/types/user'; + +const passwordSchema = z + .object({ + currentPassword: z.string().min(1, 'Current password is required'), + newPassword: z + .string() + .min(8, 'New password must be at least 8 characters'), + confirmPassword: z.string().min(1, 'Please confirm your new password'), + }) + .refine(data => data.newPassword === data.confirmPassword, { + message: "Passwords don't match", + path: ['confirmPassword'], + }); + +type PasswordFormValues = z.infer; + +interface SecurityTabProps { + user: User; +} + +const SecurityTab = ({ user }: SecurityTabProps) => { + const [isLoading, setIsLoading] = useState(false); + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(passwordSchema), + }); + + const onSubmit = async (data: PasswordFormValues) => { + setIsLoading(true); + try { + const { error } = await authClient.changePassword({ + currentPassword: data.currentPassword, + newPassword: data.newPassword, + }); + + if (error) { + toast.error(error.message || 'Failed to update password'); + } else { + toast.success('Password updated successfully'); + reset(); + } + } catch (err) { + toast.error('An unexpected error occurred'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+
+ +
+
+

+ Change Password +

+

+ Update your account password +

+
+
+ +
+
+ + + {errors.currentPassword && ( +

+ {errors.currentPassword.message} +

+ )} +
+ +
+ + + {errors.newPassword && ( +

+ {errors.newPassword.message} +

+ )} +
+ +
+ + + {errors.confirmPassword && ( +

+ {errors.confirmPassword.message} +

+ )} +
+ + + Update Password + +
+
+ +
+
+
+
+ +
+
+

+ Two-Factor Authentication +

+

+ Add an extra layer of security to your account. You can manage + this in the dedicated 2FA tab. +

+
+
+
+
+ + {user.twoFactorEnabled ? 'Enabled' : 'Disabled'} + +
+
+
+
+ ); +}; + +export default SecurityTab; diff --git a/components/profile/update/TwoFactorTab.tsx b/components/profile/update/TwoFactorTab.tsx new file mode 100644 index 00000000..1ed44c77 --- /dev/null +++ b/components/profile/update/TwoFactorTab.tsx @@ -0,0 +1,480 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { toast } from 'sonner'; +import { authClient } from '@/lib/auth-client'; +import { BoundlessButton } from '@/components/buttons'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { QRCodeSVG } from 'qrcode.react'; +import { User } from '@/types/user'; +import { + Loader2, + Copy, + ShieldCheck, + ShieldAlert, + KeyRound, + Smartphone, +} from 'lucide-react'; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from '@/components/ui/input-otp'; + +interface TwoFactorTabProps { + user: User; + onStatusChange: () => void; +} + +const TwoFactorTab = ({ user, onStatusChange }: TwoFactorTabProps) => { + const [step, setStep] = useState<'status' | 'setup' | 'verify' | 'backup'>( + 'status' + ); + const [password, setPassword] = useState(''); + const [totpUri, setTotpUri] = useState(''); + const [verificationCode, setVerificationCode] = useState(''); + const [backupCodes, setBackupCodes] = useState([]); + const [secretKey, setSecretKey] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [showCodes, setShowCodes] = useState(false); + + const handleStartSetup = async () => { + if (!password) { + toast.error('Please enter your password to enable 2FA'); + return; + } + + setIsLoading(true); + try { + const { data, error } = await authClient.twoFactor.enable({ + password, + }); + + if (error) { + toast.error(error.message || 'Failed to start 2FA setup'); + } else if (data) { + setTotpUri(data.totpURI); + // Extract secret key from URI (format: otpauth://totp/...secret=KEY&...) + const secret = data.totpURI.split('secret=')[1]?.split('&')[0]; + setSecretKey(secret || ''); + setBackupCodes(data.backupCodes); + setStep('setup'); + } + } catch (err) { + toast.error('An unexpected error occurred'); + } finally { + setIsLoading(false); + } + }; + + const handleVerifySetup = async (codeValue: string) => { + if (codeValue.length !== 6) return; + + setIsLoading(true); + try { + const { error } = await authClient.twoFactor.verifyTotp({ + code: codeValue, + }); + + if (error) { + toast.error(error.message || 'Verification failed'); + setVerificationCode(''); // Clear on error + } else { + toast.success('Two-factor authentication enabled successfully!'); + setStep('backup'); + onStatusChange(); + } + } catch (err) { + toast.error('An unexpected error occurred'); + } finally { + setIsLoading(false); + } + }; + + const handleRegenerateCodes = async () => { + if (!password) { + toast.error('Please enter your password to regenerate codes'); + return; + } + + setIsLoading(true); + try { + const { data, error } = await authClient.twoFactor.generateBackupCodes({ + password, + }); + + if (error) { + toast.error(error.message || 'Failed to regenerate backup codes'); + } else if (data) { + setBackupCodes(data.backupCodes); + setShowCodes(true); + setPassword(''); + toast.success('New backup codes generated'); + } + } catch (err) { + toast.error('An unexpected error occurred'); + } finally { + setIsLoading(false); + } + }; + + const handleDisable = async () => { + if (!password) { + toast.error('Please enter your password to disable 2FA'); + return; + } + + setIsLoading(true); + try { + const { error } = await authClient.twoFactor.disable({ + password, + }); + + if (error) { + toast.error(error.message || 'Failed to disable 2FA'); + } else { + toast.success('Two-factor authentication disabled'); + setPassword(''); + setStep('status'); + onStatusChange(); + } + } catch (err) { + toast.error('An unexpected error occurred'); + } finally { + setIsLoading(false); + } + }; + + const copyBackupCodes = (codes: string[]) => { + navigator.clipboard.writeText(codes.join('\n')); + toast.success('Backup codes copied to clipboard'); + }; + + if (user.twoFactorEnabled && step === 'status') { + return ( +
+
+
+ +
+
+

2FA is Enabled

+

+ Your account is protected with an extra layer of security. You + will be prompted for a verification code when signing in. +

+
+
+ +
+
+
+ +

Backup Codes

+
+

+ Backup codes can be used to access your account if you lose your + authentication device. Each code can only be used once. +

+ + {!showCodes ? ( +
+
+ + setPassword(e.target.value)} + className='h-11 border-zinc-800 bg-zinc-900/50 text-white placeholder:text-zinc-600' + /> +
+
+ + Regenerate Codes + + + Disable 2FA + +
+
+ ) : ( +
+
+ {backupCodes.map((code, i) => ( +
+ {code} +
+ ))} +
+
+ copyBackupCodes(backupCodes)} + > + Copy All + + setShowCodes(false)} + > + Hide Codes + +
+
+ )} +
+
+
+ ); + } + + if (step === 'status') { + return ( +
+
+
+ +
+
+

+ 2FA is Not Enabled +

+

+ We recommend enabling two-factor authentication to keep your + account secure. You'll need an authenticator app like Google + Authenticator or Authy. +

+
+
+ +
+
+ + setPassword(e.target.value)} + className='h-11 border-zinc-800 bg-zinc-900/50 text-white placeholder:text-zinc-600' + /> +
+ + Setup 2FA + +
+
+ ); + } + + if (step === 'setup') { + return ( +
+
+
+ 1 +
+

Scan QR Code

+
+ +
+
+ +
+
+
+ +

+ Scan this code with your authenticator app. +

+
+
+ +

+ If you can't scan, you can manually enter the secret key. +

+
+ + {secretKey && ( +
+
+ + Secret Key + + + {secretKey} + +
+ +
+ )} + +
+ setStep('verify')} + > + Next: Verify Code + +
+
+
+
+ ); + } + + if (step === 'verify') { + return ( +
+
+
+ 2 +
+

Verify Setup

+
+ +
+
+

+ Enter the 6-digit code from your app to confirm everything is + working. +

+
+
+ { + setVerificationCode(val); + if (val.length === 6) { + handleVerifySetup(val); + } + }} + disabled={isLoading} + autoFocus + > + + + + + + + + + +
+
+ setStep('setup')} + disabled={isLoading} + > + Back + +
+
+
+ ); + } + + if (step === 'backup') { + return ( +
+
+
+ +

2FA Enabled Successfully

+
+

+ Please save these backup codes in a safe place. You can use them to + access your account if you lose your phone. +

+
+ +
+
+ {backupCodes.map((code, i) => ( +
+ {code} +
+ ))} +
+
+ copyBackupCodes(backupCodes)} + > + Copy Codes + + { + setStep('status'); + setPassword(''); + setShowCodes(false); + }} + > + Done + +
+
+
+ ); + } + + return null; +}; + +export default TwoFactorTab; diff --git a/lib/api/auth.ts b/lib/api/auth.ts index 4f0252d9..be6e9e07 100644 --- a/lib/api/auth.ts +++ b/lib/api/auth.ts @@ -298,3 +298,112 @@ export const updateUserAvatar = async ( message: axiosRes.data.message, }; }; +/** + * Two-factor authentication interfaces + */ +export interface TwoFactorStatusResponse { + twoFactorEnabled: boolean; +} + +export interface GetTotpUriResponse { + totpURI: string; +} + +export interface VerifyTotpResponse { + status: boolean; +} + +export interface EnableTwoFactorResponse { + totpURI: string; + backupCodes: string[]; +} + +export interface GenerateBackupCodesResponse { + status: boolean; + backupCodes: string[]; +} + +/** + * Two-factor authentication API methods + */ +export const getTotpUri = async (password: string): Promise => { + const res = await api.post( + '/auth/two-factor/get-totp-uri', + { password } + ); + return res.data.totpURI; +}; + +export const verifyTotp = async ( + code: string, + trustDevice: boolean | null = null +): Promise => { + const res = await api.post( + '/auth/two-factor/verify-totp', + { code, trustDevice } + ); + return res.data.status; +}; + +export const sendTwoFactorOtp = async (): Promise => { + const res = await api.post<{ status: boolean }>('/auth/two-factor/send-otp'); + return res.data.status; +}; + +export const verifyTwoFactorOtp = async ( + code: string, + trustDevice: boolean | null = null +): Promise<{ token: string; user: User }> => { + const res = await api.post<{ token: string; user: User }>( + '/auth/two-factor/verify-otp', + { code, trustDevice } + ); + return res.data; +}; + +export const verifyBackupCode = async ( + code: string, + trustDevice: boolean | null = null, + disableSession: boolean | null = null +): Promise<{ user: User; session: any }> => { + const res = await api.post<{ user: User; session: any }>( + '/auth/two-factor/verify-backup-code', + { + code, + trustDevice, + disableSession, + } + ); + return res.data; +}; + +export const generateBackupCodes = async ( + password: string +): Promise => { + const res = await api.post( + '/auth/two-factor/generate-backup-codes', + { password } + ); + return res.data.backupCodes; +}; + +export const enableTwoFactor = async ( + password: string, + issuer: string | null = 'Boundless' +): Promise => { + const res = await api.post( + '/auth/two-factor/enable', + { + password, + issuer: issuer || 'Boundless', + } + ); + return res.data; +}; + +export const disableTwoFactor = async (password: string): Promise => { + const res = await api.post<{ status: boolean }>('/auth/two-factor/disable', { + password, + }); + return res.data.status; +}; From 5460d202edb06e141e79947b63a3e87cc30a86f9 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Thu, 5 Mar 2026 07:04:33 +0100 Subject: [PATCH 03/18] fix: fix conflict --- app/me/settings/SettingsContent.tsx | 45 +- components/auth/LoginWrapper.tsx | 14 +- components/auth/TwoFactorVerify.tsx | 36 +- components/profile/update/SecurityTab.tsx | 2 +- components/profile/update/Settings.tsx | 1033 ++++++++++---------- components/profile/update/TwoFactorTab.tsx | 48 +- lib/api/auth.ts | 36 +- 7 files changed, 654 insertions(+), 560 deletions(-) diff --git a/app/me/settings/SettingsContent.tsx b/app/me/settings/SettingsContent.tsx index ee21702c..0f24738d 100644 --- a/app/me/settings/SettingsContent.tsx +++ b/app/me/settings/SettingsContent.tsx @@ -13,6 +13,7 @@ import SecurityTab from '@/components/profile/update/SecurityTab'; import { IdentityVerificationSection } from '@/components/didit/IdentityVerificationSection'; import { invalidateAuthProfileCache } from '@/hooks/use-auth'; import { useRef } from 'react'; +import { Loader2 } from 'lucide-react'; const SettingsContent = () => { const searchParams = useSearchParams(); @@ -122,19 +123,51 @@ const SettingsContent = () => { - + {userData?.user ? ( + + ) : ( +
+ + Loading profile... +
+ )}
+ + + + + + + + + - + {userData?.user ? ( + + ) : ( +
+ + + Loading security settings... + +
+ )}
- + {userData?.user ? ( + + ) : ( +
+ + Loading 2FA settings... +
+ )}
{ setLoadingState(true); }, onSuccess: async ctx => { - const resData = ctx?.data as any; + const resData = ctx?.data as + | { + twoFactorRequired?: boolean; + twoFactorRedirect?: boolean; + } + | undefined; + if (resData?.twoFactorRequired || resData?.twoFactorRedirect) { setTwoFactorRequired(true); setIsLoading(false); @@ -247,8 +253,10 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => { setIsLoading(false); setLoadingState(false); } else if ( - (data as any)?.twoFactorRequired || - (data as any)?.twoFactorRedirect + (data as { twoFactorRequired?: boolean; twoFactorRedirect?: boolean }) + ?.twoFactorRequired || + (data as { twoFactorRequired?: boolean; twoFactorRedirect?: boolean }) + ?.twoFactorRedirect ) { setTwoFactorRequired(true); setIsLoading(false); diff --git a/components/auth/TwoFactorVerify.tsx b/components/auth/TwoFactorVerify.tsx index 7b0931d7..4ef56f64 100644 --- a/components/auth/TwoFactorVerify.tsx +++ b/components/auth/TwoFactorVerify.tsx @@ -32,7 +32,6 @@ const TwoFactorVerify = ({ onSuccess, onCancel }: TwoFactorVerifyProps) => { if (error) { toast.error(error.message || 'Verification failed'); - setIsLoading(false); setCode(''); // Clear on error to let user try again return; } @@ -43,6 +42,7 @@ const TwoFactorVerify = ({ onSuccess, onCancel }: TwoFactorVerifyProps) => { } } catch (err) { toast.error('An unexpected error occurred during verification'); + } finally { setIsLoading(false); } }; @@ -62,17 +62,18 @@ const TwoFactorVerify = ({ onSuccess, onCancel }: TwoFactorVerifyProps) => { if (error) { toast.error(error.message || 'Verification failed'); - setIsLoading(false); setBackupCode(''); return; } if (data) { toast.success('Recovery successful'); + setBackupCode(''); await onSuccess(); } } catch (err) { toast.error('An unexpected error occurred during verification'); + } finally { setIsLoading(false); } }; @@ -112,30 +113,13 @@ const TwoFactorVerify = ({ onSuccess, onCancel }: TwoFactorVerifyProps) => { autoFocus > - - - - - - + {[...Array(6)].map((_, i) => ( + + ))}
diff --git a/components/profile/update/SecurityTab.tsx b/components/profile/update/SecurityTab.tsx index fbe98f3f..4bdb5878 100644 --- a/components/profile/update/SecurityTab.tsx +++ b/components/profile/update/SecurityTab.tsx @@ -9,7 +9,7 @@ import { authClient } from '@/lib/auth-client'; import { BoundlessButton } from '@/components/buttons'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { ShieldCheck, KeyIcon, LockIcon } from 'lucide-react'; +import { ShieldCheck, LockIcon } from 'lucide-react'; import { User } from '@/types/user'; const passwordSchema = z diff --git a/components/profile/update/Settings.tsx b/components/profile/update/Settings.tsx index 4ebbc63c..44167714 100644 --- a/components/profile/update/Settings.tsx +++ b/components/profile/update/Settings.tsx @@ -63,7 +63,16 @@ const settingsSchema = z.object({ type SettingsFormData = z.infer; -const Settings = () => { +interface SettingsProps { + visibleSections?: ( + | 'notifications' + | 'privacy' + | 'appearance' + | 'preferences' + )[]; +} + +const Settings = ({ visibleSections }: SettingsProps) => { const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [settings, setSettings] = useState({ @@ -192,547 +201,419 @@ const Settings = () => { return (
- {/* Header */} -
-

Settings

-

- Manage your account preferences and privacy settings -

-
+ {/* Header - Only show if no specific sections are requested (Main Settings page) */} + {!visibleSections && ( +
+

Settings

+

+ Manage your account preferences and privacy settings +

+
+ )}
{/* Notifications */} - -
- -

- Notifications -

-
+ {(!visibleSections || visibleSections.includes('notifications')) && ( + +
+ +

+ Notifications +

+
-
- ( - -
- - Email Notifications - - - Receive email notifications about account activity - -
- - { - field.onChange(checked); - await onSubmitNotifications({ - emailNotifications: checked, - pushNotifications: form.getValues( - 'notifications.pushNotifications' - ), - }); - }} - /> - -
- )} - /> +
+ ( + +
+ + Email Notifications + + + Receive email notifications about account activity + +
+ + { + field.onChange(checked); + await onSubmitNotifications({ + emailNotifications: checked, + pushNotifications: form.getValues( + 'notifications.pushNotifications' + ), + }); + }} + /> + +
+ )} + /> - ( - -
- - Push Notifications - - - Receive push notifications in your browser - -
- - { - field.onChange(checked); - await onSubmitNotifications({ - pushNotifications: checked, - emailNotifications: form.getValues( - 'notifications.emailNotifications' - ), - }); - }} - /> - -
- )} - /> -
- + ( + +
+ + Push Notifications + + + Receive push notifications in your browser + +
+ + { + field.onChange(checked); + await onSubmitNotifications({ + pushNotifications: checked, + emailNotifications: form.getValues( + 'notifications.emailNotifications' + ), + }); + }} + /> + +
+ )} + /> +
+
+ )} {/* Privacy */} - -
- -

Privacy

-
+ {(!visibleSections || visibleSections.includes('privacy')) && ( + +
+ +

Privacy

+
-
- ( - -
- - Public Profile - - - Make your profile visible to other users - -
- - { - field.onChange(checked); - await onSubmitPrivacy({ - publicProfile: checked, - emailVisibility: form.getValues( - 'privacy.emailVisibility' - ), - locationVisibility: form.getValues( - 'privacy.locationVisibility' - ), - companyVisibility: form.getValues( - 'privacy.companyVisibility' - ), - websiteVisibility: form.getValues( - 'privacy.websiteVisibility' - ), - socialLinksVisibility: form.getValues( - 'privacy.socialLinksVisibility' - ), - }); - }} - /> - -
- )} - /> - - ( - -
- - Email Visibility - - - Show your email address on your profile - -
- - { - field.onChange(checked); - await onSubmitPrivacy({ - publicProfile: form.getValues( - 'privacy.publicProfile' - ), - emailVisibility: checked, - locationVisibility: form.getValues( - 'privacy.locationVisibility' - ), - companyVisibility: form.getValues( - 'privacy.companyVisibility' - ), - websiteVisibility: form.getValues( - 'privacy.websiteVisibility' - ), - socialLinksVisibility: form.getValues( - 'privacy.socialLinksVisibility' - ), - }); - }} - /> - -
- )} - /> - - ( - -
- - Location Visibility - - - Show your location on your profile - -
- - { - field.onChange(checked); - await onSubmitPrivacy({ - publicProfile: form.getValues( - 'privacy.publicProfile' - ), - emailVisibility: form.getValues( - 'privacy.emailVisibility' - ), - locationVisibility: checked, - companyVisibility: form.getValues( - 'privacy.companyVisibility' - ), - websiteVisibility: form.getValues( - 'privacy.websiteVisibility' - ), - socialLinksVisibility: form.getValues( - 'privacy.socialLinksVisibility' - ), - }); - }} - /> - -
- )} - /> - - ( - -
- - Company Visibility - - - Show your company on your profile - -
- - { - field.onChange(checked); - await onSubmitPrivacy({ - publicProfile: form.getValues( - 'privacy.publicProfile' - ), - emailVisibility: form.getValues( - 'privacy.emailVisibility' - ), - locationVisibility: form.getValues( - 'privacy.locationVisibility' - ), - companyVisibility: checked, - websiteVisibility: form.getValues( - 'privacy.websiteVisibility' - ), - socialLinksVisibility: form.getValues( - 'privacy.socialLinksVisibility' - ), - }); - }} - /> - -
- )} - /> - - ( - -
- - Website Visibility - - - Show your website on your profile - -
- - { - field.onChange(checked); - await onSubmitPrivacy({ - publicProfile: form.getValues( - 'privacy.publicProfile' - ), - emailVisibility: form.getValues( - 'privacy.emailVisibility' - ), - locationVisibility: form.getValues( - 'privacy.locationVisibility' - ), - companyVisibility: form.getValues( - 'privacy.companyVisibility' - ), - websiteVisibility: checked, - socialLinksVisibility: form.getValues( - 'privacy.socialLinksVisibility' - ), - }); - }} - /> - -
- )} - /> +
+ ( + +
+ + Public Profile + + + Make your profile visible to other users + +
+ + { + field.onChange(checked); + await onSubmitPrivacy({ + publicProfile: checked, + emailVisibility: form.getValues( + 'privacy.emailVisibility' + ), + locationVisibility: form.getValues( + 'privacy.locationVisibility' + ), + companyVisibility: form.getValues( + 'privacy.companyVisibility' + ), + websiteVisibility: form.getValues( + 'privacy.websiteVisibility' + ), + socialLinksVisibility: form.getValues( + 'privacy.socialLinksVisibility' + ), + }); + }} + /> + +
+ )} + /> - ( - -
- - Social Links Visibility - - - Show your social links on your profile - -
- - { - field.onChange(checked); - await onSubmitPrivacy({ - publicProfile: form.getValues( - 'privacy.publicProfile' - ), - emailVisibility: form.getValues( - 'privacy.emailVisibility' - ), - locationVisibility: form.getValues( - 'privacy.locationVisibility' - ), - companyVisibility: form.getValues( - 'privacy.companyVisibility' - ), - websiteVisibility: form.getValues( - 'privacy.websiteVisibility' - ), - socialLinksVisibility: checked, - }); - }} - /> - -
- )} - /> -
- + ( + +
+ + Email Visibility + + + Show your email address on your profile + +
+ + { + field.onChange(checked); + await onSubmitPrivacy({ + publicProfile: form.getValues( + 'privacy.publicProfile' + ), + emailVisibility: checked, + locationVisibility: form.getValues( + 'privacy.locationVisibility' + ), + companyVisibility: form.getValues( + 'privacy.companyVisibility' + ), + websiteVisibility: form.getValues( + 'privacy.websiteVisibility' + ), + socialLinksVisibility: form.getValues( + 'privacy.socialLinksVisibility' + ), + }); + }} + /> + +
+ )} + /> - {/* Appearance */} - -
- -

Appearance

-
+ ( + +
+ + Location Visibility + + + Show your location on your profile + +
+ + { + field.onChange(checked); + await onSubmitPrivacy({ + publicProfile: form.getValues( + 'privacy.publicProfile' + ), + emailVisibility: form.getValues( + 'privacy.emailVisibility' + ), + locationVisibility: checked, + companyVisibility: form.getValues( + 'privacy.companyVisibility' + ), + websiteVisibility: form.getValues( + 'privacy.websiteVisibility' + ), + socialLinksVisibility: form.getValues( + 'privacy.socialLinksVisibility' + ), + }); + }} + /> + +
+ )} + /> - ( - - Theme - - - - )} - /> -
+ ( + +
+ + Company Visibility + + + Show your company on your profile + +
+ + { + field.onChange(checked); + await onSubmitPrivacy({ + publicProfile: form.getValues( + 'privacy.publicProfile' + ), + emailVisibility: form.getValues( + 'privacy.emailVisibility' + ), + locationVisibility: form.getValues( + 'privacy.locationVisibility' + ), + companyVisibility: checked, + websiteVisibility: form.getValues( + 'privacy.websiteVisibility' + ), + socialLinksVisibility: form.getValues( + 'privacy.socialLinksVisibility' + ), + }); + }} + /> + +
+ )} + /> - {/* Preferences */} - -
- -

Preferences

-
+ ( + +
+ + Website Visibility + + + Show your website on your profile + +
+ + { + field.onChange(checked); + await onSubmitPrivacy({ + publicProfile: form.getValues( + 'privacy.publicProfile' + ), + emailVisibility: form.getValues( + 'privacy.emailVisibility' + ), + locationVisibility: form.getValues( + 'privacy.locationVisibility' + ), + companyVisibility: form.getValues( + 'privacy.companyVisibility' + ), + websiteVisibility: checked, + socialLinksVisibility: form.getValues( + 'privacy.socialLinksVisibility' + ), + }); + }} + /> + +
+ )} + /> -
- {/* Language */} - ( - - Language - - - - )} - /> + + )} + /> +
+
+ )} + + {/* Appearance */} + {(!visibleSections || visibleSections.includes('appearance')) && ( + +
+ +

Appearance

+
- {/* Timezone */} ( - Timezone + Theme @@ -740,11 +621,151 @@ const Settings = () => { )} /> +
+ )} + + {/* Preferences */} + {(!visibleSections || visibleSections.includes('preferences')) && ( + +
+ +

+ Preferences +

+
+ +
+ {/* Language */} + ( + + Language + + + + )} + /> + + {/* Timezone */} + ( + + Timezone + + + + )} + /> - {/* Categories and Skills would be implemented here if needed */} - {/* They are arrays in the API but for now we'll keep them as empty arrays */} -
-
+ {/* Categories and Skills would be implemented here if needed */} + {/* They are arrays in the API but for now we'll keep them as empty arrays */} +
+
+ )} {/* Save Button */}
diff --git a/components/profile/update/TwoFactorTab.tsx b/components/profile/update/TwoFactorTab.tsx index 1ed44c77..37c3bc1a 100644 --- a/components/profile/update/TwoFactorTab.tsx +++ b/components/profile/update/TwoFactorTab.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { toast } from 'sonner'; import { authClient } from '@/lib/auth-client'; import { BoundlessButton } from '@/components/buttons'; @@ -55,9 +55,15 @@ const TwoFactorTab = ({ user, onStatusChange }: TwoFactorTabProps) => { toast.error(error.message || 'Failed to start 2FA setup'); } else if (data) { setTotpUri(data.totpURI); - // Extract secret key from URI (format: otpauth://totp/...secret=KEY&...) - const secret = data.totpURI.split('secret=')[1]?.split('&')[0]; - setSecretKey(secret || ''); + try { + const url = new URL(data.totpURI); + const secret = url.searchParams.get('secret'); + setSecretKey(secret || ''); + } catch (e) { + // Fallback to simple split if URL parsing fails + const secret = data.totpURI.split('secret=')[1]?.split('&')[0]; + setSecretKey(secret || ''); + } setBackupCodes(data.backupCodes); setStep('setup'); } @@ -146,9 +152,37 @@ const TwoFactorTab = ({ user, onStatusChange }: TwoFactorTabProps) => { } }; - const copyBackupCodes = (codes: string[]) => { - navigator.clipboard.writeText(codes.join('\n')); - toast.success('Backup codes copied to clipboard'); + const copyBackupCodes = async (codes: string[]) => { + const text = codes.join('\n'); + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + toast.success('Backup codes copied to clipboard'); + } else { + throw new Error('Clipboard API unavailable'); + } + } catch (err) { + // Fallback for older browsers or non-secure contexts + try { + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-9999px'; + textArea.style.top = '0'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + const successful = document.execCommand('copy'); + document.body.removeChild(textArea); + if (successful) { + toast.success('Backup codes copied to clipboard'); + } else { + throw new Error('Fallback copy failed'); + } + } catch (fallbackErr) { + toast.error('Unable to copy backup codes'); + } + } }; if (user.twoFactorEnabled && step === 'status') { diff --git a/lib/api/auth.ts b/lib/api/auth.ts index be6e9e07..dd04a082 100644 --- a/lib/api/auth.ts +++ b/lib/api/auth.ts @@ -324,9 +324,17 @@ export interface GenerateBackupCodesResponse { } /** - * Two-factor authentication API methods + * AXIOS-BASED 2FA HELPERS + * + * Note: These functions use direct axios-based API calls instead of the Better Auth client plugin. + * They are preserved for use in internal tools, CLI scripts, or specific out-of-UI contexts + * where the standard authClient plugins are not appropriate. + * + * For standard UI components, prefer using `authClient.twoFactor.*`. */ -export const getTotpUri = async (password: string): Promise => { + +/** + * Get TOTP URI for setup const res = await api.post( '/auth/two-factor/get-totp-uri', { password } @@ -361,19 +369,25 @@ export const verifyTwoFactorOtp = async ( return res.data; }; +/** + * Verify backup code + */ export const verifyBackupCode = async ( code: string, trustDevice: boolean | null = null, disableSession: boolean | null = null -): Promise<{ user: User; session: any }> => { - const res = await api.post<{ user: User; session: any }>( - '/auth/two-factor/verify-backup-code', - { - code, - trustDevice, - disableSession, - } - ); +): Promise<{ + user: User; + session: { id: string; userId: string; token: string; expiresAt: Date }; +}> => { + const res = await api.post<{ + user: User; + session: { id: string; userId: string; token: string; expiresAt: Date }; + }>('/auth/two-factor/verify-backup-code', { + code, + trustDevice, + disableSession, + }); return res.data; }; From a6599eb8619bd9147011403240ce984ddd2c4889 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Fri, 6 Mar 2026 17:05:35 +0100 Subject: [PATCH 04/18] fix: fix submission form --- .../hackathons/submissions/SubmissionForm.tsx | 20 ++++++++++--------- .../hackathons/submissions/submissionTab.tsx | 3 ++- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/components/hackathons/submissions/SubmissionForm.tsx b/components/hackathons/submissions/SubmissionForm.tsx index abb2e0e4..3c48a534 100644 --- a/components/hackathons/submissions/SubmissionForm.tsx +++ b/components/hackathons/submissions/SubmissionForm.tsx @@ -1081,15 +1081,17 @@ const SubmissionFormContent: React.FC = ({
- + {process.env.NODE_ENV === 'development' && ( + + )}
= ({ {!isLoadingMySubmission && !mySubmission && isAuthenticated && - isRegistered && ( + isRegistered && + status !== 'upcoming' && (

You haven't submitted a project yet. From 222cd4582510814554c6ccccc0e45f0944cd69e1 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Sat, 7 Mar 2026 14:44:29 +0100 Subject: [PATCH 05/18] fix: fix hackathon submission and participant page --- .../hackathons/[slug]/HackathonPageClient.tsx | 2 +- .../hackathons/[slug]/submit/page.tsx | 120 +++++++++ .../hackathons/submissions/SubmissionForm.tsx | 43 +++- .../hackathons/submissions/submissionCard.tsx | 62 ++--- .../hackathons/submissions/submissionTab.tsx | 38 +-- .../settings/GeneralSettingsTab.tsx | 43 +++- components/stepper/Stepper.tsx | 40 ++- hooks/hackathon/use-participants.ts | 230 ++++++++++++------ 8 files changed, 420 insertions(+), 158 deletions(-) create mode 100644 app/(landing)/hackathons/[slug]/submit/page.tsx diff --git a/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx b/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx index 0c4d1adf..5ca55a13 100644 --- a/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx +++ b/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx @@ -296,7 +296,7 @@ export default function HackathonPageClient() { }; const handleSubmitClick = () => { - router.push('?tab=submission'); + router.push(`/hackathons/${currentHackathon?.slug}/submit`); }; const handleViewSubmissionClick = () => { diff --git a/app/(landing)/hackathons/[slug]/submit/page.tsx b/app/(landing)/hackathons/[slug]/submit/page.tsx new file mode 100644 index 00000000..f5ca054e --- /dev/null +++ b/app/(landing)/hackathons/[slug]/submit/page.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { use, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useHackathonData } from '@/lib/providers/hackathonProvider'; +import { useAuthStatus } from '@/hooks/use-auth'; +import { useSubmission } from '@/hooks/hackathon/use-submission'; +import { SubmissionFormContent } from '@/components/hackathons/submissions/SubmissionForm'; +import LoadingScreen from '@/features/projects/components/CreateProjectModal/LoadingScreen'; +import { Button } from '@/components/ui/button'; +import { ArrowLeft } from 'lucide-react'; +import { toast } from 'sonner'; + +export default function SubmitProjectPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const router = useRouter(); + const { isAuthenticated, isLoading } = useAuthStatus(); + + const resolvedParams = use(params); + const hackathonSlug = resolvedParams.slug; + + const { + currentHackathon, + loading: hackathonLoading, + setCurrentHackathon, + } = useHackathonData(); + + useEffect(() => { + if (hackathonSlug) { + setCurrentHackathon(hackathonSlug); + } + }, [hackathonSlug, setCurrentHackathon]); + + const hackathonId = currentHackathon?.id || ''; + const orgId = currentHackathon?.organizationId || undefined; + + const { + submission: mySubmission, + isFetching: isLoadingMySubmission, + fetchMySubmission, + } = useSubmission({ + hackathonSlugOrId: hackathonId || '', + autoFetch: isAuthenticated && !!hackathonId, + }); + + // Authentication check + useEffect(() => { + if (!isLoading && !isAuthenticated) { + toast.error('You must be logged in to submit a project'); + router.push( + `/auth?mode=signin&callbackUrl=/hackathons/${hackathonSlug}/submit` + ); + } + }, [isAuthenticated, isLoading, router, hackathonSlug]); + + const handleClose = () => { + router.push(`/hackathons/${hackathonSlug}`); + }; + + const handleSuccess = () => { + fetchMySubmission(); + toast.success( + mySubmission + ? 'Submission updated successfully!' + : 'Project submitted successfully!' + ); + router.push(`/hackathons/${hackathonSlug}?tab=submission`); + }; + + if ( + isLoading || + hackathonLoading || + isLoadingMySubmission || + !currentHackathon + ) { + return ; + } + + return ( +

+
+ + +
+ +
+
+
+ ); +} diff --git a/components/hackathons/submissions/SubmissionForm.tsx b/components/hackathons/submissions/SubmissionForm.tsx index 3c48a534..1604189b 100644 --- a/components/hackathons/submissions/SubmissionForm.tsx +++ b/components/hackathons/submissions/SubmissionForm.tsx @@ -125,6 +125,7 @@ interface SubmissionFormContentProps { initialData?: Partial; submissionId?: string; onSuccess?: () => void; + onClose?: () => void; } const INITIAL_STEPS: Step[] = [ @@ -198,8 +199,19 @@ const SubmissionFormContent: React.FC = ({ initialData, submissionId, onSuccess, + onClose, }) => { - const { collapse, isExpanded: open } = useExpandableScreen(); + // Use context carefully since it might not be available when used standalone + let collapse = () => {}; + let open = true; + try { + const expandableCtx = useExpandableScreen(); + collapse = expandableCtx.collapse; + open = expandableCtx.isExpanded; + } catch (e) { + // Standalone mode, not in ExpandableScreen + } + const { currentHackathon } = useHackathonData(); const { user } = useAuthStatus(); @@ -773,7 +785,11 @@ const SubmissionFormContent: React.FC = ({ } else { await create(submissionData); } - collapse(); + if (onClose) { + onClose(); + } else { + collapse(); + } onSuccess?.(); } catch { // Error handled in hook @@ -1503,22 +1519,31 @@ const SubmissionFormContent: React.FC = ({ -
+
-
+
{renderStepContent()}
{currentStep < steps.length - 1 ? ( - - - - - Edit Submission - - e.stopPropagation()}> + + + + + - - Delete Submission - - - + onEditClick?.()} + className='cursor-pointer text-gray-300 focus:bg-gray-800 focus:text-white' + > + + Edit Submission + + onDeleteClick?.()} + className='cursor-pointer text-red-500 focus:bg-red-900/20 focus:text-red-400' + > + + Delete Submission + + + +
)}
diff --git a/components/hackathons/submissions/submissionTab.tsx b/components/hackathons/submissions/submissionTab.tsx index 61f17bec..3492fb77 100644 --- a/components/hackathons/submissions/submissionTab.tsx +++ b/components/hackathons/submissions/submissionTab.tsx @@ -54,6 +54,7 @@ interface SubmissionTabContentProps extends SubmissionTabProps { fetchMySubmission: () => Promise; removeSubmission: (id: string) => Promise; hackathonId: string; + hackathonSlug: string; } const SubmissionTabContent: React.FC = ({ @@ -64,10 +65,10 @@ const SubmissionTabContent: React.FC = ({ fetchMySubmission, removeSubmission, hackathonId, + hackathonSlug, }) => { const { isAuthenticated } = useAuthStatus(); const router = useRouter(); - const { expand } = useExpandableScreen(); const [viewMode, setViewMode] = useState('grid'); @@ -129,6 +130,7 @@ const SubmissionTabContent: React.FC = ({ await removeSubmission(submissionToDelete); setSubmissionToDelete(null); toast.success('Submission deleted successfully'); + window.location.reload(); } catch (error) { reportError(error, { context: 'submission-delete', @@ -274,7 +276,7 @@ const SubmissionTabContent: React.FC = ({ You haven't submitted a project yet.

)} diff --git a/components/hackathons/hackathonStickyCard.tsx b/components/hackathons/hackathonStickyCard.tsx index 47056240..9a4a9c2a 100644 --- a/components/hackathons/hackathonStickyCard.tsx +++ b/components/hackathons/hackathonStickyCard.tsx @@ -238,20 +238,17 @@ export function HackathonStickyCard(props: HackathonStickyCardProps) { )} - {/* View Submission Button */} - {status === 'ongoing' && - isRegistered && - hasSubmitted && - onViewSubmissionClick && ( - - )} + {/* Edit / View Submission Button */} + {status === 'ongoing' && isRegistered && hasSubmitted && ( + + )} {/* Find Team Button */} {status === 'ongoing' && diff --git a/components/hackathons/submissions/submissionTab.tsx b/components/hackathons/submissions/submissionTab.tsx index 3492fb77..fff6a497 100644 --- a/components/hackathons/submissions/submissionTab.tsx +++ b/components/hackathons/submissions/submissionTab.tsx @@ -83,7 +83,8 @@ const SubmissionTabContent: React.FC = ({ setSelectedSort, setSelectedCategory, } = useSubmissions(); - const { currentHackathon } = useHackathonData(); + const { currentHackathon, loading: isHackathonDataLoading } = + useHackathonData(); const { status } = useHackathonStatus( currentHackathon?.startDate, currentHackathon?.submissionDeadline @@ -265,6 +266,14 @@ const SubmissionTabContent: React.FC = ({
+ {/* Loading State */} + {(isLoadingMySubmission || isHackathonDataLoading) && ( +
+ + Loading submissions... +
+ )} + {/* Submissions Grid with Create Button if no submission */} {!isLoadingMySubmission && !mySubmission && @@ -289,7 +298,9 @@ const SubmissionTabContent: React.FC = ({ )} {/* Submissions Grid / List */} - {submissions.length > 0 || mySubmission ? ( + {!isLoadingMySubmission && + !isHackathonDataLoading && + (submissions.length > 0 || mySubmission) ? (
{ - if (currentHackathonSlug === slug && fetchingRef.current) return; - setCurrentHackathonSlug(slug); const data = await fetchHackathonBySlug(slug); From efda241364c2ff7502798b4c2f773731e573b577 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Sat, 7 Mar 2026 23:42:47 +0100 Subject: [PATCH 07/18] fix: fix auto refresh ib submission page --- .../hackathons/[slug]/submit/page.tsx | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/app/(landing)/hackathons/[slug]/submit/page.tsx b/app/(landing)/hackathons/[slug]/submit/page.tsx index f5ca054e..97e12687 100644 --- a/app/(landing)/hackathons/[slug]/submit/page.tsx +++ b/app/(landing)/hackathons/[slug]/submit/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { use, useEffect } from 'react'; +import { use, useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useHackathonData } from '@/lib/providers/hackathonProvider'; import { useAuthStatus } from '@/hooks/use-auth'; @@ -70,12 +70,20 @@ export default function SubmitProjectPage({ router.push(`/hackathons/${hackathonSlug}?tab=submission`); }; - if ( - isLoading || - hackathonLoading || - isLoadingMySubmission || - !currentHackathon - ) { + const [hasInitialLoaded, setHasInitialLoaded] = useState(false); + + useEffect(() => { + if ( + !isLoading && + !hackathonLoading && + !isLoadingMySubmission && + currentHackathon + ) { + setHasInitialLoaded(true); + } + }, [isLoading, hackathonLoading, isLoadingMySubmission, currentHackathon]); + + if (!hasInitialLoaded || !currentHackathon) { return ; } From dffd84234b46cc40471039d3f5d82f6ef54ee101 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Sun, 8 Mar 2026 23:12:54 +0100 Subject: [PATCH 08/18] fix: hackathon submission fixes --- app/me/projects/page.tsx | 64 +++++++++------ .../hackathons/submissions/submissionTab.tsx | 1 + components/profile/ProjectsTab.tsx | 79 +++++++++++-------- components/profile/ProjectsTabPublic.tsx | 69 ++++++++++------ features/projects/components/ProjectCard.tsx | 24 +++++- features/projects/types/index.ts | 7 ++ lib/api/types.ts | 1 + 7 files changed, 161 insertions(+), 84 deletions(-) diff --git a/app/me/projects/page.tsx b/app/me/projects/page.tsx index 4c61e8f1..733415f2 100644 --- a/app/me/projects/page.tsx +++ b/app/me/projects/page.tsx @@ -102,32 +102,46 @@ export default function MyProjectsPage() { ) : (
- {sortedProjects.map(project => ( - { + // Find if this project is a hackathon submission + const submission = + meData.user.hackathonSubmissionsAsParticipant?.find( + s => s.projectId === project.id + ); + + // If it's a submission, use the submission ID for the slug to ensure correct redirection from ProjectCard + const displayId = submission?.id || project.id; + + return ( + - ))} + // Add custom properties for ProjectCard + isSubmission: !!submission, + submissionStatus: submission?.status, + fundingGoal: 0, + fundingRaised: 0, + fundingCurrency: 'USDC', + fundingEndDate: null, + milestones: [], + voteGoal: 0, + voteProgress: 0, + } as any + } + /> + ); + })}
)}
diff --git a/components/hackathons/submissions/submissionTab.tsx b/components/hackathons/submissions/submissionTab.tsx index fff6a497..1fea7d3a 100644 --- a/components/hackathons/submissions/submissionTab.tsx +++ b/components/hackathons/submissions/submissionTab.tsx @@ -83,6 +83,7 @@ const SubmissionTabContent: React.FC = ({ setSelectedSort, setSelectedCategory, } = useSubmissions(); + const { currentHackathon, loading: isHackathonDataLoading } = useHackathonData(); const { status } = useHackathonStatus( diff --git a/components/profile/ProjectsTab.tsx b/components/profile/ProjectsTab.tsx index ff2808d5..9c1b1914 100644 --- a/components/profile/ProjectsTab.tsx +++ b/components/profile/ProjectsTab.tsx @@ -100,38 +100,53 @@ export default function ProjectsTab({ user }: ProjectsTabProps) { onScrollCapture={handleScroll} >
- {projects.map(project => ( - - - - ))} + {projects.map(project => { + // Find if this project is a hackathon submission + // We need to check if the user object has submissions + const submission = ( + user.user as any + ).hackathonSubmissionsAsParticipant?.find( + (s: any) => s.projectId === project.id + ); + + // If it's a submission, use the submission ID for the URL and add ?type=submission + const displayId = submission?.id || project.id; + const href = submission + ? `/projects/${displayId}?type=submission` + : `/projects/${displayId}`; + + return ( + + + + ); + })} {!hasMore && projects.length > 0 && (
diff --git a/components/profile/ProjectsTabPublic.tsx b/components/profile/ProjectsTabPublic.tsx index 478c1faf..89aec0b2 100644 --- a/components/profile/ProjectsTabPublic.tsx +++ b/components/profile/ProjectsTabPublic.tsx @@ -33,33 +33,50 @@ export default function ProjectsTabPublic({ user }: ProjectsTabProps) {
- {user.projects.map(project => ( - - { + // Find if this project is a hackathon submission + const submission = user.hackathonSubmissionsAsParticipant?.find( + s => s.projectId === project.id + ); + + // If it's a submission, use the submission ID for the URL and add ?type=submission + const displayId = submission?.id || project.id; + const href = submission + ? `/projects/${displayId}?type=submission` + : `/projects/${displayId}`; + + return ( + + - - ))} + // Add a custom property to indicate it's a submission for ProjectCard + isSubmission: !!submission, + submissionStatus: submission?.status, + fundingGoal: 0, + fundingRaised: 0, + fundingCurrency: 'USDC', + fundingEndDate: null, + milestones: [], + voteGoal: 0, + voteProgress: 0, + } as any + } + /> + + ); + })}
); diff --git a/features/projects/components/ProjectCard.tsx b/features/projects/components/ProjectCard.tsx index 8dd194c8..c6f0e247 100644 --- a/features/projects/components/ProjectCard.tsx +++ b/features/projects/components/ProjectCard.tsx @@ -47,11 +47,33 @@ function ProjectCard({ banner || '/images/placeholders/project-banner-placeholder.png'; const handleClick = () => { - router.push(`/projects/${slug}`); + // @ts-expect-error - Custom property added in mapping + const url = data.isSubmission + ? `/projects/${slug}?type=submission` + : `/projects/${slug}`; + router.push(url); }; // Determine display status const getDisplayStatus = () => { + console.log('DEBUG: ProjectCard data:', { + title, + id: data.id, + slug: data.slug, + isSubmission: (data as any).isSubmission, + submissionStatus: (data as any).submissionStatus, + }); + // @ts-expect-error - Custom property added in mapping + if (data.isSubmission) { + // @ts-expect-error - Custom property added in mapping + const submissionStatus = data.submissionStatus; + if (submissionStatus === 'SHORTLISTED' || submissionStatus === 'ACCEPTED') + return 'Shortlisted'; + if (submissionStatus === 'SUBMITTED') return 'Submitted'; + if (submissionStatus === 'DISQUALIFIED') return 'Disqualified'; + return 'Submission'; + } + if (projectStatus === 'IDEA') return 'Validation'; if (projectStatus === 'ACTIVE') return 'Funding'; if (projectStatus === 'LIVE') return 'Funded'; diff --git a/features/projects/types/index.ts b/features/projects/types/index.ts index bce8bcc4..7d0418da 100644 --- a/features/projects/types/index.ts +++ b/features/projects/types/index.ts @@ -82,6 +82,13 @@ export interface PublicUserProfile { logo: string; createdAt: string; }>; + hackathonSubmissionsAsParticipant?: Array<{ + id: string; + projectId: string; + status: string; + hackathonId: string; + projectName: string; + }>; badges: any[]; stats: { projectsCreated: number; diff --git a/lib/api/types.ts b/lib/api/types.ts index 39010e57..d418161a 100644 --- a/lib/api/types.ts +++ b/lib/api/types.ts @@ -122,6 +122,7 @@ export interface User { rank?: number | null; submittedAt: string; hackathonId: string; + projectId: string; hackathon?: Hackathon; }>; joinedHackathons?: Array<{ From a2ddd1de6ff46fc65121625fa89e163ec6f7239f Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Mon, 9 Mar 2026 01:16:57 +0100 Subject: [PATCH 09/18] fix: fix coderabbit corrections --- .../hackathons/[slug]/submit/page.tsx | 29 ++++++++---- .../hackathons/submissions/SubmissionForm.tsx | 22 ++++----- .../hackathons/submissions/submissionCard.tsx | 15 ++++++- .../hackathons/submissions/submissionTab.tsx | 1 + .../settings/GeneralSettingsTab.tsx | 22 ++++++++- components/ui/expandable-screen.tsx | 4 ++ hooks/hackathon/use-participants.ts | 45 +++++++++++++------ lib/providers/hackathonProvider.tsx | 2 +- package-lock.json | 24 ++++++++++ package.json | 2 + 10 files changed, 128 insertions(+), 38 deletions(-) diff --git a/app/(landing)/hackathons/[slug]/submit/page.tsx b/app/(landing)/hackathons/[slug]/submit/page.tsx index 97e12687..61e39497 100644 --- a/app/(landing)/hackathons/[slug]/submit/page.tsx +++ b/app/(landing)/hackathons/[slug]/submit/page.tsx @@ -73,20 +73,31 @@ export default function SubmitProjectPage({ const [hasInitialLoaded, setHasInitialLoaded] = useState(false); useEffect(() => { - if ( - !isLoading && - !hackathonLoading && - !isLoadingMySubmission && - currentHackathon - ) { + if (!isLoading && !hackathonLoading && !isLoadingMySubmission) { setHasInitialLoaded(true); } - }, [isLoading, hackathonLoading, isLoadingMySubmission, currentHackathon]); + }, [isLoading, hackathonLoading, isLoadingMySubmission]); - if (!hasInitialLoaded || !currentHackathon) { + if (!hasInitialLoaded) { return ; } + if (!currentHackathon) { + return ( +
+

Hackathon Not Found

+ +
+ ); + } + return (
@@ -115,6 +126,8 @@ export default function SubmitProjectPage({ introduction: mySubmission.introduction, links: mySubmission.links, participationType: (mySubmission as any).participationType, + teamName: (mySubmission as any).teamName, + teamMembers: (mySubmission as any).teamMembers, } : undefined } diff --git a/components/hackathons/submissions/SubmissionForm.tsx b/components/hackathons/submissions/SubmissionForm.tsx index 1604189b..12d5273f 100644 --- a/components/hackathons/submissions/SubmissionForm.tsx +++ b/components/hackathons/submissions/SubmissionForm.tsx @@ -34,6 +34,7 @@ import { ExpandableScreenContent, ExpandableScreenTrigger, useExpandableScreen, + useOptionalExpandableScreen, } from '@/components/ui/expandable-screen'; import Stepper from '@/components/stepper/Stepper'; import { uploadService } from '@/lib/api/upload'; @@ -201,16 +202,9 @@ const SubmissionFormContent: React.FC = ({ onSuccess, onClose, }) => { - // Use context carefully since it might not be available when used standalone - let collapse = () => {}; - let open = true; - try { - const expandableCtx = useExpandableScreen(); - collapse = expandableCtx.collapse; - open = expandableCtx.isExpanded; - } catch (e) { - // Standalone mode, not in ExpandableScreen - } + const expandableCtx = useOptionalExpandableScreen(); + const collapse = expandableCtx?.collapse ?? (() => {}); + const open = expandableCtx?.isExpanded ?? true; const { currentHackathon } = useHackathonData(); const { user } = useAuthStatus(); @@ -416,7 +410,7 @@ const SubmissionFormContent: React.FC = ({ description: 'An intelligent task management application that uses machine learning to prioritize tasks...', logo: '', - videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + videoUrl: 'https://youtu.be/rOSZhhblE_8?si=Hf_YvPTMmyWTUOKQ', introduction: 'This project leverages advanced AI algorithms...', links: [ { type: 'github', url: 'https://github.com/example/ai-task-manager' }, @@ -785,12 +779,14 @@ const SubmissionFormContent: React.FC = ({ } else { await create(submissionData); } - if (onClose) { + + if (onSuccess) { + onSuccess(); + } else if (onClose) { onClose(); } else { collapse(); } - onSuccess?.(); } catch { // Error handled in hook } diff --git a/components/hackathons/submissions/submissionCard.tsx b/components/hackathons/submissions/submissionCard.tsx index 1f0d6e59..f5b51162 100644 --- a/components/hackathons/submissions/submissionCard.tsx +++ b/components/hackathons/submissions/submissionCard.tsx @@ -127,6 +127,16 @@ const SubmissionCard = ({ return formatDistanceToNow(new Date(dateString), { addSuffix: true }); }; + const handleEditSelect = (e: Event) => { + e.stopPropagation(); + onEditClick?.(); + }; + + const handleDeleteSelect = (e: Event) => { + e.stopPropagation(); + onDeleteClick?.(); + }; + return (
@@ -181,14 +192,14 @@ const SubmissionCard = ({ className='border-gray-800 bg-black text-white' > onEditClick?.()} + onSelect={handleEditSelect} className='cursor-pointer text-gray-300 focus:bg-gray-800 focus:text-white' > Edit Submission onDeleteClick?.()} + onSelect={handleDeleteSelect} className='cursor-pointer text-red-500 focus:bg-red-900/20 focus:text-red-400' > diff --git a/components/hackathons/submissions/submissionTab.tsx b/components/hackathons/submissions/submissionTab.tsx index 1fea7d3a..29193c83 100644 --- a/components/hackathons/submissions/submissionTab.tsx +++ b/components/hackathons/submissions/submissionTab.tsx @@ -277,6 +277,7 @@ const SubmissionTabContent: React.FC = ({ {/* Submissions Grid with Create Button if no submission */} {!isLoadingMySubmission && + !isHackathonDataLoading && !mySubmission && isAuthenticated && isRegistered && diff --git a/components/organization/hackathons/settings/GeneralSettingsTab.tsx b/components/organization/hackathons/settings/GeneralSettingsTab.tsx index e0b2ce74..1c1e84e6 100644 --- a/components/organization/hackathons/settings/GeneralSettingsTab.tsx +++ b/components/organization/hackathons/settings/GeneralSettingsTab.tsx @@ -55,6 +55,8 @@ interface GeneralSettingsTabProps { isPublished?: boolean; } +import TurndownService from 'turndown'; + export default function GeneralSettingsTab({ organizationId, hackathonId, @@ -78,6 +80,24 @@ export default function GeneralSettingsTab({ address: string; } | null>(null); + // Normalize HTML to Markdown for existing descriptions + const normalizedDescription = React.useMemo(() => { + let desc = initialData?.description || ''; + if (desc && /<[a-z][\\s\\S]*>/i.test(desc)) { + try { + const turndownService = new TurndownService({ + headingStyle: 'atx', + codeBlockStyle: 'fenced', + bulletListMarker: '-', + }); + desc = turndownService.turndown(desc); + } catch (err) { + console.error('Failed to convert HTML to Markdown', err); + } + } + return desc; + }, [initialData?.description]); + const form = useForm({ resolver: zodResolver(infoSchema), defaultValues: { @@ -85,7 +105,7 @@ export default function GeneralSettingsTab({ tagline: initialData?.tagline || '', slug: initialData?.slug || '', banner: initialData?.banner || '', - description: initialData?.description || '', + description: normalizedDescription, categories: Array.isArray(initialData?.categories) ? initialData.categories : [], diff --git a/components/ui/expandable-screen.tsx b/components/ui/expandable-screen.tsx index 7eaf54e9..4894a27b 100644 --- a/components/ui/expandable-screen.tsx +++ b/components/ui/expandable-screen.tsx @@ -34,6 +34,10 @@ function useExpandableScreen() { return context; } +export function useOptionalExpandableScreen() { + return useContext(ExpandableScreenContext); +} + // Root Component interface ExpandableScreenProps { children: ReactNode; diff --git a/hooks/hackathon/use-participants.ts b/hooks/hackathon/use-participants.ts index f6fe782b..45e43122 100644 --- a/hooks/hackathon/use-participants.ts +++ b/hooks/hackathon/use-participants.ts @@ -12,17 +12,17 @@ export function useParticipants() { const [apiParticipants, setApiParticipants] = useState([]); const [isLoading, setIsLoading] = useState(false); - const hackathonId = currentHackathon?.id || (params?.slug as string); + const hackathonId = currentHackathon?.id; // Fetch teams to get accurate team info and roles useEffect(() => { if (hackathonId) { setIsLoading(true); - Promise.all([ - getTeamPosts(hackathonId, { limit: 50 }), - getHackathonParticipants(hackathonId, { limit: 100 }), - ]) - .then(([teamsResponse, participantsResponse]) => { + + const fetchAllData = async () => { + try { + // Fetch teams + const teamsResponse = await getTeamPosts(hackathonId, { limit: 50 }); if (teamsResponse.success && teamsResponse.data) { const teamsArray = (teamsResponse.data as any).teams || @@ -30,16 +30,35 @@ export function useParticipants() { setTeams(teamsArray); } - if (participantsResponse.success && participantsResponse.data) { - setApiParticipants(participantsResponse.data.participants || []); + // Fetch participants with pagination + let allParticipants: any[] = []; + let page = 1; + let hasMore = true; + + while (hasMore) { + const participantsResponse = await getHackathonParticipants( + hackathonId, + { limit: 100, page } + ); + if (participantsResponse.success && participantsResponse.data) { + const newParticipants = + participantsResponse.data.participants || []; + allParticipants = [...allParticipants, ...newParticipants]; + hasMore = participantsResponse.data.pagination?.hasNext || false; + page++; + } else { + hasMore = false; + } } - }) - .catch(err => { + setApiParticipants(allParticipants); + } catch (err) { reportError(err, { context: 'participants-fetchData', hackathonId }); - }) - .finally(() => { + } finally { setIsLoading(false); - }); + } + }; + + fetchAllData(); } }, [hackathonId]); diff --git a/lib/providers/hackathonProvider.tsx b/lib/providers/hackathonProvider.tsx index e6b26fbb..e21a12c1 100644 --- a/lib/providers/hackathonProvider.tsx +++ b/lib/providers/hackathonProvider.tsx @@ -99,7 +99,7 @@ interface HackathonDataContextType { getHackathonById: (id: string) => Hackathon | undefined; getHackathonBySlug: (slug: string) => Promise; - setCurrentHackathon: (slug: string) => void; + setCurrentHackathon: (slug: string) => Promise; addDiscussion: (content: string) => Promise; addReply: (parentCommentId: string, content: string) => Promise; diff --git a/package-lock.json b/package-lock.json index 32c85f04..74be24bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -125,6 +125,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "three": "^0.180.0", + "turndown": "^7.2.2", "tw-animate-css": "^1.3.6", "uuid": "^13.0.0", "vaul": "^1.1.2", @@ -137,6 +138,7 @@ "@types/p-limit": "^2.1.0", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", + "@types/turndown": "^5.0.6", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.38.0", "@typescript-eslint/parser": "^8.38.0", @@ -1609,6 +1611,12 @@ "langium": "^4.0.0" } }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "license": "BSD-2-Clause" + }, "node_modules/@monogrid/gainmap-js": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", @@ -6352,6 +6360,13 @@ "license": "MIT", "optional": true }, + "node_modules/@types/turndown": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz", + "integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -16839,6 +16854,15 @@ } } }, + "node_modules/turndown": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz", + "integrity": "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==", + "license": "MIT", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + } + }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", diff --git a/package.json b/package.json index 8e73c10a..a557459e 100644 --- a/package.json +++ b/package.json @@ -141,6 +141,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "three": "^0.180.0", + "turndown": "^7.2.2", "tw-animate-css": "^1.3.6", "uuid": "^13.0.0", "vaul": "^1.1.2", @@ -153,6 +154,7 @@ "@types/p-limit": "^2.1.0", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", + "@types/turndown": "^5.0.6", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.38.0", "@typescript-eslint/parser": "^8.38.0", From 08b85d26b46372f01919460f78331d43a7d82131 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Mon, 9 Mar 2026 01:41:40 +0100 Subject: [PATCH 10/18] fix: fix coderabbit corrections --- app/me/projects/page.tsx | 34 ++++------- components/profile/ProjectsTab.tsx | 52 ++++++++--------- components/profile/ProjectsTabPublic.tsx | 35 +++++------ features/projects/components/ProjectCard.tsx | 61 +++++++++++++------- 4 files changed, 87 insertions(+), 95 deletions(-) diff --git a/app/me/projects/page.tsx b/app/me/projects/page.tsx index 733415f2..fd6c819b 100644 --- a/app/me/projects/page.tsx +++ b/app/me/projects/page.tsx @@ -116,29 +116,19 @@ export default function MyProjectsPage() { ); })} diff --git a/components/profile/ProjectsTab.tsx b/components/profile/ProjectsTab.tsx index 9c1b1914..4758ce18 100644 --- a/components/profile/ProjectsTab.tsx +++ b/components/profile/ProjectsTab.tsx @@ -103,11 +103,10 @@ export default function ProjectsTab({ user }: ProjectsTabProps) { {projects.map(project => { // Find if this project is a hackathon submission // We need to check if the user object has submissions - const submission = ( - user.user as any - ).hackathonSubmissionsAsParticipant?.find( - (s: any) => s.projectId === project.id - ); + const submission = + user.user.hackathonSubmissionsAsParticipant?.find( + s => s.projectId === project.id + ); // If it's a submission, use the submission ID for the URL and add ?type=submission const displayId = submission?.id || project.id; @@ -116,33 +115,28 @@ export default function ProjectsTab({ user }: ProjectsTabProps) { : `/projects/${displayId}`; return ( - + ); diff --git a/components/profile/ProjectsTabPublic.tsx b/components/profile/ProjectsTabPublic.tsx index 89aec0b2..ccbf7194 100644 --- a/components/profile/ProjectsTabPublic.tsx +++ b/components/profile/ProjectsTabPublic.tsx @@ -50,29 +50,20 @@ export default function ProjectsTabPublic({ user }: ProjectsTabProps) { ); diff --git a/features/projects/components/ProjectCard.tsx b/features/projects/components/ProjectCard.tsx index c6f0e247..155c4ec3 100644 --- a/features/projects/components/ProjectCard.tsx +++ b/features/projects/components/ProjectCard.tsx @@ -3,10 +3,36 @@ import { formatNumber, cn } from '@/lib/utils'; import { useRouter } from 'nextjs-toploader/app'; import Image from 'next/image'; import { CountdownTimer } from '@/components/ui/timer'; -import { Crowdfunding } from '@/features/projects/types'; + +export type ProjectCardData = { + id: string; + slug: string; + project: { + id?: string; + title: string; + vision?: string | null; + banner?: string | null; + logo?: string | null; + creator?: { name: string; image?: string | null }; + category?: string | null; + status: string; + _count?: { votes?: number }; + [key: string]: any; + }; + fundingGoal?: number; + fundingRaised?: number; + fundingCurrency?: string; + fundingEndDate?: string | null; + milestones?: any[]; + voteGoal?: number; + voteProgress?: number; + isSubmission?: boolean; + submissionStatus?: string | null; + [key: string]: any; +}; type ProjectCardProps = { - data: Crowdfunding; + data: ProjectCardData; newTab?: boolean; isFullWidth?: boolean; className?: string; @@ -23,13 +49,15 @@ function ProjectCard({ const { slug, project, - fundingGoal, - fundingRaised, - fundingCurrency, - fundingEndDate, - milestones, - voteGoal, - voteProgress, + fundingGoal = 0, + fundingRaised = 0, + fundingCurrency = 'USDC', + fundingEndDate = null, + milestones = [], + voteGoal = 0, + voteProgress = 0, + isSubmission, + submissionStatus, } = data; const { @@ -47,8 +75,7 @@ function ProjectCard({ banner || '/images/placeholders/project-banner-placeholder.png'; const handleClick = () => { - // @ts-expect-error - Custom property added in mapping - const url = data.isSubmission + const url = isSubmission ? `/projects/${slug}?type=submission` : `/projects/${slug}`; router.push(url); @@ -56,17 +83,7 @@ function ProjectCard({ // Determine display status const getDisplayStatus = () => { - console.log('DEBUG: ProjectCard data:', { - title, - id: data.id, - slug: data.slug, - isSubmission: (data as any).isSubmission, - submissionStatus: (data as any).submissionStatus, - }); - // @ts-expect-error - Custom property added in mapping - if (data.isSubmission) { - // @ts-expect-error - Custom property added in mapping - const submissionStatus = data.submissionStatus; + if (isSubmission) { if (submissionStatus === 'SHORTLISTED' || submissionStatus === 'ACCEPTED') return 'Shortlisted'; if (submissionStatus === 'SUBMITTED') return 'Submitted'; From fb3e14896433e7477dc57ee269fed646bb97caf8 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Mon, 9 Mar 2026 02:10:16 +0100 Subject: [PATCH 11/18] chore: write boundless on x challenge blog --- ...ou-know-about-boundless-on-x-challenge.mdx | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 content/blog/what-do-you-know-about-boundless-on-x-challenge.mdx diff --git a/content/blog/what-do-you-know-about-boundless-on-x-challenge.mdx b/content/blog/what-do-you-know-about-boundless-on-x-challenge.mdx new file mode 100644 index 00000000..baf6dc81 --- /dev/null +++ b/content/blog/what-do-you-know-about-boundless-on-x-challenge.mdx @@ -0,0 +1,157 @@ +--- +title: 'What Do You Know About Boundless? The X Challenge' +excerpt: 'Whether you’re a builder, creator, or meme-lover, the Boundless on X Challenge is your chance to explain the platform in your own words. Share your thoughts and win a share of the $100 USDC prize pool!' +coverImage: 'https://pbs.twimg.com/media/HCwbCFTWoAAdJkq?format=jpg&name=900x900' +publishedAt: '2026-03-09' +author: + name: 'Boundless Team' + image: '' +categories: ['Community', 'Challenge', 'Events'] +tags: + ['boundless', 'challenge', 'x', 'twitter', 'community', 'stellar', 'rewards'] +readingTime: 3 +isFeatured: true +seoTitle: 'What Do You Know About Boundless? Join the X Challenge' +seoDescription: 'Join the Boundless on X Challenge! Explain the platform in your own words through a tweet, thread, or meme, and win your share of $100 USDC.' +--- + +# What Do You Know About Boundless? + +We want to hear your take. + +Whether you’re a builder, creator, or meme-lover, the **Boundless on X Challenge** is your chance to explain the platform in your own words. Share a tweet, thread, or visual post, and the best entries will help shape how the world understands Boundless — and win some **USDC**! + +--- + +## So, What’s the Boundless on X Challenge All About? + +Our mission at **Boundless** is simple: to empower anyone, anywhere, to transform bold ideas into impactful projects with **transparency, community, and accountability** at the core. + +Ecosystems grow through conversation. The more people understand a platform, the easier it is for new builders to join and for great ideas to gain traction. The **Boundless on X Challenge** is your chance to contribute by telling our story in your own way. + +If you’re reading this, you’re probably researching to participate in the challenge. + +Awesome — we’re thrilled to have you. Stick around until the end of this article and you’ll get some **insider tips** to help your post stand out. + +--- + +# Finish the Challenge in Three Simple Steps + +Completing this challenge only takes **three steps** and a couple of minutes. + +## 1. Register and Follow Boundless + +To participate, go to **[boundlesfi.xyz](https://www.boundlessfi.xyz/hackathons/boundless-on-x-test?tab=participants)** and register for **Boundless on X**. + +This is a community challenge, so join the community on **X (formerly Twitter)** `@boundless_fi` and on **Discord** to meet the community. + +--- + +## 2. Choose Your Category and Create Your Post + +Share an impactful and/or entertaining **tweet, thread, or meme** about Boundless. + +**Must-haves:** + +- Your post must include **#BoundlessBuild** +- Tag both **@boundless_fi** and **@BuildOnStellar** + +--- + +## 3. Submit Your Entry + +Copy the link to your post and submit it through the **challenge page** with a short description. + +**Done.** + +--- + +# Categories You Can Join + +You can join the challenge by participating in **any of these categories**. Pick the format that best suits your creative style. + +## Threads + +Share a short, engaging **thread of 3–5 tweets** that explains Boundless or tells a story about the platform. + +## Single Tweets + +Write **one clear, impactful, concise, or clever tweet** that captures what Boundless is all about. + +## Memes or Visuals + +Get creative with an **image, graphic, or short video** that explains or promotes Boundless in a fun way. + +Winners will be selected **in each category**, along with **one overall standout entry** that really captures the spirit of the challenge. + +--- + +# What Are the Best Participants Creating? + +Top entries are the ones that: + +- Explain **Boundless** like you’re telling a friend who’s never heard of it. +- Highlight **why Boundless’ mission matters**. +- Share **what excites you most about the ecosystem**. +- Bring the idea to life with **humor, visuals, or storytelling**. + +No single approach is _“correct,”_ but these tips can help your post stand out: + +- **Keep it personal.** Your voice matters more than AI-generated material — use it sparingly or not at all. +- **Clarity over virality.** Likes and retweets are nice, but a clear, compelling explanation carries the most weight. +- **Be authentic.** Show your enthusiasm and perspective; genuine posts resonate more with the community. + +--- + +# Prizes and Key Dates + +The challenge features a **$100 USDC prize pool**, shared by the top entries selected from across all categories. + +Winning entries may also be **highlighted across the Boundless community**. + +The challenge runs for a short period, so make sure to submit your entry before the deadline: + +- **Challenge kicks off:** Saturday, March 7, 2026 +- **Last chance to submit:** Wednesday, March 11, 2026 at **12:00 PM UTC** + +--- + +# Got Questions? We’ve Got Answers + +## Who can participate? + +Anyone can join. If you have an **X account** and want to share something about Boundless, you’re welcome to take part. + +## Do I need to be a developer? + +Not at all. The challenge is open to **builders, creators, writers, designers, meme makers**, and anyone interested in the ecosystem. + +## Can I participate in more than one category? + +Yes. You can submit entries in **multiple categories**. + +For example, you might create a thread explaining Boundless and also submit a meme or visual. Each entry should be **submitted separately** with its own link and description. + +## What’s off-limits? + +**NSFW, offensive, or harmful content** is a no-go. Stick to posts that **inform, entertain, or inspire** in a way everyone can enjoy. + +## Want to ask more? + +If you have questions that aren’t answered here, don’t be shy. + +**[Tag us on X](https://x.com/boundless_fi)** or **[Start a conversation in Discord](https://discord.gg/tgpFpSHG)**. We love hearing from the community. + +--- + +# Ready to Jump In? + +Your voice matters. + +Create your post, submit it, and share your excitement about **Boundless** — for the love of the game, for a bit of glory, and for the chance to win some fun rewards too! + +Finally, as promised, here are some **insider resources** to help your post stand out and guide you through the challenge: + +- **[Quick start guide to join Boundless](https://docs.boundlessfi.xyz/getting-started/quick-start)** +- **[How Boundless works](https://docs.boundlessfi.xyz/concepts/how-boundless-works)** +- **[General FAQs](https://docs.boundlessfi.xyz/faq/general)** From 3408994d7a94f5aae53715d90c12a891e936e7a6 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Mon, 9 Mar 2026 02:16:05 +0100 Subject: [PATCH 12/18] fix: remove blog --- ...ou-know-about-boundless-on-x-challenge.mdx | 157 ------------------ 1 file changed, 157 deletions(-) delete mode 100644 content/blog/what-do-you-know-about-boundless-on-x-challenge.mdx diff --git a/content/blog/what-do-you-know-about-boundless-on-x-challenge.mdx b/content/blog/what-do-you-know-about-boundless-on-x-challenge.mdx deleted file mode 100644 index baf6dc81..00000000 --- a/content/blog/what-do-you-know-about-boundless-on-x-challenge.mdx +++ /dev/null @@ -1,157 +0,0 @@ ---- -title: 'What Do You Know About Boundless? The X Challenge' -excerpt: 'Whether you’re a builder, creator, or meme-lover, the Boundless on X Challenge is your chance to explain the platform in your own words. Share your thoughts and win a share of the $100 USDC prize pool!' -coverImage: 'https://pbs.twimg.com/media/HCwbCFTWoAAdJkq?format=jpg&name=900x900' -publishedAt: '2026-03-09' -author: - name: 'Boundless Team' - image: '' -categories: ['Community', 'Challenge', 'Events'] -tags: - ['boundless', 'challenge', 'x', 'twitter', 'community', 'stellar', 'rewards'] -readingTime: 3 -isFeatured: true -seoTitle: 'What Do You Know About Boundless? Join the X Challenge' -seoDescription: 'Join the Boundless on X Challenge! Explain the platform in your own words through a tweet, thread, or meme, and win your share of $100 USDC.' ---- - -# What Do You Know About Boundless? - -We want to hear your take. - -Whether you’re a builder, creator, or meme-lover, the **Boundless on X Challenge** is your chance to explain the platform in your own words. Share a tweet, thread, or visual post, and the best entries will help shape how the world understands Boundless — and win some **USDC**! - ---- - -## So, What’s the Boundless on X Challenge All About? - -Our mission at **Boundless** is simple: to empower anyone, anywhere, to transform bold ideas into impactful projects with **transparency, community, and accountability** at the core. - -Ecosystems grow through conversation. The more people understand a platform, the easier it is for new builders to join and for great ideas to gain traction. The **Boundless on X Challenge** is your chance to contribute by telling our story in your own way. - -If you’re reading this, you’re probably researching to participate in the challenge. - -Awesome — we’re thrilled to have you. Stick around until the end of this article and you’ll get some **insider tips** to help your post stand out. - ---- - -# Finish the Challenge in Three Simple Steps - -Completing this challenge only takes **three steps** and a couple of minutes. - -## 1. Register and Follow Boundless - -To participate, go to **[boundlesfi.xyz](https://www.boundlessfi.xyz/hackathons/boundless-on-x-test?tab=participants)** and register for **Boundless on X**. - -This is a community challenge, so join the community on **X (formerly Twitter)** `@boundless_fi` and on **Discord** to meet the community. - ---- - -## 2. Choose Your Category and Create Your Post - -Share an impactful and/or entertaining **tweet, thread, or meme** about Boundless. - -**Must-haves:** - -- Your post must include **#BoundlessBuild** -- Tag both **@boundless_fi** and **@BuildOnStellar** - ---- - -## 3. Submit Your Entry - -Copy the link to your post and submit it through the **challenge page** with a short description. - -**Done.** - ---- - -# Categories You Can Join - -You can join the challenge by participating in **any of these categories**. Pick the format that best suits your creative style. - -## Threads - -Share a short, engaging **thread of 3–5 tweets** that explains Boundless or tells a story about the platform. - -## Single Tweets - -Write **one clear, impactful, concise, or clever tweet** that captures what Boundless is all about. - -## Memes or Visuals - -Get creative with an **image, graphic, or short video** that explains or promotes Boundless in a fun way. - -Winners will be selected **in each category**, along with **one overall standout entry** that really captures the spirit of the challenge. - ---- - -# What Are the Best Participants Creating? - -Top entries are the ones that: - -- Explain **Boundless** like you’re telling a friend who’s never heard of it. -- Highlight **why Boundless’ mission matters**. -- Share **what excites you most about the ecosystem**. -- Bring the idea to life with **humor, visuals, or storytelling**. - -No single approach is _“correct,”_ but these tips can help your post stand out: - -- **Keep it personal.** Your voice matters more than AI-generated material — use it sparingly or not at all. -- **Clarity over virality.** Likes and retweets are nice, but a clear, compelling explanation carries the most weight. -- **Be authentic.** Show your enthusiasm and perspective; genuine posts resonate more with the community. - ---- - -# Prizes and Key Dates - -The challenge features a **$100 USDC prize pool**, shared by the top entries selected from across all categories. - -Winning entries may also be **highlighted across the Boundless community**. - -The challenge runs for a short period, so make sure to submit your entry before the deadline: - -- **Challenge kicks off:** Saturday, March 7, 2026 -- **Last chance to submit:** Wednesday, March 11, 2026 at **12:00 PM UTC** - ---- - -# Got Questions? We’ve Got Answers - -## Who can participate? - -Anyone can join. If you have an **X account** and want to share something about Boundless, you’re welcome to take part. - -## Do I need to be a developer? - -Not at all. The challenge is open to **builders, creators, writers, designers, meme makers**, and anyone interested in the ecosystem. - -## Can I participate in more than one category? - -Yes. You can submit entries in **multiple categories**. - -For example, you might create a thread explaining Boundless and also submit a meme or visual. Each entry should be **submitted separately** with its own link and description. - -## What’s off-limits? - -**NSFW, offensive, or harmful content** is a no-go. Stick to posts that **inform, entertain, or inspire** in a way everyone can enjoy. - -## Want to ask more? - -If you have questions that aren’t answered here, don’t be shy. - -**[Tag us on X](https://x.com/boundless_fi)** or **[Start a conversation in Discord](https://discord.gg/tgpFpSHG)**. We love hearing from the community. - ---- - -# Ready to Jump In? - -Your voice matters. - -Create your post, submit it, and share your excitement about **Boundless** — for the love of the game, for a bit of glory, and for the chance to win some fun rewards too! - -Finally, as promised, here are some **insider resources** to help your post stand out and guide you through the challenge: - -- **[Quick start guide to join Boundless](https://docs.boundlessfi.xyz/getting-started/quick-start)** -- **[How Boundless works](https://docs.boundlessfi.xyz/concepts/how-boundless-works)** -- **[General FAQs](https://docs.boundlessfi.xyz/faq/general)** From 8201463a591cf6ceeec753a27c388be63f2eae84 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Tue, 10 Mar 2026 01:02:15 +0100 Subject: [PATCH 13/18] fix: my project dashbaord count and extend hackathon deadline --- app/me/layout.tsx | 25 +++++++++++++++++++++++++ components/app-sidebar.tsx | 11 +++++++---- components/landing-page/navbar.tsx | 2 +- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/app/me/layout.tsx b/app/me/layout.tsx index 0560658f..85dabcb1 100644 --- a/app/me/layout.tsx +++ b/app/me/layout.tsx @@ -23,9 +23,14 @@ interface MeLayoutProfile { image?: string; joinedHackathons?: ProfileItemWithId[]; hackathonSubmissionsAsParticipant?: ProfileItemWithId[]; + projects?: ProfileItemWithId[]; }; image?: string; hackathonSubmissionsAsParticipant?: ProfileItemWithId[]; + projects?: ProfileItemWithId[]; + stats?: { + projectsCreated?: number; + }; } const getId = (item: ProfileItemWithId): string | undefined => @@ -69,6 +74,25 @@ const MeLayout = ({ children }: MeLayoutProps): React.ReactElement => { }).length; }, [typedProfile]); + const projectsCount = useMemo(() => { + if (!typedProfile) return 0; + // stats.projectsCreated is often the most direct count + if (typeof typedProfile.stats?.projectsCreated === 'number') { + return typedProfile.stats.projectsCreated; + } + // Fallback to array lengths + const fromUser = typedProfile.user?.projects ?? []; + const fromProfile = typedProfile.projects ?? []; + const merged = [...fromUser, ...fromProfile]; + const seen = new Set(); + return merged.filter(p => { + const id = getId(p); + if (!id || seen.has(id)) return false; + seen.add(id); + return true; + }).length; + }, [typedProfile]); + // Only show full-screen spinner on first load, not on background refetches if (isLoading && !hasLoadedOnce.current) { return ( @@ -92,6 +116,7 @@ const MeLayout = ({ children }: MeLayoutProps): React.ReactElement => { counts={{ participating: hackathonsCount, submissions: submissionsCount, + projects: projectsCount, }} variant='inset' /> diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index ec78d978..04b1fa93 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -29,11 +29,13 @@ import { import Image from 'next/image'; import Link from 'next/link'; import { useNotificationStore } from '@/lib/stores/notification-store'; +import { Logo } from './landing-page/navbar'; const getNavigationData = (counts?: { participating?: number; unreadNotifications?: number; submissions?: number; + projects?: number; }) => ({ main: [ { @@ -57,7 +59,7 @@ const getNavigationData = (counts?: { title: 'My Projects', url: '/me/projects', icon: IconFolder, - badge: '3', + badge: (counts?.projects ?? 0) > 0 ? String(counts?.projects) : undefined, }, { title: 'Create Project', @@ -132,7 +134,7 @@ export function AppSidebar({ ...props }: { user: UserData; - counts?: { participating?: number; submissions?: number }; + counts?: { participating?: number; submissions?: number; projects?: number }; } & React.ComponentProps) { const unreadNotifications = useNotificationStore(state => state.unreadCount); @@ -164,7 +166,7 @@ export function AppSidebar({ size='lg' className='group hover:bg-sidebar-accent/0 transition-all duration-200' > - + {/*
Boundless Logo
- + */} + diff --git a/components/landing-page/navbar.tsx b/components/landing-page/navbar.tsx index 930ff85f..10cddc32 100644 --- a/components/landing-page/navbar.tsx +++ b/components/landing-page/navbar.tsx @@ -124,7 +124,7 @@ export function Navbar() { ); } -function Logo() { +export function Logo() { return ( Date: Tue, 10 Mar 2026 01:02:53 +0100 Subject: [PATCH 14/18] fix: my project dashbaord count and extend hackathon deadline --- components/app-sidebar.tsx | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 04b1fa93..c4703ddb 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -166,17 +166,6 @@ export function AppSidebar({ size='lg' className='group hover:bg-sidebar-accent/0 transition-all duration-200' > - {/* -
- Boundless Logo -
- */} From 04d5abc20b9e35aac2b6eaf8ef54b3c5b2270843 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Tue, 10 Mar 2026 04:31:40 +0100 Subject: [PATCH 15/18] fix: fix auto validate wallet address and user nav --- components/app-sidebar.tsx | 4 +- components/nav-user.tsx | 57 +++++++++++++++++------- components/wallet/FamilyWalletDrawer.tsx | 53 +++++++++++++--------- 3 files changed, 77 insertions(+), 37 deletions(-) diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index c4703ddb..036deea6 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -115,8 +115,8 @@ const getNavigationData = (counts?: { url: '/me/notifications', icon: IconBell, badge: - counts?.unreadNotifications && counts.unreadNotifications > 0 - ? counts.unreadNotifications.toString() + (counts?.unreadNotifications ?? 0) > 0 + ? String(counts?.unreadNotifications) : undefined, }, ], diff --git a/components/nav-user.tsx b/components/nav-user.tsx index edcd65d0..d0d0a942 100644 --- a/components/nav-user.tsx +++ b/components/nav-user.tsx @@ -26,6 +26,9 @@ import { useSidebar, } from '@/components/ui/sidebar'; import { Badge } from './ui/badge'; +import Link from 'next/link'; +import { useAuthActions } from '@/hooks/use-auth'; +import { useNotificationStore } from '@/lib/stores/notification-store'; export interface NavUserProps { user: { @@ -37,6 +40,8 @@ export interface NavUserProps { export const NavUser = ({ user }: NavUserProps): React.ReactElement => { const { isMobile } = useSidebar(); + const { logout } = useAuthActions(); + const unreadNotifications = useNotificationStore(state => state.unreadCount); return ( @@ -68,7 +73,7 @@ export const NavUser = ({ user }: NavUserProps): React.ReactElement => { {
- + {user.name} - + {user.email}
@@ -96,22 +101,44 @@ export const NavUser = ({ user }: NavUserProps): React.ReactElement => { - - - Account Settings + + + + Account Settings + - - - Billing + + + + Billing + - - - Notifications - 3 + + + + Notifications + {unreadNotifications > 0 && ( + + {unreadNotifications} + + )} + - - + + logout()} + > Log out diff --git a/components/wallet/FamilyWalletDrawer.tsx b/components/wallet/FamilyWalletDrawer.tsx index e9e7cb67..d26fdc87 100644 --- a/components/wallet/FamilyWalletDrawer.tsx +++ b/components/wallet/FamilyWalletDrawer.tsx @@ -231,6 +231,30 @@ export function FamilyWalletDrawer({ } }, [sendDestination, sendCurrency, getErrorDisplay]); + // Auto-validate destination address + useEffect(() => { + const trimmedDest = sendDestination.trim(); + + // Reset state if empty + if (!trimmedDest) { + setValidateResult('idle'); + setValidateError(''); + return; + } + + // Immediate trigger if 56 chars + if (trimmedDest.length === 56) { + handleValidateDestination(); + return; + } + + const timer = setTimeout(() => { + handleValidateDestination(); + }, 500); + + return () => clearTimeout(timer); + }, [sendDestination, sendCurrency, handleValidateDestination]); + const handleSendSubmit = useCallback(async () => { const dest = sendDestination.trim(); const currency = sendCurrency || 'XLM'; @@ -833,37 +857,26 @@ export function FamilyWalletDrawer({ -
+
{ setSendDestination(e.target.value); - setValidateResult('idle'); - setValidateError(''); }} - className='font-mono text-sm' + className='pr-10 font-mono text-sm' /> - + ) : validateResult === 'invalid' && + sendDestination.trim().length >= 56 ? ( + + ) : null} +
{validateResult === 'invalid' && validateError && ( From cac0c04215b7addc479826269f24b49bdbfa4492 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Tue, 10 Mar 2026 12:06:10 +0100 Subject: [PATCH 16/18] fix: fix notification badge --- app/me/notifications/page.tsx | 4 ---- components/app-sidebar.tsx | 8 ++++++++ hooks/useNotifications.ts | 8 ++++++++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/app/me/notifications/page.tsx b/app/me/notifications/page.tsx index 8b7e58fa..59a71019 100644 --- a/app/me/notifications/page.tsx +++ b/app/me/notifications/page.tsx @@ -104,10 +104,6 @@ export default function NotificationsPage() { const { setUnreadCount, clearUnreadCount } = useNotificationStore(); - useEffect(() => { - setUnreadCount(unreadCount); - }, [unreadCount, setUnreadCount]); - useNotificationPolling(notificationsHook, { interval: 30000, enabled: true, diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 036deea6..1eb6d605 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -30,6 +30,8 @@ import Image from 'next/image'; import Link from 'next/link'; import { useNotificationStore } from '@/lib/stores/notification-store'; import { Logo } from './landing-page/navbar'; +import { useNotifications } from '@/hooks/useNotifications'; +import { authClient } from '@/lib/auth-client'; const getNavigationData = (counts?: { participating?: number; @@ -136,6 +138,12 @@ export function AppSidebar({ user: UserData; counts?: { participating?: number; submissions?: number; projects?: number }; } & React.ComponentProps) { + const { data: session } = authClient.useSession(); + const userId = session?.user?.id; + + // Initialize notifications hook to ensure it fetches globally and syncs with store + useNotifications(userId || undefined); + const unreadNotifications = useNotificationStore(state => state.unreadCount); const navigationData = React.useMemo( diff --git a/hooks/useNotifications.ts b/hooks/useNotifications.ts index 8493ad1f..abe31b4a 100644 --- a/hooks/useNotifications.ts +++ b/hooks/useNotifications.ts @@ -3,6 +3,7 @@ import { useSocket } from './useSocket'; import { getNotifications } from '@/lib/api/notifications'; import { Notification } from '@/types/notifications'; import { reportError } from '@/lib/error-reporting'; +import { useNotificationStore } from '@/lib/stores/notification-store'; interface UseNotificationsOptions { page?: number; @@ -46,6 +47,13 @@ export function useNotifications( const [total, setTotal] = useState(0); const [currentPage, setCurrentPage] = useState(initialPage); + const { setUnreadCount: setGlobalUnreadCount } = useNotificationStore(); + + // Sync with global store + useEffect(() => { + setGlobalUnreadCount(unreadCount); + }, [unreadCount, setGlobalUnreadCount]); + // Merge server list with current state: dedupe by id, preserve optimistic read state (short rollback path) const mergeNotifications = useCallback( (prev: Notification[], serverList: Notification[]): Notification[] => { From f9671cf78bb24f1e79a7f4989e7793c1efa3681f Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Tue, 10 Mar 2026 13:10:04 +0100 Subject: [PATCH 17/18] fix: fix coderabbit corrections --- components/app-sidebar.tsx | 2 +- components/nav-user.tsx | 10 ++++++++-- components/wallet/FamilyWalletDrawer.tsx | 12 +++++++++++- hooks/useNotifications.ts | 18 +++++++++++++++--- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 99b0d543..d4b3ac94 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -141,7 +141,7 @@ export function AppSidebar({ const userId = session?.user?.id; // Initialize notifications hook to ensure it fetches globally and syncs with store - useNotifications(userId || undefined); + useNotifications({ enabled: !!userId }); const unreadNotifications = useNotificationStore(state => state.unreadCount); diff --git a/components/nav-user.tsx b/components/nav-user.tsx index d0d0a942..df269f80 100644 --- a/components/nav-user.tsx +++ b/components/nav-user.tsx @@ -41,7 +41,13 @@ export interface NavUserProps { export const NavUser = ({ user }: NavUserProps): React.ReactElement => { const { isMobile } = useSidebar(); const { logout } = useAuthActions(); - const unreadNotifications = useNotificationStore(state => state.unreadCount); + const { unreadCount: unreadNotifications, clearUnreadCount } = + useNotificationStore(); + + const handleLogout = () => { + logout(); + clearUnreadCount(); + }; return ( @@ -137,7 +143,7 @@ export const NavUser = ({ user }: NavUserProps): React.ReactElement => { logout()} + onClick={handleLogout} > Log out diff --git a/components/wallet/FamilyWalletDrawer.tsx b/components/wallet/FamilyWalletDrawer.tsx index d26fdc87..9f49efac 100644 --- a/components/wallet/FamilyWalletDrawer.tsx +++ b/components/wallet/FamilyWalletDrawer.tsx @@ -212,6 +212,10 @@ export function FamilyWalletDrawer({ setValidateResult('idle'); try { const result = await validateSendDestination(dest, currency); + + // Protect against stale responses if the user has changed the destination + if (sendDestination.trim() !== dest) return; + if (result.valid) { setValidateResult('valid'); setValidateError(''); @@ -222,12 +226,18 @@ export function FamilyWalletDrawer({ ); } } catch (err: unknown) { + // Protect against stale responses + if (sendDestination.trim() !== dest) return; + const { message, details } = getErrorDisplay(err); setValidateResult('invalid'); setValidateError(message); setValidateErrorDetails(details); } finally { - setValidateLoading(false); + // We still want to clear loading if it's the latest call + if (sendDestination.trim() === dest) { + setValidateLoading(false); + } } }, [sendDestination, sendCurrency, getErrorDisplay]); diff --git a/hooks/useNotifications.ts b/hooks/useNotifications.ts index abe31b4a..953a504a 100644 --- a/hooks/useNotifications.ts +++ b/hooks/useNotifications.ts @@ -9,6 +9,7 @@ interface UseNotificationsOptions { page?: number; limit?: number; autoFetch?: boolean; + enabled?: boolean; } export interface UseNotificationsReturn { @@ -33,11 +34,17 @@ export function useNotifications( // Handle overloaded arguments const userId = typeof input === 'string' ? input : undefined; const options = typeof input === 'object' ? input : {}; - const { page: initialPage = 1, limit = 10, autoFetch = true } = options; + const { + page: initialPage = 1, + limit = 10, + autoFetch = true, + enabled = true, + } = options; const { socket, isConnected } = useSocket({ namespace: '/notifications', userId, + autoConnect: enabled && autoFetch, }); const [notifications, setNotifications] = useState([]); @@ -46,13 +53,16 @@ export function useNotifications( const [error, setError] = useState(null); const [total, setTotal] = useState(0); const [currentPage, setCurrentPage] = useState(initialPage); + const [hasFetched, setHasFetched] = useState(false); const { setUnreadCount: setGlobalUnreadCount } = useNotificationStore(); // Sync with global store useEffect(() => { - setGlobalUnreadCount(unreadCount); - }, [unreadCount, setGlobalUnreadCount]); + if (hasFetched) { + setGlobalUnreadCount(unreadCount); + } + }, [unreadCount, setGlobalUnreadCount, hasFetched]); // Merge server list with current state: dedupe by id, preserve optimistic read state (short rollback path) const mergeNotifications = useCallback( @@ -87,6 +97,7 @@ export function useNotifications( mergeNotifications(prev, response.notifications) ); setTotal(response.total || 0); + setHasFetched(true); } } catch (err) { reportError(err, { context: 'notifications-fetch' }); @@ -150,6 +161,7 @@ export function useNotifications( // Listen for unread count updates const handleUnreadCount = (data: { count: number }) => { setUnreadCount(data.count); + setHasFetched(true); }; // Listen for notification updates From 62b0b1993b93f88d05e6baeed6f22ea3fad1e252 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Tue, 10 Mar 2026 22:39:49 +0100 Subject: [PATCH 18/18] fix: fix pagination in organization participants page --- .../[hackathonId]/participants/page.tsx | 29 +++++++++-------- hooks/use-hackathons.ts | 32 +++++++++++-------- 2 files changed, 34 insertions(+), 27 deletions(-) diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx index 832add8f..b5a17ef1 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx @@ -53,6 +53,7 @@ const ParticipantsPage: React.FC = () => { const hackathonId = params.hackathonId as string; const [view, setView] = useState<'table' | 'grid'>('table'); + const [pageSize, setPageSize] = useState(PAGE_SIZE); const [filters, setFilters] = useState({ search: '', status: 'all', @@ -63,9 +64,9 @@ const ParticipantsPage: React.FC = () => { () => ({ organizationId, autoFetch: false, - pageSize: PAGE_SIZE, // Grid looks better with multiples of 3/4 + pageSize, // Use dynamic page size }), - [organizationId] + [organizationId, pageSize] ); const { @@ -110,7 +111,7 @@ const ParticipantsPage: React.FC = () => { fetchParticipants( actualHackathonId, 1, - PAGE_SIZE, + pageSize, mapFiltersToParams(filters, debouncedSearch) ); } @@ -120,6 +121,7 @@ const ParticipantsPage: React.FC = () => { debouncedSearch, filters.status, filters.type, + pageSize, ]); // Statistics @@ -161,12 +163,12 @@ const ParticipantsPage: React.FC = () => { }, [organizationId, actualHackathonId]); // Handlers - const handlePageChange = (page: number) => { + const handlePageChange = (page: number, limit?: number) => { if (actualHackathonId) { fetchParticipants( actualHackathonId, page, - PAGE_SIZE, + limit ?? pageSize, mapFiltersToParams(filters, debouncedSearch) ); } @@ -220,7 +222,7 @@ const ParticipantsPage: React.FC = () => { fetchParticipants( actualHackathonId, participantsPagination.currentPage, - PAGE_SIZE, + pageSize, mapFiltersToParams(filters, debouncedSearch) ); } @@ -241,16 +243,17 @@ const ParticipantsPage: React.FC = () => { }, onPaginationChange: updater => { if (typeof updater === 'function') { - const newState = ( - updater as (old: { pageIndex: number; pageSize: number }) => { - pageIndex: number; - pageSize: number; - } - )({ + const newState = updater({ pageIndex: participantsPagination.currentPage - 1, pageSize: participantsPagination.itemsPerPage, }); - handlePageChange(newState.pageIndex + 1); + + if (newState.pageSize !== participantsPagination.itemsPerPage) { + setPageSize(newState.pageSize); + handlePageChange(1, newState.pageSize); + } else { + handlePageChange(newState.pageIndex + 1); + } } }, }); diff --git a/hooks/use-hackathons.ts b/hooks/use-hackathons.ts index 9679b970..4b56eff7 100644 --- a/hooks/use-hackathons.ts +++ b/hooks/use-hackathons.ts @@ -228,17 +228,19 @@ export function useHackathons( organizationId, // Add organization filter }); + const pagination = (response.data?.pagination || + response.meta?.pagination) as any; setHackathons(response.data?.hackathons || []); setHackathonsPagination({ - currentPage: response.data?.pagination.page || 1, - totalPages: response.data?.pagination.totalPages || 1, - totalItems: response.data?.pagination.total || 0, - itemsPerPage: response.data?.pagination.limit || pageSize, - hasNext: response.data?.pagination.hasNext || false, - hasPrev: response.data?.pagination.hasPrev || false, + currentPage: pagination?.page || 1, + totalPages: pagination?.totalPages || 1, + totalItems: pagination?.total || 0, + itemsPerPage: pagination?.limit || pageSize, + hasNext: !!pagination?.hasNext, + hasPrev: !!pagination?.hasPrev, }); // Update ref immediately - hackathonsPageRef.current = response.data?.pagination.page || 1; + hackathonsPageRef.current = pagination?.page || 1; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to fetch hackathons'; @@ -575,16 +577,18 @@ export function useHackathons( filters ?? initialParticipantFilters ); + const pagination = (response.data?.pagination || + response.meta?.pagination) as any; setParticipants(response.data?.participants || []); setParticipantsPagination({ - currentPage: response.data?.pagination.page || 1, - totalPages: response.data?.pagination.totalPages || 1, - totalItems: response.data?.pagination.total || 0, - itemsPerPage: response.data?.pagination.limit || pageSize, - hasNext: response.data?.pagination.hasNext || false, - hasPrev: response.data?.pagination.hasPrev || false, + currentPage: pagination?.page || 1, + totalPages: pagination?.totalPages || 1, + totalItems: pagination?.total || 0, + itemsPerPage: pagination?.limit || pageSize, + hasNext: !!pagination?.hasNext, + hasPrev: !!pagination?.hasPrev, }); - participantsPageRef.current = response.data?.pagination.page || 1; + participantsPageRef.current = pagination?.page || 1; } catch (error) { const errorMessage = error instanceof Error