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
14 changes: 11 additions & 3 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,16 @@ To learn more about Next.js, take a look at the following resources:

You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!

## Deploy on Vercel
## Deploy on Netlify

The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
This project includes pre-configured `netlify.toml` files for automatic deployment on Netlify using `@netlify/plugin-nextjs`.

### Deployment Steps:
1. Connect your repository to Netlify.
2. Netlify will auto-detect the configuration:
- **Base directory**: `frontend`
- **Build command**: `npm run build`
- **Publish directory**: `.next`
3. Add the required environment variables in the Netlify Dashboard (**Site Settings > Environment Variables**) based on `.env.example`.
4. Deploy!

Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
18 changes: 7 additions & 11 deletions frontend/app/api/v1/live-stats/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export async function GET(request) {
}

const allRegistrations = await fetchAllSupabase(
`${supabaseUrl}/rest/v1/registrations?select=user_id,team,mu_points`,
`${supabaseUrl}/rest/v1/registrations?select=team,mu_points`,
{
apikey: supabaseKey,
Authorization: `Bearer ${supabaseKey}`,
Expand Down Expand Up @@ -89,14 +89,6 @@ export async function GET(request) {
}
});

const referral_analytics = {};
registrations.forEach((r) => {
if (r.user_id) {
referral_analytics[r.user_id] =
(referral_analytics[r.user_id] || 0) + 1;
}
});

// Calculates the statistical median of a points list
function calculateMedian(arr) {
if (!arr || arr.length === 0) return 0;
Expand Down Expand Up @@ -129,7 +121,6 @@ export async function GET(request) {

const statsPayload = {
organisation_count,
referral_analytics,
squad_points,
};

Expand All @@ -141,7 +132,12 @@ export async function GET(request) {
response: statsPayload,
data: statsPayload,
},
{ status: 200 },
{
status: 200,
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=300",
},
},
);
} catch (error) {
console.error("Next.js live-stats API error:", error);
Expand Down
8 changes: 4 additions & 4 deletions frontend/app/api/v1/profile/avatar/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,11 @@ export async function POST(request) {
);
}

// 3. Validate file type
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
if (!allowedTypes.includes(file.type)) {
// 3. Validate file type (allow any image format)
const isImage = file.type ? file.type.startsWith("image/") : /\.(jpe?g|png|webp|gif|svg|avif|bmp)$/i.test(file.name);
if (!isImage) {
return NextResponse.json(
{ success: false, error: "Invalid file type. Only JPEG, PNG, WEBP, and GIF are allowed." },
{ success: false, error: "Invalid file type. Only image files (JPEG, PNG, WEBP, GIF, etc.) are allowed." },
{ status: 400 }
);
}
Expand Down
17 changes: 2 additions & 15 deletions frontend/app/api/v1/register/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
import { sendRegistrationOtpEmail, sendRegistrationEmail } from "@/utils/email";
import { signToken, hashPassword } from "@/utils/auth";
import { adjustSquadPoints } from "@/utils/squad";
import { DISPOSABLE_DOMAINS } from "@/utils/constants";
import { DISPOSABLE_DOMAINS, AVAILABLE_TEAMS } from "@/utils/constants";

const PLAYER_COOKIE = "player_token";

Expand All @@ -29,20 +29,7 @@ function jsonError(status, code, message, details = null) {
}

const DOMAINS = ["Coder", "Creative", "Maker", "Strategist"];
const TEAMS = [
"Brazil",
"Argentina",
"Portugal",
"Germany",
"France",
"England",
"Spain",
"Netherlands",
"Belgium",
"Croatia",
"Uruguay",
"Japan",
];
const TEAMS = AVAILABLE_TEAMS;

const RegisterSchema = z.object({
name: z
Expand Down
26 changes: 0 additions & 26 deletions frontend/app/api/v1/tasks/verify/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -221,32 +221,6 @@ export async function POST(request) {
{ status: 400 },
);
}

// Check predictions
const predQuery = `${supabaseUrl}/rest/v1/match_predictions?user_id=eq.${encodeURIComponent(userId)}&select=id`;
const predRes = await fetch(predQuery, {
headers: {
apikey: supabaseKey,
Authorization: `Bearer ${supabaseKey}`,
Prefer: "count=exact",
},
});
if (!predRes.ok)
throw new Error(`Failed to check predictions: ${await predRes.text()}`);
const predCount = parseInt(
predRes.headers.get("content-range")?.split("/")[1] || "0",
10,
);
if (predCount < 1) {
return NextResponse.json(
{
success: false,
error:
"Prediction not found. Please ensure you have made at least 1 prediction on any match.",
},
{ status: 400 },
);
}
} else if (verificationMethod === "kuzhiundo") {
// Kuzhiyundo Challenge Pothole Mapping verification
const uuid = player.id; // MuFIFA registration UUID is the player.id
Expand Down
28 changes: 26 additions & 2 deletions frontend/app/leaderboard/components/SquadStandings.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import React, { useState, useEffect, useMemo } from "react";
import Image from "next/image";
import { getBackendUrl } from "@/utils/api";
import { ELIMINATED_TEAMS } from "@/utils/constants";
import { TEAMS, FLAGS, getSquadPhoto, getSquadPhotoFront, getRankStyle } from "../utils";

export default function SquadStandings({ dbStatus, setDbStatus }) {
Expand All @@ -23,6 +24,7 @@ export default function SquadStandings({ dbStatus, setDbStatus }) {

useEffect(() => {
const fetchStats = async () => {
if (typeof document !== "undefined" && document.hidden) return;
try {
const res = await fetch(getBackendUrl("live-stats"));
const data = await res.json();
Expand Down Expand Up @@ -69,7 +71,22 @@ export default function SquadStandings({ dbStatus, setDbStatus }) {

fetchStats();
const interval = setInterval(fetchStats, 30000);
return () => clearInterval(interval);

const handleVisibilityChange = () => {
if (typeof document !== "undefined" && !document.hidden) {
fetchStats();
}
};
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", handleVisibilityChange);
}

return () => {
clearInterval(interval);
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", handleVisibilityChange);
}
};
}, [setDbStatus]);

const filteredTeams = useMemo(() => {
Expand Down Expand Up @@ -248,7 +265,9 @@ export default function SquadStandings({ dbStatus, setDbStatus }) {
(searchQuery ? filteredTeams : filteredTeams.slice(3)).map((team) => (
<div
key={team.name}
className="flex items-center justify-between py-3 px-2 sm:px-3 border-b border-white/5 hover:bg-white/[0.02] transition-colors group last:border-b-0"
className={`flex items-center justify-between py-3 px-2 sm:px-3 border-b border-white/5 hover:bg-white/[0.02] transition-colors group last:border-b-0 ${
ELIMINATED_TEAMS.includes(team.name) ? "opacity-60" : ""
}`}
>
<div className="flex items-center gap-3">
<span
Expand Down Expand Up @@ -292,6 +311,11 @@ export default function SquadStandings({ dbStatus, setDbStatus }) {
aria-label={`${team.name} flag`}
/>
<span>{team.name}</span>
{ELIMINATED_TEAMS.includes(team.name) && (
<span className="text-[9px] font-extrabold text-red-400 bg-red-500/10 border border-red-500/20 px-1.5 py-0.5 rounded uppercase tracking-wider">
Eliminated
</span>
)}
</span>
</div>

Expand Down
50 changes: 26 additions & 24 deletions frontend/app/profile/[id]/components/ProfileBanner.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,37 +45,39 @@ export default function ProfileBanner({
/>

{isOwner && (
<label
htmlFor="banner-avatar-upload"
className="absolute bottom-0 right-0 w-8 h-8 bg-[#8B5CF6] hover:bg-[#7C3AED] text-white flex items-center justify-center rounded-full cursor-pointer shadow-lg border-2 border-[#090715] transition-all transform hover:scale-110 active:scale-95"
title="Edit Avatar"
>
{uploading ? (
<span className="w-4 h-4 rounded-full border-2 border-white border-t-transparent animate-spin" />
) : (
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
)}
<>
<label
htmlFor="banner-avatar-upload"
className="absolute bottom-0 right-0 z-20 w-8 h-8 bg-[#8B5CF6] hover:bg-[#7C3AED] text-white flex items-center justify-center rounded-full cursor-pointer shadow-lg border-2 border-[#090715] transition-all transform hover:scale-110 active:scale-95"
title="Edit Avatar"
>
{uploading ? (
<span className="w-4 h-4 rounded-full border-2 border-white border-t-transparent animate-spin" />
) : (
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
)}
</label>
<input
id="banner-avatar-upload"
type="file"
accept="image/*"
onChange={handleAvatarChange}
className="hidden"
className="sr-only"
disabled={uploading}
/>
</label>
</>
)}
</div>

Expand Down
Loading
Loading