import { z } from 'zod';
import { Timestamp } from 'firebase/firestore';
const GamerIdSchema = z.object({
platform: z.enum(["Epic", "Steam", "Xbox"], { required_error: "Please select a
platform."}),
id: z.string().min(1, "Gamer ID cannot be empty."),
});
// This is the base schema for a Profile object, reflecting Firestore data
structure.
// Note: `dob` can be a Firestore Timestamp.
export const ProfileSchema = z.object({
uid: z.string(),
name: z.string().min(2, "Name must be at least 2 characters."),
alias: z.string().min(2, "Alias must be at least 2 characters."),
email: z.string().email(),
dob: z.instanceof(Timestamp).or(z.instanceof(Date)).or(z.string()),
location: z.string().min(2, "Location is required."),
bio: z.string().max(500, "Bio cannot exceed 500 characters.").optional(),
photoUrl: z.string().url().optional().or(z.literal("")),
gamerIds: z.array(GamerIdSchema).max(5, "You can add up to 5 gamer
IDs.").default([]),
});
// This schema is specifically for validating the profile update form.
// It expects a `Date` object for `dob`, as that's what the DatePicker provides.
export const ProfileFormSchema = z.object({
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
alias: z.string().min(2, { message: "Alias must be at least 2 characters." }),
location: z.string().min(2, { message: "Location is required." }),
bio: z.string().max(500, { message: "Bio cannot exceed 500
characters." }).optional(),
dob: z.date({ required_error: "Date of birth is required." }).refine(
(date) => {
const today = new Date();
const thirteenYearsAgo = new Date(today.getFullYear() - 13,
today.getMonth(), today.getDate());
return date <= thirteenYearsAgo;
},
{ message: "You must be at least 13 years old." }
),
gamerIds: z.array(GamerIdSchema).max(5, "You can add up to 5 gamer
IDs.").default([]),
});
export type ProfileFormData = z.infer<typeof ProfileFormSchema>;
export type Profile = z.infer<typeof ProfileSchema> & {
dob: Date | Timestamp | string; // Loosen type for client-side flexibility
};
export const SignUpSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters."),
email: z.string().email("Please enter a valid email address."),
password: z.string().min(8, "Password must be at least 8 characters."),
confirmPassword: z.string(),
dob: z.date({ required_error: "Date of birth is a required field." }).refine(
(date) => {
const today = new Date();
const thirteenYearsAgo = new Date(today.getFullYear() - 13, today.getMonth(),
today.getDate());
return date <= thirteenYearsAgo;
},
{ message: "You must be at least 13 years old." }
),
bio: z.string().max(500, "Bio cannot exceed 500 characters.").optional(),
readyToChallenge: z.boolean().default(false),
}).refine(data => data.password === data.confirmPassword, {
message: "Passwords do not match.",
path: ["confirmPassword"],
});
export type SignUpData = z.infer<typeof SignUpSchema>;
export const CreateChallengePayloadSchema = z.object({
challengerUid: z.string().min(1),
opponent: z.string().min(1, "Opponent is required."),
category: z.string().min(1, "Category is required."),
specifics: z.string().min(1, "Specific game or activity is required."),
tier: z.string().min(1, "Tier is required."),
reason: z.string().min(1, "Reason for challenge is required."),
message: z.string().optional(),
proposedDate: z.string().optional().nullable(),
streamUrl: z.string().url({ message: "Please enter a valid
URL." }).or(z.literal("")).optional(),
proposedVenue: z.string().optional(),
isCrowdfunded: z.preprocess((val) => val === 'on' || val === true,
z.boolean()).optional(),
prizePoolGoal: z.coerce.number().optional(),
}).refine(data => {
if (data.isCrowdfunded) {
return data.prizePoolGoal !== undefined && data.prizePoolGoal > 0;
}
return true;
}, {
message: "A prize pool goal greater than 0 is required for crowdfunded
challenges.",
path: ["prizePoolGoal"],
});
export type CreateChallengeFormData = z.infer<typeof CreateChallengePayloadSchema>;
export const CrowdfundBackerSchema = z.object({
uid: z.string().optional(),
alias: z.string(),
amount: z.number(),
timestamp: z.instanceof(Timestamp),
anonymous: z.boolean().default(false),
});
export type CrowdfundBacker = z.infer<typeof CrowdfundBackerSchema>;
export const ChallengeSchema = z.object({
id: z.string(),
challengerUid: z.string(),
challengerAlias: z.string(),
opponent: z.string(), // This can be an alias or email
opponentUid: z.string().optional(),
game: z.string(),
tier: z.string(),
status: z.enum(['Pending', 'Accepted', 'Declined', 'Completed',
'AwaitingVerification', 'Disputed']),
date: z.date(),
reason: z.string(),
message: z.string().optional(),
winner: z.string().optional(),
challengerResult: z.enum(['win', 'loss']).nullable().optional(),
opponentResult: z.enum(['win', 'loss']).nullable().optional(),
streamUrl: z.string().url().optional(),
venue: z.string().optional(),
videoUrl: z.string().url().optional(),
// Crowdfunding fields
isCrowdfunded: z.boolean().optional(),
prizePoolGoal: z.number().optional(),
currentFunding: z.number().optional().default(0),
crowdfundingStatus: z.enum(['pending', 'active', 'complete',
'refunded']).optional(),
backers: z.array(CrowdfundBackerSchema).optional().default([]),
});
export type Challenge = z.infer<typeof ChallengeSchema>;
export const RankingSchema = z.object({
rank: z.number(),
alias: z.string(),
challenges: z.number(),
wins: z.number(),
losses: z.number(),
winRate: z.number(),
});
export type Ranking = z.infer<typeof RankingSchema>;
"use server";
import { revalidatePath } from "next/cache";
import { suggestOpponents, type SuggestOpponentsInput } from "@/ai/flows/suggest-
opponents";
import { z } from "zod";
import { getWelcomeEmail, getChallengeIssuedEmail, getChallengeReceivedEmail,
getChallengeAcceptedEmail, getChallengeDeclinedEmail, getPartnershipInquiryEmail,
getChallengeCompleteEmail } from "@/lib/emails";
import sgMail from '@sendgrid/mail';
import Stripe from 'stripe';
import { challengeLevels } from "@/lib/constants";
import { getChallengeById, getUserProfile, getUserByAlias, getUserByEmail } from
"@/lib/data";
import { CreateChallengePayloadSchema, type Profile, type Challenge } from
"@/lib/types";
import { db, storage } from "@/lib/firebase";
import { doc, setDoc, updateDoc, collection, addDoc, Timestamp, getDoc } from
"firebase/firestore";
import { ref, uploadBytes, getDownloadURL } from "firebase/storage";
import { calculateAge } from "./utils";
// Helper function to send email or log to console
async function sendEmail({ to, subject, text, html, fromName = 'Vendetta Global',
replyTo }: { to: string; subject:string; text: string; html: string; fromName?:
string, replyTo?: string }) {
const apiKey = process.env.SENDGRID_API_KEY;
const fromEmail = process.env.SENDGRID_FROM_EMAIL;
const replyToEmail = replyTo || fromEmail || "
[email protected]";
if (!apiKey || !fromEmail) {
console.warn("--- SendGrid not configured. Simulating email. ---");
console.log(`To: ${to}`);
console.log(`Subject: ${subject}`);
return { success: true, message: "Email simulated in console." };
}
sgMail.setApiKey(apiKey);
const msg = { to, from: { name: fromName, email: fromEmail }, replyTo:
replyToEmail, subject, text, html };
try {
await sgMail.send(msg);
console.log(`Email successfully sent to ${to}`);
return { success: true, message: `Email sent to ${to}` };
} catch (error: any) {
console.error('Error sending email via SendGrid:', JSON.stringify(error, null,
2));
return { success: false, error: error.response?.body?.errors[0]?.message ||
error.message || "An unknown error occurred." };
}
}
export async function createChallenge(payload: any) {
try {
const parsed = CreateChallengePayloadSchema.parse(payload);
const {
challengerUid,
opponent,
tier,
} = parsed;
const userProfile = await getUserProfile(challengerUid);
if (!userProfile || !userProfile.email) {
throw new Error("Could not find your user profile or email. Please ensure you
are logged in.");
}
if (!userProfile.dob) {
throw new Error("Your Date of Birth is missing from your profile. Please
update it before creating a challenge.");
}
const userAge = calculateAge(userProfile.dob);
if (userAge < 18 && parsed.category !== 'E-Sports') {
throw new Error("Users under 18 can only create challenges in the 'E-
Sports' category.");
}
let opponentEmail: string;
let opponentNameForEmail: string;
let opponentUid: string | undefined;
let opponentIdentifierForStorage: string;
const emailCheck = z.string().email().safeParse(opponent);
if (emailCheck.success) {
// Challenged by email
opponentEmail = opponent;
const opponentProfile = await getUserByEmail(opponent);
if (opponentProfile) {
// User exists, use their canonical alias
opponentIdentifierForStorage = opponentProfile.alias;
opponentNameForEmail = opponentProfile.name || opponentProfile.alias;
opponentUid = opponentProfile.uid;
} else {
// New user, use their email as the identifier for now
opponentIdentifierForStorage = opponent;
opponentNameForEmail = opponent.split('@')[0];
}
} else {
// Challenged by alias
const opponentProfile = await getUserByAlias(opponent);
if (!opponentProfile?.email || !opponentProfile.alias) {
throw new Error(`Could not find a registered user with alias "${opponent}".
To challenge someone new, use their email.`);
}
opponentEmail = opponentProfile.email;
opponentNameForEmail = opponentProfile.name || opponent;
opponentUid = opponentProfile.uid;
opponentIdentifierForStorage = opponentProfile.alias;
}
const challengeLevel = challengeLevels.find(level => level.tier === tier);
if (!challengeLevel) {
throw new Error("Invalid challenge tier selected.");
}
const allDataForFulfillment = {
...parsed,
opponent: opponentIdentifierForStorage, // Use the canonical identifier
challengerName: userProfile.name,
challengerEmail: userProfile.email,
challengerAlias: userProfile.alias,
opponentEmail,
opponentNameForEmail,
opponentUid,
};
if (challengeLevel.priceInCents === 0) {
await fulfillChallengeOrder(allDataForFulfillment);
revalidatePath('/my-challenges');
return { success: true, message: "Free challenge created successfully!" };
}
const stripeSecretKey = process.env.STRIPE_SECRET_KEY;
if (!stripeSecretKey) {
throw new Error("Payment processing is not configured. Please contact
support.");
}
const stripe = new Stripe(stripeSecretKey);
const lineItems: Stripe.Checkout.SessionCreateParams.LineItem[] = [];
if (challengeLevel.priceInCents > 0) {
lineItems.push({
price_data: {
currency: 'usd',
product_data: { name: `${challengeLevel.name} Challenge Fee`,
description: `Entry fee for a ${tier} tier challenge.` },
unit_amount: challengeLevel.priceInCents,
},
quantity: 1,
});
}
const metadata: { [key: string]: string } = {};
for (const [key, value] of Object.entries(allDataForFulfillment)) {
if (value instanceof Date) {
metadata[key] = value.toISOString();
} else if (value !== undefined && value !== null) {
metadata[key] = String(value);
}
}
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: lineItems,
mode: 'payment',
success_url: `${process.env.NEXT_PUBLIC_BASE_URL ||
'http://localhost:9002'}/my-challenges?payment=success`,
cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL ||
'http://localhost:9002'}/challenge/create?tier=${tier}`,
metadata,
});
if (!session.url) {
throw new Error("Could not create a payment session URL.");
}
return { success: true, url: session.url, message: "Redirecting to
payment..." };
} catch (err: any) {
const errorMessage = err instanceof z.ZodError
? err.issues.map(i => i.message).join(', ')
: err.message || "An unknown server error occurred.";
return { success: false, error: errorMessage };
}
}
export async function getOpponentSuggestions(payload: any) {
try {
const OpponentSuggestionSchema = z.object({
name: z.string(),
alias: z.string(),
dob: z.string(),
location: z.string(),
bio: z.string(),
photoDataUri: z.string().optional(),
challengeHistory: z.string().optional(),
desiredChallengeLevel: z.enum(['Free', 'Bronze']),
});
const parsed = OpponentSuggestionSchema.parse(payload);
const input: SuggestOpponentsInput = {
userProfile: {
name: parsed.name,
alias: parsed.alias,
dob: parsed.dob,
location: parsed.location,
bio: parsed.bio,
photoDataUri: parsed.photoDataUri,
},
challengeHistory: parsed.challengeHistory?.split(',').filter(Boolean) || [],
desiredChallengeLevel: parsed.desiredChallengeLevel,
};
const result = await suggestOpponents(input);
revalidatePath("/opponents");
return { success: true, data: result };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "An unknown error
occurred.";
return { success: false, error: `Failed to get suggestions: ${errorMessage}` };
}
}
export async function fulfillChallengeOrder(challengeData: any) {
const {
challengerUid,
challengerAlias,
challengerEmail,
challengerName,
opponent,
opponentEmail,
opponentNameForEmail,
opponentUid,
tier,
specifics,
message,
reason,
proposedDate,
streamUrl,
proposedVenue,
isCrowdfunded,
prizePoolGoal
} = challengeData;
const challengesCollectionRef = collection(db, 'challenges');
const newChallengeRef = doc(challengesCollectionRef); // Create a reference
with a new ID
const isCrowdfundedBool = isCrowdfunded === 'true' || isCrowdfunded === true;
const newChallengeData = {
challengerUid,
challengerAlias,
opponent: opponent,
opponentUid: opponentUid || '',
game: specifics,
tier: tier,
status: 'Pending',
date: proposedDate ? new Date(proposedDate) : new Date(),
reason: reason,
message: message || '',
streamUrl: streamUrl || '',
venue: proposedVenue || '',
videoUrl: '',
winner: '',
challengerResult: null,
opponentResult: null,
isCrowdfunded: isCrowdfundedBool,
prizePoolGoal: isCrowdfundedBool ? (prizePoolGoal ? Number(prizePoolGoal) :
0) : null,
currentFunding: 0,
crowdfundingStatus: isCrowdfundedBool ? 'pending' : null,
backers: [],
};
// Set the document with its own ID as a field.
await setDoc(newChallengeRef, newChallengeData as any);
const challengerFirstName = challengerName?.split(' ')[0] || "Challenger";
const emailDataChallenger = getChallengeIssuedEmail({
firstName: challengerFirstName,
opponentName: opponent,
tier: tier,
sportOrGame: specifics,
streamUrl: streamUrl,
proposedVenue: proposedVenue,
});
await sendEmail({ to: challengerEmail, subject: emailDataChallenger.subject,
text: emailDataChallenger.text, html: emailDataChallenger.html, fromName: "Vendetta
Global" });
const emailDataOpponent = getChallengeReceivedEmail({
challengerName: challengerName,
opponentName: opponentNameForEmail,
tier,
sportOrGame: specifics,
proposedDate: proposedDate ? new Date(proposedDate).toLocaleDateString('en-
US', { dateStyle: 'long', timeZone: 'UTC' }) : 'To be determined',
specialTerms: message || 'None',
streamUrl: streamUrl,
proposedVenue: proposedVenue,
});
await sendEmail({ to: opponentEmail, subject: emailDataOpponent.subject, text:
emailDataOpponent.text, html: emailDataOpponent.html, fromName: "Vendetta
Global" });
}
export async function reportMatchOutcome(payload: { challengeId: string; result:
'win' | 'loss'; reportingUid: string }) {
try {
const { challengeId, result, reportingUid } = payload;
if (!challengeId || !result || !reportingUid) {
return { success: false, error: "Missing required fields." };
}
const challengeRef = doc(db, 'challenges', challengeId);
const challengeSnap = await getDoc(challengeRef);
if (!challengeSnap.exists()) {
return { success: false, error: "Challenge not found." };
}
const challenge = { ...challengeSnap.data(), id: challengeSnap.id } as
Challenge;
const isChallenger = reportingUid === challenge.challengerUid;
let isOpponent = reportingUid === challenge.opponentUid;
// Fallback check for opponent verification if opponentUid is missing
if (!isOpponent && !challenge.opponentUid) {
const opponentProfile = await getUserProfile(reportingUid);
if (opponentProfile && (opponentProfile.email === challenge.opponent ||
opponentProfile.alias === challenge.opponent)) {
isOpponent = true;
// If we successfully identify the opponent, let's update the challenge
document
await updateDoc(challengeRef, { opponentUid: reportingUid });
challenge.opponentUid = reportingUid; // Also update the local object
}
}
if (!isChallenger && !isOpponent) {
return { success: false, error: "You are not a participant in this
challenge." };
}
const allowedStatuses = ['Accepted', 'AwaitingVerification'];
if (!allowedStatuses.includes(challenge.status)) {
return { success: false, error: `Results can only be reported for
'Accepted' or 'Awaiting Verification' challenges. Current status: $
{challenge.status}` };
}
const updates: any = {};
let finalChallengerResult = challenge.challengerResult;
let finalOpponentResult = challenge.opponentResult;
if (isChallenger) {
updates.challengerResult = result;
finalChallengerResult = result;
} else if (isOpponent) {
updates.opponentResult = result;
finalOpponentResult = result;
}
if (finalChallengerResult && finalOpponentResult) {
if (finalChallengerResult === 'win' && finalOpponentResult === 'loss') {
updates.status = 'Completed';
updates.winner = challenge.challengerAlias;
} else if (finalChallengerResult === 'loss' && finalOpponentResult ===
'win') {
updates.status = 'Completed';
updates.winner = challenge.opponent;
} else {
updates.status = 'Disputed';
updates.winner = '';
}
if (updates.status === 'Completed') {
if(challenge.isCrowdfunded) updates.crowdfundingStatus = 'complete';
const challengerProfile = await
getUserProfile(challenge.challengerUid);
const opponentProfile = challenge.opponentUid ? await
getUserProfile(challenge.opponentUid) : null;
if (challengerProfile?.email && opponentProfile?.email) {
const emailData = getChallengeCompleteEmail({
challengerName: challengerProfile.name ||
challengerProfile.alias || 'Challenger',
opponentName: opponentProfile.name || opponentProfile.alias ||
'Opponent',
});
await sendEmail({ to: challengerProfile.email, ...emailData });
await sendEmail({ to: opponentProfile.email, ...emailData });
}
}
} else {
updates.status = 'AwaitingVerification';
}
await updateDoc(challengeRef, updates);
revalidatePath(`/challenge/${challengeId}`);
revalidatePath('/my-challenges');
revalidatePath(`/crowdfund/${challengeId}`);
return { success: true, message: "Your result has been recorded." };
} catch (err: any) {
return { success: false, error: err.message || "Failed to report outcome." };
}
}
export async function acceptChallenge(payload: { challengeId: string,
acceptingUserUid: string }) {
try {
const { challengeId, acceptingUserUid } = payload;
if (!challengeId || !acceptingUserUid) return { success: false, error:
"Challenge ID or User ID is missing" };
const acceptingUserProfile = await getUserProfile(acceptingUserUid);
if (!acceptingUserProfile) return { success: false, error: "Could not find your
profile." };
const challengeRef = doc(db, 'challenges', challengeId);
const challengeSnap = await getDoc(challengeRef);
if (!challengeSnap.exists()) {
throw new Error("Challenge not found");
}
const challengeData = challengeSnap.data();
const updates: any = {
status: 'Accepted',
opponentUid: acceptingUserUid,
opponent: acceptingUserProfile.alias, // Standardize opponent to their alias
on acceptance
};
if (challengeData.isCrowdfunded) {
updates.crowdfundingStatus = 'active';
}
await updateDoc(challengeRef, updates);
const challenge = await getChallengeById(challengeId);
if (challenge) {
const challenger = await getUserProfile(challenge.challengerUid);
if (challenger && challenger.email) {
const { subject, text, html } = getChallengeAcceptedEmail({
challengerName: challenger.name?.split(' ')[0] || 'Challenger',
opponentName: challenge.opponent,
sportOrGame: challenge.game,
tier: challenge.tier,
confirmedDate: challenge.date.toLocaleDateString('en-US',
{ dateStyle: 'long', timeZone: 'UTC' }),
specialTerms: challenge.message || 'None',
});
await sendEmail({ to: challenger.email, subject, text, html });
}
}
revalidatePath(`/challenge/${challengeId}`);
revalidatePath('/my-challenges');
revalidatePath(`/crowdfund/${challengeId}`);
return { success: true, message: "Challenge accepted." };
} catch (err: any) {
return { success: false, error: err.message || "Failed to accept challenge." };
}
}
export async function declineChallenge(payload: { challengeId: string }) {
try {
const { challengeId } = payload;
if (!challengeId) return { success: false, error: "Challenge ID is missing" };
const challengeRef = doc(db, 'challenges', challengeId);
const challengeSnap = await getDoc(challengeRef);
if (!challengeSnap.exists()) {
throw new Error("Challenge not found");
}
const challengeData = challengeSnap.data();
const updates: any = { status: 'Declined' };
if (challengeData.isCrowdfunded) {
updates.crowdfundingStatus = 'refunded';
// TODO: Implement actual Stripe refund logic for all backers here.
// This would involve iterating through the 'backers' array and issuing
refunds via the Stripe API.
}
await updateDoc(challengeRef, updates);
const challenge = await getChallengeById(challengeId);
if (challenge) {
const challenger = await getUserProfile(challenge.challengerUid);
if (challenger && challenger.email) {
const { subject, text, html } = getChallengeDeclinedEmail({
challengerName: challenger.name?.split(' ')[0] || 'Challenger',
opponentName: challenge.opponent,
});
await sendEmail({ to: challenger.email, subject, text, html });
}
}
revalidatePath(`/challenge/${challengeId}`);
revalidatePath('/my-challenges');
revalidatePath(`/crowdfund/${challengeId}`);
return { success: true, message: "Challenge declined." };
} catch (err: any) {
return { success: false, error: err.message || "Failed to decline
challenge." };
}
}
export async function handleContactForm(payload: any) {
try {
const ContactFormSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
organization: z.string().optional(),
interest: z.enum(["Venue Partner", "Volunteer Referee", "Paid Referee",
"Volunteer Medical", "Volunteer Videographer", "Paid Videographer", "Sponsorship",
"Other"]),
message: z.string().min(10),
});
const parsed = ContactFormSchema.parse(payload);
const recipient = process.env.CONTACT_FORM_RECIPIENT_EMAIL ||
process.env.SENDGRID_FROM_EMAIL || '
[email protected]';
const { name, email, interest, message, organization } = parsed;
const emailData = getPartnershipInquiryEmail({ name, email, interest, message,
organization });
await sendEmail({ to: recipient, subject: emailData.subject, text:
emailData.text, html: emailData.html, replyTo: email });
revalidatePath("/contact");
return { success: true };
} catch(error) {
const errorMessage = error instanceof z.ZodError ? error.issues.map(i =>
i.message).join(', ') : (error as Error).message;
return { success: false, error: errorMessage };
}
}
function safeParseDate(dateInput: any): Date | undefined {
if (!dateInput) return undefined;
if (dateInput instanceof Date && !isNaN(dateInput.getTime())) {
return dateInput;
}
const date = new Date(dateInput);
if (date instanceof Date && !isNaN(date.getTime())) {
return date;
}
return undefined;
}
export async function initializeUserProfile(data: { uid: string, name: string,
email: string, alias: string, dob: Date, bio?: string }) {
const userDocRef = doc(db, 'users', data.uid);
const dobDate = safeParseDate(data.dob);
if (!dobDate) {
throw new Error("Invalid Date of Birth provided.");
}
const newProfile: Omit<Profile, 'uid'> & { dob: Timestamp } = {
name: data.name,
email: data.email,
alias: data.alias,
dob: Timestamp.fromDate(dobDate),
bio: data.bio || `I'm new to the arena, ready for a challenge!`,
location: "",
gamerIds: [],
photoUrl: "",
};
await setDoc(userDocRef, newProfile as any);
const firstName = data.name.split(' ')[0] || 'Challenger';
const emailData = getWelcomeEmail({ firstName });
await sendEmail({ to: data.email, subject: emailData.subject, text:
emailData.text, html: emailData.html, fromName: "The Vendetta Team" });
}
export async function updateUserProfile(payload: any) {
try {
const uid = payload.uid as string;
if (!uid) { throw new Error('User ID is missing. Cannot update profile.'); }
// Safely parse gamerIds to prevent JSON parsing errors
let parsedGamerIds: any[] = [];
if (typeof payload.gamerIds === 'string' && payload.gamerIds.trim() !== '') {
try {
parsedGamerIds = JSON.parse(payload.gamerIds);
} catch (error) {
console.error("Failed to parse gamerIds JSON, defaulting to empty array:",
error);
}
} else if (Array.isArray(payload.gamerIds)) {
parsedGamerIds = payload.gamerIds;
}
const { ProfileFormSchema } = await import("@/lib/types");
const parsed = ProfileFormSchema.parse({
...payload,
dob: safeParseDate(payload.dob as string),
gamerIds: parsedGamerIds,
});
const userDocRef = doc(db, 'users', uid);
const { dob, ...rest } = parsed;
const dataToUpdate: any = { ...rest, dob: dob ? Timestamp.fromDate(dob) :
Timestamp.now() };
await updateDoc(userDocRef, dataToUpdate);
revalidatePath('/profile');
revalidatePath('/opponents');
return { success: true, message: "Your profile has been saved." };
} catch (error) {
const errorMessage = error instanceof z.ZodError ? error.issues.map(i =>
i.message).join(', ') : (error as Error).message;
return { success: false, error: errorMessage };
}
}
export async function uploadMatchReplay(payload: {challengeId: string, video:
File}) {
const { challengeId, video: videoFile } = payload;
if (!challengeId) { return { success: false, error: "Challenge ID is
missing." }; }
if (!videoFile || videoFile.size === 0) { return { success: false, error:
"Please select a video file to upload." }; }
try {
const storageRef = ref(storage, `replays/${challengeId}/$
{videoFile.name}`);
const snapshot = await uploadBytes(storageRef, videoFile);
const downloadURL = await getDownloadURL(snapshot.ref);
const challengeRef = doc(db, 'challenges', challengeId);
await updateDoc(challengeRef, { videoUrl: downloadURL });
revalidatePath(`/challenge/${challengeId}`);
revalidatePath('/replays');
return { success: true, message: "Replay uploaded successfully." };
} catch (error) {
console.error("Error uploading match replay:", error);
const errorMessage = error instanceof Error ? error.message : "An unknown
server error occurred.";
return { success: false, error: `Upload failed: ${errorMessage}` };
}
}
"use client";
import { useEffect, useState } from "react";
import { notFound, useParams } from "next/navigation";
import { doc, onSnapshot } from "firebase/firestore";
import { db } from "@/lib/firebase";
import type { Challenge } from "@/lib/types";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { format } from "date-fns";
import { DollarSign, Swords, Calendar, Gamepad2, Info, Share2, Users } from
"lucide-react";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useToast } from "@/hooks/use-toast";
function CrowdfundSkeleton() {
return (
<div className="max-w-4xl mx-auto space-y-6">
<Card>
<CardHeader className="text-center">
<Skeleton className="h-10 w-3/4 mx-auto mb-2" />
<Skeleton className="h-5 w-1/2 mx-auto" />
</CardHeader>
<CardContent className="space-y-8 p-6 md:p-8">
<div className="text-center p-6 border rounded-lg">
<Skeleton className="h-6 w-1/4 mx-auto mb-2" />
<Skeleton className="h-8 w-1/2 mx-auto mb-4" />
<Skeleton className="h-4 w-full" />
</div>
<Separator />
<div className="space-y-4">
<Skeleton className="h-8 w-1/3" />
<div className="space-y-3">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
</div>
</CardContent>
</Card>
</div>
);
}
function StatusAlert({ status, goal }: { status: Challenge['crowdfundingStatus'],
goal?: number }) {
if (!status) return null;
const commonProps = { className: "mb-6" };
switch (status) {
case 'pending':
return <Alert {...commonProps}><Info className="h-4 w-4"
/><AlertTitle>Awaiting Opponent</AlertTitle><AlertDescription>This challenge is
waiting for the opponent to respond. All contributions will be automatically
refunded if the challenge is declined.</AlertDescription></Alert>;
case 'active':
if (goal && goal > 0) return <Alert {...commonProps} variant="default"
className="bg-green-500/10 border-green-500/50 text-green-400 [&>svg]:text-green-
400"><Info className="h-4 w-4" /><AlertTitle>Challenge
Accepted!</AlertTitle><AlertDescription>This challenge is officially on!
Contributions are still welcome to raise the stakes.</AlertDescription></Alert>;
return null;
case 'complete':
return <Alert {...commonProps}><Info className="h-4 w-4"
/><AlertTitle>Challenge Complete!</AlertTitle><AlertDescription>This crowdfunding
campaign has ended. Thanks to all who contributed!</AlertDescription></Alert>;
case 'refunded':
return <Alert {...commonProps} variant="destructive"><Info
className="h-4 w-4" /><AlertTitle>Challenge
Canceled</AlertTitle><AlertDescription>This challenge was declined or expired. All
contributions have been refunded to backers.</AlertDescription></Alert>;
default:
return null;
}
}
export default function CrowdfundPage() {
const params = useParams();
const { toast } = useToast();
const id = params.id as string;
const [challenge, setChallenge] = useState<Challenge | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!id) {
setLoading(false);
return;
}
const unsub = onSnapshot(doc(db, "challenges", id), (doc) => {
if (doc.exists()) {
const data = doc.data();
const challengeData = {
...data,
id: doc.id,
date: (data.date as any).toDate(),
} as Challenge;
setChallenge(challengeData);
} else {
notFound();
}
setLoading(false);
}, (error) => {
console.error("Error fetching challenge:", error);
setLoading(false);
});
return () => unsub();
}, [id]);
const handleCopyLink = () => {
navigator.clipboard.writeText(window.location.href);
toast({
title: "Link Copied!",
description: "The crowdfunding page link has been copied to your
clipboard.",
});
}
if (loading) {
return <CrowdfundSkeleton />;
}
if (!challenge || !challenge.isCrowdfunded) {
notFound();
}
const progress = challenge.prizePoolGoal ? ((challenge.currentFunding || 0) /
challenge.prizePoolGoal) * 100 : 0;
return (
<div className="max-w-4xl mx-auto animate-fade-in-up space-y-6">
<Card className="overflow-hidden">
<CardHeader className="text-center bg-muted/50 p-6">
<CardTitle className="text-4xl font-bold font-headline text-
primary flex items-center justify-center gap-3">
<Swords className="w-10 h-10"/> {challenge.challengerAlias}
vs. {challenge.opponent}
</CardTitle>
<CardDescription className="flex items-center justify-center
gap-4 text-base">
<span className="flex items-center gap-2"><Gamepad2
className="w-4 h-4"/> {challenge.game} ({challenge.tier} Tier)</span>
<Separator orientation="vertical" className="h-4" />
<span className="flex items-center gap-2"><Calendar
className="w-4 h-4"/> {format(challenge.date, "PPP")}</span>
</CardDescription>
</CardHeader>
<CardContent className="p-6 md:p-8 space-y-8">
<StatusAlert status={challenge.crowdfundingStatus}
goal={challenge.prizePoolGoal} />
<div>
<h3 className="font-semibold mb-2 flex items-center gap-
2"><Info className="w-4 h-4 text-primary"/> The Story</h3>
<blockquote className="text-muted-foreground bg-muted/30
border-l-4 border-primary/50 pl-4 py-3 italic">
{challenge.reason}
</blockquote>
</div>
<Separator />
<div className="text-center p-6 border-2 border-dashed rounded-
lg space-y-4">
<h2 className="text-2xl font-headline">Prize Pool</h2>
<div className="text-4xl font-bold text-primary">
${(challenge.currentFunding || 0).toLocaleString()}
<span className="text-xl text-muted-foreground"> / $
{ (challenge.prizePoolGoal || 0).toLocaleString()}</span>
</div>
<Progress value={progress} className="h-4" />
<Button size="lg" className="font-bold"
disabled={challenge.crowdfundingStatus !== 'pending' &&
challenge.crowdfundingStatus !== 'active'}>
<DollarSign className="mr-2 h-5 w-5"/> Back This
Challenge
</Button>
<p className="text-xs text-muted-foreground">Contributions
are processed securely via Stripe. This feature is a demo and does not process real
payments.</p>
</div>
<Separator />
<div className="space-y-4">
<h3 className="font-semibold flex items-center gap-
2"><Users className="w-4 h-4 text-primary"/> {challenge.backers?.length || 0}
Backers</h3>
{challenge.backers && challenge.backers.length > 0 ? (
<div className="grid gap-3">
{challenge.backers.sort((a,b) => b.amount -
a.amount).map((backer, index) => (
<div key={index} className="flex items-center
justify-between p-3 rounded-md bg-muted/50">
<div className="flex items-center gap-3">
<Avatar>
<AvatarFallback>{backer.anonymous ?
'A' : backer.alias.charAt(0)}</AvatarFallback>
</Avatar>
<p className="font-
medium">{backer.anonymous ? 'Anonymous Backer' : backer.alias}</p>
</div>
<p className="font-bold text-lg text-
primary">${backer.amount.toLocaleString()}</p>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center
py-4">Be the first one to back this challenge and get the hype train rolling!</p>
)}
</div>
</CardContent>
<CardFooter>
<div className="flex flex-col sm:flex-row gap-4 items-center
justify-center w-full">
<p className="font-semibold">Share this challenge:</p>
<div className="flex gap-2">
<Button variant="outline" size="sm"
onClick={handleCopyLink}><Share2 className="mr-2"/> Copy Link</Button>
<Button asChild variant="outline" size="sm"><a
href={`https://twitter.com/intent/tweet?text=Check%20out%20this%20Vendetta%20Arena
%20challenge%20and%20support%20a%20competitor!&url=${encodeURIComponent(typeof
window !== 'undefined' ? window.location.href : '')}`} target="_blank"
rel="noopener noreferrer">Share on X</a></Button>
<Button asChild variant="outline" size="sm"><a
href={`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(typeof
window !== 'undefined' ? window.location.href : '')}`} target="_blank"
rel="noopener noreferrer">Share on Facebook</a></Button>
</div>
</div>
</CardFooter>
</Card>
<Button asChild variant="outline">
<Link href={`/challenge/${id}`}>
← Back to Challenge Details
</Link>
</Button>
</div>
);
}
"use client";
import { getChallengeById, getUserProfile } from "@/lib/data";
import { notFound, useRouter } from "next/navigation";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { format } from "date-fns";
import type { Challenge, Profile } from "@/lib/types";
import { getStatusBadgeVariant } from "@/app/(app)/my-challenges/page";
import { Swords, Calendar, Gamepad2, Trophy, Shield, Info, Clapperboard, MapPin,
Check, X, Loader2, DollarSign } from "lucide-react";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import { reportMatchOutcome, acceptChallenge, declineChallenge, uploadMatchReplay }
from "@/lib/actions";
import React, { useEffect, useState, useTransition } from "react";
import { useAuth } from "@/hooks/use-auth";
import { Skeleton } from "@/components/ui/skeleton";
import { useToast } from "@/hooks/use-toast";
function ReportResultForm({ challengeId, reportingUid }: { challengeId: string,
reportingUid: string }) {
const [isPending, startTransition] = useTransition();
const { toast } = useToast();
const handleReport = (result: 'win' | 'loss') => {
startTransition(async () => {
const response = await reportMatchOutcome({ challengeId, result,
reportingUid });
if (response.success) {
toast({ title: "Result Reported", description: response.message });
} else if (response.error) {
toast({ variant: "destructive", title: "Error", description:
response.error });
}
});
}
return (
<div className="mt-6 p-4 border rounded-lg bg-muted/50">
<h3 className="font-bold mb-2">Report Match Outcome</h3>
<p className="text-sm text-muted-foreground mb-4">Once the match is
complete, report the result below. Both participants must report for
verification.</p>
<div className="flex gap-4">
<Button onClick={() => handleReport('win')} disabled={isPending}
className="w-full bg-green-600 hover:bg-green-700">
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin"
/>} I Won
</Button>
<Button onClick={() => handleReport('loss')} disabled={isPending}
className="w-full" variant="destructive">
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin"
/>} I Lost
</Button>
</div>
</div>
)
}
function RespondToChallengeForm({ challengeId, acceptingUserUid }: { challengeId:
string, acceptingUserUid: string }) {
const [isPending, startTransition] = useTransition();
const { toast } = useToast();
const handleResponse = (action: 'accept' | 'decline') => {
startTransition(async () => {
const response = action === 'accept'
? await acceptChallenge({ challengeId, acceptingUserUid })
: await declineChallenge({ challengeId });
if (response.success) {
toast({ title: "Response Sent", description: `You have ${action}ed
the challenge.` });
} else if (response.error) {
toast({ variant: "destructive", title: "Error", description:
response.error });
}
});
};
return (
<div className="mt-6 p-4 border rounded-lg bg-muted/50">
<h3 className="font-bold mb-2">You Have Been Challenged!</h3>
<p className="text-sm text-muted-foreground mb-4">Respond to the
challenge below. Your decision is final.</p>
<div className="flex gap-4">
<Button onClick={() => handleResponse('accept')}
disabled={isPending} className="w-full bg-green-600 hover:bg-green-700">
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin"
/>} <Check className="mr-2 h-4 w-4" /> Accept
</Button>
<Button onClick={() => handleResponse('decline')}
disabled={isPending} className="w-full" variant="destructive">
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-
spin" />} <X className="mr-2 h-4 w-4" /> Decline
</Button>
</div>
</div>
)
}
function VideoPlayerCard({ challenge }: { challenge: Challenge }) {
if (challenge.status !== 'Completed' || !challenge.videoUrl) {
return null;
}
return (
<Card className="mt-6">
<CardHeader>
<CardTitle className="flex items-center gap-2 font-headline">
<Clapperboard /> Match Replay
</CardTitle>
<CardDescription>The official footage from the completed
match.</CardDescription>
</CardHeader>
<CardContent>
<video src={challenge.videoUrl} controls className="w-full rounded-lg
aspect-video bg-black"></video>
</CardContent>
</Card>
);
}
function UploadReplayForm({ challengeId }: { challengeId: string }) {
const { toast } = useToast();
const [isPending, startTransition] = useTransition();
const formRef = React.useRef<HTMLFormElement>(null);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const payload = {
challengeId: formData.get('challengeId') as string,
video: formData.get('video') as File
};
startTransition(async () => {
const result = await uploadMatchReplay(payload);
if (result.success) {
toast({
title: "Success",
description: result.message,
});
formRef.current?.reset();
} else if (result.error) {
toast({
variant: "destructive",
title: "Error",
description: result.error,
});
}
});
};
return (
<div className="mt-6 p-4 border-2 border-dashed rounded-lg bg-muted/50">
<h3 className="font-bold mb-2">Upload Match Replay</h3>
<p className="text-sm text-muted-foreground mb-4">Upload the video
footage for this completed match. This will be made public in the Replays
section.</p>
<form ref={formRef} onSubmit={handleSubmit}>
<input type="hidden" name="challengeId" value={challengeId} />
<Input type="file" name="video" accept="video/*" required />
<Button type="submit" className="w-full mt-4" disabled={isPending}>
{isPending ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />
Uploading...</>
) : (
"Upload Video"
)}
</Button>
</form>
</div>
)
}
function PageSkeleton() {
return (
<div className="max-w-3xl mx-auto">
<Card>
<CardHeader>
<div className="flex justify-between items-start">
<div>
<Skeleton className="h-9 w-48 mb-3" />
<Skeleton className="h-5 w-32" />
</div>
<Skeleton className="h-8 w-24" />
</div>
</CardHeader>
<CardContent className="space-y-6">
<Separator />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
<Separator />
<div className="space-y-2">
<Skeleton className="h-5 w-40 mb-2" />
<Skeleton className="h-16 w-full" />
</div>
</CardContent>
<CardFooter>
<Skeleton className="h-10 w-full" />
</CardFooter>
</Card>
</div>
)
}
export default function ChallengeDetailsPage({ params }: { params: { id:
string } }) {
const { user } = useAuth();
const router = useRouter();
const [challenge, setChallenge] = useState<Challenge | null>(null);
const [currentUserProfile, setCurrentUserProfile] = useState<Profile |
null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!params.id) return;
async function fetchData() {
setLoading(true);
const challengeData = await getChallengeById(params.id);
if (!challengeData) {
notFound();
return;
}
setChallenge(challengeData);
if (user) {
const profileData = await getUserProfile(user.uid, user.email,
user.displayName);
setCurrentUserProfile(profileData);
}
setLoading(false);
}
fetchData();
}, [params.id, user, router]);
if (loading || !challenge) {
return <PageSkeleton />;
}
const isOpponent = currentUserProfile && (currentUserProfile.alias ===
challenge.opponent || currentUserProfile.email === challenge.opponent);
const isChallenger = currentUserProfile && currentUserProfile.uid ===
challenge.challengerUid;
const isParticipant = isChallenger || isOpponent;
const currentUserHasReported = (isChallenger && challenge.challengerResult) ||
(isOpponent && challenge.opponentResult);
return (
<div className="max-w-3xl mx-auto animate-fade-in-up">
<Card>
<CardHeader>
<div className="flex justify-between items-start">
<div>
<CardTitle className="text-3xl font-bold font-headline text-primary
flex items-center gap-2">
<Swords /> {challenge.challengerAlias} vs. {challenge.opponent}
</CardTitle>
<CardDescription className="flex items-center gap-2 mt-2">
<Gamepad2 className="w-4 h-4" /> {challenge.game}
</CardDescription>
</div>
<Badge variant={getStatusBadgeVariant(challenge.status)}
className="text-base px-4 py-1">{challenge.status}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<Separator />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm">
<div className="flex items-center gap-3">
<Shield className="w-5 h-5 text-primary" />
<div>
<p className="text-muted-foreground">Tier</p>
<p className="font-semibold">{challenge.tier}</p>
</div>
</div>
<div className="flex items-center gap-3">
<Calendar className="w-5 h-5 text-primary" />
<div>
<p className="text-muted-foreground">Date</p>
<p className="font-semibold">{format(challenge.date, "PPP
'at' p")}</p>
</div>
</div>
{challenge.streamUrl && (
<div className="flex items-center gap-3 md:col-span-2">
<Clapperboard className="w-5 h-5 text-primary" />
<div>
<p className="text-muted-foreground">Stream URL</p>
<Link href={challenge.streamUrl} target="_blank"
rel="noopener noreferrer" className="font-semibold text-blue-400 hover:underline
break-all">{challenge.streamUrl}</Link>
</div>
</div>
)}
{challenge.venue && (
<div className="flex items-center gap-3 md:col-span-2">
<MapPin className="w-5 h-5 text-primary" />
<div>
<p className="text-muted-foreground">Venue</p>
<p className="font-semibold">{challenge.venue}</p>
</div>
</div>
)}
</div>
<Separator />
<div>
<h3 className="font-semibold mb-2 flex items-center gap-2"><Info
className="w-4 h-4 text-primary"/> Reason for Challenge</h3>
<blockquote className="text-sm text-muted-foreground bg-muted/30
border-l-4 border-primary/50 pl-4 py-2 italic">
{challenge.reason}
</blockquote>
</div>
{challenge.message && (
<div>
<h3 className="font-semibold mb-2 flex items-center gap-
2"><Info className="w-4 h-4 text-primary"/> Message to Opponent</h3>
<blockquote className="text-sm text-muted-foreground
bg-muted/30 border-l-4 border-primary/50 pl-4 py-2 italic">
{challenge.message}
</blockquote>
</div>
)}
{challenge.isCrowdfunded && (
<div className="mt-6">
<Button asChild className="w-full bg-gradient-to-r from-yellow-
400 to-orange-500 text-white hover:from-yellow-500 hover:to-orange-600 shadow-lg">
<Link href={`/crowdfund/${challenge.id}`}>
<DollarSign className="mr-2 h-4 w-4" /> View
Crowdfunding Page & Contribute
</Link>
</Button>
</div>
)}
{challenge.status === 'Completed' && challenge.videoUrl &&
<VideoPlayerCard challenge={challenge} />}
{challenge.status === 'Pending' && isOpponent && user &&
<RespondToChallengeForm challengeId={challenge.id} acceptingUserUid={user.uid}/>}
{challenge.status === 'Accepted' && isParticipant && user && !
currentUserHasReported && (
<ReportResultForm challengeId={challenge.id}
reportingUid={user.uid} />
)}
{challenge.status === 'AwaitingVerification' && isParticipant && (
<div className="mt-6 p-4 border-yellow-500/50 rounded-lg bg-
yellow-500/10 text-center">
<h3 className="font-bold text-lg">Awaiting Verification</h3>
<p className="text-sm text-yellow-400">
{currentUserHasReported
? "Your result has been recorded. Waiting for your
opponent to report."
: "Your opponent has reported the result. Please report
your outcome to complete the challenge."
}
</p>
{!currentUserHasReported && user && <ReportResultForm
challengeId={challenge.id} reportingUid={user.uid} />}
</div>
)}
{challenge.status === 'Disputed' && (
<div className="mt-6 p-4 border-red-500/50 rounded-lg bg-red-
500/10 text-center">
<h3 className="font-bold text-lg">Result Disputed</h3>
<p className="text-sm text-red-400">The reported results from
both participants are conflicting. An administrator will review the case and make a
final decision.</p>
</div>
)}
{challenge.status === 'Completed' && challenge.winner && (
<div className="mt-6 p-4 border border-green-500/50 rounded-lg bg-
green-500/10 text-center">
<Trophy className="w-8 h-8 mx-auto text-green-500 mb-2"/>
<h3 className="font-bold text-lg">Challenge Complete!</h3>
<p className="text-sm text-green-400">Winner:
{challenge.winner}</p>
</div>
)}
{challenge.status === 'Completed' && challenge.tier === 'Bronze' && !
challenge.videoUrl && isParticipant && <UploadReplayForm challengeId={challenge.id}
/>}
{challenge.status === 'Pending' && !isOpponent && (
<div className="mt-6 p-4 border-yellow-500/50 rounded-lg bg-
yellow-500/10 text-center">
<h3 className="font-bold text-lg">Awaiting Response</h3>
<p className="text-sm text-yellow-400">The opponent has not yet
responded to your challenge.</p>
</div>
)}
{challenge.status === 'Declined' && (
<div className="mt-6 p-4 border-red-500/50 rounded-lg bg-red-
500/10 text-center">
<h3 className="font-bold text-lg">Challenge Declined</h3>
<p className="text-sm text-red-400">The opponent has declined
the challenge.</p>
</div>
)}
</CardContent>
<CardFooter>
<Button variant="outline" asChild className="w-full">
<Link href="/my-challenges">Back to All Challenges</Link>
</Button>
</CardFooter>
</Card>
</div>
);
}
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Textarea } from "@/components/ui/textarea";
import { challengeLevels, categoryOptions, gameOptions } from "@/lib/constants";
import { Info, Loader2, Clapperboard, MapPin, AlertCircle, DollarSign } from
"lucide-react";
import Link from "next/link";
import React, { useState, useEffect, useTransition } from "react";
import { useToast } from "@/hooks/use-toast";
import { Skeleton } from "@/components/ui/skeleton";
import { createChallenge } from "@/lib/actions";
import { DatePicker } from "@/components/ui/date-picker";
import { useAuth } from "@/hooks/use-auth";
import { getUserProfile } from "@/lib/data";
import { calculateAge } from "@/lib/utils";
import { Switch } from "@/components/ui/switch";
function CreateChallengeForm() {
const { user } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const { toast } = useToast();
const [isPending, startTransition] = useTransition();
const [loading, setLoading] = useState(true);
const [userAge, setUserAge] = useState<number | null>(null);
const tierParam = searchParams.get('tier');
const opponentFromQuery = searchParams.get('opponent');
const validTiers = challengeLevels.map(l => l.tier);
const [tier, setTier] = useState(validTiers.includes(tierParam || '') ?
tierParam! : 'Free');
const [category, setCategory] = useState('');
const [specifics, setSpecifics] = useState('');
const [specificOptions, setSpecificOptions] = useState<string[]>([]);
const [opponent, setOpponent] = useState(opponentFromQuery || '');
const [streamUrl, setStreamUrl] = useState('');
const [proposedVenue, setProposedVenue] = useState('');
const [proposedDate, setProposedDate] = useState<Date | undefined>();
const [reason, setReason] = useState('');
const [message, setMessage] = useState('');
const [isCrowdfunded, setIsCrowdfunded] = useState(false);
const [prizePoolGoal, setPrizePoolGoal] = useState<number | undefined>();
const crowdfundableTiers = ['Gold', 'Onyx', 'Platinum'];
const handleTierChange = (newTier: string) => {
setTier(newTier);
if (!crowdfundableTiers.includes(newTier)) {
setIsCrowdfunded(false);
}
}
useEffect(() => {
if (user) {
const fetchUserData = async () => {
setLoading(true);
const profile = await getUserProfile(user.uid);
let determinedAge = null;
let initialCategory = categoryOptions[0];
if (profile && profile.dob) {
determinedAge = calculateAge(profile.dob);
setUserAge(determinedAge);
if (determinedAge < 18) {
initialCategory = 'E-Sports';
}
}
setCategory(initialCategory);
const newOptions = gameOptions[initialCategory as keyof typeof
gameOptions] || [];
setSpecificOptions(newOptions);
setSpecifics(newOptions[0] || '');
setLoading(false);
};
fetchUserData();
} else {
setLoading(false);
}
}, [user]);
useEffect(() => {
// This effect runs when the user manually changes the category
if (loading) return; // Don't run on initial load
const newOptions = gameOptions[category as keyof typeof gameOptions] || [];
setSpecificOptions(newOptions);
setSpecifics(newOptions[0] || '');
}, [category, loading]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) {
toast({ variant: 'destructive', title: 'Error', description: 'You must be
signed in to create a challenge.' });
return;
}
startTransition(async () => {
const payload = {
challengerUid: user.uid,
opponent: opponent,
category: category,
specifics: specifics,
tier: tier,
reason: reason,
message: message,
proposedDate: proposedDate ? proposedDate.toISOString() : null,
streamUrl: streamUrl,
proposedVenue: proposedVenue,
isCrowdfunded: isCrowdfunded,
prizePoolGoal: prizePoolGoal,
};
const result = await createChallenge(payload);
if (result?.success) {
if (result.url) {
window.top.location.href = result.url;
} else {
toast({ title: "Challenge Sent!", description: result.message });
router.push('/my-challenges');
}
} else {
toast({ variant: "destructive", title: "Challenge Creation Failed",
description: result?.error || "An unknown error occurred." });
}
});
};
if (loading || !user) {
return <FormSkeleton />;
}
const isUnder18 = userAge !== null && userAge < 18;
return (
<div className="max-w-2xl mx-auto animate-fade-in-up">
<form onSubmit={handleSubmit}>
<Card>
<CardHeader>
<CardTitle className="text-3xl font-bold font-headline text-
primary">Issue a Challenge</CardTitle>
<CardDescription>Define your rivalry. Fill out the details below to
start your Vendetta.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{isUnder18 && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Age Restriction Applied</AlertTitle>
<AlertDescription>
Because you are under 18, you are restricted to creating
challenges in the E-Sports category only.
</AlertDescription>
</Alert>
)}
<div className="grid gap-2">
<Label htmlFor="opponent">Opponent's Alias or Email</Label>
<Input id="opponent" value={opponent} onChange={(e) =>
setOpponent(e.target.value)} placeholder="e.g., TheRival or [email protected]"
required />
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="category-select">Challenge Category</Label>
<Select value={category} onValueChange={setCategory} required
disabled={isUnder18}>
<SelectTrigger id="category-select"><SelectValue
placeholder="Select a category..." /></SelectTrigger>
<SelectContent>
{categoryOptions.map(opt => <SelectItem key={opt}
value={opt}>{opt}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="grid gap-2 animate-fade-in-up">
<Label htmlFor="specifics-select">Specific Game/Activity</Label>
<Select value={specifics} onValueChange={setSpecifics} required
disabled={!specificOptions.length}>
<SelectTrigger id="specifics-select"><SelectValue
placeholder="Select a game/activity" /></SelectTrigger>
<SelectContent>
{specificOptions.map((game) => <SelectItem key={game}
value={game}>{game}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
{category === 'E-Sports' && (
<div className="grid gap-2 animate-fade-in-up">
<Label htmlFor="streamUrl" className="flex items-center gap-
2"><Clapperboard className="w-4 h-4" /> Your Stream URL (Optional)</Label>
<Input id="streamUrl" value={streamUrl} onChange={(e) =>
setStreamUrl(e.target.value)} type="url" placeholder="e.g.,
https://twitch.tv/yourchannel" />
</div>
)}
{category === 'Physical Sport' && (
<div className="grid gap-2 animate-fade-in-up">
<Label htmlFor="proposedVenue" className="flex items-center gap-
2"><MapPin className="w-4 h-4" /> Proposed Venue / Location (Optional)</Label>
<Input id="proposedVenue" value={proposedVenue} onChange={(e) =>
setProposedVenue(e.target.value)} placeholder="e.g., Katy, TX or a specific gym
name" />
</div>
)}
<div className="grid gap-2">
<Label htmlFor="tier-select" className="flex justify-between items-
center">Challenge Tier <Link href="/challenge/levels" className="flex items-center
text-xs text-muted-foreground hover:text-primary"><Info className="w-3 h-3 mr-1" />
Learn more</Link></Label>
<Select value={tier} onValueChange={handleTierChange} required
disabled={!!tierParam}>
<SelectTrigger id="tier-select"><SelectValue /></SelectTrigger>
<SelectContent>
{challengeLevels.map((level) => <SelectItem key={level.name}
value={level.tier}><div className="flex items-center gap-2"><level.icon
className="w-4 h-4" />{level.name}</div></SelectItem>)}
</SelectContent>
</Select>
</div>
{crowdfundableTiers.includes(tier) && (
<div className="grid gap-2 animate-fade-in-up">
<div className="flex items-center justify-between rounded-lg
border p-3 shadow-sm">
<div className="space-y-0.5">
<Label>Enable Crowdfunding</Label>
<p className="text-xs text-muted-foreground">Allow the
community to contribute to a prize pool.</p>
</div>
<Switch checked={isCrowdfunded}
onCheckedChange={setIsCrowdfunded} name="isCrowdfunded" />
</div>
</div>
)}
{isCrowdfunded && (
<div className="grid gap-2 animate-fade-in-up">
<Label htmlFor="prizePoolGoal" className="flex items-center
gap-2"><DollarSign className="w-4 h-4" /> Prize Pool Goal ($)</Label>
<Input id="prizePoolGoal" value={prizePoolGoal || ''}
onChange={(e) => setPrizePoolGoal(Number(e.target.value))} type="number"
placeholder="e.g., 500" required />
</div>
)}
<div className="grid gap-2">
<Label>Proposed Date (Optional)</Label>
<DatePicker date={proposedDate} setDate={setProposedDate} disablePast
/>
</div>
<div className="grid gap-2">
<Label htmlFor="reason">Reason for Challenge</Label>
<Textarea id="reason" value={reason} onChange={(e) =>
setReason(e.target.value)} placeholder="e.g. Last year I lost by one split of a
second..." rows={4} required />
</div>
<div className="grid gap-2">
<Label htmlFor="message">Challenge Message (Optional)</Label>
<Textarea id="message" value={message} onChange={(e) =>
setMessage(e.target.value)} placeholder="The time for talk is over. Let's settle
this." rows={4} />
</div>
</CardContent>
<CardFooter>
<Button type="submit" className="w-full" disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isPending ? 'Processing...' : 'Proceed to Payment / Send'}
</Button>
</CardFooter>
</Card>
</form>
</div>
);
}
function FormSkeleton() {
return (
<div className="max-w-2xl mx-auto">
<Card>
<CardHeader>
<Skeleton className="h-9 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2"><Skeleton className="h-4 w-1/4" /><Skeleton
className="h-10 w-full" /></div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2"><Skeleton className="h-4 w-1/4" /><Skeleton
className="h-10 w-full" /></div>
<div className="space-y-2"><Skeleton className="h-4 w-1/4" /><Skeleton
className="h-10 w-full" /></div>
</div>
<div className="space-y-2"><Skeleton className="h-4 w-1/4" /><Skeleton
className="h-10 w-full" /></div>
<div className="space-y-2"><Skeleton className="h-4 w-1/4" /><Skeleton
className="h-20 w-full" /></div>
<div className="space-y-2"><Skeleton className="h-4 w-1/4" /><Skeleton
className="h-20 w-full" /></div>
</CardContent>
<CardFooter><Skeleton className="h-10 w-full" /></CardFooter>
</Card>
</div>
);
}
export default function CreateChallengePage() {
return (
<React.Suspense fallback={<FormSkeleton />}>
<CreateChallengeForm />
</React.Suspense>
);
}