0% found this document useful (0 votes)
14 views32 pages

Crowd

The document defines various schemas using Zod for validating user profiles, challenge creation, and crowdfunding data in a gaming context. It includes rules for user attributes such as name, email, date of birth, and challenge details like opponent, tier, and prize pool. Additionally, it implements functions for sending emails, creating challenges, and suggesting opponents based on user profiles and preferences.

Uploaded by

Mario Agnese
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as TXT, PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
14 views32 pages

Crowd

The document defines various schemas using Zod for validating user profiles, challenge creation, and crowdfunding data in a gaming context. It includes rules for user attributes such as name, email, date of birth, and challenge details like opponent, tier, and prize pool. Additionally, it implements functions for sending emails, creating challenges, and suggesting opponents based on user profiles and preferences.

Uploaded by

Mario Agnese
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as TXT, PDF, TXT or read online on Scribd

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}`}>
&larr; 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>
);
}

You might also like