Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 17 additions & 14 deletions app/(landing)/hackathons/[slug]/teams/[teamId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Link from 'next/link';
import { useHackathon, useTeam } from '@/hooks/hackathon/use-hackathon-queries';
import { Button } from '@/components/ui/button';
import BasicAvatar from '@/components/avatars/BasicAvatar';
import { readTeamContact } from '@/lib/api/hackathons/teams';
import {
ArrowLeft,
Calendar,
Expand Down Expand Up @@ -227,22 +228,24 @@ export default function TeamDetailsPage({
</div>
)}

{team.contactInfo && (
<div className='rounded-2xl border border-white/5 bg-[#0A0B0D] p-6'>
<h2 className='mb-4 text-[10px] font-black tracking-[0.2em] text-[#555555] uppercase'>
Contact
</h2>
<div className='flex items-start gap-3 text-sm text-gray-300'>
<Mail className='mt-0.5 h-4 w-4 shrink-0 text-gray-500' />
<span className='break-all'>{team.contactInfo}</span>
</div>
{team.contactMethod && (
{(() => {
const contact = readTeamContact(team.contactInfo);
if (!contact) return null;
return (
<div className='rounded-2xl border border-white/5 bg-[#0A0B0D] p-6'>
<h2 className='mb-4 text-[10px] font-black tracking-[0.2em] text-[#555555] uppercase'>
Contact
</h2>
<div className='flex items-start gap-3 text-sm text-gray-300'>
<Mail className='mt-0.5 h-4 w-4 shrink-0 text-gray-500' />
<span className='break-all'>{contact.value}</span>
</div>
<p className='mt-2 text-[10px] font-bold tracking-wider text-gray-500 uppercase'>
Via {team.contactMethod}
Via {contact.method}
</p>
)}
</div>
)}
</div>
);
})()}
</div>
</div>
</div>
Expand Down
25 changes: 15 additions & 10 deletions components/hackathons/team-formation/ContactTeamModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import {
ExternalLink,
Check,
} from 'lucide-react';
import { TeamRecruitmentPost } from '@/lib/api/hackathons/teams';
import {
readTeamContact,
TeamRecruitmentPost,
} from '@/lib/api/hackathons/teams';
import { useState } from 'react';
import { toast } from 'sonner';

Expand All @@ -39,10 +42,16 @@ export function ContactTeamModal({

if (!team) return null;

const { teamName, contactMethod, contactInfo, id } = team;
const { teamName, contactInfo, id } = team;
const contact = readTeamContact(contactInfo);

// If the team has no contact info at all, render nothing: the parent
// should have already gated this modal, but defend against bad data.
if (!contact) return null;
const { method: contactMethod, value: contactValue } = contact;

const handleCopy = () => {
navigator.clipboard.writeText(contactInfo);
navigator.clipboard.writeText(contactValue);
setCopied(true);
toast.success('Contact info copied to clipboard');
setTimeout(() => setCopied(false), 2000);
Expand All @@ -56,8 +65,6 @@ export function ContactTeamModal({
case 'telegram':
case 'discord':
return <MessageCircle className='h-5 w-5' />;
case 'github':
return <Github className='h-5 w-5' />;
default:
return <Globe className='h-5 w-5' />;
}
Expand All @@ -71,15 +78,13 @@ export function ContactTeamModal({
return 'Telegram Username/Link';
case 'discord':
return 'Discord Username';
case 'github':
return 'GitHub Profile';
default:
return 'Contact Info';
}
};

const isLink =
contactInfo.startsWith('http') || contactInfo.startsWith('https');
contactValue.startsWith('http') || contactValue.startsWith('https');

return (
<Dialog open={open} onOpenChange={onOpenChange}>
Expand All @@ -104,7 +109,7 @@ export function ContactTeamModal({
{getLabel()}
</p>
<p className='truncate text-lg font-bold text-white'>
{contactInfo}
{contactValue}
</p>
</div>
</div>
Expand Down Expand Up @@ -132,7 +137,7 @@ export function ContactTeamModal({
variant='outline'
className='h-12 flex-1 rounded-xl border-white/10 bg-white/5 font-bold hover:bg-white/10'
onClick={() => {
window.open(contactInfo, '_blank', 'noopener,noreferrer');
window.open(contactValue, '_blank', 'noopener,noreferrer');
onTrackContact?.(id);
}}
>
Expand Down
50 changes: 33 additions & 17 deletions components/hackathons/team-formation/CreateTeamPostModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ import { BoundlessButton } from '@/components/buttons/BoundlessButton';
import { useTeamPosts } from '@/hooks/hackathon/use-team-posts';
import { Loader2, Plus, X, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { type TeamRecruitmentPost } from '@/lib/api/hackathons/teams';
import {
readTeamContact,
type TeamContactInfo,
type TeamRecruitmentPost,
} from '@/lib/api/hackathons/teams';

const roleSchema = z.object({
role: z.string().min(1, 'Role name is required'),
Expand Down Expand Up @@ -86,13 +90,12 @@ export function CreateTeamPostModal({
maxRoles,
`You can add at most ${maxRoles} open role${maxRoles === 1 ? '' : 's'} (the team is capped at ${effectiveTeamMax} member${effectiveTeamMax === 1 ? '' : 's'} including you)`
),
contactMethod: z.enum([
'email',
'telegram',
'discord',
'github',
'other',
]),
// contactMethod stays on the form for UX (radio-style selector)
// but is NOT sent to the backend as a separate field. Backend stores
// contact as a sparse object keyed by channel (TeamContactInfo);
// onSubmit transforms { contactMethod, contactInfo } into that shape.
// github / other were dropped because the backend cannot store them.
contactMethod: z.enum(['email', 'telegram', 'discord']),
contactInfo: z.string().min(1, 'Contact info is required'),
}),
[maxRoles, effectiveTeamMax]
Expand All @@ -105,6 +108,11 @@ export function CreateTeamPostModal({

const isEditMode = !!initialData;

// Flatten the backend's sparse contactInfo object back into the form's
// single { method, value } UI representation. Used both as the initial
// form value and in the edit-mode reset below.
const initialContact = readTeamContact(initialData?.contactInfo);

const form = useForm<TeamPostFormData>({
resolver: zodResolver(teamPostSchema),
defaultValues: {
Expand All @@ -115,8 +123,8 @@ export function CreateTeamPostModal({
role: typeof roleObj === 'string' ? roleObj : roleObj.role,
skills: typeof roleObj === 'string' ? [] : roleObj.skills || [],
})) || [],
contactMethod: initialData?.contactMethod || 'email',
contactInfo: initialData?.contactInfo || '',
contactMethod: initialContact?.method ?? 'email',
contactInfo: initialContact?.value ?? '',
},
});

Expand All @@ -125,15 +133,16 @@ export function CreateTeamPostModal({

useEffect(() => {
if (open && initialData) {
const contact = readTeamContact(initialData.contactInfo);
form.reset({
teamName: initialData.teamName,
description: initialData.description,
lookingFor: initialData.lookingFor.map(roleObj => ({
role: typeof roleObj === 'string' ? roleObj : roleObj.role,
skills: typeof roleObj === 'string' ? [] : roleObj.skills || [],
})),
contactMethod: initialData.contactMethod || 'email',
contactInfo: initialData.contactInfo,
contactMethod: contact?.method ?? 'email',
contactInfo: contact?.value ?? '',
});
} else if (!open) {
form.reset();
Expand Down Expand Up @@ -207,18 +216,27 @@ export function CreateTeamPostModal({
};

const onSubmit = async (data: TeamPostFormData) => {
// Project the form's single { method, value } shape into the backend's
// sparse TeamContactInfo object. The channel is implicit in the key.
const contactInfo: TeamContactInfo = {
[data.contactMethod]: data.contactInfo,
};
try {
if (isEditMode && initialData) {
await updatePost(initialData.id, {
teamName: data.teamName,
description: data.description,
lookingFor: data.lookingFor,
isOpen: data.lookingFor.length > 0,
contactMethod: data.contactMethod,
contactInfo: data.contactInfo,
contactInfo,
});
} else {
await createPost(data);
await createPost({
teamName: data.teamName,
description: data.description,
lookingFor: data.lookingFor,
contactInfo,
});
}

onOpenChange(false);
Expand Down Expand Up @@ -483,8 +501,6 @@ export function CreateTeamPostModal({
<SelectItem value='email'>Email</SelectItem>
<SelectItem value='telegram'>Telegram</SelectItem>
<SelectItem value='discord'>Discord</SelectItem>
<SelectItem value='github'>GitHub</SelectItem>
<SelectItem value='other'>Other</SelectItem>
</SelectContent>
</Select>
<FormMessage />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ export const useScoreForm = ({
: await submitJudgingScore({
submissionId,
criteriaScores: scoreData,
comment: overallComment,
notes: overallComment,
});

const isSuccess = response.success !== false;
Expand Down
61 changes: 60 additions & 1 deletion hooks/hackathon/use-submission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,61 @@ export type SubmissionFormData = Omit<
}>;
};

/**
* Fields the backend `UpdateSubmissionDto` accepts. Backend rejects any
* other field after PR #187 (forbidNonWhitelisted: true). Keep this list
* in sync with src/modules/hackathons/dto/submission.dto.ts.
*
* Notably NOT on update: participationType, teamId, teamName,
* organizationId, hackathonId, participantId. Those are set on create
* only and cannot be changed via PATCH.
*/
const UPDATE_SUBMISSION_FIELDS: readonly (keyof SubmissionFormData)[] = [
'projectName',
'category',
'description',
'logo',
'banner',
'videoUrl',
'introduction',
'links',
'socialLinks',
'teamMembers',
'trackIds',
'trackAnswers',
'tagline',
'builtWith',
'screenshots',
'license',
'codeAttested',
] as const;

function pickUpdateSubmissionFields(
data: Partial<SubmissionFormData>
): Partial<SubmissionFormData> {
const out: Partial<SubmissionFormData> = {};
for (const key of UPDATE_SUBMISSION_FIELDS) {
if (key in data && data[key] !== undefined) {
// The index type below is awkward because SubmissionFormData has
// many optional fields with different shapes. The cast is safe
// because each `key` is a real key of SubmissionFormData.
(out as Record<string, unknown>)[key] = data[key];
}
}
// teamMembers entries must match the backend TeamMemberDto exactly:
// { userId, name, username?, role } — no `email`, no `avatar`. Project
// each entry down to that shape so unknown fields never reach the wire.
if (Array.isArray(out.teamMembers)) {
out.teamMembers = out.teamMembers.map(member => ({
userId: member.userId,
name: member.name,
role: member.role,
...(member.username ? { username: member.username } : {}),
}));
}
return out;
}

interface UseSubmissionOptions {
hackathonSlugOrId: string;
organizationId?: string;
Expand Down Expand Up @@ -170,7 +225,11 @@ export function useSubmission({
setError(null);

try {
const response = await updateSubmission(submissionId, data);
// Strip create-only fields (participationType, teamId, teamName,
// organizationId, ...) and per-team-member email so the request
// matches UpdateSubmissionDto under forbidNonWhitelisted: true.
const payload = pickUpdateSubmissionFields(data);
const response = await updateSubmission(submissionId, payload);

if (response?.success && response?.data) {
setSubmission(response.data);
Expand Down
17 changes: 9 additions & 8 deletions lib/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,15 @@ export const getAuthHeaders = (): Record<string, string> => {
};

/**
* Update user profile request interface - matches API payload specification
* Update user profile request interface. Mirrors the backend
* `UpdateProfileDto` (src/modules/users/dto/update-profile.dto.ts).
*
* Notably absent: a `preferences` block. The backend has dedicated
* endpoints for theme / language / timezone / notification toggles
* (see updateAppearanceSettings, updateNotificationsSettings,
* updateUserSettings in lib/api/user/settings.ts). Sending a
* `preferences` field here would be rejected as an unknown property
* once the backend enables forbidNonWhitelisted (PR #187).
*/
export interface UpdateUserProfileRequest {
bio?: string;
Expand All @@ -69,13 +77,6 @@ export interface UpdateUserProfileRequest {
linkedin?: string;
discord?: string;
};
preferences?: {
theme?: 'light' | 'dark' | 'auto';
language?: string;
timezone?: string;
emailNotifications?: boolean;
pushNotifications?: boolean;
};
}

/**
Expand Down
19 changes: 17 additions & 2 deletions lib/api/hackathons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,9 +502,24 @@ export interface PublishHackathonRequest extends Hackathon {
escrowDetails?: object;
}

export type UpdateHackathonRequest = Partial<Hackathon> & {
/**
* Narrow shape for the PUT /hackathons/:id endpoint. The previous wider
* `Partial<Hackathon>` typing let callers leak Hackathon-shape fields
* (id, organizationId, creatorId, status, ...) into the request body,
* any of which would 400 once the backend enables
* forbidNonWhitelisted (PR #187). The only caller today is the rank
* override save in JudgingResultsTable, which sends only `rewards`.
*
* Note: the backend route this calls (PUT /hackathons/:id) does not
* currently exist as a separate endpoint; the section-specific PATCH
* routes (/content, /schedule, /financial) are where edits should go.
* That mismatch is a separate pre-existing bug, but the narrow type
* here at least prevents the request from carrying unknown fields if
* the route is wired up later.
*/
export interface UpdateHackathonRequest {
rewards?: HackathonRewards;
};
}

// Response Types
export interface CreateDraftResponse extends ApiResponse<HackathonDraft> {
Expand Down
2 changes: 1 addition & 1 deletion lib/api/hackathons/judging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ export interface CriterionScoreRequest {
export interface SubmitJudgingScoreRequest {
submissionId: string;
criteriaScores: CriterionScoreRequest[];
comment?: string; // Optional global feedback
notes?: string; // Optional global feedback (per-judge notes on this submission)
}

export interface OverrideSubmissionScoreRequest {
Expand Down
Loading
Loading