Compare commits

..

No commits in common. "main" and "feat/database-migration-and-dev-platform" have entirely different histories.

137 changed files with 4987 additions and 40368 deletions

View file

@ -35,6 +35,7 @@ data
.env
load-ids.txt
server
tmp
types
.git

View file

@ -7,22 +7,14 @@ COPY package.json package-lock.json* pnpm-lock.yaml* npm-shrinkwrap.json* ./
# Install dependencies
RUN if [ -f pnpm-lock.yaml ]; then npm install -g pnpm && pnpm install --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci --legacy-peer-deps; \
else npm install --legacy-peer-deps; fi
elif [ -f package-lock.json ]; then npm ci; \
else npm install; fi
# Copy source code
COPY . .
# Build-time env vars (VITE_* are baked into the bundle at build time)
ARG VITE_SUPABASE_URL
ARG VITE_SUPABASE_ANON_KEY
ARG VITE_AUTHENTIK_PROVIDER
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
ENV VITE_AUTHENTIK_PROVIDER=$VITE_AUTHENTIK_PROVIDER
# Build the client so the Activity gets compiled JS (no Vite dev mode in Discord iframe)
RUN npm run build:client
# Build the app (frontend + server)
RUN npm run build
# Set environment
ENV NODE_ENV=production
@ -32,4 +24,4 @@ ENV PORT=3000
EXPOSE 3000
# Start the server
CMD ["npm", "run", "dev"]
CMD ["npm", "start"]

View file

@ -1,187 +0,0 @@
import { supabase } from "../_supabase.js";
export default async (req: Request) => {
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
const { data: userData } = await supabase.auth.getUser(token);
if (!userData.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
// Check if user is admin
const { data: profile } = await supabase
.from("profiles")
.select("role")
.eq("id", userData.user.id)
.single();
if (!profile || profile.role !== "admin") {
return new Response(JSON.stringify({ error: "Forbidden" }), { status: 403, headers: { "Content-Type": "application/json" } });
}
const url = new URL(req.url);
const period = url.searchParams.get("period") || "30"; // days
try {
if (req.method === "GET") {
const daysAgo = new Date();
daysAgo.setDate(daysAgo.getDate() - parseInt(period));
// Get total users and growth
const { count: totalUsers } = await supabase
.from("profiles")
.select("*", { count: "exact", head: true });
const { count: newUsersThisPeriod } = await supabase
.from("profiles")
.select("*", { count: "exact", head: true })
.gte("created_at", daysAgo.toISOString());
// Get active users (logged in within period)
const { count: activeUsers } = await supabase
.from("profiles")
.select("*", { count: "exact", head: true })
.gte("last_login_at", daysAgo.toISOString());
// Get opportunities stats
const { count: totalOpportunities } = await supabase
.from("aethex_opportunities")
.select("*", { count: "exact", head: true });
const { count: openOpportunities } = await supabase
.from("aethex_opportunities")
.select("*", { count: "exact", head: true })
.eq("status", "open");
const { count: newOpportunities } = await supabase
.from("aethex_opportunities")
.select("*", { count: "exact", head: true })
.gte("created_at", daysAgo.toISOString());
// Get applications stats
const { count: totalApplications } = await supabase
.from("aethex_applications")
.select("*", { count: "exact", head: true });
const { count: newApplications } = await supabase
.from("aethex_applications")
.select("*", { count: "exact", head: true })
.gte("created_at", daysAgo.toISOString());
// Get contracts stats
const { count: totalContracts } = await supabase
.from("nexus_contracts")
.select("*", { count: "exact", head: true });
const { count: activeContracts } = await supabase
.from("nexus_contracts")
.select("*", { count: "exact", head: true })
.eq("status", "active");
// Get community stats
const { count: totalPosts } = await supabase
.from("community_posts")
.select("*", { count: "exact", head: true });
const { count: newPosts } = await supabase
.from("community_posts")
.select("*", { count: "exact", head: true })
.gte("created_at", daysAgo.toISOString());
// Get creator stats
const { count: totalCreators } = await supabase
.from("aethex_creators")
.select("*", { count: "exact", head: true });
// Get daily signups for trend (last 30 days)
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const { data: signupTrend } = await supabase
.from("profiles")
.select("created_at")
.gte("created_at", thirtyDaysAgo.toISOString())
.order("created_at");
// Group signups by date
const signupsByDate: Record<string, number> = {};
signupTrend?.forEach((user) => {
const date = new Date(user.created_at).toISOString().split("T")[0];
signupsByDate[date] = (signupsByDate[date] || 0) + 1;
});
const dailySignups = Object.entries(signupsByDate).map(([date, count]) => ({
date,
count
}));
// Revenue data (if available)
const { data: revenueData } = await supabase
.from("nexus_payments")
.select("amount, created_at")
.eq("status", "completed")
.gte("created_at", daysAgo.toISOString());
const totalRevenue = revenueData?.reduce((sum, p) => sum + (p.amount || 0), 0) || 0;
// Top performing opportunities
const { data: topOpportunities } = await supabase
.from("aethex_opportunities")
.select(`
id,
title,
aethex_applications(count)
`)
.eq("status", "open")
.order("created_at", { ascending: false })
.limit(5);
return new Response(JSON.stringify({
users: {
total: totalUsers || 0,
new: newUsersThisPeriod || 0,
active: activeUsers || 0,
creators: totalCreators || 0
},
opportunities: {
total: totalOpportunities || 0,
open: openOpportunities || 0,
new: newOpportunities || 0
},
applications: {
total: totalApplications || 0,
new: newApplications || 0
},
contracts: {
total: totalContracts || 0,
active: activeContracts || 0
},
community: {
posts: totalPosts || 0,
newPosts: newPosts || 0
},
revenue: {
total: totalRevenue,
period: `${period} days`
},
trends: {
dailySignups,
topOpportunities: topOpportunities?.map(o => ({
id: o.id,
title: o.title,
applications: o.aethex_applications?.[0]?.count || 0
})) || []
},
period: parseInt(period)
}), { headers: { "Content-Type": "application/json" } });
}
return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } });
} catch (err: any) {
console.error("Analytics API error:", err);
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } });
}
};

View file

@ -1,245 +0,0 @@
import { supabase } from "../_supabase.js";
export default async (req: Request) => {
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
const { data: userData } = await supabase.auth.getUser(token);
if (!userData.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
// Check if user is admin
const { data: profile } = await supabase
.from("profiles")
.select("role")
.eq("id", userData.user.id)
.single();
if (!profile || profile.role !== "admin") {
return new Response(JSON.stringify({ error: "Forbidden" }), { status: 403, headers: { "Content-Type": "application/json" } });
}
const url = new URL(req.url);
try {
// GET - Fetch reports and stats
if (req.method === "GET") {
const status = url.searchParams.get("status") || "open";
const type = url.searchParams.get("type"); // report, dispute, user
// Get content reports
let reportsQuery = supabase
.from("moderation_reports")
.select(`
*,
reporter:profiles!moderation_reports_reporter_id_fkey(id, full_name, email, avatar_url)
`)
.order("created_at", { ascending: false })
.limit(50);
if (status !== "all") {
reportsQuery = reportsQuery.eq("status", status);
}
if (type && type !== "all") {
reportsQuery = reportsQuery.eq("target_type", type);
}
const { data: reports, error: reportsError } = await reportsQuery;
if (reportsError) console.error("Reports error:", reportsError);
// Get disputes
let disputesQuery = supabase
.from("nexus_disputes")
.select(`
*,
reporter:profiles!nexus_disputes_reported_by_fkey(id, full_name, email)
`)
.order("created_at", { ascending: false })
.limit(50);
if (status !== "all") {
disputesQuery = disputesQuery.eq("status", status);
}
const { data: disputes, error: disputesError } = await disputesQuery;
if (disputesError) console.error("Disputes error:", disputesError);
// Get flagged users (users with warnings/bans)
const { data: flaggedUsers } = await supabase
.from("profiles")
.select("id, full_name, email, avatar_url, is_banned, warning_count, created_at")
.or("is_banned.eq.true,warning_count.gt.0")
.order("created_at", { ascending: false })
.limit(50);
// Calculate stats
const { count: openReports } = await supabase
.from("moderation_reports")
.select("*", { count: "exact", head: true })
.eq("status", "open");
const { count: openDisputes } = await supabase
.from("nexus_disputes")
.select("*", { count: "exact", head: true })
.eq("status", "open");
const { count: resolvedToday } = await supabase
.from("moderation_reports")
.select("*", { count: "exact", head: true })
.eq("status", "resolved")
.gte("updated_at", new Date(new Date().setHours(0, 0, 0, 0)).toISOString());
const stats = {
openReports: openReports || 0,
openDisputes: openDisputes || 0,
resolvedToday: resolvedToday || 0,
flaggedUsers: flaggedUsers?.length || 0
};
return new Response(JSON.stringify({
reports: reports || [],
disputes: disputes || [],
flaggedUsers: flaggedUsers || [],
stats
}), { headers: { "Content-Type": "application/json" } });
}
// POST - Take moderation action
if (req.method === "POST") {
const body = await req.json();
// Resolve/ignore report
if (body.action === "update_report") {
const { report_id, status, resolution_notes } = body;
const { data, error } = await supabase
.from("moderation_reports")
.update({
status,
resolution_notes,
resolved_by: userData.user.id,
updated_at: new Date().toISOString()
})
.eq("id", report_id)
.select()
.single();
if (error) throw error;
return new Response(JSON.stringify({ report: data }), { headers: { "Content-Type": "application/json" } });
}
// Resolve dispute
if (body.action === "update_dispute") {
const { dispute_id, status, resolution_notes } = body;
const { data, error } = await supabase
.from("nexus_disputes")
.update({
status,
resolution_notes,
resolved_by: userData.user.id,
resolved_at: new Date().toISOString()
})
.eq("id", dispute_id)
.select()
.single();
if (error) throw error;
return new Response(JSON.stringify({ dispute: data }), { headers: { "Content-Type": "application/json" } });
}
// Ban/warn user
if (body.action === "moderate_user") {
const { user_id, action_type, reason } = body;
if (action_type === "ban") {
const { data, error } = await supabase
.from("profiles")
.update({
is_banned: true,
ban_reason: reason,
banned_at: new Date().toISOString(),
banned_by: userData.user.id
})
.eq("id", user_id)
.select()
.single();
if (error) throw error;
return new Response(JSON.stringify({ user: data, action: "banned" }), { headers: { "Content-Type": "application/json" } });
}
if (action_type === "warn") {
const { data: currentUser } = await supabase
.from("profiles")
.select("warning_count")
.eq("id", user_id)
.single();
const { data, error } = await supabase
.from("profiles")
.update({
warning_count: (currentUser?.warning_count || 0) + 1,
last_warning_at: new Date().toISOString(),
last_warning_reason: reason
})
.eq("id", user_id)
.select()
.single();
if (error) throw error;
return new Response(JSON.stringify({ user: data, action: "warned" }), { headers: { "Content-Type": "application/json" } });
}
if (action_type === "unban") {
const { data, error } = await supabase
.from("profiles")
.update({
is_banned: false,
ban_reason: null,
unbanned_at: new Date().toISOString(),
unbanned_by: userData.user.id
})
.eq("id", user_id)
.select()
.single();
if (error) throw error;
return new Response(JSON.stringify({ user: data, action: "unbanned" }), { headers: { "Content-Type": "application/json" } });
}
}
// Delete content
if (body.action === "delete_content") {
const { content_type, content_id } = body;
const tableMap: Record<string, string> = {
post: "community_posts",
comment: "community_comments",
project: "projects",
opportunity: "aethex_opportunities"
};
const table = tableMap[content_type];
if (!table) {
return new Response(JSON.stringify({ error: "Invalid content type" }), { status: 400, headers: { "Content-Type": "application/json" } });
}
const { error } = await supabase.from(table).delete().eq("id", content_id);
if (error) throw error;
return new Response(JSON.stringify({ success: true, deleted: content_type }), { headers: { "Content-Type": "application/json" } });
}
return new Response(JSON.stringify({ error: "Invalid action" }), { status: 400, headers: { "Content-Type": "application/json" } });
}
return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } });
} catch (err: any) {
console.error("Moderation API error:", err);
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } });
}
};

View file

@ -1,196 +0,0 @@
import { supabase } from "../_supabase.js";
export default async (req: Request) => {
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const { data: userData } = await supabase.auth.getUser(token);
if (!userData.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const userId = userData.user.id;
const url = new URL(req.url);
try {
// GET - Fetch interviews
if (req.method === "GET") {
const status = url.searchParams.get("status");
const upcoming = url.searchParams.get("upcoming") === "true";
let query = supabase
.from("candidate_interviews")
.select(
`
*,
employer:profiles!candidate_interviews_employer_id_fkey(
full_name,
avatar_url,
email
)
`,
)
.eq("candidate_id", userId)
.order("scheduled_at", { ascending: true });
if (status) {
query = query.eq("status", status);
}
if (upcoming) {
query = query
.gte("scheduled_at", new Date().toISOString())
.in("status", ["scheduled", "rescheduled"]);
}
const { data: interviews, error } = await query;
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
// Group by status
const grouped = {
upcoming: interviews?.filter(
(i) =>
["scheduled", "rescheduled"].includes(i.status) &&
new Date(i.scheduled_at) >= new Date(),
) || [],
past: interviews?.filter(
(i) =>
i.status === "completed" ||
new Date(i.scheduled_at) < new Date(),
) || [],
cancelled: interviews?.filter((i) => i.status === "cancelled") || [],
};
return new Response(
JSON.stringify({
interviews: interviews || [],
grouped,
total: interviews?.length || 0,
}),
{
headers: { "Content-Type": "application/json" },
},
);
}
// POST - Create interview (for self-scheduling or employer invites)
if (req.method === "POST") {
const body = await req.json();
const {
application_id,
employer_id,
opportunity_id,
scheduled_at,
duration_minutes,
meeting_link,
meeting_type,
notes,
} = body;
if (!scheduled_at || !employer_id) {
return new Response(
JSON.stringify({ error: "scheduled_at and employer_id are required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
const { data, error } = await supabase
.from("candidate_interviews")
.insert({
application_id,
candidate_id: userId,
employer_id,
opportunity_id,
scheduled_at,
duration_minutes: duration_minutes || 30,
meeting_link,
meeting_type: meeting_type || "video",
notes,
status: "scheduled",
})
.select()
.single();
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ interview: data }), {
status: 201,
headers: { "Content-Type": "application/json" },
});
}
// PATCH - Update interview (feedback, reschedule)
if (req.method === "PATCH") {
const body = await req.json();
const { id, candidate_feedback, status, scheduled_at } = body;
if (!id) {
return new Response(JSON.stringify({ error: "Interview id is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const updateData: Record<string, any> = {};
if (candidate_feedback !== undefined)
updateData.candidate_feedback = candidate_feedback;
if (status !== undefined) updateData.status = status;
if (scheduled_at !== undefined) {
updateData.scheduled_at = scheduled_at;
updateData.status = "rescheduled";
}
const { data, error } = await supabase
.from("candidate_interviews")
.update(updateData)
.eq("id", id)
.eq("candidate_id", userId)
.select()
.single();
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ interview: data }), {
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { "Content-Type": "application/json" },
});
} catch (err: any) {
console.error("Candidate interviews API error:", err);
return new Response(JSON.stringify({ error: err.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};

View file

@ -1,136 +0,0 @@
import { supabase } from "../_supabase.js";
export default async (req: Request) => {
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const { data: userData } = await supabase.auth.getUser(token);
if (!userData.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const userId = userData.user.id;
try {
// GET - Fetch offers
if (req.method === "GET") {
const { data: offers, error } = await supabase
.from("candidate_offers")
.select(
`
*,
employer:profiles!candidate_offers_employer_id_fkey(
full_name,
avatar_url,
email
)
`,
)
.eq("candidate_id", userId)
.order("created_at", { ascending: false });
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
// Group by status
const grouped = {
pending: offers?.filter((o) => o.status === "pending") || [],
accepted: offers?.filter((o) => o.status === "accepted") || [],
declined: offers?.filter((o) => o.status === "declined") || [],
expired: offers?.filter((o) => o.status === "expired") || [],
withdrawn: offers?.filter((o) => o.status === "withdrawn") || [],
};
return new Response(
JSON.stringify({
offers: offers || [],
grouped,
total: offers?.length || 0,
}),
{
headers: { "Content-Type": "application/json" },
},
);
}
// PATCH - Respond to offer (accept/decline)
if (req.method === "PATCH") {
const body = await req.json();
const { id, status, notes } = body;
if (!id) {
return new Response(JSON.stringify({ error: "Offer id is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (!["accepted", "declined"].includes(status)) {
return new Response(
JSON.stringify({ error: "Status must be accepted or declined" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
const { data, error } = await supabase
.from("candidate_offers")
.update({
status,
notes,
candidate_response_at: new Date().toISOString(),
})
.eq("id", id)
.eq("candidate_id", userId)
.eq("status", "pending") // Can only respond to pending offers
.select()
.single();
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
if (!data) {
return new Response(
JSON.stringify({ error: "Offer not found or already responded" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
},
);
}
return new Response(JSON.stringify({ offer: data }), {
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { "Content-Type": "application/json" },
});
} catch (err: any) {
console.error("Candidate offers API error:", err);
return new Response(JSON.stringify({ error: err.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};

View file

@ -1,191 +0,0 @@
import { supabase } from "../_supabase.js";
interface ProfileData {
headline?: string;
bio?: string;
resume_url?: string;
portfolio_urls?: string[];
work_history?: WorkHistory[];
education?: Education[];
skills?: string[];
availability?: string;
desired_rate?: number;
rate_type?: string;
location?: string;
remote_preference?: string;
is_public?: boolean;
}
interface WorkHistory {
company: string;
position: string;
start_date: string;
end_date?: string;
current: boolean;
description?: string;
}
interface Education {
institution: string;
degree: string;
field: string;
start_year: number;
end_year?: number;
current: boolean;
}
export default async (req: Request) => {
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const { data: userData } = await supabase.auth.getUser(token);
if (!userData.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const userId = userData.user.id;
try {
// GET - Fetch candidate profile
if (req.method === "GET") {
const { data: profile, error } = await supabase
.from("candidate_profiles")
.select("*")
.eq("user_id", userId)
.single();
if (error && error.code !== "PGRST116") {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
// Get user info for basic profile
const { data: userProfile } = await supabase
.from("profiles")
.select("full_name, avatar_url, email")
.eq("id", userId)
.single();
// Get application stats
const { data: applications } = await supabase
.from("aethex_applications")
.select("id, status")
.eq("applicant_id", userId);
const stats = {
total_applications: applications?.length || 0,
pending: applications?.filter((a) => a.status === "pending").length || 0,
reviewed: applications?.filter((a) => a.status === "reviewed").length || 0,
accepted: applications?.filter((a) => a.status === "accepted").length || 0,
rejected: applications?.filter((a) => a.status === "rejected").length || 0,
};
return new Response(
JSON.stringify({
profile: profile || null,
user: userProfile,
stats,
}),
{
headers: { "Content-Type": "application/json" },
},
);
}
// POST - Create or update profile
if (req.method === "POST") {
const body: ProfileData = await req.json();
// Check if profile exists
const { data: existing } = await supabase
.from("candidate_profiles")
.select("id")
.eq("user_id", userId)
.single();
if (existing) {
// Update existing profile
const { data, error } = await supabase
.from("candidate_profiles")
.update({
...body,
portfolio_urls: body.portfolio_urls
? JSON.stringify(body.portfolio_urls)
: undefined,
work_history: body.work_history
? JSON.stringify(body.work_history)
: undefined,
education: body.education
? JSON.stringify(body.education)
: undefined,
})
.eq("user_id", userId)
.select()
.single();
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ profile: data }), {
headers: { "Content-Type": "application/json" },
});
} else {
// Create new profile
const { data, error } = await supabase
.from("candidate_profiles")
.insert({
user_id: userId,
...body,
portfolio_urls: body.portfolio_urls
? JSON.stringify(body.portfolio_urls)
: "[]",
work_history: body.work_history
? JSON.stringify(body.work_history)
: "[]",
education: body.education
? JSON.stringify(body.education)
: "[]",
})
.select()
.single();
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ profile: data }), {
status: 201,
headers: { "Content-Type": "application/json" },
});
}
}
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { "Content-Type": "application/json" },
});
} catch (err: any) {
console.error("Candidate profile API error:", err);
return new Response(JSON.stringify({ error: err.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};

View file

@ -38,6 +38,9 @@ export default async function handler(req: any, res: any) {
client_secret: clientSecret,
grant_type: "authorization_code",
code,
redirect_uri:
process.env.DISCORD_ACTIVITY_REDIRECT_URI ||
"https://aethex.dev/activity",
}).toString(),
},
);

View file

@ -1,62 +0,0 @@
import { supabase } from "../_supabase.js";
export default async (req: Request) => {
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
const { data: userData } = await supabase.auth.getUser(token);
if (!userData.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
const userId = userData.user.id;
try {
if (req.method === "GET") {
const { data: announcements, error } = await supabase
.from("staff_announcements")
.select(`*, author:profiles!staff_announcements_author_id_fkey(full_name, avatar_url)`)
.or(`expires_at.is.null,expires_at.gt.${new Date().toISOString()}`)
.order("is_pinned", { ascending: false })
.order("published_at", { ascending: false });
if (error) throw error;
// Mark read status
const withReadStatus = announcements?.map(a => ({
...a,
is_read: a.read_by?.includes(userId) || false
}));
return new Response(JSON.stringify({ announcements: withReadStatus || [] }), { headers: { "Content-Type": "application/json" } });
}
if (req.method === "POST") {
const body = await req.json();
// Mark as read
if (body.action === "mark_read" && body.id) {
const { data: current } = await supabase
.from("staff_announcements")
.select("read_by")
.eq("id", body.id)
.single();
const readBy = current?.read_by || [];
if (!readBy.includes(userId)) {
await supabase
.from("staff_announcements")
.update({ read_by: [...readBy, userId] })
.eq("id", body.id);
}
return new Response(JSON.stringify({ success: true }), { headers: { "Content-Type": "application/json" } });
}
}
return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } });
} catch (err: any) {
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } });
}
};

View file

@ -1,100 +0,0 @@
import { supabase } from "../_supabase.js";
export default async (req: Request) => {
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
const { data: userData } = await supabase.auth.getUser(token);
if (!userData.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
const userId = userData.user.id;
try {
if (req.method === "GET") {
// Get all courses
const { data: courses, error: coursesError } = await supabase
.from("staff_courses")
.select("*")
.order("title");
if (coursesError) throw coursesError;
// Get user's progress
const { data: progress, error: progressError } = await supabase
.from("staff_course_progress")
.select("*")
.eq("user_id", userId);
if (progressError) throw progressError;
// Merge progress with courses
const coursesWithProgress = courses?.map(course => {
const userProgress = progress?.find(p => p.course_id === course.id);
return {
...course,
progress: userProgress?.progress_percent || 0,
status: userProgress?.status || "available",
started_at: userProgress?.started_at,
completed_at: userProgress?.completed_at
};
});
const stats = {
total: courses?.length || 0,
completed: coursesWithProgress?.filter(c => c.status === "completed").length || 0,
in_progress: coursesWithProgress?.filter(c => c.status === "in_progress").length || 0,
required: courses?.filter(c => c.is_required).length || 0
};
return new Response(JSON.stringify({ courses: coursesWithProgress || [], stats }), { headers: { "Content-Type": "application/json" } });
}
if (req.method === "POST") {
const body = await req.json();
const { course_id, action, progress } = body;
if (action === "start") {
const { data, error } = await supabase
.from("staff_course_progress")
.upsert({
user_id: userId,
course_id,
status: "in_progress",
progress_percent: 0,
started_at: new Date().toISOString()
}, { onConflict: "user_id,course_id" })
.select()
.single();
if (error) throw error;
return new Response(JSON.stringify({ progress: data }), { headers: { "Content-Type": "application/json" } });
}
if (action === "update_progress") {
const isComplete = progress >= 100;
const { data, error } = await supabase
.from("staff_course_progress")
.upsert({
user_id: userId,
course_id,
progress_percent: Math.min(progress, 100),
status: isComplete ? "completed" : "in_progress",
completed_at: isComplete ? new Date().toISOString() : null
}, { onConflict: "user_id,course_id" })
.select()
.single();
if (error) throw error;
return new Response(JSON.stringify({ progress: data }), { headers: { "Content-Type": "application/json" } });
}
}
return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } });
} catch (err: any) {
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } });
}
};

View file

@ -1,96 +0,0 @@
import { supabase } from "../_supabase.js";
export default async (req: Request) => {
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
const { data: userData } = await supabase.auth.getUser(token);
if (!userData.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
const userId = userData.user.id;
try {
if (req.method === "GET") {
const { data: expenses, error } = await supabase
.from("staff_expense_reports")
.select("*")
.eq("user_id", userId)
.order("created_at", { ascending: false });
if (error) throw error;
const stats = {
total: expenses?.length || 0,
pending: expenses?.filter(e => e.status === "pending").length || 0,
approved: expenses?.filter(e => e.status === "approved").length || 0,
reimbursed: expenses?.filter(e => e.status === "reimbursed").length || 0,
total_amount: expenses?.reduce((sum, e) => sum + parseFloat(e.amount), 0) || 0,
pending_amount: expenses?.filter(e => e.status === "pending").reduce((sum, e) => sum + parseFloat(e.amount), 0) || 0
};
return new Response(JSON.stringify({ expenses: expenses || [], stats }), { headers: { "Content-Type": "application/json" } });
}
if (req.method === "POST") {
const body = await req.json();
const { title, description, amount, category, receipt_url } = body;
const { data, error } = await supabase
.from("staff_expense_reports")
.insert({
user_id: userId,
title,
description,
amount,
category,
receipt_url,
status: "pending",
submitted_at: new Date().toISOString()
})
.select()
.single();
if (error) throw error;
return new Response(JSON.stringify({ expense: data }), { status: 201, headers: { "Content-Type": "application/json" } });
}
if (req.method === "PATCH") {
const body = await req.json();
const { id, ...updates } = body;
const { data, error } = await supabase
.from("staff_expense_reports")
.update(updates)
.eq("id", id)
.eq("user_id", userId)
.select()
.single();
if (error) throw error;
return new Response(JSON.stringify({ expense: data }), { headers: { "Content-Type": "application/json" } });
}
if (req.method === "DELETE") {
const url = new URL(req.url);
const id = url.searchParams.get("id");
const { error } = await supabase
.from("staff_expense_reports")
.delete()
.eq("id", id)
.eq("user_id", userId)
.in("status", ["draft", "pending"]);
if (error) throw error;
return new Response(JSON.stringify({ success: true }), { headers: { "Content-Type": "application/json" } });
}
return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } });
} catch (err: any) {
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } });
}
};

View file

@ -1,46 +0,0 @@
import { supabase } from "../_supabase.js";
export default async (req: Request) => {
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
const { data: userData } = await supabase.auth.getUser(token);
if (!userData.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
try {
if (req.method === "GET") {
const { data: sections, error } = await supabase
.from("staff_handbook_sections")
.select("*")
.order("category")
.order("order_index");
if (error) throw error;
// Group by category
const grouped = sections?.reduce((acc, section) => {
if (!acc[section.category]) {
acc[section.category] = [];
}
acc[section.category].push(section);
return acc;
}, {} as Record<string, typeof sections>);
const categories = Object.keys(grouped || {});
return new Response(JSON.stringify({
sections: sections || [],
grouped: grouped || {},
categories
}), { headers: { "Content-Type": "application/json" } });
}
return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } });
} catch (err: any) {
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } });
}
};

View file

@ -1,72 +0,0 @@
import { supabase } from "../_supabase.js";
export default async (req: Request) => {
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
const { data: userData } = await supabase.auth.getUser(token);
if (!userData.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
const url = new URL(req.url);
try {
if (req.method === "GET") {
const category = url.searchParams.get("category");
const search = url.searchParams.get("search");
let query = supabase
.from("staff_knowledge_articles")
.select(`*, author:profiles!staff_knowledge_articles_author_id_fkey(full_name, avatar_url)`)
.eq("is_published", true)
.order("views", { ascending: false });
if (category && category !== "all") {
query = query.eq("category", category);
}
if (search) {
query = query.or(`title.ilike.%${search}%,content.ilike.%${search}%`);
}
const { data: articles, error } = await query;
if (error) throw error;
// Get unique categories
const { data: allArticles } = await supabase
.from("staff_knowledge_articles")
.select("category")
.eq("is_published", true);
const categories = [...new Set(allArticles?.map(a => a.category) || [])];
return new Response(JSON.stringify({ articles: articles || [], categories }), { headers: { "Content-Type": "application/json" } });
}
if (req.method === "POST") {
const body = await req.json();
// Increment view count
if (body.action === "view" && body.id) {
await supabase.rpc("increment_kb_views", { article_id: body.id });
return new Response(JSON.stringify({ success: true }), { headers: { "Content-Type": "application/json" } });
}
// Mark as helpful
if (body.action === "helpful" && body.id) {
await supabase
.from("staff_knowledge_articles")
.update({ helpful_count: supabase.rpc("increment") })
.eq("id", body.id);
return new Response(JSON.stringify({ success: true }), { headers: { "Content-Type": "application/json" } });
}
}
return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } });
} catch (err: any) {
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } });
}
};

View file

@ -1,126 +0,0 @@
import { supabase } from "../_supabase.js";
export default async (req: Request) => {
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
const { data: userData } = await supabase.auth.getUser(token);
if (!userData.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
const userId = userData.user.id;
try {
if (req.method === "GET") {
// Get items
const { data: items, error: itemsError } = await supabase
.from("staff_marketplace_items")
.select("*")
.eq("is_available", true)
.order("points_cost");
if (itemsError) throw itemsError;
// Get user's points
let { data: points } = await supabase
.from("staff_points")
.select("*")
.eq("user_id", userId)
.single();
// Create points record if doesn't exist
if (!points) {
const { data: newPoints } = await supabase
.from("staff_points")
.insert({ user_id: userId, balance: 1000, lifetime_earned: 1000 })
.select()
.single();
points = newPoints;
}
// Get user's orders
const { data: orders } = await supabase
.from("staff_marketplace_orders")
.select(`*, item:staff_marketplace_items(name, image_url)`)
.eq("user_id", userId)
.order("created_at", { ascending: false });
return new Response(JSON.stringify({
items: items || [],
points: points || { balance: 0, lifetime_earned: 0 },
orders: orders || []
}), { headers: { "Content-Type": "application/json" } });
}
if (req.method === "POST") {
const body = await req.json();
const { item_id, quantity, shipping_address } = body;
// Get item
const { data: item } = await supabase
.from("staff_marketplace_items")
.select("*")
.eq("id", item_id)
.single();
if (!item) {
return new Response(JSON.stringify({ error: "Item not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
}
// Check stock
if (item.stock_count !== null && item.stock_count < (quantity || 1)) {
return new Response(JSON.stringify({ error: "Insufficient stock" }), { status: 400, headers: { "Content-Type": "application/json" } });
}
// Check points
const { data: points } = await supabase
.from("staff_points")
.select("balance")
.eq("user_id", userId)
.single();
const totalCost = item.points_cost * (quantity || 1);
if (!points || points.balance < totalCost) {
return new Response(JSON.stringify({ error: "Insufficient points" }), { status: 400, headers: { "Content-Type": "application/json" } });
}
// Create order
const { data: order, error: orderError } = await supabase
.from("staff_marketplace_orders")
.insert({
user_id: userId,
item_id,
quantity: quantity || 1,
shipping_address,
status: "pending"
})
.select()
.single();
if (orderError) throw orderError;
// Deduct points
await supabase
.from("staff_points")
.update({ balance: points.balance - totalCost })
.eq("user_id", userId);
// Update stock if applicable
if (item.stock_count !== null) {
await supabase
.from("staff_marketplace_items")
.update({ stock_count: item.stock_count - (quantity || 1) })
.eq("id", item_id);
}
return new Response(JSON.stringify({ order }), { status: 201, headers: { "Content-Type": "application/json" } });
}
return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } });
} catch (err: any) {
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } });
}
};

View file

@ -1,208 +1,57 @@
import { supabase } from "../_supabase.js";
export default async (req: Request) => {
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
if (req.method !== "GET") {
return new Response("Method not allowed", { status: 405 });
}
const { data: userData } = await supabase.auth.getUser(token);
if (!userData.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
const userId = userData.user.id;
const url = new URL(req.url);
try {
// GET - Fetch OKRs with key results
if (req.method === "GET") {
const quarter = url.searchParams.get("quarter");
const year = url.searchParams.get("year");
const status = url.searchParams.get("status");
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response("Unauthorized", { status: 401 });
}
let query = supabase
.from("staff_okrs")
.select(`
*,
key_results:staff_key_results(*)
`)
.or(`user_id.eq.${userId},owner_type.in.(team,company)`)
.order("created_at", { ascending: false });
const { data: userData } = await supabase.auth.getUser(token);
if (!userData.user) {
return new Response("Unauthorized", { status: 401 });
}
if (quarter) query = query.eq("quarter", parseInt(quarter));
if (year) query = query.eq("year", parseInt(year));
if (status) query = query.eq("status", status);
const { data: okrs, error } = await supabase
.from("staff_okrs")
.select(
`
id,
user_id,
objective,
description,
status,
quarter,
year,
key_results(
id,
title,
progress,
target_value
),
created_at
`,
)
.eq("user_id", userData.user.id)
.order("created_at", { ascending: false });
const { data: okrs, error } = await query;
if (error) throw error;
// Calculate stats
const myOkrs = okrs?.filter(o => o.user_id === userId) || [];
const stats = {
total: myOkrs.length,
active: myOkrs.filter(o => o.status === "active").length,
completed: myOkrs.filter(o => o.status === "completed").length,
avgProgress: myOkrs.length > 0
? Math.round(myOkrs.reduce((sum, o) => sum + (o.progress || 0), 0) / myOkrs.length)
: 0
};
return new Response(JSON.stringify({ okrs: okrs || [], stats }), {
headers: { "Content-Type": "application/json" },
if (error) {
console.error("OKRs fetch error:", error);
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
});
}
// POST - Create OKR or Key Result
if (req.method === "POST") {
const body = await req.json();
// Create new OKR
if (body.action === "create_okr") {
const { objective, description, quarter, year, team, owner_type } = body;
const { data: okr, error } = await supabase
.from("staff_okrs")
.insert({
user_id: userId,
objective,
description,
quarter,
year,
team,
owner_type: owner_type || "individual",
status: "draft"
})
.select()
.single();
if (error) throw error;
return new Response(JSON.stringify({ okr }), { status: 201, headers: { "Content-Type": "application/json" } });
}
// Add key result to OKR
if (body.action === "add_key_result") {
const { okr_id, title, description, target_value, metric_type, unit, due_date } = body;
const { data: keyResult, error } = await supabase
.from("staff_key_results")
.insert({
okr_id,
title,
description,
target_value,
metric_type: metric_type || "percentage",
unit,
due_date
})
.select()
.single();
if (error) throw error;
return new Response(JSON.stringify({ key_result: keyResult }), { status: 201, headers: { "Content-Type": "application/json" } });
}
// Update key result progress
if (body.action === "update_key_result") {
const { key_result_id, current_value, status } = body;
// Get target value to calculate progress
const { data: kr } = await supabase
.from("staff_key_results")
.select("target_value, start_value")
.eq("id", key_result_id)
.single();
const progress = kr ? Math.min(100, Math.round(((current_value - (kr.start_value || 0)) / (kr.target_value - (kr.start_value || 0))) * 100)) : 0;
const { data: keyResult, error } = await supabase
.from("staff_key_results")
.update({
current_value,
progress: Math.max(0, progress),
status: status || (progress >= 100 ? "completed" : progress >= 70 ? "on_track" : progress >= 40 ? "at_risk" : "behind"),
updated_at: new Date().toISOString()
})
.eq("id", key_result_id)
.select()
.single();
if (error) throw error;
return new Response(JSON.stringify({ key_result: keyResult }), { headers: { "Content-Type": "application/json" } });
}
// Add check-in
if (body.action === "add_checkin") {
const { okr_id, notes, progress_snapshot } = body;
const { data: checkin, error } = await supabase
.from("staff_okr_checkins")
.insert({
okr_id,
user_id: userId,
notes,
progress_snapshot
})
.select()
.single();
if (error) throw error;
return new Response(JSON.stringify({ checkin }), { status: 201, headers: { "Content-Type": "application/json" } });
}
return new Response(JSON.stringify({ error: "Invalid action" }), { status: 400, headers: { "Content-Type": "application/json" } });
}
// PUT - Update OKR
if (req.method === "PUT") {
const body = await req.json();
const { id, objective, description, status, quarter, year } = body;
const { data: okr, error } = await supabase
.from("staff_okrs")
.update({
objective,
description,
status,
quarter,
year,
updated_at: new Date().toISOString()
})
.eq("id", id)
.eq("user_id", userId)
.select()
.single();
if (error) throw error;
return new Response(JSON.stringify({ okr }), { headers: { "Content-Type": "application/json" } });
}
// DELETE - Delete OKR or Key Result
if (req.method === "DELETE") {
const id = url.searchParams.get("id");
const type = url.searchParams.get("type") || "okr";
if (type === "key_result") {
const { error } = await supabase
.from("staff_key_results")
.delete()
.eq("id", id);
if (error) throw error;
} else {
const { error } = await supabase
.from("staff_okrs")
.delete()
.eq("id", id)
.eq("user_id", userId);
if (error) throw error;
}
return new Response(JSON.stringify({ success: true }), { headers: { "Content-Type": "application/json" } });
}
return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } });
return new Response(JSON.stringify(okrs || []), {
headers: { "Content-Type": "application/json" },
});
} catch (err: any) {
console.error("OKR API error:", err);
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } });
return new Response(JSON.stringify({ error: err.message }), {
status: 500,
});
}
};

View file

@ -1,289 +0,0 @@
import { supabase } from "../_supabase.js";
interface ChecklistItem {
id: string;
checklist_item: string;
phase: string;
completed: boolean;
completed_at: string | null;
notes: string | null;
}
interface OnboardingMetadata {
start_date: string;
manager_id: string | null;
department: string | null;
role_title: string | null;
onboarding_completed: boolean;
}
// Default checklist items for new staff
const DEFAULT_CHECKLIST_ITEMS = [
// Day 1
{ item: "Complete HR paperwork", phase: "day1" },
{ item: "Set up workstation", phase: "day1" },
{ item: "Join Discord server", phase: "day1" },
{ item: "Meet your manager", phase: "day1" },
{ item: "Review company handbook", phase: "day1" },
{ item: "Set up email and accounts", phase: "day1" },
// Week 1
{ item: "Complete security training", phase: "week1" },
{ item: "Set up development environment", phase: "week1" },
{ item: "Review codebase architecture", phase: "week1" },
{ item: "Attend team standup", phase: "week1" },
{ item: "Complete first small task", phase: "week1" },
{ item: "Meet team members", phase: "week1" },
// Month 1
{ item: "Complete onboarding course", phase: "month1" },
{ item: "Contribute to first sprint", phase: "month1" },
{ item: "30-day check-in with manager", phase: "month1" },
{ item: "Set Q1 OKRs", phase: "month1" },
{ item: "Shadow a senior team member", phase: "month1" },
];
export default async (req: Request) => {
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const { data: userData } = await supabase.auth.getUser(token);
if (!userData.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const userId = userData.user.id;
const url = new URL(req.url);
try {
// GET - Fetch onboarding progress
if (req.method === "GET") {
// Check for admin view (managers viewing team progress)
if (url.pathname.endsWith("/admin")) {
// Get team members for this manager
const { data: teamMembers, error: teamError } = await supabase
.from("staff_members")
.select("user_id, full_name, email, avatar_url, start_date")
.eq("manager_id", userId);
if (teamError) {
return new Response(JSON.stringify({ error: teamError.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
if (!teamMembers || teamMembers.length === 0) {
return new Response(JSON.stringify({ team: [] }), {
headers: { "Content-Type": "application/json" },
});
}
// Get progress for all team members
const userIds = teamMembers.map((m) => m.user_id);
const { data: progressData } = await supabase
.from("staff_onboarding_progress")
.select("*")
.in("user_id", userIds);
// Calculate completion for each team member
const teamProgress = teamMembers.map((member) => {
const memberProgress = progressData?.filter(
(p) => p.user_id === member.user_id,
);
const completed =
memberProgress?.filter((p) => p.completed).length || 0;
const total = DEFAULT_CHECKLIST_ITEMS.length;
return {
...member,
progress_completed: completed,
progress_total: total,
progress_percentage: Math.round((completed / total) * 100),
};
});
return new Response(JSON.stringify({ team: teamProgress }), {
headers: { "Content-Type": "application/json" },
});
}
// Regular user view - get own progress
const { data: progress, error: progressError } = await supabase
.from("staff_onboarding_progress")
.select("*")
.eq("user_id", userId)
.order("created_at", { ascending: true });
// Get or create metadata
let { data: metadata, error: metadataError } = await supabase
.from("staff_onboarding_metadata")
.select("*")
.eq("user_id", userId)
.single();
// If no metadata exists, create it
if (!metadata && metadataError?.code === "PGRST116") {
const { data: newMetadata } = await supabase
.from("staff_onboarding_metadata")
.insert({ user_id: userId })
.select()
.single();
metadata = newMetadata;
}
// Get staff member info for name/department
const { data: staffMember } = await supabase
.from("staff_members")
.select("full_name, department, role, avatar_url")
.eq("user_id", userId)
.single();
// Get manager info if exists
let managerInfo = null;
if (metadata?.manager_id) {
const { data: manager } = await supabase
.from("staff_members")
.select("full_name, email, avatar_url")
.eq("user_id", metadata.manager_id)
.single();
managerInfo = manager;
}
// If no progress exists, initialize with default items
let progressItems = progress || [];
if (!progress || progress.length === 0) {
const itemsToInsert = DEFAULT_CHECKLIST_ITEMS.map((item) => ({
user_id: userId,
checklist_item: item.item,
phase: item.phase,
completed: false,
}));
const { data: insertedItems } = await supabase
.from("staff_onboarding_progress")
.insert(itemsToInsert)
.select();
progressItems = insertedItems || [];
}
// Group by phase
const groupedProgress = {
day1: progressItems.filter((p) => p.phase === "day1"),
week1: progressItems.filter((p) => p.phase === "week1"),
month1: progressItems.filter((p) => p.phase === "month1"),
};
// Calculate overall progress
const completed = progressItems.filter((p) => p.completed).length;
const total = progressItems.length;
return new Response(
JSON.stringify({
progress: groupedProgress,
metadata: metadata || { start_date: new Date().toISOString() },
staff_member: staffMember,
manager: managerInfo,
summary: {
completed,
total,
percentage: total > 0 ? Math.round((completed / total) * 100) : 0,
},
}),
{
headers: { "Content-Type": "application/json" },
},
);
}
// POST - Mark item complete/incomplete
if (req.method === "POST") {
const body = await req.json();
const { checklist_item, completed, notes } = body;
if (!checklist_item) {
return new Response(
JSON.stringify({ error: "checklist_item is required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
// Upsert the progress item
const { data, error } = await supabase
.from("staff_onboarding_progress")
.upsert(
{
user_id: userId,
checklist_item,
phase:
DEFAULT_CHECKLIST_ITEMS.find((i) => i.item === checklist_item)
?.phase || "day1",
completed: completed ?? true,
completed_at: completed ? new Date().toISOString() : null,
notes: notes || null,
},
{
onConflict: "user_id,checklist_item",
},
)
.select()
.single();
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
// Check if all items are complete
const { data: allProgress } = await supabase
.from("staff_onboarding_progress")
.select("completed")
.eq("user_id", userId);
const allCompleted = allProgress?.every((p) => p.completed);
// Update metadata if all completed
if (allCompleted) {
await supabase
.from("staff_onboarding_metadata")
.update({
onboarding_completed: true,
onboarding_completed_at: new Date().toISOString(),
})
.eq("user_id", userId);
}
return new Response(
JSON.stringify({
item: data,
all_completed: allCompleted,
}),
{
headers: { "Content-Type": "application/json" },
},
);
}
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { "Content-Type": "application/json" },
});
} catch (err: any) {
console.error("Onboarding API error:", err);
return new Response(JSON.stringify({ error: err.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};

View file

@ -1,102 +0,0 @@
import { supabase } from "../_supabase.js";
export default async (req: Request) => {
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
const { data: userData } = await supabase.auth.getUser(token);
if (!userData.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
const userId = userData.user.id;
try {
if (req.method === "GET") {
// Get projects where user is lead or team member
const { data: projects, error } = await supabase
.from("staff_projects")
.select(`
*,
lead:profiles!staff_projects_lead_id_fkey(full_name, avatar_url)
`)
.or(`lead_id.eq.${userId},team_members.cs.{${userId}}`)
.order("updated_at", { ascending: false });
if (error) throw error;
// Get tasks for each project
const projectIds = projects?.map(p => p.id) || [];
const { data: tasks } = await supabase
.from("staff_project_tasks")
.select("*")
.in("project_id", projectIds);
// Attach tasks to projects
const projectsWithTasks = projects?.map(project => ({
...project,
tasks: tasks?.filter(t => t.project_id === project.id) || [],
task_stats: {
total: tasks?.filter(t => t.project_id === project.id).length || 0,
done: tasks?.filter(t => t.project_id === project.id && t.status === "done").length || 0
}
}));
const stats = {
total: projects?.length || 0,
active: projects?.filter(p => p.status === "active").length || 0,
completed: projects?.filter(p => p.status === "completed").length || 0
};
return new Response(JSON.stringify({ projects: projectsWithTasks || [], stats }), { headers: { "Content-Type": "application/json" } });
}
if (req.method === "POST") {
const body = await req.json();
// Update task status
if (body.action === "update_task") {
const { task_id, status } = body;
const { data, error } = await supabase
.from("staff_project_tasks")
.update({
status,
completed_at: status === "done" ? new Date().toISOString() : null
})
.eq("id", task_id)
.select()
.single();
if (error) throw error;
return new Response(JSON.stringify({ task: data }), { headers: { "Content-Type": "application/json" } });
}
// Create task
if (body.action === "create_task") {
const { project_id, title, description, due_date, priority } = body;
const { data, error } = await supabase
.from("staff_project_tasks")
.insert({
project_id,
title,
description,
due_date,
priority,
assignee_id: userId,
status: "todo"
})
.select()
.single();
if (error) throw error;
return new Response(JSON.stringify({ task: data }), { status: 201, headers: { "Content-Type": "application/json" } });
}
}
return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } });
} catch (err: any) {
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } });
}
};

View file

@ -1,60 +0,0 @@
import { supabase } from "../_supabase.js";
export default async (req: Request) => {
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
const { data: userData } = await supabase.auth.getUser(token);
if (!userData.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
const userId = userData.user.id;
try {
if (req.method === "GET") {
const { data: reviews, error } = await supabase
.from("staff_performance_reviews")
.select(`
*,
reviewer:profiles!staff_performance_reviews_reviewer_id_fkey(full_name, avatar_url)
`)
.eq("employee_id", userId)
.order("created_at", { ascending: false });
if (error) throw error;
const stats = {
total: reviews?.length || 0,
pending: reviews?.filter(r => r.status === "pending").length || 0,
completed: reviews?.filter(r => r.status === "completed").length || 0,
average_rating: reviews?.filter(r => r.overall_rating).reduce((sum, r) => sum + r.overall_rating, 0) / (reviews?.filter(r => r.overall_rating).length || 1) || 0
};
return new Response(JSON.stringify({ reviews: reviews || [], stats }), { headers: { "Content-Type": "application/json" } });
}
if (req.method === "POST") {
const body = await req.json();
const { review_id, employee_comments } = body;
// Employee can only add their comments
const { data, error } = await supabase
.from("staff_performance_reviews")
.update({ employee_comments })
.eq("id", review_id)
.eq("employee_id", userId)
.select()
.single();
if (error) throw error;
return new Response(JSON.stringify({ review: data }), { headers: { "Content-Type": "application/json" } });
}
return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } });
} catch (err: any) {
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } });
}
};

View file

@ -1,245 +0,0 @@
import { supabase } from "../_supabase.js";
export default async (req: Request) => {
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
const { data: userData } = await supabase.auth.getUser(token);
if (!userData.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
const userId = userData.user.id;
const url = new URL(req.url);
try {
// GET - Fetch time entries and timesheets
if (req.method === "GET") {
const startDate = url.searchParams.get("start_date");
const endDate = url.searchParams.get("end_date");
const view = url.searchParams.get("view") || "week"; // week, month, all
// Calculate default date range based on view
const now = new Date();
let defaultStart: string;
let defaultEnd: string;
if (view === "week") {
const dayOfWeek = now.getDay();
const weekStart = new Date(now);
weekStart.setDate(now.getDate() - dayOfWeek);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
defaultStart = weekStart.toISOString().split("T")[0];
defaultEnd = weekEnd.toISOString().split("T")[0];
} else if (view === "month") {
defaultStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split("T")[0];
defaultEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().split("T")[0];
} else {
defaultStart = new Date(now.getFullYear(), 0, 1).toISOString().split("T")[0];
defaultEnd = new Date(now.getFullYear(), 11, 31).toISOString().split("T")[0];
}
const rangeStart = startDate || defaultStart;
const rangeEnd = endDate || defaultEnd;
// Get time entries
const { data: entries, error: entriesError } = await supabase
.from("staff_time_entries")
.select(`
*,
project:staff_projects(id, name),
task:staff_project_tasks(id, title)
`)
.eq("user_id", userId)
.gte("date", rangeStart)
.lte("date", rangeEnd)
.order("date", { ascending: false })
.order("start_time", { ascending: false });
if (entriesError) throw entriesError;
// Get projects for dropdown
const { data: projects } = await supabase
.from("staff_projects")
.select("id, name")
.or(`lead_id.eq.${userId},team_members.cs.{${userId}}`)
.eq("status", "active");
// Calculate stats
const totalMinutes = entries?.reduce((sum, e) => sum + (e.duration_minutes || 0), 0) || 0;
const billableMinutes = entries?.filter(e => e.is_billable).reduce((sum, e) => sum + (e.duration_minutes || 0), 0) || 0;
const stats = {
totalHours: Math.round((totalMinutes / 60) * 10) / 10,
billableHours: Math.round((billableMinutes / 60) * 10) / 10,
entriesCount: entries?.length || 0,
avgHoursPerDay: entries?.length ? Math.round((totalMinutes / 60 / new Set(entries.map(e => e.date)).size) * 10) / 10 : 0
};
return new Response(JSON.stringify({
entries: entries || [],
projects: projects || [],
stats,
dateRange: { start: rangeStart, end: rangeEnd }
}), { headers: { "Content-Type": "application/json" } });
}
// POST - Create time entry or actions
if (req.method === "POST") {
const body = await req.json();
// Create time entry
if (body.action === "create_entry") {
const { project_id, task_id, description, date, start_time, end_time, duration_minutes, is_billable, notes } = body;
// Calculate duration if start/end provided
let calculatedDuration = duration_minutes;
if (start_time && end_time && !duration_minutes) {
const [sh, sm] = start_time.split(":").map(Number);
const [eh, em] = end_time.split(":").map(Number);
calculatedDuration = (eh * 60 + em) - (sh * 60 + sm);
}
const { data: entry, error } = await supabase
.from("staff_time_entries")
.insert({
user_id: userId,
project_id,
task_id,
description,
date: date || new Date().toISOString().split("T")[0],
start_time,
end_time,
duration_minutes: calculatedDuration || 0,
is_billable: is_billable !== false,
notes
})
.select()
.single();
if (error) throw error;
return new Response(JSON.stringify({ entry }), { status: 201, headers: { "Content-Type": "application/json" } });
}
// Start timer (quick entry)
if (body.action === "start_timer") {
const { project_id, description } = body;
const now = new Date();
const { data: entry, error } = await supabase
.from("staff_time_entries")
.insert({
user_id: userId,
project_id,
description: description || "Time tracking",
date: now.toISOString().split("T")[0],
start_time: now.toTimeString().split(" ")[0].substring(0, 5),
duration_minutes: 0,
is_billable: true
})
.select()
.single();
if (error) throw error;
return new Response(JSON.stringify({ entry }), { status: 201, headers: { "Content-Type": "application/json" } });
}
// Stop timer
if (body.action === "stop_timer") {
const { entry_id } = body;
const now = new Date();
const endTime = now.toTimeString().split(" ")[0].substring(0, 5);
// Get the entry to calculate duration
const { data: existing } = await supabase
.from("staff_time_entries")
.select("start_time")
.eq("id", entry_id)
.single();
if (existing?.start_time) {
const [sh, sm] = existing.start_time.split(":").map(Number);
const [eh, em] = endTime.split(":").map(Number);
const duration = (eh * 60 + em) - (sh * 60 + sm);
const { data: entry, error } = await supabase
.from("staff_time_entries")
.update({
end_time: endTime,
duration_minutes: Math.max(0, duration),
updated_at: new Date().toISOString()
})
.eq("id", entry_id)
.eq("user_id", userId)
.select()
.single();
if (error) throw error;
return new Response(JSON.stringify({ entry }), { headers: { "Content-Type": "application/json" } });
}
}
return new Response(JSON.stringify({ error: "Invalid action" }), { status: 400, headers: { "Content-Type": "application/json" } });
}
// PUT - Update time entry
if (req.method === "PUT") {
const body = await req.json();
const { id, project_id, task_id, description, date, start_time, end_time, duration_minutes, is_billable, notes } = body;
// Calculate duration if times provided
let calculatedDuration = duration_minutes;
if (start_time && end_time) {
const [sh, sm] = start_time.split(":").map(Number);
const [eh, em] = end_time.split(":").map(Number);
calculatedDuration = (eh * 60 + em) - (sh * 60 + sm);
}
const { data: entry, error } = await supabase
.from("staff_time_entries")
.update({
project_id,
task_id,
description,
date,
start_time,
end_time,
duration_minutes: calculatedDuration,
is_billable,
notes,
updated_at: new Date().toISOString()
})
.eq("id", id)
.eq("user_id", userId)
.eq("status", "draft")
.select()
.single();
if (error) throw error;
return new Response(JSON.stringify({ entry }), { headers: { "Content-Type": "application/json" } });
}
// DELETE - Delete time entry
if (req.method === "DELETE") {
const id = url.searchParams.get("id");
const { error } = await supabase
.from("staff_time_entries")
.delete()
.eq("id", id)
.eq("user_id", userId)
.eq("status", "draft");
if (error) throw error;
return new Response(JSON.stringify({ success: true }), { headers: { "Content-Type": "application/json" } });
}
return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } });
} catch (err: any) {
console.error("Time tracking API error:", err);
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } });
}
};

View file

@ -1,10 +1,11 @@
import "./global.css";
import React, { useEffect } from "react";
import { Toaster } from "@/components/ui/toaster";
import { createRoot } from "react-dom/client";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route, useNavigate } from "react-router-dom";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { useDiscordActivity } from "./contexts/DiscordActivityContext";
import { AuthProvider } from "./contexts/AuthContext";
import { Web3Provider } from "./contexts/Web3Context";
@ -14,19 +15,24 @@ import { MaintenanceProvider } from "./contexts/MaintenanceContext";
import MaintenanceGuard from "./components/MaintenanceGuard";
import PageTransition from "./components/PageTransition";
import SkipAgentController from "./components/SkipAgentController";
import Index from "./pages/Index";
import Onboarding from "./pages/Onboarding";
import Dashboard from "./pages/Dashboard";
import Login from "./pages/Login";
import Link from "./pages/Link";
import GameDevelopment from "./pages/GameDevelopment";
import MentorshipPrograms from "./pages/MentorshipPrograms";
import ResearchLabs from "./pages/ResearchLabs";
import Labs from "./pages/Labs";
import GameForge from "./pages/GameForge";
import Foundation from "./pages/Foundation";
import Corp from "./pages/Corp";
import Staff from "./pages/Staff";
import Nexus from "./pages/Nexus";
import Arms from "./pages/Arms";
import ExternalRedirect from "./components/ExternalRedirect";
import CorpScheduleConsultation from "./pages/corp/CorpScheduleConsultation";
import CorpViewCaseStudies from "./pages/corp/CorpViewCaseStudies";
import CorpContactUs from "./pages/corp/CorpContactUs";
import RequireAccess from "@/components/RequireAccess";
import Engage from "./pages/Pricing";
import DocsLayout from "@/components/docs/DocsLayout";
@ -49,6 +55,7 @@ import GameJoltIntegration from "./pages/docs/integrations/GameJolt";
import ItchIoIntegration from "./pages/docs/integrations/ItchIo";
import DocsCurriculum from "./pages/docs/DocsCurriculum";
import DocsCurriculumEthos from "./pages/docs/DocsCurriculumEthos";
import EthosGuild from "./pages/community/EthosGuild";
import TrackLibrary from "./pages/ethos/TrackLibrary";
import ArtistProfile from "./pages/ethos/ArtistProfile";
import ArtistSettings from "./pages/ethos/ArtistSettings";
@ -64,6 +71,7 @@ import DevelopersDirectory from "./pages/DevelopersDirectory";
import ProfilePassport from "./pages/ProfilePassport";
import SubdomainPassport from "./pages/SubdomainPassport";
import Profile from "./pages/Profile";
import LegacyPassportRedirect from "./pages/LegacyPassportRedirect";
import { SubdomainPassportProvider } from "./contexts/SubdomainPassportContext";
import About from "./pages/About";
import Contact from "./pages/Contact";
@ -72,28 +80,32 @@ import Careers from "./pages/Careers";
import Privacy from "./pages/Privacy";
import Terms from "./pages/Terms";
import Admin from "./pages/Admin";
import AdminModeration from "./pages/admin/AdminModeration";
import AdminAnalytics from "./pages/admin/AdminAnalytics";
import Feed from "./pages/Feed";
import AdminFeed from "./pages/AdminFeed";
import ProjectsNew from "./pages/ProjectsNew";
import Opportunities from "./pages/Opportunities";
import Explore from "./pages/Explore";
import ResetPassword from "./pages/ResetPassword";
import Teams from "./pages/Teams";
import Squads from "./pages/Squads";
import MenteeHub from "./pages/MenteeHub";
import ProjectBoard from "./pages/ProjectBoard";
import ProjectDetail from "./pages/ProjectDetail";
import { Navigate } from "react-router-dom";
import FourOhFourPage from "./pages/404";
import SignupRedirect from "./pages/SignupRedirect";
import MentorshipRequest from "./pages/community/MentorshipRequest";
import MentorApply from "./pages/community/MentorApply";
import MentorProfile from "./pages/community/MentorProfile";
import Realms from "./pages/Realms";
import Investors from "./pages/Investors";
import NexusDashboard from "./pages/dashboards/NexusDashboard";
import LabsDashboard from "./pages/dashboards/LabsDashboard";
import GameForgeDashboard from "./pages/dashboards/GameForgeDashboard";
import StaffDashboard from "./pages/dashboards/StaffDashboard";
import Roadmap from "./pages/Roadmap";
import Trust from "./pages/Trust";
import PressKit from "./pages/PressKit";
const Downloads = React.lazy(() => import("./pages/Downloads"));
import Downloads from "./pages/Downloads";
import Projects from "./pages/Projects";
import ProjectsAdmin from "./pages/ProjectsAdmin";
import Directory from "./pages/Directory";
@ -117,7 +129,13 @@ import OpportunitiesHub from "./pages/opportunities/OpportunitiesHub";
import OpportunityDetail from "./pages/opportunities/OpportunityDetail";
import OpportunityPostForm from "./pages/opportunities/OpportunityPostForm";
import MyApplications from "./pages/profile/MyApplications";
// Hub pages moved to aethex.co (aethex-corp app)
import ClientHub from "./pages/hub/ClientHub";
import ClientProjects from "./pages/hub/ClientProjects";
import ClientDashboard from "./pages/hub/ClientDashboard";
import ClientInvoices from "./pages/hub/ClientInvoices";
import ClientContracts from "./pages/hub/ClientContracts";
import ClientReports from "./pages/hub/ClientReports";
import ClientSettings from "./pages/hub/ClientSettings";
import Space1Welcome from "./pages/internal-docs/Space1Welcome";
import Space1AxiomModel from "./pages/internal-docs/Space1AxiomModel";
import Space1FindYourRole from "./pages/internal-docs/Space1FindYourRole";
@ -136,19 +154,20 @@ import Space4ClientOps from "./pages/internal-docs/Space4ClientOps";
import Space4PlatformStrategy from "./pages/internal-docs/Space4PlatformStrategy";
import Space5Onboarding from "./pages/internal-docs/Space5Onboarding";
import Space5Finance from "./pages/internal-docs/Space5Finance";
import Staff from "./pages/Staff";
import StaffLogin from "./pages/StaffLogin";
import StaffDashboard from "./pages/dashboards/StaffDashboard";
import StaffDirectory from "./pages/StaffDirectory";
import StaffAdmin from "./pages/StaffAdmin";
import StaffChat from "./pages/StaffChat";
import StaffDocs from "./pages/StaffDocs";
import StaffDirectory from "./pages/StaffDirectory";
import StaffAchievements from "./pages/StaffAchievements";
import StaffTimeTracking from "./pages/staff/StaffTimeTracking";
import CandidatePortal from "./pages/candidate/CandidatePortal";
import CandidateInterviews from "./pages/candidate/CandidateInterviews";
import CandidateOffers from "./pages/candidate/CandidateOffers";
import CandidateProfile from "./pages/candidate/CandidateProfile";
import StaffAnnouncements from "./pages/staff/StaffAnnouncements";
import StaffExpenseReports from "./pages/staff/StaffExpenseReports";
import StaffInternalMarketplace from "./pages/staff/StaffInternalMarketplace";
import StaffKnowledgeBase from "./pages/staff/StaffKnowledgeBase";
import StaffLearningPortal from "./pages/staff/StaffLearningPortal";
import StaffPerformanceReviews from "./pages/staff/StaffPerformanceReviews";
import StaffProjectTracking from "./pages/staff/StaffProjectTracking";
import StaffTeamHandbook from "./pages/staff/StaffTeamHandbook";
import DeveloperDashboard from "./pages/dev-platform/DeveloperDashboard";
import ApiReference from "./pages/dev-platform/ApiReference";
import QuickStart from "./pages/dev-platform/QuickStart";
@ -162,21 +181,6 @@ import DeveloperPlatform from "./pages/dev-platform/DeveloperPlatform";
const queryClient = new QueryClient();
// Detects staff.aethex.tech and navigates to /staff inside the SPA.
// Must be inside BrowserRouter so useNavigate works.
const StaffSubdomainRedirect = ({ children }: { children: React.ReactNode }) => {
const navigate = useNavigate();
useEffect(() => {
if (
window.location.hostname === "staff.aethex.tech" &&
!window.location.pathname.startsWith("/staff")
) {
navigate("/staff", { replace: true });
}
}, [navigate]);
return <>{children}</>;
};
const DiscordActivityWrapper = ({ children }: { children: React.ReactNode }) => {
const { isActivity } = useDiscordActivity();
@ -197,7 +201,6 @@ const App = () => (
<Toaster />
<Analytics />
<BrowserRouter>
<StaffSubdomainRedirect>
<DiscordActivityWrapper>
<SubdomainPassportProvider>
<ArmThemeProvider>
@ -233,14 +236,20 @@ const App = () => (
path="/dashboard/dev-link"
element={<Navigate to="/dashboard/nexus" replace />}
/>
{/* Hub routes → aethex.co */}
<Route path="/hub/*" element={<ExternalRedirect to="https://aethex.co/hub" />} />
<Route
path="/hub/client"
element={
<RequireAccess>
<ClientHub />
</RequireAccess>
}
/>
<Route path="/realms" element={<Realms />} />
<Route path="/investors" element={<Investors />} />
<Route path="/roadmap" element={<Roadmap />} />
<Route path="/trust" element={<Trust />} />
<Route path="/press" element={<PressKit />} />
<Route path="/downloads" element={<React.Suspense fallback={null}><Downloads /></React.Suspense>} />
<Route path="/downloads" element={<Downloads />} />
<Route path="/projects" element={<Projects />} />
<Route
path="/projects/admin"
@ -250,22 +259,6 @@ const App = () => (
<Route path="/admin" element={<Admin />} />
<Route path="/admin/feed" element={<AdminFeed />} />
<Route path="/admin/docs-sync" element={<DocsSync />} />
<Route
path="/admin/moderation"
element={
<RequireAccess>
<AdminModeration />
</RequireAccess>
}
/>
<Route
path="/admin/analytics"
element={
<RequireAccess>
<AdminAnalytics />
</RequireAccess>
}
/>
<Route path="/arms" element={<Arms />} />
<Route path="/feed" element={<Navigate to="/community/feed" replace />} />
<Route path="/teams" element={<Teams />} />
@ -276,10 +269,6 @@ const App = () => (
path="/projects/:projectId/board"
element={<ProjectBoard />}
/>
<Route
path="/projects/:projectId"
element={<ProjectDetail />}
/>
<Route path="/profile" element={<Profile />} />
<Route path="/profile/me" element={<Profile />} />
<Route
@ -304,7 +293,6 @@ const App = () => (
element={<ProfilePassport />}
/>
<Route path="/login" element={<Login />} />
<Route path="/link" element={<Link />} />
<Route path="/signup" element={<SignupRedirect />} />
<Route
path="/reset-password"
@ -412,27 +400,143 @@ const App = () => (
{/* Foundation page with auto-redirect to aethex.foundation (Non-Profit Guardian - Axiom Model) */}
<Route path="/foundation" element={<Foundation />} />
{/* Corp routes → aethex.co */}
<Route path="/corp" element={<ExternalRedirect to="https://aethex.co" />} />
<Route path="/corp/*" element={<ExternalRedirect to="https://aethex.co" />} />
<Route path="/corp" element={<Corp />} />
<Route
path="/corp/schedule-consultation"
element={<CorpScheduleConsultation />}
/>
<Route
path="/corp/view-case-studies"
element={<CorpViewCaseStudies />}
/>
<Route
path="/corp/contact-us"
element={<CorpContactUs />}
/>
{/* Staff routes */}
{/* Staff Arm Routes */}
<Route path="/staff" element={<Staff />} />
<Route path="/staff/login" element={<StaffLogin />} />
<Route path="/staff/dashboard" element={<StaffDashboard />} />
<Route path="/staff/admin" element={<StaffAdmin />} />
<Route path="/staff/chat" element={<StaffChat />} />
<Route path="/staff/docs" element={<StaffDocs />} />
<Route path="/staff/directory" element={<StaffDirectory />} />
<Route path="/staff/achievements" element={<StaffAchievements />} />
<Route path="/staff/time-tracking" element={<StaffTimeTracking />} />
{/* Unbuilt staff sub-pages fall back to dashboard */}
<Route path="/staff/*" element={<Navigate to="/staff/dashboard" replace />} />
{/* Candidate routes */}
<Route path="/candidate" element={<CandidatePortal />} />
<Route path="/candidate/interviews" element={<CandidateInterviews />} />
<Route path="/candidate/offers" element={<CandidateOffers />} />
<Route path="/candidate/profile" element={<CandidateProfile />} />
{/* Staff Dashboard Routes */}
<Route
path="/staff/dashboard"
element={
<RequireAccess>
<StaffDashboard />
</RequireAccess>
}
/>
{/* Staff Management Routes */}
<Route
path="/staff/directory"
element={
<RequireAccess>
<StaffDirectory />
</RequireAccess>
}
/>
<Route
path="/staff/admin"
element={
<RequireAccess>
<StaffAdmin />
</RequireAccess>
}
/>
{/* Staff Tools & Resources */}
<Route
path="/staff/chat"
element={
<RequireAccess>
<StaffChat />
</RequireAccess>
}
/>
<Route
path="/staff/docs"
element={
<RequireAccess>
<StaffDocs />
</RequireAccess>
}
/>
<Route
path="/staff/achievements"
element={
<RequireAccess>
<StaffAchievements />
</RequireAccess>
}
/>
{/* Staff Admin Pages */}
<Route
path="/staff/announcements"
element={
<RequireAccess>
<StaffAnnouncements />
</RequireAccess>
}
/>
<Route
path="/staff/expense-reports"
element={
<RequireAccess>
<StaffExpenseReports />
</RequireAccess>
}
/>
<Route
path="/staff/marketplace"
element={
<RequireAccess>
<StaffInternalMarketplace />
</RequireAccess>
}
/>
<Route
path="/staff/knowledge-base"
element={
<RequireAccess>
<StaffKnowledgeBase />
</RequireAccess>
}
/>
<Route
path="/staff/learning-portal"
element={
<RequireAccess>
<StaffLearningPortal />
</RequireAccess>
}
/>
<Route
path="/staff/performance-reviews"
element={
<RequireAccess>
<StaffPerformanceReviews />
</RequireAccess>
}
/>
<Route
path="/staff/project-tracking"
element={
<RequireAccess>
<StaffProjectTracking />
</RequireAccess>
}
/>
<Route
path="/staff/team-handbook"
element={
<RequireAccess>
<StaffTeamHandbook />
</RequireAccess>
}
/>
{/* Dev-Link routes - now redirect to Nexus Opportunities with ecosystem filter */}
<Route path="/dev-link" element={<Navigate to="/opportunities?ecosystem=roblox" replace />} />
@ -441,8 +545,55 @@ const App = () => (
element={<Navigate to="/opportunities?ecosystem=roblox" replace />}
/>
{/* Client Hub routes → aethex.co */}
<Route path="/hub/client/*" element={<ExternalRedirect to="https://aethex.co/hub" />} />
{/* Client Hub routes */}
<Route
path="/hub/client/dashboard"
element={
<RequireAccess>
<ClientDashboard />
</RequireAccess>
}
/>
<Route
path="/hub/client/projects"
element={
<RequireAccess>
<ClientProjects />
</RequireAccess>
}
/>
<Route
path="/hub/client/invoices"
element={
<RequireAccess>
<ClientInvoices />
</RequireAccess>
}
/>
<Route
path="/hub/client/contracts"
element={
<RequireAccess>
<ClientContracts />
</RequireAccess>
}
/>
<Route
path="/hub/client/reports"
element={
<RequireAccess>
<ClientReports />
</RequireAccess>
}
/>
<Route
path="/hub/client/settings"
element={
<RequireAccess>
<ClientSettings />
</RequireAccess>
}
/>
{/* Nexus routes */}
<Route path="/nexus" element={<Nexus />} />
@ -462,10 +613,6 @@ const App = () => (
path="curriculum"
element={<DocsCurriculum />}
/>
<Route
path="curriculum/ethos"
element={<DocsCurriculumEthos />}
/>
<Route
path="getting-started"
element={<DocsGettingStarted />}
@ -568,6 +715,88 @@ const App = () => (
{/* Discord Activity route */}
<Route path="/activity" element={<Activity />} />
{/* Docs routes */}
<Route
path="/docs"
element={
<DocsLayout>
<DocsOverview />
</DocsLayout>
}
/>
<Route
path="/docs/getting-started"
element={
<DocsLayout>
<DocsGettingStarted />
</DocsLayout>
}
/>
<Route
path="/docs/platform"
element={
<DocsLayout>
<DocsPlatform />
</DocsLayout>
}
/>
<Route
path="/docs/api"
element={
<DocsLayout>
<DocsApiReference />
</DocsLayout>
}
/>
<Route
path="/docs/cli"
element={
<DocsLayout>
<DocsCli />
</DocsLayout>
}
/>
<Route
path="/docs/tutorials"
element={
<DocsLayout>
<DocsTutorials />
</DocsLayout>
}
/>
<Route
path="/docs/examples"
element={
<DocsLayout>
<DocsExamples />
</DocsLayout>
}
/>
<Route
path="/docs/integrations"
element={
<DocsLayout>
<DocsIntegrations />
</DocsLayout>
}
/>
<Route
path="/docs/curriculum"
element={
<DocsLayout>
<DocsCurriculum />
</DocsLayout>
}
/>
<Route
path="/docs/curriculum/ethos"
element={
<DocsLayout>
<DocsCurriculumEthos />
</DocsLayout>
}
/>
{/* Internal Docs Hub Routes */}
<Route
path="/internal-docs"
@ -672,7 +901,6 @@ const App = () => (
</ArmThemeProvider>
</SubdomainPassportProvider>
</DiscordActivityWrapper>
</StaffSubdomainRedirect>
</BrowserRouter>
</TooltipProvider>
</DiscordProvider>

View file

@ -68,22 +68,9 @@ const ARMS: Arm[] = [
textColor: "text-purple-400",
href: "/staff",
},
{
id: "studio",
name: "AeThex | Studio",
label: "Studio",
color: "#00ffff",
bgColor: "bg-cyan-500/20",
textColor: "text-cyan-400",
href: "https://aethex.studio",
external: true,
},
];
const STUDIO_SVG = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="96" fill="%23050505"/><polygon points="256,48 444,152 444,360 256,464 68,360 68,152" fill="none" stroke="%2300ffff" stroke-width="18" opacity="0.9"/><text x="256" y="320" text-anchor="middle" font-family="Orbitron,monospace" font-size="220" font-weight="700" fill="%2300ffff">&#198;</text></svg>')}`;
const LOGO_URLS: Record<string, string> = {
studio: STUDIO_SVG,
staff:
"https://cdn.builder.io/api/v1/image/assets%2Ffc53d607e21d497595ac97e0637001a1%2Fc0414efd7af54ef4b821a05d469150d0?format=webp&width=800",
labs: "https://cdn.builder.io/api/v1/image/assets%2Ffc53d607e21d497595ac97e0637001a1%2Fd93f7113d34347469e74421c3a3412e5?format=webp&width=800",

File diff suppressed because it is too large Load diff

View file

@ -196,7 +196,7 @@ export function ProfileEditor({
return (
<Tabs defaultValue="basic" className="w-full">
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-3 md:grid-cols-5">
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="basic">Basic</TabsTrigger>
<TabsTrigger value="social">Social</TabsTrigger>
<TabsTrigger value="skills">Skills</TabsTrigger>

View file

@ -80,7 +80,7 @@ export default function AdminStaffAdmin() {
</div>
<Tabs value={adminTab} onValueChange={setAdminTab} className="space-y-4">
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-3 lg:grid-cols-6">
<TabsList className="grid w-full grid-cols-3 lg:grid-cols-6">
<TabsTrigger value="users" className="flex items-center gap-2">
<Users className="w-4 h-4" />
<span className="hidden sm:inline">Users</span>

View file

@ -209,7 +209,7 @@ export const AIChat: React.FC<AIChatProps> = ({
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="fixed bottom-4 right-4 md:bottom-6 md:right-6 w-[calc(100vw-2rem)] md:w-[450px] h-[70vh] sm:h-[600px] max-h-[80vh] bg-background border border-border rounded-2xl shadow-2xl z-50 flex flex-col overflow-hidden"
className="fixed bottom-4 right-4 md:bottom-6 md:right-6 w-[calc(100vw-2rem)] md:w-[450px] h-[600px] max-h-[80vh] bg-background border border-border rounded-2xl shadow-2xl z-50 flex flex-col overflow-hidden"
>
<div className={`flex items-center justify-between p-4 border-b border-border bg-gradient-to-r ${currentPersona.theme.gradient} bg-opacity-10`}>
<PersonaSelector

View file

@ -21,122 +21,18 @@ import {
User,
Menu,
X,
Zap,
FlaskConical,
LayoutDashboard,
ChevronRight,
} from "lucide-react";
export interface DevPlatformNavProps {
className?: string;
}
interface NavEntry {
name: string;
href: string;
icon: React.ElementType;
description: string;
comingSoon?: boolean;
}
interface NavGroup {
label: string;
items: NavEntry[];
}
// ── Grouped nav structure ──────────────────────────────────────────────────────
const NAV_GROUPS: NavGroup[] = [
{
label: "Learn",
items: [
{
name: "Quick Start",
href: "/dev-platform/quick-start",
icon: Zap,
description: "Up and running in under 5 minutes",
},
{
name: "Documentation",
href: "/docs",
icon: BookOpen,
description: "Guides, concepts, and deep dives",
},
{
name: "Code Examples",
href: "/dev-platform/examples",
icon: FlaskConical,
description: "Copy-paste snippets for common patterns",
},
],
},
{
label: "Build",
items: [
{
name: "API Reference",
href: "/dev-platform/api-reference",
icon: Code2,
description: "Full endpoint docs with live samples",
},
{
name: "SDK",
href: "/sdk",
icon: Package,
description: "Client libraries for JS, Python, Go and more",
comingSoon: true,
},
{
name: "Templates",
href: "/dev-platform/templates",
icon: LayoutTemplate,
description: "Project starters and boilerplates",
},
{
name: "Marketplace",
href: "/dev-platform/marketplace",
icon: Store,
description: "Plugins, integrations, and extensions",
comingSoon: true,
},
],
},
];
// ── Shared dropdown item component ────────────────────────────────────────────
function DropdownItem({ item, onClick }: { item: NavEntry; onClick?: () => void }) {
return (
<NavigationMenuLink asChild>
<Link
to={item.href}
onClick={onClick}
className="group flex select-none gap-3 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
>
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border/60 bg-muted/50 group-hover:border-primary/30 group-hover:bg-primary/10 transition-colors">
<item.icon className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium leading-none">{item.name}</span>
{item.comingSoon && (
<span className="rounded-full bg-primary/15 px-1.5 py-0.5 text-[10px] font-medium text-primary leading-none">
Soon
</span>
)}
</div>
<p className="mt-1 text-xs leading-snug text-muted-foreground line-clamp-2">
{item.description}
</p>
</div>
</Link>
</NavigationMenuLink>
);
}
export function DevPlatformNav({ className }: DevPlatformNavProps) {
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
const [searchOpen, setSearchOpen] = React.useState(false);
const location = useLocation();
// Command palette keyboard shortcut
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
@ -144,12 +40,46 @@ export function DevPlatformNav({ className }: DevPlatformNavProps) {
setSearchOpen(true);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
const isGroupActive = (group: NavGroup) =>
group.items.some((item) => location.pathname.startsWith(item.href));
const navLinks = [
{
name: "Docs",
href: "/docs",
icon: BookOpen,
description: "Guides, tutorials, and API concepts",
},
{
name: "API Reference",
href: "/api-reference",
icon: Code2,
description: "Complete API documentation",
},
{
name: "SDK",
href: "/sdk",
icon: Package,
description: "Download SDKs for all platforms",
},
{
name: "Templates",
href: "/templates",
icon: LayoutTemplate,
description: "Project starters and boilerplates",
},
{
name: "Marketplace",
href: "/marketplace",
icon: Store,
description: "Plugins and extensions (coming soon)",
comingSoon: true,
},
];
const isActive = (path: string) => location.pathname.startsWith(path);
return (
<nav
@ -161,7 +91,7 @@ export function DevPlatformNav({ className }: DevPlatformNavProps) {
<div className="container flex h-16 items-center">
{/* Logo */}
<Link
to="/dev-platform"
to="/"
className="mr-8 flex items-center space-x-2 transition-opacity hover:opacity-80"
>
<FileCode className="h-6 w-6 text-primary" />
@ -174,68 +104,55 @@ export function DevPlatformNav({ className }: DevPlatformNavProps) {
<div className="hidden md:flex md:flex-1 md:items-center md:justify-between">
<NavigationMenu>
<NavigationMenuList>
{NAV_GROUPS.map((group) => (
<NavigationMenuItem key={group.label}>
<NavigationMenuTrigger
className={cn(
"h-10 text-sm font-medium",
isGroupActive(group) && "text-primary"
)}
>
{group.label}
</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-[420px] gap-1 p-3">
{/* Group header */}
<li className="px-2 pb-1">
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/60">
{group.label}
</p>
</li>
{group.items.map((item) => (
<li key={item.href}>
<DropdownItem item={item} />
</li>
))}
</ul>
</NavigationMenuContent>
{navLinks.map((link) => (
<NavigationMenuItem key={link.href}>
<Link to={link.href}>
<NavigationMenuLink
className={cn(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50",
isActive(link.href) &&
"bg-accent text-accent-foreground"
)}
>
<link.icon className="mr-2 h-4 w-4" />
{link.name}
{link.comingSoon && (
<span className="ml-2 rounded-full bg-primary/20 px-2 py-0.5 text-xs text-primary">
Soon
</span>
)}
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
))}
{/* Standalone Dashboard link */}
<NavigationMenuItem>
<NavigationMenuLink asChild>
<Link
to="/dev-platform/dashboard"
className={cn(
"group inline-flex h-10 items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:outline-none",
location.pathname.startsWith("/dev-platform/dashboard") &&
"bg-accent text-accent-foreground"
)}
>
<LayoutDashboard className="mr-2 h-4 w-4" />
Dashboard
</Link>
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
{/* Right side actions */}
<div className="flex items-center space-x-3">
<div className="flex items-center space-x-4">
{/* Search button */}
<Button
variant="outline"
size="sm"
className="relative h-9 justify-start text-sm text-muted-foreground w-48"
className="relative h-9 w-full justify-start text-sm text-muted-foreground sm:w-64"
onClick={() => setSearchOpen(true)}
>
<Command className="mr-2 h-4 w-4 shrink-0" />
<span>Search docs...</span>
<kbd className="pointer-events-none absolute right-2 hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium sm:flex">
<Command className="mr-2 h-4 w-4" />
<span className="hidden lg:inline-flex">Search...</span>
<span className="inline-flex lg:hidden">Search</span>
<kbd className="pointer-events-none absolute right-2 hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex">
<span className="text-xs"></span>K
</kbd>
</Button>
{/* Dashboard link */}
<Link to="/dashboard">
<Button variant="ghost" size="sm">
Dashboard
</Button>
</Link>
{/* User menu */}
<Link to="/profile">
<Button variant="ghost" size="icon" className="h-9 w-9">
<User className="h-4 w-4" />
@ -251,7 +168,11 @@ export function DevPlatformNav({ className }: DevPlatformNavProps) {
size="icon"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
{mobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
</div>
</div>
@ -259,50 +180,41 @@ export function DevPlatformNav({ className }: DevPlatformNavProps) {
{/* Mobile Navigation */}
{mobileMenuOpen && (
<div className="border-t border-border/40 md:hidden">
<div className="container py-4 space-y-4">
{NAV_GROUPS.map((group) => (
<div key={group.label}>
<p className="px-3 pb-1 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/60">
{group.label}
</p>
{group.items.map((item) => (
<Link
key={item.href}
to={item.href}
onClick={() => setMobileMenuOpen(false)}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground",
location.pathname.startsWith(item.href) && "bg-accent text-accent-foreground"
)}
>
<item.icon className="h-4 w-4 shrink-0" />
<span className="flex-1">{item.name}</span>
{item.comingSoon && (
<span className="rounded-full bg-primary/15 px-1.5 py-0.5 text-[10px] font-medium text-primary">
Soon
</span>
)}
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
</Link>
))}
</div>
<div className="container space-y-1 py-4">
{navLinks.map((link) => (
<Link
key={link.href}
to={link.href}
onClick={() => setMobileMenuOpen(false)}
className={cn(
"flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground",
isActive(link.href) && "bg-accent text-accent-foreground"
)}
>
<link.icon className="mr-3 h-4 w-4" />
{link.name}
{link.comingSoon && (
<span className="ml-auto rounded-full bg-primary/20 px-2 py-0.5 text-xs text-primary">
Soon
</span>
)}
</Link>
))}
<div className="border-t border-border/40 pt-3">
<div className="border-t border-border/40 pt-4 mt-4">
<Link
to="/dev-platform/dashboard"
to="/dashboard"
onClick={() => setMobileMenuOpen(false)}
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground"
className="flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground"
>
<LayoutDashboard className="h-4 w-4" />
Dashboard
</Link>
<Link
to="/profile"
onClick={() => setMobileMenuOpen(false)}
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground"
className="flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground"
>
<User className="h-4 w-4" />
<User className="mr-3 h-4 w-4" />
Profile
</Link>
</div>
@ -310,20 +222,20 @@ export function DevPlatformNav({ className }: DevPlatformNavProps) {
</div>
)}
{/* Command Palette */}
{/* Command Palette Placeholder - will be implemented separately */}
{searchOpen && (
<div
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={() => setSearchOpen(false)}
>
<div
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
onClick={(e) => e.stopPropagation()}
>
<div className="rounded-xl border bg-background p-8 shadow-2xl min-w-80 text-center space-y-2">
<Command className="mx-auto h-8 w-8 text-muted-foreground" />
<p className="text-muted-foreground font-medium">Command palette coming soon</p>
<p className="text-sm text-muted-foreground/60">Press Esc or click outside to close</p>
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="rounded-lg border bg-background p-8 shadow-lg">
<p className="text-center text-muted-foreground">
Command palette coming soon...
</p>
<p className="text-center text-sm text-muted-foreground mt-2">
Press Esc to close
</p>
</div>
</div>
</div>

View file

@ -26,16 +26,61 @@ interface DocNavItem {
description?: string;
}
const docNavigation: Omit<DocNavItem, "icon">[] = [
{ title: "Overview", path: "/docs", description: "Get started with AeThex" },
{ title: "Getting Started", path: "/docs/getting-started", description: "Quick start guide" },
{ title: "Platform", path: "/docs/platform", description: "Platform architecture & features" },
{ title: "API Reference", path: "/docs/api", description: "Complete API documentation" },
{ title: "CLI", path: "/docs/cli", description: "Command line tools" },
{ title: "Tutorials", path: "/docs/tutorials", description: "Step-by-step guides" },
{ title: "Examples", path: "/docs/examples", description: "Code examples" },
{ title: "Integrations", path: "/docs/integrations", description: "Third-party integrations" },
{ title: "Curriculum", path: "/docs/curriculum", description: "Learning paths" },
const docNavigation: DocNavItem[] = [
{
title: "Overview",
path: "/docs",
icon: <BookOpen className="h-5 w-5" />,
description: "Get started with AeThex",
},
{
title: "Getting Started",
path: "/docs/getting-started",
icon: <Zap className="h-5 w-5" />,
description: "Quick start guide",
},
{
title: "Platform",
path: "/docs/platform",
icon: <Layers className="h-5 w-5" />,
description: "Platform architecture & features",
},
{
title: "API Reference",
path: "/docs/api",
icon: <Code2 className="h-5 w-5" />,
description: "Complete API documentation",
},
{
title: "CLI",
path: "/docs/cli",
icon: <GitBranch className="h-5 w-5" />,
description: "Command line tools",
},
{
title: "Tutorials",
path: "/docs/tutorials",
icon: <BookMarked className="h-5 w-5" />,
description: "Step-by-step guides",
},
{
title: "Examples",
path: "/docs/examples",
icon: <FileText className="h-5 w-5" />,
description: "Code examples",
},
{
title: "Integrations",
path: "/docs/integrations",
icon: <Zap className="h-5 w-5" />,
description: "Third-party integrations",
},
{
title: "Curriculum",
path: "/docs/curriculum",
icon: <BookOpen className="h-5 w-5" />,
description: "Learning paths",
},
];
interface DocsLayoutProps {
@ -58,27 +103,15 @@ function DocsLayoutContent({
const location = useLocation();
const { colors, toggleTheme, theme } = useDocsTheme();
const navWithIcons: DocNavItem[] = useMemo(() => [
{ ...docNavigation[0], icon: <BookOpen className="h-5 w-5" /> },
{ ...docNavigation[1], icon: <Zap className="h-5 w-5" /> },
{ ...docNavigation[2], icon: <Layers className="h-5 w-5" /> },
{ ...docNavigation[3], icon: <Code2 className="h-5 w-5" /> },
{ ...docNavigation[4], icon: <GitBranch className="h-5 w-5" /> },
{ ...docNavigation[5], icon: <BookMarked className="h-5 w-5" /> },
{ ...docNavigation[6], icon: <FileText className="h-5 w-5" /> },
{ ...docNavigation[7], icon: <Zap className="h-5 w-5" /> },
{ ...docNavigation[8], icon: <BookOpen className="h-5 w-5" /> },
], []);
const filteredNav = useMemo(() => {
if (!searchQuery) return navWithIcons;
if (!searchQuery) return docNavigation;
const query = searchQuery.toLowerCase();
return navWithIcons.filter(
return docNavigation.filter(
(item) =>
item.title.toLowerCase().includes(query) ||
item.description?.toLowerCase().includes(query),
);
}, [searchQuery, navWithIcons]);
}, [searchQuery]);
const isCurrentPage = (path: string) => location.pathname === path;
@ -243,13 +276,13 @@ function DocsLayoutContent({
</Link>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8 px-6 md:px-8 py-8 max-w-6xl mx-auto">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8 px-6 md:px-8 py-8 max-w-7xl mx-auto">
{/* Content */}
<div className="lg:col-span-3">
{title && (
<div className="mb-8">
<h1
className={`text-4xl font-bold ${colors.headingColor} mb-3`}
className={`text-5xl font-bold ${colors.headingColor} mb-3`}
>
{title}
</h1>

View file

@ -1,166 +0,0 @@
import { Link, useLocation } from "react-router-dom";
import { cn } from "@/lib/utils";
import { useAuth } from "@/contexts/AuthContext";
import {
Music2,
Users,
FileText,
Settings,
ChevronLeft,
Headphones,
} from "lucide-react";
interface EthosLayoutProps {
children: React.ReactNode;
}
interface NavItem {
name: string;
href: string;
icon: React.ElementType;
memberOnly?: boolean;
}
const NAV_ITEMS: NavItem[] = [
{ name: "Library", href: "/ethos/library", icon: Headphones },
{ name: "Artists", href: "/ethos/artists", icon: Users },
{ name: "Licensing", href: "/ethos/licensing", icon: FileText, memberOnly: true },
{ name: "Settings", href: "/ethos/settings", icon: Settings, memberOnly: true },
];
export default function EthosLayout({ children }: EthosLayoutProps) {
const location = useLocation();
const { user } = useAuth();
const isActive = (href: string) => location.pathname.startsWith(href);
return (
<div style={{ minHeight: "100vh", background: "#050505", color: "#e0e0e0" }}>
{/* Top bar */}
<header style={{
position: "sticky", top: 0, zIndex: 50,
background: "rgba(5,5,5,0.97)",
borderBottom: "1px solid rgba(168,85,247,0.15)",
backdropFilter: "blur(12px)",
}}>
{/* Purple accent stripe */}
<div style={{ height: 2, background: "linear-gradient(90deg, #7c3aed 0%, #a855f7 50%, #7c3aed 100%)", opacity: 0.6 }} />
<div style={{
maxWidth: 1200, margin: "0 auto",
padding: "0 24px",
display: "flex", alignItems: "center",
height: 52, gap: 0,
}}>
{/* Back to main site */}
<Link
to="/"
style={{
display: "flex", alignItems: "center", gap: 6,
color: "rgba(168,85,247,0.5)", textDecoration: "none",
fontSize: 11, fontFamily: "monospace", letterSpacing: 1,
marginRight: 24, flexShrink: 0,
transition: "color 0.2s",
}}
onMouseEnter={e => (e.currentTarget.style.color = "rgba(168,85,247,0.9)")}
onMouseLeave={e => (e.currentTarget.style.color = "rgba(168,85,247,0.5)")}
>
<ChevronLeft className="h-3.5 w-3.5" />
aethex.dev
</Link>
{/* Brand */}
<Link
to="/ethos/library"
style={{
display: "flex", alignItems: "center", gap: 8,
textDecoration: "none", marginRight: 40, flexShrink: 0,
}}
>
<div style={{
width: 28, height: 28,
background: "linear-gradient(135deg, #7c3aed, #a855f7)",
borderRadius: "50%",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<Music2 className="h-3.5 w-3.5 text-white" />
</div>
<span style={{
fontFamily: "monospace", fontWeight: 700, fontSize: 13,
letterSpacing: 3, color: "#a855f7", textTransform: "uppercase",
}}>
Ethos Guild
</span>
</Link>
{/* Nav tabs */}
<nav style={{ display: "flex", alignItems: "stretch", gap: 2, flex: 1, height: "100%" }}>
{NAV_ITEMS.filter(item => !item.memberOnly || user).map(item => (
<Link
key={item.href}
to={item.href}
style={{
display: "flex", alignItems: "center", gap: 6,
padding: "0 16px",
textDecoration: "none",
fontFamily: "monospace", fontSize: 11, letterSpacing: 1,
textTransform: "uppercase",
color: isActive(item.href) ? "#a855f7" : "rgba(255,255,255,0.4)",
borderBottom: isActive(item.href) ? "2px solid #a855f7" : "2px solid transparent",
transition: "color 0.2s, border-color 0.2s",
marginBottom: -1,
}}
onMouseEnter={e => {
if (!isActive(item.href)) e.currentTarget.style.color = "rgba(168,85,247,0.8)";
}}
onMouseLeave={e => {
if (!isActive(item.href)) e.currentTarget.style.color = "rgba(255,255,255,0.4)";
}}
>
<item.icon className="h-3.5 w-3.5" />
{item.name}
{item.memberOnly && (
<span style={{
fontSize: 8, padding: "1px 4px",
background: "rgba(168,85,247,0.15)",
color: "#a855f7", borderRadius: 2,
letterSpacing: 1,
}}>
MEMBER
</span>
)}
</Link>
))}
</nav>
{/* Sign in prompt for guests */}
{!user && (
<Link
to="/login"
style={{
fontFamily: "monospace", fontSize: 10, letterSpacing: 2,
color: "#a855f7", textDecoration: "none",
border: "1px solid rgba(168,85,247,0.4)",
padding: "5px 12px",
transition: "all 0.2s",
}}
onMouseEnter={e => {
e.currentTarget.style.background = "rgba(168,85,247,0.1)";
e.currentTarget.style.borderColor = "rgba(168,85,247,0.7)";
}}
onMouseLeave={e => {
e.currentTarget.style.background = "transparent";
e.currentTarget.style.borderColor = "rgba(168,85,247,0.4)";
}}
>
JOIN GUILD
</Link>
)}
</div>
</header>
{/* Page content */}
<main>{children}</main>
</div>
);
}

View file

@ -205,7 +205,7 @@ export default function CommentsModal({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] flex flex-col h-[80vh] sm:h-[600px] max-h-[600px]">
<DialogContent className="sm:max-w-[500px] flex flex-col h-[600px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MessageCircle className="h-5 w-5" />

View file

@ -1,184 +0,0 @@
import { Link, useLocation } from "react-router-dom";
import { cn } from "@/lib/utils";
import { useAuth } from "@/contexts/AuthContext";
import {
Gamepad2,
LayoutDashboard,
FolderKanban,
Users2,
Box,
ChevronLeft,
Lock,
} from "lucide-react";
interface GameForgeLayoutProps {
children: React.ReactNode;
}
interface SidebarSection {
label: string;
items: {
name: string;
href: string;
icon: React.ElementType;
authRequired?: boolean;
}[];
}
const SIDEBAR: SidebarSection[] = [
{
label: "Overview",
items: [
{ name: "GameForge Home", href: "/gameforge", icon: Gamepad2 },
],
},
{
label: "Studio",
items: [
{ name: "Dashboard", href: "/gameforge/manage", icon: LayoutDashboard, authRequired: true },
{ name: "Projects", href: "/gameforge/manage/projects", icon: FolderKanban, authRequired: true },
{ name: "Team", href: "/gameforge/manage/team", icon: Users2, authRequired: true },
{ name: "Assets", href: "/gameforge/manage/assets", icon: Box, authRequired: true },
],
},
];
export default function GameForgeLayout({ children }: GameForgeLayoutProps) {
const location = useLocation();
const { user } = useAuth();
const isActive = (href: string) =>
href === "/gameforge"
? location.pathname === href
: location.pathname.startsWith(href);
return (
<div style={{ minHeight: "100vh", background: "#050505", color: "#e0e0e0", display: "flex", flexDirection: "column" }}>
{/* Top strip */}
<div style={{ height: 2, background: "linear-gradient(90deg, #ff6b00, #ff9500, #ff6b00)", opacity: 0.7, flexShrink: 0 }} />
<div style={{ display: "flex", flex: 1 }}>
{/* Sidebar */}
<aside style={{
width: 220, flexShrink: 0,
background: "rgba(10,10,10,0.98)",
borderRight: "1px solid rgba(255,107,0,0.12)",
position: "sticky", top: 0, height: "100vh",
display: "flex", flexDirection: "column",
padding: "20px 0",
}}>
{/* Brand */}
<div style={{ padding: "0 20px 20px", borderBottom: "1px solid rgba(255,107,0,0.1)" }}>
<Link
to="/"
style={{
display: "flex", alignItems: "center", gap: 5,
color: "rgba(255,107,0,0.45)", textDecoration: "none",
fontSize: 10, fontFamily: "monospace", letterSpacing: 1,
marginBottom: 14,
}}
>
<ChevronLeft className="h-3 w-3" />aethex.dev
</Link>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{
width: 32, height: 32, background: "linear-gradient(135deg, #ff6b00, #ff9500)",
borderRadius: 4,
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<Gamepad2 className="h-4 w-4 text-white" />
</div>
<div>
<div style={{ fontFamily: "monospace", fontWeight: 700, fontSize: 12, letterSpacing: 2, color: "#ff7a00" }}>
GAMEFORGE
</div>
<div style={{ fontFamily: "monospace", fontSize: 9, color: "rgba(255,107,0,0.4)", letterSpacing: 1 }}>
STUDIO MANAGEMENT
</div>
</div>
</div>
</div>
{/* Nav sections */}
<nav style={{ flex: 1, padding: "16px 0", overflowY: "auto" }}>
{SIDEBAR.map(section => (
<div key={section.label} style={{ marginBottom: 20 }}>
<div style={{
padding: "0 20px 6px",
fontSize: 9, fontFamily: "monospace", letterSpacing: 2,
textTransform: "uppercase", color: "rgba(255,107,0,0.3)",
}}>
{section.label}
</div>
{section.items.map(item => {
const locked = item.authRequired && !user;
const active = isActive(item.href);
return (
<Link
key={item.href}
to={locked ? "/login" : item.href}
style={{
display: "flex", alignItems: "center", gap: 10,
padding: "8px 20px",
textDecoration: "none",
fontFamily: "monospace", fontSize: 11, letterSpacing: 0.5,
color: locked
? "rgba(255,255,255,0.2)"
: active
? "#ff7a00"
: "rgba(255,255,255,0.5)",
background: active ? "rgba(255,107,0,0.07)" : "transparent",
borderLeft: active ? "2px solid #ff7a00" : "2px solid transparent",
transition: "all 0.15s",
}}
onMouseEnter={e => {
if (!active && !locked) {
e.currentTarget.style.color = "rgba(255,122,0,0.8)";
e.currentTarget.style.background = "rgba(255,107,0,0.04)";
}
}}
onMouseLeave={e => {
if (!active && !locked) {
e.currentTarget.style.color = "rgba(255,255,255,0.5)";
e.currentTarget.style.background = "transparent";
}
}}
>
<item.icon className="h-3.5 w-3.5 shrink-0" />
<span style={{ flex: 1 }}>{item.name}</span>
{locked && <Lock className="h-3 w-3 opacity-40" />}
</Link>
);
})}
</div>
))}
</nav>
{/* Footer hint */}
{!user && (
<div style={{ padding: "16px 20px", borderTop: "1px solid rgba(255,107,0,0.1)" }}>
<Link
to="/login"
style={{
display: "block", textAlign: "center",
fontFamily: "monospace", fontSize: 10, letterSpacing: 2,
color: "#ff7a00", textDecoration: "none",
border: "1px solid rgba(255,107,0,0.4)",
padding: "7px 0",
transition: "all 0.2s",
}}
onMouseEnter={e => (e.currentTarget.style.background = "rgba(255,107,0,0.08)")}
onMouseLeave={e => (e.currentTarget.style.background = "transparent")}
>
SIGN IN TO MANAGE
</Link>
</div>
)}
</aside>
{/* Main content */}
<main style={{ flex: 1, minWidth: 0 }}>{children}</main>
</div>
</div>
);
}

View file

@ -243,7 +243,6 @@ export default function NotificationBell({
<DropdownMenuContent
align="end"
className="w-80 border-border/40 bg-background/95 backdrop-blur"
style={{ zIndex: 99999 }}
>
<DropdownMenuLabel className="flex items-center justify-between">
<span className="text-sm font-semibold text-foreground">

View file

@ -21,7 +21,7 @@ import {
checkProfileComplete,
} from "@/lib/aethex-database-adapter";
type SupportedOAuthProvider = "github" | "google" | "discord" | string;
type SupportedOAuthProvider = "github" | "google" | "discord";
interface LinkedProvider {
provider: SupportedOAuthProvider;
@ -165,9 +165,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
const [loading, setLoading] = useState(true);
const rewardsActivatedRef = useRef(false);
const storageClearedRef = useRef(false);
// True after the very first auth event resolves — distinguishes session
// restoration (page load) from a real user-initiated sign-in.
const initialEventFired = useRef(false);
useEffect(() => {
let sessionRestored = false;
@ -200,12 +197,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
// - IndexedDB (where Supabase stores sessions)
// Clearing these breaks session persistence across page reloads/redirects!
// If the server set the SSO remember-me cookie (Authentik login), promote
// it to localStorage so the session survives across browser restarts.
if (document.cookie.includes("aethex_sso_remember=1")) {
window.localStorage.setItem("aethex_remember_me", "1");
}
storageClearedRef.current = true;
} catch {
storageClearedRef.current = true;
@ -230,21 +221,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
data: { session },
} = await supabase.auth.getSession();
// If "remember me" was NOT checked when the user last signed in, clear
// the persisted session so closing the browser actually logs them out.
// SSO (Authentik) logins always set this flag, so this only affects
// email/password logins where the user explicitly unchecked it.
if (session?.user) {
const rememberMe = window.localStorage.getItem("aethex_remember_me");
if (rememberMe === null) {
// No flag — user didn't ask to be remembered; clear local session.
await supabase.auth.signOut({ scope: "local" });
sessionRestored = true;
setLoading(false);
return;
}
}
// If no session but tokens exist, the session might not have restored yet
// Wait for onAuthStateChange to trigger
if (!session && hasAuthTokens()) {
@ -300,24 +276,17 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
}, 50);
}
// Only toast on real user-initiated events, not session restoration on page load.
// INITIAL_SESSION fires first on page load (Supabase v2); after that every
// SIGNED_IN is a genuine login.
const isInitialRestore = !initialEventFired.current;
initialEventFired.current = true;
if (event === "SIGNED_IN" && !isInitialRestore) {
// Show toast notifications for auth events
if (event === "SIGNED_IN") {
aethexToast.success({
title: "Signed in",
description: "Welcome back to AeThex OS",
title: "Welcome back!",
description: "Successfully signed in to AeThex OS",
});
} else if (event === "SIGNED_OUT") {
aethexToast.info({
title: "Signed out",
description: "Come back soon!",
});
} else if (event === "TOKEN_REFRESHED") {
// Silently refresh — no toast
}
});
@ -715,7 +684,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
}
const { data, error } = await supabase.auth.signInWithOAuth({
provider: provider as any,
provider,
options: {
redirectTo: `${window.location.origin}/login`,
},
@ -1013,16 +982,13 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
return;
}
// Only clear session for actual Supabase auth errors — be very specific.
// "unauthorized" and "auth/" were removed: they're too broad and match
// normal API 401s or any URL containing "auth/", which falsely logs users out.
// Only clear session for actual auth errors
const authErrorPatterns = [
"invalid refresh token",
"refresh_token_not_found",
"session expired",
"token_expired",
"revoked",
"jwt expired",
"unauthorized",
"auth/",
];
if (authErrorPatterns.some((pattern) => messageStr.includes(pattern))) {
@ -1067,7 +1033,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
// Step 2: Clear localStorage and IndexedDB
console.log("Clearing localStorage and IndexedDB...");
if (typeof window !== "undefined") {
window.localStorage.removeItem("aethex_remember_me");
try {
window.localStorage.removeItem("onboarding_complete");
window.localStorage.removeItem("aethex_onboarding_progress_v1");

View file

@ -284,17 +284,17 @@ export const DiscordActivityProvider: React.FC<
// Subscribe to speaking updates if in voice channel
if (sdk.channelId) {
try {
await sdk.subscribe("SPEAKING_START", (data: any) => {
sdk.subscribe("SPEAKING_START", (data: any) => {
console.log("[Discord Activity] Speaking start:", data);
if (data?.user_id) {
setSpeakingUsers(prev => new Set(prev).add(data.user_id));
setParticipants(prev => prev.map(p =>
setParticipants(prev => prev.map(p =>
p.id === data.user_id ? { ...p, speaking: true } : p
));
}
}, { channel_id: sdk.channelId });
await sdk.subscribe("SPEAKING_STOP", (data: any) => {
sdk.subscribe("SPEAKING_STOP", (data: any) => {
console.log("[Discord Activity] Speaking stop:", data);
if (data?.user_id) {
setSpeakingUsers(prev => {
@ -302,7 +302,7 @@ export const DiscordActivityProvider: React.FC<
next.delete(data.user_id);
return next;
});
setParticipants(prev => prev.map(p =>
setParticipants(prev => prev.map(p =>
p.id === data.user_id ? { ...p, speaking: false } : p
));
}

View file

@ -1,183 +1,90 @@
@import url("https://fonts.googleapis.com/css2?family=Electrolize&family=Orbitron:wght@400;600;700;900&family=Share+Tech+Mono&family=Source+Code+Pro:wght@300;400;500;600&family=VT323&family=Press+Start+2P&family=Merriweather:wght@400;700&display=swap");
@import url("https://fonts.googleapis.com/css2?family=VT323&family=Press+Start+2P&family=Merriweather:wght@400;700&family=Roboto+Mono:wght@300;400;500&display=swap");
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ── AeThex Cyberpunk Theme — copied verbatim from AeThex-Passport-Engine/client/src/index.css ── */
:root {
--button-outline: rgba(0, 255, 255, .15);
--badge-outline: rgba(0, 255, 255, .08);
--opaque-button-border-intensity: 8;
--elevate-1: rgba(0, 255, 255, .04);
--elevate-2: rgba(0, 255, 255, .08);
--background: 0 0% 2%;
--foreground: 0 0% 95%;
--border: 180 100% 50% / 0.2;
--card: 0 0% 5%;
--card-foreground: 0 0% 95%;
--card-border: 180 100% 50% / 0.15;
--sidebar: 0 0% 4%;
--sidebar-foreground: 0 0% 90%;
--sidebar-border: 180 100% 50% / 0.15;
--sidebar-primary: 180 100% 50%;
--sidebar-primary-foreground: 0 0% 0%;
--sidebar-accent: 0 0% 8%;
--sidebar-accent-foreground: 0 0% 90%;
--sidebar-ring: 180 100% 50%;
--popover: 0 0% 5%;
--popover-foreground: 0 0% 95%;
--popover-border: 180 100% 50% / 0.2;
--primary: 180 100% 50%;
--primary-foreground: 0 0% 0%;
--secondary: 0 0% 10%;
--secondary-foreground: 0 0% 85%;
--muted: 0 0% 8%;
--muted-foreground: 0 0% 55%;
--accent: 195 100% 45%;
--accent-foreground: 0 0% 0%;
--destructive: 340 100% 50%;
--destructive-foreground: 0 0% 98%;
--input: 0 0% 12%;
--ring: 180 100% 50%;
--chart-1: 180 100% 50%;
--chart-2: 300 100% 50%;
--chart-3: 142 76% 45%;
--chart-4: 340 100% 50%;
--chart-5: 260 100% 65%;
--neon-purple: 270 100% 65%;
--neon-magenta: 300 100% 55%;
--neon-cyan: 180 100% 50%;
--gameforge-green: 142 76% 45%;
--gameforge-dark: 142 30% 6%;
--font-sans: 'Electrolize', 'Source Code Pro', monospace;
--font-serif: Georgia, serif;
--font-mono: 'Source Code Pro', 'JetBrains Mono', monospace;
--font-display: 'Electrolize', monospace;
--font-pixel: Oxanium, sans-serif;
--radius: 0rem;
--shadow-2xs: 0px 2px 0px 0px hsl(0 0% 0% / 0.00);
--shadow-xs: 0px 2px 0px 0px hsl(0 0% 0% / 0.00);
--shadow-sm: 0px 2px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow: 0px 2px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow-md: 0px 2px 0px 0px hsl(0 0% 0% / 0.00), 0px 2px 4px -1px hsl(0 0% 0% / 0.00);
--shadow-lg: 0px 2px 0px 0px hsl(0 0% 0% / 0.00), 0px 4px 6px -1px hsl(0 0% 0% / 0.00);
--shadow-xl: 0px 2px 0px 0px hsl(0 0% 0% / 0.00), 0px 8px 10px -1px hsl(0 0% 0% / 0.00);
--shadow-2xl: 0px 2px 0px 0px hsl(0 0% 0% / 0.00);
--tracking-normal: 0em;
--spacing: 0.25rem;
@layer base {
/**
* Tailwind CSS theme
* tailwind.config.ts expects the following color variables to be expressed as HSL values.
* A different format will require also updating the theme in tailwind.config.ts.
*
* SPACING SYSTEM:
* Container: container mx-auto px-4 sm:px-6 lg:px-8
* Page Container: + py-8 lg:py-12
* Max Widths: max-w-7xl (app), max-w-6xl (content), max-w-4xl (articles)
* Vertical Spacing: space-y-8 (sections), space-y-6 (cards), space-y-4 (content)
* Gaps: gap-6 (cards), gap-4 (buttons/forms), gap-2 (tags)
*/
:root {
--background: 222 84% 4.9%;
/* AeThex Brand Colors — cyan palette */
--aethex-50: 180 100% 97%;
--aethex-100: 180 100% 92%;
--aethex-200: 180 100% 80%;
--aethex-300: 180 100% 70%;
--aethex-400: 180 100% 60%;
--aethex-500: 180 100% 50%;
--aethex-600: 180 100% 40%;
--aethex-700: 180 100% 30%;
--aethex-800: 180 100% 20%;
--aethex-900: 180 100% 12%;
--aethex-950: 180 100% 6%;
/* Spacing tokens */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--space-section-y: var(--space-6);
--foreground: 210 40% 98%;
/* Neon accent palette */
--neon-green: 142 76% 45%;
--neon-yellow: 50 100% 65%;
--card: 222 84% 4.9%;
--card-foreground: 210 40% 98%;
/* Spacing tokens */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--space-section-y: var(--space-6);
--popover: 222 84% 4.9%;
--popover-foreground: 210 40% 98%;
/* Fallback for older browsers */
--sidebar-primary-border: hsl(var(--sidebar-primary));
--sidebar-primary-border: hsl(from hsl(var(--sidebar-primary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
--sidebar-accent-border: hsl(var(--sidebar-accent));
--sidebar-accent-border: hsl(from hsl(var(--sidebar-accent)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
--primary-border: hsl(var(--primary));
--primary-border: hsl(from hsl(var(--primary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
--secondary-border: hsl(var(--secondary));
--secondary-border: hsl(from hsl(var(--secondary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
--muted-border: hsl(var(--muted));
--muted-border: hsl(from hsl(var(--muted)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
--accent-border: hsl(var(--accent));
--accent-border: hsl(from hsl(var(--accent)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
--destructive-border: hsl(var(--destructive));
--destructive-border: hsl(from hsl(var(--destructive)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
}
--primary: 250 100% 60%;
--primary-foreground: 210 40% 98%;
.dark {
--button-outline: rgba(0, 255, 255, .15);
--badge-outline: rgba(0, 255, 255, .08);
--opaque-button-border-intensity: 8;
--elevate-1: rgba(0, 255, 255, .04);
--elevate-2: rgba(0, 255, 255, .08);
--background: 0 0% 2%;
--foreground: 0 0% 95%;
--border: 180 100% 50% / 0.2;
--card: 0 0% 5%;
--card-foreground: 0 0% 95%;
--card-border: 180 100% 50% / 0.15;
--sidebar: 0 0% 4%;
--sidebar-foreground: 0 0% 90%;
--sidebar-border: 180 100% 50% / 0.15;
--sidebar-primary: 180 100% 50%;
--sidebar-primary-foreground: 0 0% 0%;
--sidebar-accent: 0 0% 8%;
--sidebar-accent-foreground: 0 0% 90%;
--sidebar-ring: 180 100% 50%;
--popover: 0 0% 5%;
--popover-foreground: 0 0% 95%;
--popover-border: 180 100% 50% / 0.2;
--primary: 180 100% 50%;
--primary-foreground: 0 0% 0%;
--secondary: 0 0% 10%;
--secondary-foreground: 0 0% 85%;
--muted: 0 0% 8%;
--muted-foreground: 0 0% 55%;
--accent: 195 100% 45%;
--accent-foreground: 0 0% 0%;
--destructive: 340 100% 50%;
--destructive-foreground: 0 0% 98%;
--input: 0 0% 12%;
--ring: 180 100% 50%;
--chart-1: 180 100% 50%;
--chart-2: 300 100% 50%;
--chart-3: 142 76% 45%;
--chart-4: 340 100% 50%;
--chart-5: 260 100% 65%;
--neon-purple: 270 100% 65%;
--neon-magenta: 300 100% 55%;
--neon-cyan: 180 100% 50%;
--gameforge-green: 142 76% 45%;
--gameforge-dark: 142 30% 6%;
--shadow-2xs: 0px 2px 0px 0px hsl(0 0% 0% / 0.00);
--shadow-xs: 0px 2px 0px 0px hsl(0 0% 0% / 0.00);
--shadow-sm: 0px 2px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow: 0px 2px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow-md: 0px 2px 0px 0px hsl(0 0% 0% / 0.00), 0px 2px 4px -1px hsl(0 0% 0% / 0.00);
--shadow-lg: 0px 2px 0px 0px hsl(0 0% 0% / 0.00), 0px 4px 6px -1px hsl(0 0% 0% / 0.00);
--shadow-xl: 0px 2px 0px 0px hsl(0 0% 0% / 0.00), 0px 8px 10px -1px hsl(0 0% 0% / 0.00);
--shadow-2xl: 0px 2px 0px 0px hsl(0 0% 0% / 0.00);
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--sidebar-primary-border: hsl(var(--sidebar-primary));
--sidebar-primary-border: hsl(from hsl(var(--sidebar-primary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
--sidebar-accent-border: hsl(var(--sidebar-accent));
--sidebar-accent-border: hsl(from hsl(var(--sidebar-accent)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
--primary-border: hsl(var(--primary));
--primary-border: hsl(from hsl(var(--primary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
--secondary-border: hsl(var(--secondary));
--secondary-border: hsl(from hsl(var(--secondary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
--muted-border: hsl(var(--muted));
--muted-border: hsl(from hsl(var(--muted)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
--accent-border: hsl(var(--accent));
--accent-border: hsl(from hsl(var(--accent)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
--destructive-border: hsl(var(--destructive));
--destructive-border: hsl(from hsl(var(--destructive)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 250 100% 70%;
--radius: 0.5rem;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 250 100% 60%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
/* AeThex Brand Colors */
--aethex-50: 250 100% 97%;
--aethex-100: 250 100% 95%;
--aethex-200: 250 100% 90%;
--aethex-300: 250 100% 80%;
--aethex-400: 250 100% 70%;
--aethex-500: 250 100% 60%;
--aethex-600: 250 100% 50%;
--aethex-700: 250 100% 40%;
--aethex-800: 250 100% 30%;
--aethex-900: 250 100% 20%;
--aethex-950: 250 100% 10%;
/* Neon Colors for Accents */
--neon-purple: 280 100% 70%;
--neon-blue: 210 100% 70%;
--neon-green: 120 100% 70%;
--neon-yellow: 50 100% 70%;
}
}
@layer base {
@ -186,280 +93,529 @@
}
body {
@apply font-sans antialiased bg-background text-foreground;
}
/* Scanline overlay — from AeThex-Passport-Engine */
body::before {
content: '';
position: fixed;
inset: 0;
background-image: repeating-linear-gradient(
0deg,
transparent,
transparent 1px,
rgba(0, 255, 255, 0.015) 1px,
rgba(0, 255, 255, 0.015) 2px
);
pointer-events: none;
z-index: 9999;
}
/* Grid background — from AeThex-Passport-Engine */
body::after {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(0, 255, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 255, 255, 0.03) 1px, transparent 1px);
background-size: 50px 50px;
pointer-events: none;
z-index: -1;
@apply bg-background text-foreground;
font-family: "Courier New", "Courier", monospace;
letter-spacing: 0.025em;
}
html {
scroll-behavior: smooth;
-ms-overflow-style: none;
/* Hide scrollbar while keeping functionality */
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
html::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
/* Hide horizontal scrollbar on all elements */
* {
scrollbar-width: none;
}
html::-webkit-scrollbar { display: none; }
*::-webkit-scrollbar { display: none; }
* { scrollbar-width: none; }
*::-webkit-scrollbar {
display: none;
}
.container {
@apply px-4 sm:px-6 lg:px-8;
}
}
/* ── Elevation system — from AeThex-Passport-Engine ── */
@layer utilities {
input[type="search"]::-webkit-search-cancel-button {
@apply hidden;
/* Arm Theme Font Classes */
.font-labs {
font-family: "VT323", "Courier New", monospace;
letter-spacing: 0.05em;
}
[contenteditable][data-placeholder]:empty::before {
content: attr(data-placeholder);
color: hsl(var(--muted-foreground));
pointer-events: none;
.font-gameforge {
font-family: "Press Start 2P", "Arial Black", sans-serif;
letter-spacing: 0.1em;
font-size: 0.875em;
}
.no-default-hover-elevate {}
.no-default-active-elevate {}
.toggle-elevate::before,
.toggle-elevate-2::before {
content: "";
pointer-events: none;
position: absolute;
inset: 0px;
border-radius: inherit;
z-index: -1;
.font-corp {
font-family: "Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI",
sans-serif;
font-weight: 600;
}
.toggle-elevate.toggle-elevated::before {
background-color: var(--elevate-2);
.font-foundation {
font-family: "Merriweather", "Georgia", serif;
font-weight: 700;
letter-spacing: -0.02em;
}
.border.toggle-elevate::before { inset: -1px; }
.hover-elevate:not(.no-default-hover-elevate),
.active-elevate:not(.no-default-active-elevate),
.hover-elevate-2:not(.no-default-hover-elevate),
.active-elevate-2:not(.no-default-active-elevate) {
position: relative;
z-index: 0;
.font-devlink {
font-family: "Roboto Mono", "Courier New", monospace;
font-weight: 400;
letter-spacing: 0.02em;
}
.hover-elevate:not(.no-default-hover-elevate)::after,
.active-elevate:not(.no-default-active-elevate)::after,
.hover-elevate-2:not(.no-default-hover-elevate)::after,
.active-elevate-2:not(.no-default-active-elevate)::after {
content: "";
pointer-events: none;
position: absolute;
inset: 0px;
border-radius: inherit;
z-index: 999;
.font-staff {
font-family: "Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI",
sans-serif;
font-weight: 600;
}
.hover-elevate:hover:not(.no-default-hover-elevate)::after,
.active-elevate:active:not(.no-default-active-elevate)::after {
background-color: var(--elevate-1);
.font-nexus {
font-family: "Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI",
sans-serif;
font-weight: 600;
}
.hover-elevate-2:hover:not(.no-default-hover-elevate)::after,
.active-elevate-2:active:not(.no-default-active-elevate)::after {
background-color: var(--elevate-2);
.font-default {
font-family: "Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI",
sans-serif;
}
.border.hover-elevate:not(.no-hover-interaction-elevate)::after,
.border.active-elevate:not(.no-active-interaction-elevate)::after,
.border.hover-elevate-2:not(.no-hover-interaction-elevate)::after,
.border.active-elevate-2:not(.no-active-interaction-elevate)::after {
inset: -1px;
}
}
/* ── AeThex OS brand utilities ── */
@layer utilities {
.ax-orbitron { font-family: "Orbitron", monospace !important; }
.ax-mono { font-family: "Share Tech Mono", monospace !important; }
.ax-electrolize { font-family: "Electrolize", monospace !important; }
.ax-corner-bracket { position: relative; }
.ax-corner-bracket::before,
.ax-corner-bracket::after {
content: ""; position: absolute; width: 14px; height: 14px; pointer-events: none;
}
.ax-corner-bracket::before {
top: -1px; left: -1px;
border-top: 1px solid rgba(0,255,255,0.5);
border-left: 1px solid rgba(0,255,255,0.5);
}
.ax-corner-bracket::after {
bottom: -1px; right: -1px;
border-bottom: 1px solid rgba(0,255,255,0.5);
border-right: 1px solid rgba(0,255,255,0.5);
}
.ax-card-sweep { position: relative; overflow: hidden; }
.ax-card-sweep::after {
content: ""; position: absolute; top: 0; left: -100%;
width: 50%; height: 100%;
background: linear-gradient(90deg, transparent, rgba(0,255,255,0.04), transparent);
animation: ax-sweep 6s infinite; pointer-events: none;
}
.ax-clip {
clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 8px 100%, 0 calc(100% - 8px));
}
.ax-vignette::after {
content: ""; position: fixed; inset: 0;
background: radial-gradient(ellipse at center, transparent 50%, rgba(0,0,0,0.55) 100%);
pointer-events: none; z-index: 9989;
}
/* ── Arm wallpaper patterns ── */
/* Arm Theme Wallpaper Patterns */
.wallpaper-labs {
background-image: radial-gradient(circle, rgba(251,191,36,0.08) 1px, transparent 1px);
background-image: radial-gradient(
circle,
rgba(251, 191, 36, 0.08) 1px,
transparent 1px
);
background-size: 20px 20px;
background-attachment: fixed;
}
.wallpaper-gameforge {
background-image:
linear-gradient(45deg, rgba(34,197,94,0.06) 25%, transparent 25%, transparent 75%, rgba(34,197,94,0.06) 75%),
linear-gradient(45deg, rgba(34,197,94,0.06) 25%, transparent 25%, transparent 75%, rgba(34,197,94,0.06) 75%);
background-image: linear-gradient(
45deg,
rgba(34, 197, 94, 0.06) 25%,
transparent 25%,
transparent 75%,
rgba(34, 197, 94, 0.06) 75%
),
linear-gradient(
45deg,
rgba(34, 197, 94, 0.06) 25%,
transparent 25%,
transparent 75%,
rgba(34, 197, 94, 0.06) 75%
);
background-size: 40px 40px;
background-position: 0 0, 20px 20px;
background-attachment: fixed;
}
.wallpaper-corp {
background-image:
linear-gradient(90deg, rgba(59,130,246,0.05) 1px, transparent 1px),
linear-gradient(rgba(59,130,246,0.05) 1px, transparent 1px);
background-image: linear-gradient(
90deg,
rgba(59, 130, 246, 0.05) 1px,
transparent 1px
),
linear-gradient(rgba(59, 130, 246, 0.05) 1px, transparent 1px);
background-size: 20px 20px;
background-attachment: fixed;
}
.wallpaper-foundation {
background-image: repeating-linear-gradient(0deg, rgba(239,68,68,0.04) 0px, rgba(239,68,68,0.04) 1px, transparent 1px, transparent 2px);
background-image: repeating-linear-gradient(
0deg,
rgba(239, 68, 68, 0.04) 0px,
rgba(239, 68, 68, 0.04) 1px,
transparent 1px,
transparent 2px
);
background-attachment: fixed;
}
.wallpaper-devlink {
background-image:
linear-gradient(0deg, transparent 24%, rgba(6,182,212,0.08) 25%, rgba(6,182,212,0.08) 26%, transparent 27%, transparent 74%, rgba(6,182,212,0.08) 75%, rgba(6,182,212,0.08) 76%, transparent 77%),
linear-gradient(90deg, transparent 24%, rgba(6,182,212,0.08) 25%, rgba(6,182,212,0.08) 26%, transparent 27%, transparent 74%, rgba(6,182,212,0.08) 75%, rgba(6,182,212,0.08) 76%, transparent 77%);
background-image: linear-gradient(
0deg,
transparent 24%,
rgba(6, 182, 212, 0.08) 25%,
rgba(6, 182, 212, 0.08) 26%,
transparent 27%,
transparent 74%,
rgba(6, 182, 212, 0.08) 75%,
rgba(6, 182, 212, 0.08) 76%,
transparent 77%,
transparent
),
linear-gradient(
90deg,
transparent 24%,
rgba(6, 182, 212, 0.08) 25%,
rgba(6, 182, 212, 0.08) 26%,
transparent 27%,
transparent 74%,
rgba(6, 182, 212, 0.08) 75%,
rgba(6, 182, 212, 0.08) 76%,
transparent 77%,
transparent
);
background-size: 50px 50px;
background-attachment: fixed;
}
.wallpaper-staff {
background-image: radial-gradient(circle, rgba(168,85,247,0.08) 1px, transparent 1px);
background-image: radial-gradient(
circle,
rgba(168, 85, 247, 0.08) 1px,
transparent 1px
);
background-size: 20px 20px;
background-attachment: fixed;
}
.wallpaper-nexus {
background-image:
linear-gradient(45deg, rgba(236,72,153,0.06) 25%, transparent 25%, transparent 75%, rgba(236,72,153,0.06) 75%),
linear-gradient(45deg, rgba(236,72,153,0.06) 25%, transparent 25%, transparent 75%, rgba(236,72,153,0.06) 75%);
background-image: linear-gradient(
45deg,
rgba(236, 72, 153, 0.06) 25%,
transparent 25%,
transparent 75%,
rgba(236, 72, 153, 0.06) 75%
),
linear-gradient(
45deg,
rgba(236, 72, 153, 0.06) 25%,
transparent 25%,
transparent 75%,
rgba(236, 72, 153, 0.06) 75%
);
background-size: 40px 40px;
background-position: 0 0, 20px 20px;
background-attachment: fixed;
}
.wallpaper-default {
background-image: linear-gradient(135deg, rgba(0,255,255,0.03) 0%, rgba(0,255,255,0.01) 100%);
background-image: linear-gradient(
135deg,
rgba(167, 139, 250, 0.05) 0%,
rgba(96, 165, 250, 0.05) 100%
);
}
/* ── Font aliases (arm theming) ── */
.font-labs { font-family: "VT323", "Courier New", monospace; letter-spacing: 0.05em; }
.font-gameforge { font-family: "Press Start 2P", "Arial Black", sans-serif; letter-spacing: 0.1em; font-size: 0.875em; }
.font-corp { font-family: "Electrolize", "Source Code Pro", monospace; font-weight: 600; }
.font-foundation { font-family: "Merriweather", "Georgia", serif; font-weight: 700; letter-spacing: -0.02em; }
.font-devlink { font-family: "Source Code Pro", "Electrolize", monospace; font-weight: 400; letter-spacing: 0.02em; }
.font-staff { font-family: "Electrolize", "Source Code Pro", monospace; font-weight: 600; }
.font-nexus { font-family: "Electrolize", "Source Code Pro", monospace; font-weight: 600; }
.font-default { font-family: "Electrolize", "Source Code Pro", monospace; }
.section-cozy {
padding-block: var(--space-section-y);
}
.gap-cozy {
gap: var(--space-5);
}
.pad-cozy {
padding: var(--space-5);
}
/* ── Text gradients ── */
.text-gradient {
@apply bg-gradient-to-r from-aethex-300 via-aethex-500 to-neon-purple bg-clip-text text-transparent;
@apply bg-gradient-to-r from-aethex-400 via-neon-blue to-aethex-600 bg-clip-text text-transparent;
background-size: 200% 200%;
animation: gradient-shift 3s ease-in-out infinite;
}
.text-gradient-purple {
@apply bg-gradient-to-r from-neon-purple via-aethex-500 to-aethex-300 bg-clip-text text-transparent;
@apply bg-gradient-to-r from-neon-purple via-aethex-500 to-neon-blue bg-clip-text text-transparent;
background-size: 200% 200%;
animation: gradient-shift 4s ease-in-out infinite;
}
.bg-aethex-gradient {
@apply bg-gradient-to-br from-aethex-900 via-background to-aethex-800;
}
/* ── Interaction ── */
.hover-lift { transition: transform 0.3s ease, box-shadow 0.3s ease; }
.hover-lift:hover { transform: translateY(-4px); }
.hover-glow { transition: all 0.3s ease; }
.hover-glow:hover { filter: brightness(1.1) drop-shadow(0 0 8px rgba(0,255,255,0.4)); }
.interactive-scale { transition: transform 0.2s ease; }
.interactive-scale:hover { transform: scale(1.03); }
.interactive-scale:active { transform: scale(0.98); }
.border-gradient {
@apply relative overflow-hidden;
}
/* ── Spacing helpers ── */
.section-cozy { padding-block: var(--space-section-y); }
.gap-cozy { gap: var(--space-5); }
.pad-cozy { padding: var(--space-5); }
.border-gradient::before {
content: "";
@apply absolute inset-0 rounded-[inherit] p-[1px] bg-gradient-to-r from-aethex-400 via-neon-blue to-aethex-600;
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: xor;
background-size: 200% 200%;
animation: gradient-shift 2s ease-in-out infinite;
}
.glow-purple {
box-shadow:
0 0 20px rgba(139, 92, 246, 0.3),
0 0 40px rgba(139, 92, 246, 0.2);
transition: box-shadow 0.3s ease;
}
.glow-purple:hover {
box-shadow:
0 0 30px rgba(139, 92, 246, 0.5),
0 0 60px rgba(139, 92, 246, 0.3);
}
.glow-blue {
box-shadow:
0 0 20px rgba(59, 130, 246, 0.3),
0 0 40px rgba(59, 130, 246, 0.2);
transition: box-shadow 0.3s ease;
}
.glow-blue:hover {
box-shadow:
0 0 30px rgba(59, 130, 246, 0.5),
0 0 60px rgba(59, 130, 246, 0.3);
}
.glow-green {
box-shadow:
0 0 20px rgba(34, 197, 94, 0.3),
0 0 40px rgba(34, 197, 94, 0.2);
transition: box-shadow 0.3s ease;
}
.glow-yellow {
box-shadow:
0 0 20px rgba(251, 191, 36, 0.3),
0 0 40px rgba(251, 191, 36, 0.2);
transition: box-shadow 0.3s ease;
}
.animate-fade-in {
animation: fade-in 0.6s ease-out;
}
.animate-slide-up {
animation: slide-up 0.6s ease-out;
}
.animate-slide-down {
animation: slide-down 0.6s ease-out;
}
.animate-slide-left {
animation: slide-left 0.6s ease-out;
}
.animate-slide-right {
animation: slide-right 0.6s ease-out;
}
.animate-scale-in {
animation: scale-in 0.4s ease-out;
}
.animate-bounce-gentle {
animation: bounce-gentle 2s ease-in-out infinite;
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
.animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
/* ── Animations ── */
.animate-fade-in { animation: fade-in 0.6s ease-out; }
.animate-slide-up { animation: slide-up 0.6s ease-out; }
.animate-slide-down { animation: slide-down 0.6s ease-out; }
.animate-slide-left { animation: slide-left 0.6s ease-out; }
.animate-slide-right { animation: slide-right 0.6s ease-out; }
.animate-scale-in { animation: scale-in 0.4s ease-out; }
.animate-typing {
animation: typing 3s steps(40, end), blink-caret 0.75s step-end infinite;
animation:
typing 3s steps(40, end),
blink-caret 0.75s step-end infinite;
overflow: hidden;
border-right: 3px solid;
white-space: nowrap;
margin: 0 auto;
}
.hover-lift {
transition:
transform 0.3s ease,
box-shadow 0.3s ease;
}
.hover-lift:hover {
transform: translateY(-8px);
}
.hover-glow {
transition: all 0.3s ease;
}
.hover-glow:hover {
filter: brightness(1.1) drop-shadow(0 0 10px currentColor);
}
.interactive-scale {
transition: transform 0.2s ease;
}
.interactive-scale:hover {
transform: scale(1.05);
}
.interactive-scale:active {
transform: scale(0.98);
}
.loading-dots::after {
content: "";
animation: loading-dots 1.5s infinite;
}
.skeleton {
background: linear-gradient(90deg, transparent, rgba(0,255,255,0.06), transparent);
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.1),
transparent
);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
}
}
/* ── Keyframes ── */
@keyframes ax-sweep { 0%{left:-100%} 100%{left:200%} }
@keyframes ax-blink { 0%,50%{opacity:1} 51%,100%{opacity:0} }
@keyframes fade-in { from{opacity:0} to{opacity:1} }
@keyframes slide-up { from{opacity:0;transform:translateY(20px)} to{opacity:1;transform:translateY(0)} }
@keyframes slide-down { from{opacity:0;transform:translateY(-20px)} to{opacity:1;transform:translateY(0)} }
@keyframes slide-left { from{opacity:0;transform:translateX(20px)} to{opacity:1;transform:translateX(0)} }
@keyframes slide-right { from{opacity:0;transform:translateX(-20px)} to{opacity:1;transform:translateX(0)} }
@keyframes scale-in { from{opacity:0;transform:scale(0.95)} to{opacity:1;transform:scale(1)} }
@keyframes typing { from{width:0} to{width:100%} }
@keyframes blink-caret { from,to{border-color:transparent} 50%{border-color:currentColor} }
@keyframes skeleton-loading { 0%{background-position:-200% 0} 100%{background-position:200% 0} }
@keyframes gradient-shift {
0%,
100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-down {
from {
opacity: 0;
transform: translateY(-30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-left {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slide-right {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes bounce-gentle {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
@keyframes float {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-20px);
}
}
@keyframes pulse-glow {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.05);
}
}
@keyframes typing {
from {
width: 0;
}
to {
width: 100%;
}
}
@keyframes blink-caret {
from,
to {
border-color: transparent;
}
50% {
border-color: currentColor;
}
}
@keyframes loading-dots {
0% {
content: "";
}
25% {
content: ".";
}
50% {
content: "..";
}
75% {
content: "...";
}
100% {
content: "";
}
}
@keyframes skeleton-loading {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
@media (prefers-reduced-motion: reduce) {
* {

View file

@ -3,8 +3,8 @@
import { supabase, isSupabaseConfigured } from "@/lib/supabase";
import type { Database } from "./database.types";
// Derive UserProfile from the live generated schema
type UserProfile = Database["public"]["Tables"]["user_profiles"]["Row"];
// Use the existing database user profile type directly
import type { UserProfile } from "./database.types";
// API Base URL for fetch requests
const API_BASE = import.meta.env.VITE_API_BASE || "";

View file

@ -1,30 +0,0 @@
import { supabase } from "@/lib/supabase";
/**
* Authenticated fetch wrapper.
* Automatically injects `Authorization: Bearer <token>` from the active
* Supabase session. Falls back to an unauthenticated request if no session
* exists (lets public endpoints still work normally).
*
* Drop-in replacement for `fetch` same signature, same return value.
*/
export async function authFetch(
input: RequestInfo | URL,
init: RequestInit = {}
): Promise<Response> {
const {
data: { session },
} = await supabase.auth.getSession();
const headers = new Headers(init.headers);
if (session?.access_token) {
headers.set("Authorization", `Bearer ${session.access_token}`);
}
if (init.body && typeof init.body === "string" && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
return fetch(input, { ...init, headers });
}

File diff suppressed because it is too large Load diff

View file

@ -1,101 +0,0 @@
/**
* AeThex Design System Tokens
* Centralized design constants for consistent layout, typography, and spacing
*/
export const DESIGN_TOKENS = {
/**
* Content Width Constraints
* Use appropriate container based on content type
*/
width: {
// Text-heavy content (docs, articles, reading material)
prose: "max-w-5xl",
// Standard content sections (most pages)
content: "max-w-6xl",
// Wide layouts (dashboards, data tables, complex grids)
wide: "max-w-7xl",
},
/**
* Typography Scale
* Consistent heading and text sizes across the application
*/
typography: {
// Page hero headings (H1)
hero: "text-4xl md:text-5xl lg:text-6xl",
// Major section headings (H2)
sectionHeading: "text-3xl md:text-4xl",
// Subsection headings (H3)
subsectionHeading: "text-2xl md:text-3xl",
// Card/component titles (H4)
cardTitle: "text-xl md:text-2xl",
// Stats and large numbers
statNumber: "text-3xl md:text-4xl",
// Body text (large)
bodyLarge: "text-lg md:text-xl",
// Body text (standard)
body: "text-base",
// Body text (small)
bodySmall: "text-sm",
},
/**
* Spacing Scale
* Vertical spacing between sections and elements
*/
spacing: {
// Tight spacing within components
tight: "space-y-4",
// Standard spacing between related elements
standard: "space-y-6",
// Spacing between sections
section: "space-y-12",
// Major page sections
page: "space-y-20",
},
/**
* Padding Scale
* Internal padding for cards and containers
*/
padding: {
// Compact elements
compact: "p-4",
// Standard cards and containers
standard: "p-6",
// Feature cards with more emphasis
feature: "p-8",
// Hero sections and CTAs
hero: "p-12",
// Responsive vertical padding for page sections
sectionY: "py-16 lg:py-24",
},
/**
* Grid Layouts
* Standard grid configurations for responsive layouts
*/
grid: {
// Two-column layout
cols2: "grid-cols-1 md:grid-cols-2",
// Three-column layout
cols3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
// Four-column layout
cols4: "grid-cols-2 md:grid-cols-4",
// Standard gap between grid items
gapStandard: "gap-6",
// Larger gap for emphasized spacing
gapLarge: "gap-8",
},
} as const;
/**
* Helper function to combine design tokens
* Usage: cn(DESIGN_TOKENS.width.content, "mx-auto", "px-4")
*/
export const getContentContainer = (
width: keyof typeof DESIGN_TOKENS.width = "content"
) => {
return `${DESIGN_TOKENS.width[width]} mx-auto px-4`;
};

View file

@ -1042,7 +1042,7 @@ function PollsTab({ userId, username }: { userId?: string; username?: string })
},
);
channel.subscribe();
channel.subscribe().catch(() => {});
return () => {
supabase.removeChannel(channel);
};
@ -2657,7 +2657,7 @@ function ChatTab({
},
);
channel.subscribe();
channel.subscribe().catch(() => {});
return () => {
clearInterval(interval);

View file

@ -343,7 +343,7 @@ export default function Admin() {
<div className="min-h-screen bg-aethex-gradient flex">
<AdminSidebar activeTab={activeTab} onTabChange={setActiveTab} />
<div className="flex-1 overflow-y-auto py-8">
<div className="container mx-auto px-4 max-w-6xl">
<div className="container mx-auto px-4 max-w-7xl">
<div className="mb-8 animate-slide-down">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div className="space-y-3 flex-1">

View file

@ -199,7 +199,7 @@ export default function Arms() {
</div>
{/* Arms Grid */}
<div className="w-full max-w-6xl grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8">
<div className="w-full max-w-7xl grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8">
{ARMS.map((arm) => (
<button
key={arm.id}

View file

@ -232,7 +232,7 @@ const Blog = () => {
/>
<section className="border-b border-border/30 bg-background/60 py-12">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-6xl">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[0.4em] text-muted-foreground">
@ -264,7 +264,7 @@ const Blog = () => {
<BlogTrendingRail posts={trendingPosts} />
<section className="border-b border-border/30 bg-background/80 py-16">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-6xl grid gap-6 md:grid-cols-3">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl grid gap-6 md:grid-cols-3">
{insights.map((insight) => (
<Card
key={insight.label}
@ -292,7 +292,7 @@ const Blog = () => {
</section>
<section className="py-20">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-6xl space-y-12">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl space-y-12">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[0.4em] text-muted-foreground">
@ -323,7 +323,7 @@ const Blog = () => {
<BlogCTASection variant="both" />
<section className="bg-background/70 py-16">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-6xl">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="rounded-2xl border border-border/40 bg-background/80 p-8">
<div className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
<div className="space-y-2">

View file

@ -224,7 +224,7 @@ export default function BotPanel() {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-purple-900/20 to-gray-900 p-6">
<div className="max-w-6xl mx-auto space-y-6">
<div className="max-w-7xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-3 bg-purple-500/20 rounded-xl">
@ -347,7 +347,7 @@ export default function BotPanel() {
</div>
)}
<Separator className="bg-gray-700" />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-400">Commands</p>
<p className="text-lg font-semibold text-white">
@ -379,7 +379,7 @@ export default function BotPanel() {
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="grid grid-cols-3 gap-4">
<div className="text-center p-4 bg-gray-700/30 rounded-lg">
<p className="text-2xl font-bold text-white">{feedStats?.totalPosts || 0}</p>
<p className="text-sm text-gray-400">Total Posts</p>

View file

@ -45,9 +45,6 @@ import {
CheckCircle2,
Github,
Mail,
Loader2,
Unlink,
Link as LinkIcon,
} from "lucide-react";
const DiscordIcon = () => (
@ -149,93 +146,6 @@ const OAUTH_PROVIDERS: readonly ProviderDescriptor[] = [
},
];
const API_BASE = import.meta.env.VITE_API_BASE || window.location.origin;
function AeThexIDConnection({ user }: { user: any }) {
const isLinked = !!user?.user_metadata?.authentik_linked;
const sub = user?.user_metadata?.authentik_sub as string | undefined;
const [unlinking, setUnlinking] = useState(false);
const handleLink = () => {
window.location.href = `${API_BASE}/api/auth/authentik/start?redirectTo=/dashboard?tab=connections`;
};
const handleUnlink = async () => {
setUnlinking(true);
try {
const res = await fetch(`${API_BASE}/api/auth/authentik/unlink`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${(await import("@/lib/supabase")).supabase.auth.getSession().then(s => s.data.session?.access_token || "")}` },
});
if (res.ok) {
aethexToast.success({ title: "AeThex ID unlinked", description: "You can re-link at any time." });
setTimeout(() => window.location.reload(), 800);
} else {
aethexToast.error({ title: "Unlink failed", description: "Try again." });
}
} catch {
aethexToast.error({ title: "Unlink failed", description: "Try again." });
} finally {
setUnlinking(false);
}
};
return (
<section
className={`flex flex-col gap-4 rounded-xl border p-4 md:flex-row md:items-center md:justify-between mt-4 ${
isLinked ? "border-cyan-500/40 bg-cyan-500/5" : "border-border/50 bg-background/20"
}`}
>
<div className="flex flex-1 items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg" style={{ background: "linear-gradient(135deg, rgba(0,255,255,0.2), rgba(0,255,255,0.05))", border: "1px solid rgba(0,255,255,0.3)" }}>
<svg viewBox="0 0 100 100" width={28} height={28}>
<polygon points="50,5 95,27.5 95,72.5 50,95 5,72.5 5,27.5" fill="none" stroke="#00ffff" strokeWidth="4" opacity="0.9"/>
<text x="50" y="63" textAnchor="middle" fontFamily="Orbitron" fontSize="36" fontWeight="700" fill="#00ffff">Æ</text>
</svg>
</div>
<div className="flex-1 space-y-2">
<div className="flex flex-col gap-1 md:flex-row md:items-center md:gap-3">
<h3 className="text-lg font-semibold text-foreground">AeThex ID</h3>
{isLinked ? (
<span className="inline-flex items-center gap-1 rounded-full bg-cyan-600/80 px-2 py-0.5 text-xs font-medium text-white">
<Shield className="h-3 w-3" /> Linked
</span>
) : (
<span className="inline-flex items-center rounded-full border border-border/50 px-2 py-0.5 text-xs text-muted-foreground">
Not linked
</span>
)}
<span className="inline-flex items-center rounded-full bg-amber-500/10 border border-amber-500/30 px-2 py-0.5 text-xs text-amber-400">
AeThex Staff
</span>
</div>
<p className="text-sm text-muted-foreground">
Single sign-on via <span className="text-cyan-400 font-mono text-xs">auth.aethex.tech</span> for AeThex employees and internal team members.
</p>
{isLinked && sub && (
<p className="text-xs text-muted-foreground font-mono truncate">
<span className="text-foreground font-medium">Identity:</span> {sub.slice(0, 16)}
</p>
)}
</div>
</div>
<div className="flex items-center gap-3 md:self-center">
{isLinked ? (
<Button variant="outline" className="flex items-center gap-2" disabled={unlinking} onClick={handleUnlink} type="button">
{unlinking ? <Loader2 className="h-4 w-4 animate-spin" /> : <Unlink className="h-4 w-4" />}
Unlink
</Button>
) : (
<Button className="flex items-center gap-2" style={{ background: "rgba(0,255,255,0.15)", border: "1px solid rgba(0,255,255,0.4)", color: "#00ffff" }} onClick={handleLink} type="button">
<LinkIcon className="h-4 w-4" />
Link AeThex ID
</Button>
)}
</div>
</section>
);
}
export default function Dashboard() {
const navigate = useNavigate();
const {
@ -416,7 +326,7 @@ export default function Dashboard() {
return (
<Layout>
<div className="min-h-screen bg-gradient-to-b from-black via-purple-950/20 to-black">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 lg:py-12 max-w-6xl space-y-8">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 lg:py-12 max-w-7xl space-y-8">
{/* Header Section */}
<div className="space-y-4 animate-slide-down">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
@ -483,7 +393,7 @@ export default function Dashboard() {
onValueChange={setActiveTab}
className="w-full"
>
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-4 bg-purple-950/30 border border-purple-500/20 p-1">
<TabsList className="grid w-full grid-cols-4 lg:grid-cols-4 bg-purple-950/30 border border-purple-500/20 p-1">
<TabsTrigger value="realms" className="text-sm md:text-base">
<span className="hidden sm:inline">Realms</span>
<span className="sm:hidden">Arms</span>
@ -768,7 +678,7 @@ export default function Dashboard() {
linkedProviderMap={
linkedProviders
? Object.fromEntries(
linkedProviders.map((p) => [p.provider, p as any]),
linkedProviders.map((p) => [p.provider, p]),
)
: {}
}
@ -776,9 +686,6 @@ export default function Dashboard() {
onLink={linkProvider}
onUnlink={unlinkProvider}
/>
{/* AeThex ID (Authentik SSO) — staff/internal identity */}
<AeThexIDConnection user={user} />
</CardContent>
</Card>
</TabsContent>

View file

@ -437,7 +437,7 @@ export default function Directory() {
</div>
</section>
<section className="container mx-auto max-w-6xl px-4 mt-6">
<section className="container mx-auto max-w-7xl px-4 mt-6">
<Tabs defaultValue="devs">
<TabsList>
<TabsTrigger value="devs">Developers</TabsTrigger>

View file

@ -1,24 +1,31 @@
import Layout from "@/components/Layout";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useArmTheme } from "@/contexts/ArmThemeContext";
import {
Heart,
ExternalLink,
ArrowRight,
Gamepad2,
Users,
BookOpen,
Code,
Users,
Zap,
ArrowRight,
GraduationCap,
Gamepad2,
Sparkles,
Trophy,
Compass,
ExternalLink,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useEffect, useState, useRef } from "react";
import LoadingScreen from "@/components/LoadingScreen";
import { useArmToast } from "@/hooks/use-arm-toast";
export default function Foundation() {
const navigate = useNavigate();
const { theme } = useArmTheme();
const armToast = useArmToast();
const [isLoading, setIsLoading] = useState(true);
const [showTldr, setShowTldr] = useState(false);
const [showExitModal, setShowExitModal] = useState(false);
@ -27,31 +34,14 @@ export default function Foundation() {
useEffect(() => {
const timer = setTimeout(() => {
setIsLoading(false);
if (!toastShownRef.current) {
armToast.system("Foundation network connected");
toastShownRef.current = true;
}
}, 900);
return () => clearTimeout(timer);
}, []);
// Countdown timer for auto-redirect
useEffect(() => {
if (isLoading) return;
const interval = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
window.location.href = "https://aethex.foundation";
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, [isLoading]);
const handleRedirect = () => {
window.location.href = "https://aethex.foundation";
};
}, [armToast]);
// Exit intent detection
useEffect(() => {
@ -82,7 +72,7 @@ export default function Foundation() {
<div className="min-h-screen bg-gradient-to-b from-black via-red-950/20 to-black">
{/* Persistent Info Banner */}
<div className="bg-red-500/10 border-b border-red-400/30 py-3 sticky top-0 z-50 backdrop-blur-sm">
<div className="container mx-auto max-w-6xl px-4">
<div className="container mx-auto max-w-7xl px-4">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-3">
<ExternalLink className="h-5 w-5 text-red-400" />
@ -105,7 +95,7 @@ export default function Foundation() {
</div>
</div>
<div className="container mx-auto px-4 max-w-6xl space-y-20 py-16 lg:py-24">
<div className="container mx-auto px-4 max-w-7xl space-y-20 py-16 lg:py-24">
{/* Hero Section */}
<div className="text-center space-y-8 animate-slide-down">
<div className="flex justify-center mb-6">
@ -177,7 +167,7 @@ export default function Foundation() {
{/* Flagship: GameForge Section */}
<Card className="bg-gradient-to-br from-green-950/40 via-emerald-950/30 to-green-950/40 border-green-500/40 overflow-hidden">
<CardContent className="pb-3">
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<Gamepad2 className="h-8 w-8 text-green-400" />
<div>
@ -188,135 +178,311 @@ export default function Foundation() {
30-day mentorship sprints where developers ship real games
</p>
</div>
<Badge className="bg-red-600/50 text-red-100">
Non-Profit Guardian
</Badge>
<h1 className="text-4xl md:text-5xl font-bold bg-gradient-to-r from-red-300 via-pink-300 to-red-300 bg-clip-text text-transparent">
AeThex Foundation
</h1>
<p className="text-xl text-gray-300 max-w-2xl mx-auto leading-relaxed">
The heart of our ecosystem. Dedicated to community, mentorship,
and advancing game development through open-source innovation.
</p>
</div>
{/* Redirect Notice */}
<div className="bg-black/40 rounded-xl p-6 border border-red-500/20 text-center space-y-4">
<div className="flex items-center justify-center gap-2 text-red-300">
<Sparkles className="h-5 w-5" />
<span className="font-semibold">Foundation Has Moved</span>
</div>
<p className="text-gray-300">
The AeThex Foundation now has its own dedicated home. Visit our
new site for programs, resources, and community updates.
</p>
<Button
onClick={handleRedirect}
className="bg-gradient-to-r from-red-600 to-pink-600 hover:from-red-700 hover:to-pink-700 h-12 px-8 text-base"
>
<ExternalLink className="h-5 w-5 mr-2" />
Visit aethex.foundation
<ArrowRight className="h-5 w-5 ml-2" />
</Button>
<p className="text-sm text-gray-500">
Redirecting automatically in {countdown} seconds...
</p>
</div>
{/* Quick Links */}
</CardHeader>
<CardContent className="space-y-6">
{/* What is GameForge? */}
<div className="space-y-4">
<h2 className="text-lg font-semibold text-white text-center">
Foundation Highlights
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<a
href="https://aethex.foundation/gameforge"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-4 rounded-lg bg-black/30 border border-green-500/20 hover:border-green-500/40 transition-all group"
>
<div className="p-2 rounded bg-green-500/20 text-green-400">
<Gamepad2 className="h-5 w-5" />
</div>
<div className="flex-1">
<p className="font-semibold text-white group-hover:text-green-300 transition-colors">
GameForge Program
</p>
<p className="text-sm text-gray-400">
30-day mentorship sprints
</p>
</div>
<ExternalLink className="h-4 w-4 text-gray-500 group-hover:text-green-400" />
</a>
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Compass className="h-5 w-5 text-green-400" />
What is GameForge?
</h3>
<p className="text-gray-300 leading-relaxed">
GameForge is the Foundation's flagship "master-apprentice"
mentorship program. It's our "gym" where developers
collaborate on focused, high-impact game projects within
30-day sprints. Teams of 5 (1 mentor + 4 mentees) tackle real
game development challenges and ship playable games to our
community arcade.
</p>
</div>
<a
href="https://aethex.foundation/mentorship"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-4 rounded-lg bg-black/30 border border-red-500/20 hover:border-red-500/40 transition-all group"
>
<div className="p-2 rounded bg-red-500/20 text-red-400">
<GraduationCap className="h-5 w-5" />
</div>
<div className="flex-1">
<p className="font-semibold text-white group-hover:text-red-300 transition-colors">
Mentorship Network
</p>
<p className="text-sm text-gray-400">
Learn from industry veterans
</p>
</div>
<ExternalLink className="h-4 w-4 text-gray-500 group-hover:text-red-400" />
</a>
<a
href="https://aethex.foundation/community"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-4 rounded-lg bg-black/30 border border-blue-500/20 hover:border-blue-500/40 transition-all group"
>
<div className="p-2 rounded bg-blue-500/20 text-blue-400">
<Users className="h-5 w-5" />
</div>
<div className="flex-1">
<p className="font-semibold text-white group-hover:text-blue-300 transition-colors">
Community Hub
</p>
<p className="text-sm text-gray-400">
Connect with developers
</p>
</div>
<ExternalLink className="h-4 w-4 text-gray-500 group-hover:text-blue-400" />
</a>
<a
href="https://aethex.foundation/axiom"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-4 rounded-lg bg-black/30 border border-purple-500/20 hover:border-purple-500/40 transition-all group"
>
<div className="p-2 rounded bg-purple-500/20 text-purple-400">
<Code className="h-5 w-5" />
</div>
<div className="flex-1">
<p className="font-semibold text-white group-hover:text-purple-300 transition-colors">
Axiom Protocol
</p>
<p className="text-sm text-gray-400">
Open-source innovation
</p>
</div>
<ExternalLink className="h-4 w-4 text-gray-500 group-hover:text-purple-400" />
</a>
{/* The Triple Win */}
<div className="space-y-3">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Trophy className="h-5 w-5 text-green-400" />
Why GameForge Matters
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="p-4 bg-black/40 rounded-lg border border-green-500/20 space-y-2">
<p className="font-semibold text-green-300">
Role 1: Community
</p>
<p className="text-sm text-gray-400">
Our "campfire" where developers meet, collaborate, and
build their `aethex.me` passports through real project
work.
</p>
</div>
<div className="p-4 bg-black/40 rounded-lg border border-green-500/20 space-y-2">
<p className="font-semibold text-green-300">
Role 2: Education
</p>
<p className="text-sm text-gray-400">
Learn professional development practices: Code Review
(SOP-102), Scope Management (KND-001), and shipping
excellence.
</p>
</div>
<div className="p-4 bg-black/40 rounded-lg border border-green-500/20 space-y-2">
<p className="font-semibold text-green-300">
Role 3: Pipeline
</p>
<p className="text-sm text-gray-400">
Top performers become "Architects" ready to work on
high-value projects. Your GameForge portfolio proves you
can execute.
</p>
</div>
</div>
</div>
{/* Footer Note */}
<div className="text-center pt-4 border-t border-red-500/10">
<p className="text-sm text-gray-500">
The AeThex Foundation is a 501(c)(3) non-profit organization
dedicated to advancing game development education and community.
{/* How It Works */}
<div className="space-y-3">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Zap className="h-5 w-5 text-green-400" />
How It Works
</h3>
<div className="space-y-2">
<div className="flex gap-3 p-3 bg-black/30 rounded-lg border border-green-500/10">
<span className="text-green-400 font-bold shrink-0">
1.
</span>
<div>
<p className="font-semibold text-white text-sm">
Join a 5-Person Team
</p>
<p className="text-xs text-gray-400 mt-0.5">
1 Forge Master (Mentor) + 4 Apprentices (Scripter,
Builder, Sound, Narrative)
</p>
</div>
</div>
<div className="flex gap-3 p-3 bg-black/30 rounded-lg border border-green-500/10">
<span className="text-green-400 font-bold shrink-0">
2.
</span>
<div>
<p className="font-semibold text-white text-sm">
Ship in 30 Days
</p>
<p className="text-xs text-gray-400 mt-0.5">
Focused sprint with a strict 1-paragraph GDD. No scope
creep. Execute with excellence.
</p>
</div>
</div>
<div className="flex gap-3 p-3 bg-black/30 rounded-lg border border-green-500/10">
<span className="text-green-400 font-bold shrink-0">
3.
</span>
<div>
<p className="font-semibold text-white text-sm">
Ship to the Arcade
</p>
<p className="text-xs text-gray-400 mt-0.5">
Your finished game goes live on aethex.fun. Add it to
your Passport portfolio.
</p>
</div>
</div>
<div className="flex gap-3 p-3 bg-black/30 rounded-lg border border-green-500/10">
<span className="text-green-400 font-bold shrink-0">
4.
</span>
<div>
<p className="font-semibold text-white text-sm">
Level Up Your Career
</p>
<p className="text-xs text-gray-400 mt-0.5">
3 shipped games = Architect status. Qualify for premium
opportunities on NEXUS.
</p>
</div>
</div>
</div>
</div>
{/* CTA Button */}
<Button
onClick={() => navigate("/gameforge")}
className="w-full bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 h-12 text-base font-semibold"
>
<Gamepad2 className="h-5 w-5 mr-2" />
Join the Next GameForge Cohort
<ArrowRight className="h-5 w-5 ml-auto" />
</Button>
</CardContent>
</Card>
{/* Foundation Mission & Values */}
<div className="space-y-4">
<h2 className="text-3xl font-bold text-white flex items-center gap-2">
<Heart className="h-8 w-8 text-red-400" />
Our Mission
</h2>
<Card className="bg-gradient-to-br from-red-950/40 to-red-900/20 border-red-500/20">
<CardContent className="p-6 space-y-4">
<p className="text-gray-300 text-lg leading-relaxed">
The AeThex Foundation is a non-profit organization dedicated
to advancing game development through community-driven
mentorship, open-source innovation, and educational
excellence. We believe that great developers are built, not
bornand that the future of gaming lies in collaboration,
transparency, and shared knowledge.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<h3 className="font-semibold text-red-300 flex items-center gap-2">
<Users className="h-5 w-5" />
Community is Our Core
</h3>
<p className="text-sm text-gray-400">
Building lasting relationships and support networks within
game development.
</p>
</div>
<div className="space-y-2">
<h3 className="font-semibold text-red-300 flex items-center gap-2">
<Code className="h-5 w-5" />
Open Innovation
</h3>
<p className="text-sm text-gray-400">
Advancing the industry through open-source Axiom Protocol
and shared tools.
</p>
</div>
<div className="space-y-2">
<h3 className="font-semibold text-red-300 flex items-center gap-2">
<GraduationCap className="h-5 w-5" />
Excellence & Growth
</h3>
<p className="text-sm text-gray-400">
Mentoring developers to ship real products and achieve
their potential.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Other Programs */}
<div className="space-y-4">
<h2 className="text-3xl font-bold text-white flex items-center gap-2">
<BookOpen className="h-8 w-8 text-red-400" />
Foundation Programs
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Mentorship Program */}
<Card className="bg-gradient-to-br from-red-950/40 to-red-900/20 border-red-500/20 hover:border-red-500/40 transition">
<CardHeader>
<CardTitle className="text-xl">Mentorship Network</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-gray-300 text-sm leading-relaxed">
Learn from industry veterans. Our mentors bring real-world
experience from studios, indie teams, and AAA development.
</p>
<Button
onClick={() => navigate("/mentorship")}
variant="outline"
className="w-full border-red-500/30 text-red-300 hover:bg-red-500/10"
>
Learn More <ArrowRight className="h-4 w-4 ml-2" />
</Button>
</CardContent>
</Card>
{/* Open Source */}
<Card className="bg-gradient-to-br from-red-950/40 to-red-900/20 border-red-500/20 hover:border-red-500/40 transition">
<CardHeader>
<CardTitle className="text-xl">Axiom Protocol</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-gray-300 text-sm leading-relaxed">
Our open-source protocol for game development. Contribute,
learn, and help shape the future of the industry.
</p>
<Button
onClick={() => navigate("/docs")}
variant="outline"
className="w-full border-red-500/30 text-red-300 hover:bg-red-500/10"
>
Explore Protocol <ArrowRight className="h-4 w-4 ml-2" />
</Button>
</CardContent>
</Card>
{/* Courses */}
<Card className="bg-gradient-to-br from-red-950/40 to-red-900/20 border-red-500/20 hover:border-red-500/40 transition">
<CardHeader>
<CardTitle className="text-xl">Learning Paths</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-gray-300 text-sm leading-relaxed">
Structured curricula covering game design, programming, art,
sound, and narrative design from basics to advanced.
</p>
<Button
onClick={() => navigate("/docs/curriculum")}
variant="outline"
className="w-full border-red-500/30 text-red-300 hover:bg-red-500/10"
>
Start Learning <ArrowRight className="h-4 w-4 ml-2" />
</Button>
</CardContent>
</Card>
{/* Community */}
<Card className="bg-gradient-to-br from-red-950/40 to-red-900/20 border-red-500/20 hover:border-red-500/40 transition">
<CardHeader>
<CardTitle className="text-xl">Community Hub</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-gray-300 text-sm leading-relaxed">
Connect with developers, share projects, get feedback, and
build lasting professional relationships.
</p>
<Button
onClick={() => navigate("/community")}
variant="outline"
className="w-full border-red-500/30 text-red-300 hover:bg-red-500/10"
>
Join Community <ArrowRight className="h-4 w-4 ml-2" />
</Button>
</CardContent>
</Card>
</div>
</div>
{/* Call to Action */}
<Card className="bg-gradient-to-r from-red-600/20 via-pink-600/10 to-red-600/20 border-red-500/40">
<CardContent className="p-12 text-center space-y-6">
<div className="space-y-2">
<h2 className="text-3xl font-bold text-white">
Ready to Join the Foundation?
</h2>
<p className="text-gray-300 text-lg">
Whether you're looking to learn, mentor others, or contribute
to open-source game development, there's a place for you here.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button
onClick={() => navigate("/gameforge")}
className="bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 h-12 px-8 text-base"
>
<Gamepad2 className="h-5 w-5 mr-2" />
Join GameForge Now
</Button>
<Button
onClick={() => navigate("/login")}
variant="outline"
className="border-red-500/30 text-red-300 hover:bg-red-500/10 h-12 px-8 text-base"
>
Sign In
</Button>
</div>
</CardContent>
</Card>

View file

@ -211,7 +211,7 @@ export default function FoundationDownloadCenter() {
return (
<Layout>
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-purple-900/20 to-slate-950 py-12 px-4">
<div className="max-w-6xl mx-auto">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-12 text-center">
<h1 className="text-4xl font-bold text-white mb-4">

View file

@ -1,4 +1,4 @@
import GameForgeLayout from "@/components/gameforge/GameForgeLayout";
import Layout from "@/components/Layout";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@ -102,11 +102,11 @@ export default function GameForge() {
];
return (
<GameForgeLayout>
<Layout>
<div className="relative min-h-screen bg-black text-white overflow-hidden">
{/* Persistent Info Banner */}
<div className="bg-green-500/10 border-b border-green-400/30 py-3 sticky top-0 z-50 backdrop-blur-sm">
<div className="container mx-auto max-w-6xl px-4">
<div className="container mx-auto max-w-7xl px-4">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-3">
<ExternalLink className="h-5 w-5 text-green-400" />
@ -138,7 +138,7 @@ export default function GameForge() {
<main className="relative z-10">
{/* Hero Section */}
<section className="py-20 lg:py-32">
<div className="container mx-auto max-w-6xl px-4">
<div className="container mx-auto max-w-7xl px-4">
<div className="text-center space-y-8">
<div className="flex justify-center mb-6">
<img
@ -211,7 +211,7 @@ export default function GameForge() {
{/* Stats Section */}
<section className="py-16 border-y border-green-400/10 bg-black/40 backdrop-blur-sm">
<div className="container mx-auto max-w-6xl px-4">
<div className="container mx-auto max-w-7xl px-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
{productionStats.map((stat, idx) => {
const Icon = stat.icon;
@ -234,7 +234,7 @@ export default function GameForge() {
{/* Features Grid */}
<section className="py-20 lg:py-28">
<div className="container mx-auto max-w-6xl px-4">
<div className="container mx-auto max-w-7xl px-4">
<div className="text-center mb-16">
<h2 className="text-4xl md:text-5xl font-black text-green-300 mb-4">
Why Join GameForge?
@ -352,7 +352,7 @@ export default function GameForge() {
{/* Team Roles */}
<section className="py-20 lg:py-28">
<div className="container mx-auto max-w-6xl px-4">
<div className="container mx-auto max-w-7xl px-4">
<div className="text-center mb-16">
<h2 className="text-4xl md:text-5xl font-black text-green-300 mb-4">
Squad Structure
@ -478,6 +478,6 @@ export default function GameForge() {
</div>
</div>
)}
</GameForgeLayout>
</Layout>
);
}

View file

@ -1,3 +1,4 @@
import { useState, useEffect } from "react";
import SEO from "@/components/SEO";
import Layout from "@/components/Layout";
import { Button } from "@/components/ui/button";
@ -26,38 +27,50 @@ const ecosystemPillars = [
{
icon: Boxes,
title: "Six Realms",
description: "Nexus, GameForge, Foundation, Labs, Corp, and Staff each with unique APIs and capabilities",
description: "Nexus, GameForge, Foundation, Labs, Corp, and Staff—each with unique APIs and capabilities",
href: "/realms",
gradient: "from-purple-500 via-purple-600 to-indigo-600",
accentColor: "hsl(var(--primary))",
},
{
icon: Database,
title: "Developer APIs",
description: "Comprehensive REST APIs for users, content, achievements, and more",
href: "/dev-platform/api-reference",
gradient: "from-blue-500 via-blue-600 to-cyan-600",
accentColor: "hsl(var(--primary))",
},
{
icon: Terminal,
title: "SDK & Tools",
description: "TypeScript SDK, CLI tools, and pre-built templates to ship faster",
href: "/dev-platform/quick-start",
gradient: "from-cyan-500 via-teal-600 to-emerald-600",
accentColor: "hsl(var(--primary))",
},
{
icon: Layers,
title: "Marketplace",
description: "Premium integrations, plugins, and components from the community",
href: "/dev-platform/marketplace",
gradient: "from-emerald-500 via-green-600 to-lime-600",
accentColor: "hsl(var(--primary))",
},
{
icon: Users,
title: "Community",
description: "Join 12,000+ developers building on AeThex",
href: "/community",
gradient: "from-amber-500 via-orange-600 to-red-600",
accentColor: "hsl(var(--primary))",
},
{
icon: Trophy,
title: "Opportunities",
description: "Get paid to build contracts, bounties, and commissions",
description: "Get paid to build—contracts, bounties, and commissions",
href: "/opportunities",
gradient: "from-pink-500 via-rose-600 to-red-600",
accentColor: "hsl(var(--primary))",
},
];
@ -72,39 +85,47 @@ const features = [
{
icon: Layers,
title: "Cross-Platform Integration Layer",
description: "One unified API to build across Roblox, VRChat, RecRoom, Spatial, Decentraland, The Sandbox, Minecraft, Meta Horizon, Fortnite, and Zepeto no more managing separate platform SDKs",
description: "One unified API to build across Roblox, VRChat, RecRoom, Spatial, Decentraland, The Sandbox, Minecraft, Meta Horizon, Fortnite, and Zepeto—no more managing separate platform SDKs or gated gardens",
},
{
icon: Code2,
title: "Enterprise-Grade Developer Tools",
description: "TypeScript SDK, REST APIs, unified authentication, cross-platform achievements, content delivery, and CLI tools all integrated and production-ready",
description: "TypeScript SDK, REST APIs, unified authentication, cross-platform achievements, content delivery, and CLI tools—all integrated and production-ready",
},
{
icon: Gamepad2,
title: "Six Specialized Realms",
description: "Nexus (social hub), GameForge (games), Foundation (education), Labs (AI/innovation), Corp (business), Staff (governance) each with unique APIs and tools",
description: "Nexus (social hub), GameForge (games), Foundation (education), Labs (AI/innovation), Corp (business), Staff (governance)—each with unique APIs and tools",
},
{
icon: Trophy,
title: "Monetize Your Skills",
description: "Get paid to build access contracts, bounties, and commissions. 12K+ developers earning while creating cross-platform games, apps, and integrations",
description: "Get paid to build—access contracts, bounties, and commissions. 12K+ developers earning while creating cross-platform games, apps, and integrations",
},
{
icon: Users,
title: "Thriving Creator Economy",
description: "Join squads, collaborate on projects, share assets in the marketplace, and grow your reputation across all six realms",
description: "Join squads, collaborate on projects, share assets in the marketplace that work across all platforms, and grow your reputation across all six realms",
},
{
icon: Rocket,
title: "Ship Everywhere, Fast",
description: "150+ cross-platform code examples, pre-built templates, OAuth integration, Supabase backend — one-command deployment to every metaverse",
description: "150+ cross-platform code examples, pre-built templates for VRChat, RecRoom, Spatial, Decentraland, The Sandbox, Roblox, and more—OAuth integration, Supabase backend, and one-command deployment to every metaverse",
},
];
const platforms = ["Roblox", "Minecraft", "Meta Horizon", "Fortnite", "VRChat", "Zepeto"];
const platformIcons = [Gamepad2, Boxes, Globe, Zap, Users, Sparkles];
export default function Index() {
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
const [hoveredCard, setHoveredCard] = useState<number | null>(null);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
setMousePosition({ x: e.clientX, y: e.clientY });
};
window.addEventListener("mousemove", handleMouseMove);
return () => window.removeEventListener("mousemove", handleMouseMove);
}, []);
return (
<Layout hideFooter>
<SEO
@ -117,154 +138,308 @@ export default function Index() {
}
/>
{/* Static background — radial glow only; grid/scanlines come from body::after/::before in global.css */}
{/* Animated Background */}
<div className="fixed inset-0 pointer-events-none overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_70%_40%_at_50%_-10%,hsl(var(--primary)/0.08),transparent)]" />
<motion.div
className="absolute w-[800px] h-[800px] rounded-full blur-[128px] opacity-20 bg-primary/30"
style={{
left: mousePosition.x - 400,
top: mousePosition.y - 400,
}}
animate={{
x: [0, 50, 0],
y: [0, -50, 0],
}}
transition={{
duration: 20,
repeat: Infinity,
ease: "easeInOut",
}}
/>
<motion.div
className="absolute w-[600px] h-[600px] rounded-full blur-[128px] opacity-20 bg-primary/40"
style={{
right: -100,
top: 200,
}}
animate={{
x: [0, -30, 0],
y: [0, 40, 0],
}}
transition={{
duration: 15,
repeat: Infinity,
ease: "easeInOut",
}}
/>
<motion.div
className="absolute w-[700px] h-[700px] rounded-full blur-[128px] opacity-15 bg-primary/35"
style={{
left: -100,
bottom: -100,
}}
animate={{
x: [0, 40, 0],
y: [0, -40, 0],
}}
transition={{
duration: 18,
repeat: Infinity,
ease: "easeInOut",
}}
/>
{/* Cyber Grid */}
<div
className="absolute inset-0 opacity-[0.03]"
style={{
backgroundImage: `
linear-gradient(to right, hsl(var(--primary)) 1px, transparent 1px),
linear-gradient(to bottom, hsl(var(--primary)) 1px, transparent 1px)
`,
backgroundSize: "60px 60px",
}}
/>
{/* Scanlines */}
<div
className="absolute inset-0 opacity-[0.03] pointer-events-none"
style={{
backgroundImage: "repeating-linear-gradient(0deg, transparent, transparent 2px, hsl(var(--primary) / 0.1) 2px, hsl(var(--primary) / 0.1) 4px)",
}}
/>
{/* Corner Accents */}
<div className="absolute top-0 left-0 w-64 h-64 border-t-2 border-l-2 border-primary/30" />
<div className="absolute top-0 right-0 w-64 h-64 border-t-2 border-r-2 border-primary/30" />
<div className="absolute bottom-0 left-0 w-64 h-64 border-b-2 border-l-2 border-primary/30" />
<div className="absolute bottom-0 right-0 w-64 h-64 border-b-2 border-r-2 border-primary/30" />
</div>
<div className="relative space-y-28 pb-28">
{/* Hero */}
<section className="relative min-h-[88vh] flex items-center justify-center pt-20">
<div className="relative text-center max-w-5xl mx-auto space-y-8 px-4">
<div className="relative space-y-32 pb-32">
<section className="relative min-h-[90vh] flex items-center justify-center overflow-hidden pt-20">
<div className="relative text-center max-w-6xl mx-auto space-y-10 px-4">
<motion.div
initial={{ opacity: 0, y: -12 }}
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
transition={{ duration: 0.8 }}
>
<Badge className="text-xs px-4 py-1.5 bg-primary/10 border-primary/30 uppercase tracking-widest font-semibold">
<Sparkles className="w-3 h-3 mr-1.5 inline" />
<Badge
className="text-sm px-6 py-2 backdrop-blur-xl bg-primary/10 border-primary/50 shadow-[0_0_30px_rgba(168,85,247,0.4)] hover:shadow-[0_0_50px_rgba(168,85,247,0.6)] transition-all uppercase tracking-wider font-bold"
>
<Sparkles className="w-4 h-4 mr-2 inline animate-pulse" />
AeThex Developer Ecosystem
</Badge>
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.1 }}
className="text-6xl md:text-7xl lg:text-8xl font-black tracking-tight leading-none"
>
Build on{" "}
<span className="text-primary">AeThex</span>
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
className="text-lg md:text-xl text-muted-foreground max-w-3xl mx-auto leading-relaxed"
>
The <span className="text-foreground font-medium">integration layer</span> connecting all metaverse platforms.
Six specialized realms. <span className="text-foreground font-medium">12K+ developers</span>. One powerful ecosystem.
</motion.p>
{/* Platform pills */}
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3 }}
className="flex flex-wrap items-center justify-center gap-2 pt-2"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.8, delay: 0.2 }}
>
{platforms.map((name, i) => {
const Icon = platformIcons[i];
return (
<div
key={name}
className="flex items-center gap-1.5 bg-secondary/60 px-3 py-1.5 rounded-full border border-border text-sm text-muted-foreground"
>
<Icon className="w-3.5 h-3.5 text-primary" />
<span className="font-medium">{name}</span>
</div>
);
})}
<div className="flex items-center gap-1.5 bg-primary/10 px-3 py-1.5 rounded-full border border-primary/20 text-sm text-primary font-medium">
& More
<h1 className="text-7xl md:text-8xl lg:text-9xl font-black tracking-tight leading-none">
Build on
<br />
<span className="relative inline-block mt-4">
<span className="relative z-10 text-primary drop-shadow-[0_0_25px_rgba(168,85,247,0.8)]" style={{ textShadow: '0 0 40px rgba(168, 85, 247, 0.6)' }}>
AeThex
</span>
<motion.div
className="absolute -inset-8 bg-primary blur-3xl opacity-40"
animate={{
opacity: [0.4, 0.7, 0.4],
scale: [1, 1.1, 1],
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut",
}}
/>
</span>
</h1>
</motion.div>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.4 }}
className="text-2xl md:text-3xl text-muted-foreground max-w-4xl mx-auto leading-relaxed font-light"
>
The <span className="text-primary font-bold">integration layer</span> connecting all metaverse platforms.
<br className="hidden md:block" />
Six specialized realms. <span className="text-primary font-semibold">12K+ developers</span>. One powerful ecosystem.
</motion.p>
{/* Platform Highlights */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.5 }}
className="flex flex-wrap items-center justify-center gap-3 pt-4 text-sm md:text-base max-w-4xl mx-auto"
>
<div className="flex items-center gap-2 backdrop-blur-xl bg-primary/5 px-4 py-2 rounded-full border-2 border-primary/30 hover:border-primary/60 hover:bg-primary/10 hover:shadow-[0_0_20px_rgba(168,85,247,0.3)] transition-all">
<Gamepad2 className="w-4 h-4 text-primary drop-shadow-[0_0_8px_rgba(168,85,247,0.8)]" />
<span className="text-foreground/90 font-bold uppercase tracking-wide">Roblox</span>
</div>
<div className="flex items-center gap-2 backdrop-blur-xl bg-primary/5 px-4 py-2 rounded-full border-2 border-primary/30 hover:border-primary/60 hover:bg-primary/10 hover:shadow-[0_0_20px_rgba(168,85,247,0.3)] transition-all">
<Boxes className="w-4 h-4 text-primary drop-shadow-[0_0_8px_rgba(168,85,247,0.8)]" />
<span className="text-foreground/90 font-bold uppercase tracking-wide">Minecraft</span>
</div>
<div className="flex items-center gap-2 backdrop-blur-xl bg-primary/5 px-4 py-2 rounded-full border-2 border-primary/30 hover:border-primary/60 hover:bg-primary/10 hover:shadow-[0_0_20px_rgba(168,85,247,0.3)] transition-all">
<Globe className="w-4 h-4 text-primary drop-shadow-[0_0_8px_rgba(168,85,247,0.8)]" />
<span className="text-foreground/90 font-bold uppercase tracking-wide">Meta Horizon</span>
</div>
<div className="flex items-center gap-2 backdrop-blur-xl bg-primary/5 px-4 py-2 rounded-full border-2 border-primary/30 hover:border-primary/60 hover:bg-primary/10 hover:shadow-[0_0_20px_rgba(168,85,247,0.3)] transition-all">
<Zap className="w-4 h-4 text-primary drop-shadow-[0_0_8px_rgba(168,85,247,0.8)]" />
<span className="text-foreground/90 font-bold uppercase tracking-wide">Fortnite</span>
</div>
<div className="flex items-center gap-2 backdrop-blur-xl bg-primary/5 px-4 py-2 rounded-full border-2 border-primary/30 hover:border-primary/60 hover:bg-primary/10 hover:shadow-[0_0_20px_rgba(168,85,247,0.3)] transition-all">
<Users className="w-4 h-4 text-primary drop-shadow-[0_0_8px_rgba(168,85,247,0.8)]" />
<span className="text-foreground/90 font-bold uppercase tracking-wide">Zepeto</span>
</div>
<div className="flex items-center gap-2 backdrop-blur-xl bg-primary/10 px-4 py-2 rounded-full border-2 border-primary/40 shadow-[0_0_20px_rgba(168,85,247,0.4)]">
<Sparkles className="w-4 h-4 text-primary drop-shadow-[0_0_8px_rgba(168,85,247,0.8)] animate-pulse" />
<span className="text-foreground/90 font-black uppercase tracking-wide">& More</span>
</div>
</motion.div>
{/* CTAs */}
<motion.div
initial={{ opacity: 0, y: 16 }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.4 }}
className="flex flex-wrap gap-3 justify-center pt-4"
transition={{ duration: 0.8, delay: 0.6 }}
className="flex flex-wrap gap-4 justify-center pt-8"
>
<Link to="/dev-platform/quick-start">
<Button size="lg" className="px-8 h-12 font-semibold">
<Button
size="lg"
className="text-xl px-10 h-16 bg-primary hover:bg-primary/90 shadow-[0_0_40px_rgba(168,85,247,0.6)] hover:shadow-[0_0_60px_rgba(168,85,247,0.8)] hover:scale-105 transition-all duration-300 font-black uppercase tracking-wide border-2 border-primary/50"
>
Start Building
<Rocket className="w-4 h-4 ml-2" />
<Rocket className="w-6 h-6 ml-3" />
</Button>
</Link>
<Link to="/dev-platform/api-reference">
<Button size="lg" variant="outline" className="px-8 h-12 font-semibold">
<BookOpen className="w-4 h-4 mr-2" />
<Button
size="lg"
variant="outline"
className="text-xl px-10 h-16 backdrop-blur-xl bg-background/50 border-2 border-primary/40 hover:bg-primary/10 hover:border-primary/60 shadow-[0_0_20px_rgba(168,85,247,0.3)] hover:shadow-[0_0_40px_rgba(168,85,247,0.5)] hover:scale-105 transition-all duration-300 font-black uppercase tracking-wide"
>
<BookOpen className="w-6 h-6 mr-3" />
Explore APIs
</Button>
</Link>
</motion.div>
{/* Stats */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.8, delay: 0.6 }}
className="grid grid-cols-2 md:grid-cols-4 gap-4 pt-12 max-w-3xl mx-auto"
transition={{ duration: 1, delay: 0.8 }}
className="grid grid-cols-2 md:grid-cols-4 gap-8 pt-16 max-w-4xl mx-auto"
>
{stats.map((stat) => (
<div
{stats.map((stat, i) => (
<motion.div
key={stat.label}
className="bg-secondary/40 border border-border rounded-xl p-5 text-center"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.8 + i * 0.1 }}
className="relative group"
>
<p className="text-3xl font-black text-primary">{stat.value}</p>
<p className="text-xs text-muted-foreground mt-1 font-medium uppercase tracking-wide">{stat.label}</p>
</div>
<div className="relative backdrop-blur-xl bg-background/30 border border-primary/20 rounded-2xl p-6 hover:border-primary/40 transition-all duration-300 hover:scale-105">
<p className="text-4xl md:text-5xl font-black text-primary mb-2">
{stat.value}
</p>
<p className="text-sm text-muted-foreground font-medium">{stat.label}</p>
<div className="absolute inset-0 bg-primary/0 group-hover:bg-primary/5 rounded-2xl transition-all duration-300" />
</div>
</motion.div>
))}
</motion.div>
</div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1, y: [0, 10, 0] }}
transition={{
opacity: { delay: 1.5, duration: 0.5 },
y: { duration: 2, repeat: Infinity, ease: "easeInOut" },
}}
className="absolute bottom-8 left-1/2 transform -translate-x-1/2"
>
<div className="w-6 h-10 border-2 border-primary/30 rounded-full flex items-start justify-center p-2">
<motion.div
className="w-1 h-2 bg-primary rounded-full"
animate={{ y: [0, 12, 0] }}
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
/>
</div>
</motion.div>
</section>
{/* Ecosystem Pillars */}
<section className="space-y-10 px-4">
<section className="space-y-12 px-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="text-center space-y-3"
transition={{ duration: 0.8 }}
className="text-center space-y-4"
>
<h2 className="text-4xl md:text-5xl font-black">The AeThex Ecosystem</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
<h2 className="text-5xl md:text-6xl font-black text-primary">
The AeThex Ecosystem
</h2>
<p className="text-xl text-muted-foreground max-w-3xl mx-auto">
Six interconnected realms, each with unique capabilities and APIs to power your applications
</p>
</motion.div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4 max-w-6xl mx-auto">
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-7xl mx-auto">
{ecosystemPillars.map((pillar, index) => (
<motion.div
key={pillar.title}
initial={{ opacity: 0, y: 24 }}
initial={{ opacity: 0, y: 50 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.07 }}
transition={{ duration: 0.6, delay: index * 0.1 }}
onMouseEnter={() => setHoveredCard(index)}
onMouseLeave={() => setHoveredCard(null)}
>
<Link to={pillar.href}>
<Card className="group h-full border-border hover:border-primary/30 transition-colors duration-200 bg-card">
<div className="p-6 space-y-4">
<div className="w-11 h-11 rounded-lg bg-primary/10 border border-primary/20 flex items-center justify-center group-hover:bg-primary/15 transition-colors">
<pillar.icon className="w-5 h-5 text-primary" />
<Card className="group relative overflow-hidden h-full border-2 hover:border-transparent transition-all duration-300">
<div
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 bg-primary/10"
/>
{hoveredCard === index && (
<motion.div
className="absolute inset-0 blur-xl opacity-30 bg-primary"
initial={{ opacity: 0 }}
animate={{ opacity: 0.3 }}
exit={{ opacity: 0 }}
/>
)}
<div className="relative p-8 space-y-4 backdrop-blur-sm">
<div
className={`w-16 h-16 rounded-2xl bg-gradient-to-br ${pillar.gradient} flex items-center justify-center shadow-2xl group-hover:scale-110 transition-transform duration-300`}
style={{
boxShadow: `0 20px 40px hsl(var(--primary) / 0.4)`,
}}
>
<pillar.icon className="w-8 h-8 text-white" />
</div>
<div className="space-y-1.5">
<h3 className="font-semibold text-foreground group-hover:text-primary transition-colors">
<div className="space-y-2">
<h3 className="text-2xl font-bold group-hover:text-primary transition-all duration-300">
{pillar.title}
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<p className="text-muted-foreground leading-relaxed">
{pillar.description}
</p>
</div>
<div className="flex items-center text-primary/70 group-hover:text-primary group-hover:translate-x-1 transition-all duration-200 text-sm">
<span className="font-medium mr-1">Explore</span>
<ArrowRight className="w-3.5 h-3.5" />
<div className="flex items-center text-primary group-hover:translate-x-2 transition-transform duration-300">
<span className="text-sm font-medium mr-2">Explore</span>
<ArrowRight className="w-4 h-4" />
</div>
</div>
</Card>
@ -274,37 +449,38 @@ export default function Index() {
</div>
</section>
{/* Why AeThex */}
<section className="space-y-10 px-4">
<section className="space-y-12 px-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="text-center space-y-3"
transition={{ duration: 0.8 }}
className="text-center space-y-4"
>
<h2 className="text-4xl md:text-5xl font-black">Why Build on AeThex?</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
<h2 className="text-5xl md:text-6xl font-black text-primary">
Why Build on AeThex?
</h2>
<p className="text-xl text-muted-foreground max-w-3xl mx-auto">
Join a growing ecosystem designed for creators, developers, and entrepreneurs
</p>
</motion.div>
<div className="grid md:grid-cols-3 gap-4 max-w-6xl mx-auto">
<div className="grid md:grid-cols-3 gap-8 max-w-6xl mx-auto">
{features.map((feature, index) => (
<motion.div
key={feature.title}
initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.08 }}
transition={{ duration: 0.6, delay: index * 0.2 }}
>
<Card className="p-6 space-y-4 border-border bg-card h-full">
<div className="w-10 h-10 rounded-lg bg-primary/10 border border-primary/20 flex items-center justify-center">
<feature.icon className="w-5 h-5 text-primary" />
<Card className="p-8 space-y-6 backdrop-blur-xl bg-background/50 border-primary/20 hover:border-primary/40 hover:scale-105 transition-all duration-300 h-full">
<div className="w-16 h-16 rounded-2xl bg-primary flex items-center justify-center shadow-2xl shadow-primary/50">
<feature.icon className="w-8 h-8 text-primary-foreground" />
</div>
<div className="space-y-2">
<h3 className="font-semibold text-foreground">{feature.title}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<div className="space-y-3">
<h3 className="text-2xl font-bold">{feature.title}</h3>
<p className="text-muted-foreground leading-relaxed">
{feature.description}
</p>
</div>
@ -314,47 +490,108 @@ export default function Index() {
</div>
</section>
{/* CTA */}
<section className="px-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="relative overflow-hidden rounded-2xl max-w-5xl mx-auto border border-primary/20 bg-primary/5"
transition={{ duration: 0.8 }}
className="relative overflow-hidden rounded-3xl max-w-6xl mx-auto border-2 border-primary/40"
>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_60%_80%_at_80%_50%,hsl(var(--primary)/0.08),transparent)]" />
<div className="relative z-10 p-12 md:p-16 text-center space-y-6">
<Badge className="text-xs px-4 py-1.5 bg-primary/10 border-primary/30 uppercase tracking-widest font-semibold">
<Terminal className="w-3 h-3 mr-1.5 inline" />
Start Building Today
</Badge>
<h2 className="text-3xl md:text-4xl lg:text-5xl font-black leading-tight">
Ready to Build Something{" "}
<span className="text-primary">Epic?</span>
</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
Get your API key and start deploying across{" "}
<span className="text-foreground font-medium">5+ metaverse platforms</span> in minutes
</p>
<div className="flex flex-wrap gap-3 justify-center pt-2">
{/* Animated Background */}
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 via-primary/10 to-background/50 backdrop-blur-xl" />
{/* Animated Grid */}
<div
className="absolute inset-0 opacity-[0.05]"
style={{
backgroundImage: `
linear-gradient(to right, hsl(var(--primary)) 1px, transparent 1px),
linear-gradient(to bottom, hsl(var(--primary)) 1px, transparent 1px)
`,
backgroundSize: "40px 40px",
}}
/>
{/* Glowing Orb */}
<motion.div
className="absolute top-0 right-0 w-96 h-96 rounded-full bg-primary/30 blur-[120px]"
animate={{
scale: [1, 1.2, 1],
opacity: [0.3, 0.5, 0.3],
}}
transition={{
duration: 4,
repeat: Infinity,
ease: "easeInOut",
}}
/>
<div className="relative z-10 p-12 md:p-20 text-center space-y-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<Badge className="text-sm px-6 py-2 bg-primary/20 border-2 border-primary/50 shadow-[0_0_30px_rgba(168,85,247,0.4)] uppercase tracking-wider font-bold mb-6">
<Terminal className="w-4 h-4 mr-2 inline" />
Start Building Today
</Badge>
</motion.div>
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.3 }}
className="text-5xl md:text-7xl font-black leading-tight"
>
Ready to Build Something
<br />
<span className="text-primary drop-shadow-[0_0_30px_rgba(168,85,247,0.6)]">Epic?</span>
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.4 }}
className="text-xl md:text-2xl text-muted-foreground max-w-3xl mx-auto font-light"
>
Get your API key and start deploying across <span className="text-primary font-semibold">5+ metaverse platforms</span> in minutes
</motion.p>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.5 }}
className="flex flex-wrap gap-4 justify-center pt-6"
>
<Link to="/dev-platform/dashboard">
<Button size="lg" className="px-8 h-12 font-semibold">
<Button
size="lg"
className="text-xl px-10 h-16 bg-primary hover:bg-primary/90 shadow-[0_0_40px_rgba(168,85,247,0.6)] hover:shadow-[0_0_60px_rgba(168,85,247,0.8)] hover:scale-105 transition-all duration-300 font-black uppercase tracking-wide border-2 border-primary/50"
>
Get Your API Key
<ArrowRight className="w-4 h-4 ml-2" />
<ArrowRight className="w-6 h-6 ml-3" />
</Button>
</Link>
<Link to="/realms">
<Button size="lg" variant="outline" className="px-8 h-12 font-semibold">
<Button
size="lg"
variant="outline"
className="text-xl px-10 h-16 backdrop-blur-xl bg-background/50 border-2 border-primary/40 hover:bg-primary/10 hover:border-primary/60 shadow-[0_0_20px_rgba(168,85,247,0.3)] hover:shadow-[0_0_40px_rgba(168,85,247,0.5)] hover:scale-105 transition-all duration-300 font-black uppercase tracking-wide"
>
Explore Realms
<Boxes className="w-4 h-4 ml-2" />
<Boxes className="w-6 h-6 ml-3" />
</Button>
</Link>
</div>
</motion.div>
</div>
</motion.div>
</section>
</div>
</Layout>
);

View file

@ -128,7 +128,7 @@ export default function Labs() {
<div className="relative min-h-screen bg-black text-white overflow-hidden">
{/* Persistent Info Banner */}
<div className="bg-yellow-500/10 border-b border-yellow-400/30 py-3 sticky top-0 z-50 backdrop-blur-sm">
<div className="container mx-auto max-w-6xl px-4">
<div className="container mx-auto max-w-7xl px-4">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-3">
<ExternalLink className="h-5 w-5 text-yellow-400" />
@ -161,7 +161,7 @@ export default function Labs() {
<main className="relative z-10">
{/* Hero Section */}
<section className="relative overflow-hidden py-20 lg:py-32">
<div className="container mx-auto max-w-6xl px-4">
<div className="container mx-auto max-w-7xl px-4">
<div className="text-center space-y-8">
<div className="flex justify-center mb-6">
<img

View file

@ -1,131 +0,0 @@
import { useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Link2, CheckCircle2, AlertCircle, Loader2 } from "lucide-react";
export default function Link() {
const [code, setCode] = useState("");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!code.trim() || code.length !== 6) {
setError("Please enter a valid 6-character code");
return;
}
setLoading(true);
setError("");
setSuccess(false);
try {
const response = await fetch("/api/auth/verify-device-code", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: code.toUpperCase() })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || "Failed to link device");
}
setSuccess(true);
setCode("");
} catch (err: any) {
setError(err.message || "Failed to link device. Please try again.");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-purple-950/20 to-slate-950 flex items-center justify-center p-4">
<Card className="w-full max-w-md bg-slate-900/95 border-purple-500/20">
<CardHeader className="text-center space-y-4">
<div className="mx-auto w-16 h-16 rounded-full bg-purple-500/20 flex items-center justify-center">
<Link2 className="h-8 w-8 text-purple-400" />
</div>
<div>
<CardTitle className="text-2xl text-white">Link Your Device</CardTitle>
<CardDescription className="text-gray-400 mt-2">
Enter the 6-character code displayed in your game or app
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-6">
{success ? (
<Alert className="bg-green-950/50 border-green-500/50">
<CheckCircle2 className="h-4 w-4 text-green-400" />
<AlertDescription className="text-green-300">
Device linked successfully! You can now return to your game.
</AlertDescription>
</Alert>
) : (
<>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label htmlFor="code" className="text-sm font-medium text-gray-300">
Device Code
</label>
<Input
id="code"
type="text"
placeholder="ABC123"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
maxLength={6}
className="text-center text-2xl tracking-widest font-mono bg-slate-800/50 border-purple-500/30 text-white placeholder:text-gray-600"
disabled={loading}
autoFocus
/>
</div>
{error && (
<Alert className="bg-red-950/50 border-red-500/50">
<AlertCircle className="h-4 w-4 text-red-400" />
<AlertDescription className="text-red-300">
{error}
</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full bg-purple-600 hover:bg-purple-700 text-white"
disabled={loading || code.length !== 6}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Linking...
</>
) : (
"Link Device"
)}
</Button>
</form>
<div className="pt-4 border-t border-slate-700">
<div className="space-y-3 text-sm text-gray-400">
<p className="font-semibold text-gray-300">Where to find your code:</p>
<ul className="space-y-2 pl-4">
<li> <strong className="text-white">VRChat:</strong> Check the in-world AeThex panel</li>
<li> <strong className="text-white">RecRoom:</strong> Look for the code display board</li>
<li> <strong className="text-white">Other Games:</strong> Check your authentication menu</li>
</ul>
</div>
</div>
</>
)}
</CardContent>
</Card>
</div>
);
}

View file

@ -65,7 +65,6 @@ export default function Login() {
const [fullName, setFullName] = useState("");
const [showReset, setShowReset] = useState(false);
const [resetEmail, setResetEmail] = useState("");
const [rememberMe, setRememberMe] = useState(true);
const [errorFromUrl, setErrorFromUrl] = useState<string | null>(null);
const [discordLinkedEmail, setDiscordLinkedEmail] = useState<string | null>(
null,
@ -176,12 +175,6 @@ export default function Login() {
});
} else {
await signIn(email, password);
// Store remember-me preference — read by AuthContext on next page load
if (rememberMe) {
localStorage.setItem("aethex_remember_me", "1");
} else {
localStorage.removeItem("aethex_remember_me");
}
toastInfo({
title: "Signing you in",
description: "Redirecting...",
@ -345,39 +338,6 @@ export default function Login() {
) : null}
{/* Social Login Buttons */}
<div className="space-y-3">
{/* AeThex ID — primary SSO */}
<div className="space-y-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
AeThex Identity
</p>
<button
type="button"
className="ax-mono ax-clip w-full"
style={{
display: "flex", alignItems: "center", justifyContent: "center", gap: 10,
border: "1px solid rgba(0,255,255,0.5)", color: "#00ffff",
padding: "11px 20px", background: "rgba(0,255,255,0.06)",
fontSize: 11, letterSpacing: 2, textTransform: "uppercase",
cursor: "pointer", transition: "all 0.2s", width: "100%",
}}
onMouseEnter={e => { e.currentTarget.style.background = "rgba(0,255,255,0.14)"; e.currentTarget.style.boxShadow = "0 0 20px rgba(0,255,255,0.2)"; }}
onMouseLeave={e => { e.currentTarget.style.background = "rgba(0,255,255,0.06)"; e.currentTarget.style.boxShadow = "none"; }}
onClick={() => {
// Server-side OIDC flow — bypass Supabase social auth
const redirectTo = encodeURIComponent(location.state?.from?.pathname || "/dashboard");
window.location.href = `${API_BASE}/api/auth/authentik/start?redirectTo=${redirectTo}`;
}}
>
{/* Hex icon */}
<svg viewBox="0 0 100 100" width={16} height={16} style={{ flexShrink: 0 }}>
<polygon points="50,5 95,27.5 95,72.5 50,95 5,72.5 5,27.5" fill="none" stroke="#00ffff" strokeWidth="3" opacity="0.8"/>
<text x="50" y="63" textAnchor="middle" fontFamily="Orbitron" fontSize="38" fontWeight="700" fill="#00ffff">Æ</text>
</svg>
Sign in with AeThex ID
<span style={{ fontSize: 9, color: "rgba(0,255,255,0.4)", letterSpacing: 1 }}>auth.aethex.tech</span>
</button>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Quick Sign In
@ -567,8 +527,6 @@ export default function Login() {
<input
type="checkbox"
className="rounded border-border/50"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
/>
<span className="text-muted-foreground">
Remember me

View file

@ -108,7 +108,7 @@ export default function MaintenancePage() {
<div className="h-px bg-border" />
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-center text-xs">
<div className="grid grid-cols-3 gap-4 text-center text-xs">
<div className="space-y-1">
<div className="text-muted-foreground">STATUS</div>
<div className="text-blue-400 font-semibold flex items-center justify-center gap-1">

View file

@ -121,7 +121,7 @@ export default function Network() {
return (
<Layout>
<div className="min-h-screen bg-aethex-gradient py-8">
<div className="container mx-auto px-4 max-w-6xl grid grid-cols-1 lg:grid-cols-12 gap-6">
<div className="container mx-auto px-4 max-w-7xl grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Public Profile */}
<div className="lg:col-span-4 space-y-6">
<Card className="bg-card/50 border-border/50">

View file

@ -41,7 +41,7 @@ export default function Portal() {
return (
<Layout>
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 lg:py-12 max-w-6xl">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 lg:py-12 max-w-7xl">
<div className="mb-8">
<Badge variant="outline" className="mb-2">
Portal

View file

@ -590,7 +590,7 @@ const ProfilePassport = () => {
variant="ghost"
className="h-8 px-2 text-xs text-aethex-200"
>
<Link to={`/projects/${project.id}`}>
<Link to="/projects/new">
View mission
<ExternalLink className="ml-1 h-3.5 w-3.5" />
</Link>

View file

@ -1,280 +0,0 @@
import { useEffect, useState } from "react";
import { useParams, Link } from "react-router-dom";
import Layout from "@/components/Layout";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Github,
ExternalLink,
LayoutDashboard,
Calendar,
Cpu,
Activity,
} from "lucide-react";
const API_BASE = import.meta.env.VITE_API_BASE || "";
interface Project {
id: string;
title: string;
description?: string | null;
status?: string | null;
technologies?: string[] | null;
github_url?: string | null;
live_url?: string | null;
image_url?: string | null;
engine?: string | null;
priority?: string | null;
progress?: number | null;
created_at?: string | null;
updated_at?: string | null;
}
interface Owner {
id: string;
username?: string | null;
full_name?: string | null;
avatar_url?: string | null;
}
const STATUS_COLORS: Record<string, string> = {
planning: "bg-slate-500/20 text-slate-300 border-slate-600",
in_progress: "bg-blue-500/20 text-blue-300 border-blue-600",
completed: "bg-green-500/20 text-green-300 border-green-600",
on_hold: "bg-yellow-500/20 text-yellow-300 border-yellow-600",
};
const STATUS_LABELS: Record<string, string> = {
planning: "Planning",
in_progress: "In Progress",
completed: "Completed",
on_hold: "On Hold",
};
const formatDate = (v?: string | null) => {
if (!v) return null;
const d = new Date(v);
if (isNaN(d.getTime())) return null;
return d.toLocaleDateString(undefined, { dateStyle: "medium" });
};
export default function ProjectDetail() {
const { projectId } = useParams<{ projectId: string }>();
const [project, setProject] = useState<Project | null>(null);
const [owner, setOwner] = useState<Owner | null>(null);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
useEffect(() => {
if (!projectId) return;
setLoading(true);
fetch(`${API_BASE}/api/projects/${projectId}`)
.then((r) => {
if (r.status === 404) { setNotFound(true); return null; }
return r.json();
})
.then((body) => {
if (!body) return;
setProject(body.project);
setOwner(body.owner);
})
.catch(() => setNotFound(true))
.finally(() => setLoading(false));
}, [projectId]);
if (loading) {
return (
<Layout>
<div className="flex items-center justify-center min-h-[60vh]">
<div className="animate-pulse text-slate-400">Loading project</div>
</div>
</Layout>
);
}
if (notFound || !project) {
return (
<Layout>
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
<p className="text-xl text-slate-300">Project not found.</p>
<Button asChild variant="outline">
<Link to="/projects">Browse projects</Link>
</Button>
</div>
</Layout>
);
}
const statusKey = project.status ?? "planning";
const statusClass = STATUS_COLORS[statusKey] ?? STATUS_COLORS.planning;
const statusLabel = STATUS_LABELS[statusKey] ?? statusKey;
const ownerSlug = owner?.username ?? owner?.id;
const ownerName = owner?.full_name || owner?.username || "Unknown";
const ownerInitials = ownerName
.split(" ")
.map((w) => w[0])
.join("")
.slice(0, 2)
.toUpperCase();
return (
<Layout>
<div className="max-w-4xl mx-auto px-4 py-10 space-y-8">
{/* Header */}
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<Badge className={`text-xs border ${statusClass}`}>
{statusLabel}
</Badge>
{project.priority && (
<Badge variant="outline" className="text-xs border-slate-600 text-slate-400">
{project.priority} priority
</Badge>
)}
</div>
<h1 className="text-3xl font-bold text-white">{project.title}</h1>
{project.description && (
<p className="text-slate-300 leading-relaxed text-base max-w-2xl">
{project.description}
</p>
)}
</div>
{/* Action buttons */}
<div className="flex flex-wrap gap-3">
<Button asChild>
<Link to={`/projects/${project.id}/board`}>
<LayoutDashboard className="mr-2 h-4 w-4" />
Project Board
</Link>
</Button>
{project.github_url && (
<Button asChild variant="outline">
<a href={project.github_url} target="_blank" rel="noopener noreferrer">
<Github className="mr-2 h-4 w-4" />
Repository
</a>
</Button>
)}
{project.live_url && (
<Button asChild variant="outline">
<a href={project.live_url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
Live
</a>
</Button>
)}
</div>
<Separator className="border-slate-700" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Meta card */}
<Card className="bg-slate-900/60 border-slate-700 md:col-span-1 space-y-0">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-400 uppercase tracking-wide">
Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
{owner && (
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={owner.avatar_url ?? undefined} />
<AvatarFallback className="bg-slate-700 text-slate-300 text-xs">
{ownerInitials}
</AvatarFallback>
</Avatar>
<div>
<p className="text-slate-400 text-xs">Owner</p>
{ownerSlug ? (
<Link
to={`/u/${ownerSlug}`}
className="text-aethex-300 hover:underline font-medium"
>
{ownerName}
</Link>
) : (
<span className="text-slate-200">{ownerName}</span>
)}
</div>
</div>
)}
{project.engine && (
<div className="flex items-start gap-2 text-slate-300">
<Cpu className="h-4 w-4 mt-0.5 text-slate-500 shrink-0" />
<div>
<p className="text-slate-400 text-xs">Engine</p>
<p>{project.engine}</p>
</div>
</div>
)}
{typeof project.progress === "number" && (
<div className="space-y-1">
<div className="flex items-center gap-2 text-slate-400 text-xs">
<Activity className="h-3.5 w-3.5" />
<span>Progress {project.progress}%</span>
</div>
<div className="w-full bg-slate-700 rounded-full h-1.5">
<div
className="bg-aethex-500 h-1.5 rounded-full transition-all"
style={{ width: `${Math.min(100, project.progress)}%` }}
/>
</div>
</div>
)}
{(project.created_at || project.updated_at) && (
<div className="flex items-start gap-2 text-slate-300">
<Calendar className="h-4 w-4 mt-0.5 text-slate-500 shrink-0" />
<div className="space-y-0.5">
{project.created_at && (
<p className="text-xs text-slate-400">
Created {formatDate(project.created_at)}
</p>
)}
{project.updated_at && (
<p className="text-xs text-slate-400">
Updated {formatDate(project.updated_at)}
</p>
)}
</div>
</div>
)}
</CardContent>
</Card>
{/* Technologies */}
{project.technologies && project.technologies.length > 0 && (
<Card className="bg-slate-900/60 border-slate-700 md:col-span-2">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-400 uppercase tracking-wide">
Technologies
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{project.technologies.map((tech) => (
<Badge
key={tech}
variant="outline"
className="border-slate-600 text-slate-300 text-xs"
>
{tech}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
</div>
</div>
</Layout>
);
}

View file

@ -87,7 +87,7 @@ export default function Projects() {
</div>
</section>
<section className="container mx-auto max-w-6xl px-4 mt-6">
<section className="container mx-auto max-w-7xl px-4 mt-6">
{hasProjects ? (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{items.map((p) => (

View file

@ -158,7 +158,7 @@ export default function ProjectsAdmin() {
value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="grid grid-cols-2 gap-3">
<select
className="rounded border border-border/40 bg-background/70 px-3 py-2"
value={draft.org_unit}

View file

@ -75,7 +75,7 @@ export default function Realms() {
/>
</div>
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 lg:py-12 max-w-6xl relative">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 lg:py-12 max-w-7xl relative">
{/* Hero Section */}
<motion.div
initial={{ opacity: 0, y: 20 }}
@ -87,7 +87,7 @@ export default function Realms() {
<Sparkles className="w-4 h-4 mr-2 inline animate-pulse" />
Six Specialized Realms
</Badge>
<h1 className="text-4xl md:text-5xl lg:text-6xl font-black tracking-tight">
<h1 className="text-5xl md:text-7xl font-black tracking-tight">
Choose Your{" "}
<span className="text-primary drop-shadow-[0_0_25px_rgba(168,85,247,0.8)]">Realm</span>
</h1>

View file

@ -87,7 +87,7 @@ export default function Squads() {
return (
<Layout>
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(110,141,255,0.12),transparent_60%)]">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 lg:py-12 max-w-6xl space-y-8">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 lg:py-12 max-w-7xl space-y-8">
{/* Header */}
<section className="rounded-3xl border border-border/40 bg-background/80 p-6 shadow-2xl backdrop-blur">
<div className="flex items-start justify-between">

View file

@ -74,7 +74,7 @@ export default function StaffAdmin() {
<Card className="bg-slate-900/50 border-purple-500/20">
<CardContent className="pt-6">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-3 md:grid-cols-5 bg-slate-800/50">
<TabsList className="grid w-full grid-cols-5 bg-slate-800/50">
<TabsTrigger value="users" className="gap-2">
<Users className="w-4 h-4" />
<span className="hidden sm:inline">Users</span>

View file

@ -71,7 +71,7 @@ export default function StaffChat() {
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 h-[calc(100vh-200px)] sm:h-[600px] min-h-[400px]">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 h-[600px]">
{/* Channels Sidebar */}
<Card className="bg-slate-900/50 border-purple-500/20 lg:col-span-1">
<CardHeader>

View file

@ -155,7 +155,7 @@ export default function StaffDashboard() {
</CardHeader>
<CardContent>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-4 bg-slate-800/50">
<TabsList className="grid w-full grid-cols-4 bg-slate-800/50">
<TabsTrigger value="overview" className="gap-2">
<BarChart3 className="w-4 h-4" />
<span className="hidden sm:inline">Overview</span>

View file

@ -102,7 +102,7 @@ export default function Teams() {
return (
<Layout>
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(110,141,255,0.12),transparent_60%)]">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 lg:py-12 max-w-6xl space-y-8">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 lg:py-12 max-w-7xl space-y-8">
<section className="rounded-3xl border border-border/40 bg-background/80 p-6 shadow-2xl backdrop-blur">
<h1 className="text-3xl font-semibold text-foreground">Teams</h1>
<p className="mt-1 text-sm text-muted-foreground">

View file

@ -27,7 +27,7 @@ export default function WixCaseStudies() {
<CardDescription>{c.summary}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
<div className="grid grid-cols-3 gap-3 text-sm">
{c.metrics.map((m, i) => (
<div
key={i}

View file

@ -1,362 +0,0 @@
import { useState, useEffect } from "react";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
BarChart3,
Users,
Briefcase,
FileText,
DollarSign,
TrendingUp,
Activity,
MessageSquare,
Loader2,
ArrowUpRight,
ArrowDownRight,
} from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { aethexToast } from "@/lib/aethex-toast";
interface Analytics {
users: {
total: number;
new: number;
active: number;
creators: number;
};
opportunities: {
total: number;
open: number;
new: number;
};
applications: {
total: number;
new: number;
};
contracts: {
total: number;
active: number;
};
community: {
posts: number;
newPosts: number;
};
revenue: {
total: number;
period: string;
};
trends: {
dailySignups: Array<{ date: string; count: number }>;
topOpportunities: Array<{ id: string; title: string; applications: number }>;
};
period: number;
}
export default function AdminAnalytics() {
const { session } = useAuth();
const [analytics, setAnalytics] = useState<Analytics | null>(null);
const [loading, setLoading] = useState(true);
const [period, setPeriod] = useState("30");
useEffect(() => {
if (session?.access_token) {
fetchAnalytics();
}
}, [session?.access_token, period]);
const fetchAnalytics = async () => {
try {
const res = await fetch(`/api/admin/analytics?period=${period}`, {
headers: { Authorization: `Bearer ${session?.access_token}` },
});
const data = await res.json();
if (res.ok) {
setAnalytics(data);
} else {
aethexToast.error(data.error || "Failed to load analytics");
}
} catch (err) {
aethexToast.error("Failed to load analytics");
} finally {
setLoading(false);
}
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0
}).format(amount);
};
const maxSignups = analytics?.trends.dailySignups
? Math.max(...analytics.trends.dailySignups.map(d => d.count), 1)
: 1;
if (loading) {
return (
<Layout>
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
</div>
</Layout>
);
}
return (
<Layout>
<SEO title="Analytics Dashboard" description="Platform analytics and insights" />
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
<div className="absolute inset-0 opacity-30">
<div className="absolute top-20 left-10 w-96 h-96 bg-cyan-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
<div className="absolute bottom-20 right-10 w-96 h-96 bg-blue-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
</div>
<div className="relative z-10">
<div className="container mx-auto max-w-7xl px-4 py-16">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
<div className="flex items-center gap-3">
<div className="p-3 rounded-lg bg-cyan-500/20 border border-cyan-500/30">
<BarChart3 className="h-6 w-6 text-cyan-400" />
</div>
<div>
<h1 className="text-2xl sm:text-4xl font-bold text-cyan-100">Analytics</h1>
<p className="text-cyan-200/70 text-sm sm:text-base">Platform insights and metrics</p>
</div>
</div>
<Select value={period} onValueChange={setPeriod}>
<SelectTrigger className="w-full sm:w-40 bg-slate-800 border-slate-700 text-slate-100">
<SelectValue placeholder="Period" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
<SelectItem value="90">Last 90 days</SelectItem>
<SelectItem value="365">Last year</SelectItem>
</SelectContent>
</Select>
</div>
{/* Overview Stats */}
<div className="grid md:grid-cols-4 gap-4 mb-8">
<Card className="bg-cyan-950/30 border-cyan-500/30">
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-cyan-200/70">Total Users</p>
<p className="text-3xl font-bold text-cyan-100">{analytics?.users.total.toLocaleString()}</p>
<div className="flex items-center gap-1 mt-1 text-green-400 text-sm">
<ArrowUpRight className="h-4 w-4" />
+{analytics?.users.new} this period
</div>
</div>
<Users className="h-8 w-8 text-cyan-400" />
</div>
</CardContent>
</Card>
<Card className="bg-cyan-950/30 border-cyan-500/30">
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-cyan-200/70">Active Users</p>
<p className="text-3xl font-bold text-cyan-100">{analytics?.users.active.toLocaleString()}</p>
<p className="text-sm text-slate-400 mt-1">
{analytics?.users.total ? Math.round((analytics.users.active / analytics.users.total) * 100) : 0}% of total
</p>
</div>
<Activity className="h-8 w-8 text-cyan-400" />
</div>
</CardContent>
</Card>
<Card className="bg-cyan-950/30 border-cyan-500/30">
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-cyan-200/70">Opportunities</p>
<p className="text-3xl font-bold text-cyan-100">{analytics?.opportunities.open}</p>
<div className="flex items-center gap-1 mt-1 text-green-400 text-sm">
<ArrowUpRight className="h-4 w-4" />
+{analytics?.opportunities.new} new
</div>
</div>
<Briefcase className="h-8 w-8 text-cyan-400" />
</div>
</CardContent>
</Card>
<Card className="bg-cyan-950/30 border-cyan-500/30">
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-cyan-200/70">Revenue</p>
<p className="text-3xl font-bold text-cyan-100">{formatCurrency(analytics?.revenue.total || 0)}</p>
<p className="text-sm text-slate-400 mt-1">Last {period} days</p>
</div>
<DollarSign className="h-8 w-8 text-cyan-400" />
</div>
</CardContent>
</Card>
</div>
{/* Detailed Stats Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
{/* Applications */}
<Card className="bg-slate-800/50 border-slate-700/50">
<CardHeader>
<CardTitle className="text-slate-100 flex items-center gap-2">
<FileText className="h-5 w-5 text-cyan-400" />
Applications
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-slate-400">Total</span>
<span className="text-xl font-bold text-cyan-100">{analytics?.applications.total}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">This Period</span>
<span className="text-xl font-bold text-green-400">+{analytics?.applications.new}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Avg per Opportunity</span>
<span className="text-xl font-bold text-cyan-100">
{analytics?.opportunities.total
? (analytics.applications.total / analytics.opportunities.total).toFixed(1)
: 0}
</span>
</div>
</div>
</CardContent>
</Card>
{/* Contracts */}
<Card className="bg-slate-800/50 border-slate-700/50">
<CardHeader>
<CardTitle className="text-slate-100 flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-cyan-400" />
Contracts
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-slate-400">Total</span>
<span className="text-xl font-bold text-cyan-100">{analytics?.contracts.total}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Active</span>
<span className="text-xl font-bold text-green-400">{analytics?.contracts.active}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Completion Rate</span>
<span className="text-xl font-bold text-cyan-100">
{analytics?.contracts.total
? Math.round(((analytics.contracts.total - analytics.contracts.active) / analytics.contracts.total) * 100)
: 0}%
</span>
</div>
</div>
</CardContent>
</Card>
{/* Community */}
<Card className="bg-slate-800/50 border-slate-700/50">
<CardHeader>
<CardTitle className="text-slate-100 flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-cyan-400" />
Community
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-slate-400">Total Posts</span>
<span className="text-xl font-bold text-cyan-100">{analytics?.community.posts}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">New Posts</span>
<span className="text-xl font-bold text-green-400">+{analytics?.community.newPosts}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Creators</span>
<span className="text-xl font-bold text-cyan-100">{analytics?.users.creators}</span>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Charts Row */}
<div className="grid md:grid-cols-2 gap-6">
{/* Signup Trend */}
<Card className="bg-slate-800/50 border-slate-700/50">
<CardHeader>
<CardTitle className="text-slate-100">Daily Signups</CardTitle>
<CardDescription className="text-slate-400">User registrations over the last 30 days</CardDescription>
</CardHeader>
<CardContent>
<div className="h-48 flex items-end gap-1">
{analytics?.trends.dailySignups.slice(-30).map((day, i) => (
<div
key={day.date}
className="flex-1 bg-cyan-500/30 hover:bg-cyan-500/50 transition-colors rounded-t"
style={{ height: `${(day.count / maxSignups) * 100}%`, minHeight: "4px" }}
title={`${day.date}: ${day.count} signups`}
/>
))}
</div>
<div className="flex justify-between mt-2 text-xs text-slate-500">
<span>30 days ago</span>
<span>Today</span>
</div>
</CardContent>
</Card>
{/* Top Opportunities */}
<Card className="bg-slate-800/50 border-slate-700/50">
<CardHeader>
<CardTitle className="text-slate-100">Top Opportunities</CardTitle>
<CardDescription className="text-slate-400">By number of applications</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{analytics?.trends.topOpportunities.map((opp, i) => (
<div key={opp.id} className="flex items-center gap-4">
<span className="text-lg font-bold text-cyan-400 w-6">#{i + 1}</span>
<div className="flex-1 min-w-0">
<p className="text-slate-200 truncate">{opp.title}</p>
<p className="text-sm text-slate-500">{opp.applications} applications</p>
</div>
</div>
))}
{(!analytics?.trends.topOpportunities || analytics.trends.topOpportunities.length === 0) && (
<p className="text-slate-500 text-center py-4">No opportunities yet</p>
)}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
</Layout>
);
}

View file

@ -1,594 +0,0 @@
import { useState, useEffect } from "react";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import {
Shield,
AlertTriangle,
Flag,
UserX,
CheckCircle,
XCircle,
Loader2,
Eye,
Ban,
AlertCircle,
} from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { aethexToast } from "@/lib/aethex-toast";
interface Report {
id: string;
reporter_id: string;
target_type: string;
target_id: string;
reason: string;
details?: string;
status: string;
created_at: string;
reporter?: {
id: string;
full_name: string;
email: string;
avatar_url?: string;
};
}
interface Dispute {
id: string;
contract_id: string;
reason: string;
status: string;
resolution_notes?: string;
created_at: string;
reporter?: {
id: string;
full_name: string;
email: string;
};
}
interface FlaggedUser {
id: string;
full_name: string;
email: string;
avatar_url?: string;
is_banned: boolean;
warning_count: number;
created_at: string;
}
interface Stats {
openReports: number;
openDisputes: number;
resolvedToday: number;
flaggedUsers: number;
}
const getStatusColor = (status: string) => {
switch (status) {
case "open":
return "bg-red-500/20 text-red-300 border-red-500/30";
case "resolved":
return "bg-green-500/20 text-green-300 border-green-500/30";
case "ignored":
return "bg-slate-500/20 text-slate-300 border-slate-500/30";
default:
return "bg-slate-500/20 text-slate-300";
}
};
const getTypeColor = (type: string) => {
switch (type) {
case "post":
return "bg-blue-500/20 text-blue-300";
case "comment":
return "bg-purple-500/20 text-purple-300";
case "user":
return "bg-amber-500/20 text-amber-300";
case "project":
return "bg-cyan-500/20 text-cyan-300";
default:
return "bg-slate-500/20 text-slate-300";
}
};
export default function AdminModeration() {
const { session } = useAuth();
const [reports, setReports] = useState<Report[]>([]);
const [disputes, setDisputes] = useState<Dispute[]>([]);
const [flaggedUsers, setFlaggedUsers] = useState<FlaggedUser[]>([]);
const [stats, setStats] = useState<Stats>({ openReports: 0, openDisputes: 0, resolvedToday: 0, flaggedUsers: 0 });
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState("open");
const [selectedReport, setSelectedReport] = useState<Report | null>(null);
const [selectedDispute, setSelectedDispute] = useState<Dispute | null>(null);
const [selectedUser, setSelectedUser] = useState<FlaggedUser | null>(null);
const [resolution, setResolution] = useState("");
useEffect(() => {
if (session?.access_token) {
fetchModeration();
}
}, [session?.access_token, statusFilter]);
const fetchModeration = async () => {
try {
const res = await fetch(`/api/admin/moderation?status=${statusFilter}`, {
headers: { Authorization: `Bearer ${session?.access_token}` },
});
const data = await res.json();
if (res.ok) {
setReports(data.reports || []);
setDisputes(data.disputes || []);
setFlaggedUsers(data.flaggedUsers || []);
setStats(data.stats || { openReports: 0, openDisputes: 0, resolvedToday: 0, flaggedUsers: 0 });
} else {
aethexToast.error(data.error || "Failed to load moderation data");
}
} catch (err) {
aethexToast.error("Failed to load moderation data");
} finally {
setLoading(false);
}
};
const updateReport = async (reportId: string, status: string) => {
try {
const res = await fetch("/api/admin/moderation", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session?.access_token}`,
},
body: JSON.stringify({
action: "update_report",
report_id: reportId,
status,
resolution_notes: resolution
}),
});
if (res.ok) {
aethexToast.success(`Report ${status}`);
setSelectedReport(null);
setResolution("");
fetchModeration();
}
} catch (err) {
aethexToast.error("Failed to update report");
}
};
const updateDispute = async (disputeId: string, status: string) => {
try {
const res = await fetch("/api/admin/moderation", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session?.access_token}`,
},
body: JSON.stringify({
action: "update_dispute",
dispute_id: disputeId,
status,
resolution_notes: resolution
}),
});
if (res.ok) {
aethexToast.success(`Dispute ${status}`);
setSelectedDispute(null);
setResolution("");
fetchModeration();
}
} catch (err) {
aethexToast.error("Failed to update dispute");
}
};
const moderateUser = async (userId: string, actionType: string) => {
const reason = prompt(`Enter reason for ${actionType}:`);
if (!reason && actionType !== "unban") return;
try {
const res = await fetch("/api/admin/moderation", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session?.access_token}`,
},
body: JSON.stringify({
action: "moderate_user",
user_id: userId,
action_type: actionType,
reason
}),
});
if (res.ok) {
aethexToast.success(`User ${actionType}ned successfully`);
setSelectedUser(null);
fetchModeration();
}
} catch (err) {
aethexToast.error(`Failed to ${actionType} user`);
}
};
if (loading) {
return (
<Layout>
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-red-400" />
</div>
</Layout>
);
}
return (
<Layout>
<SEO title="Moderation Dashboard" description="Admin content moderation" />
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
<div className="absolute inset-0 opacity-30">
<div className="absolute top-20 left-10 w-96 h-96 bg-red-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
<div className="absolute bottom-20 right-10 w-96 h-96 bg-orange-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
</div>
<div className="relative z-10">
<div className="container mx-auto max-w-7xl px-4 py-16">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-3">
<div className="p-3 rounded-lg bg-red-500/20 border border-red-500/30">
<Shield className="h-6 w-6 text-red-400" />
</div>
<div>
<h1 className="text-2xl sm:text-4xl font-bold text-red-100">Moderation</h1>
<p className="text-red-200/70 text-sm sm:text-base">Content moderation and user management</p>
</div>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full sm:w-40 bg-slate-800 border-slate-700 text-slate-100">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="open">Open</SelectItem>
<SelectItem value="resolved">Resolved</SelectItem>
<SelectItem value="ignored">Ignored</SelectItem>
<SelectItem value="all">All</SelectItem>
</SelectContent>
</Select>
</div>
{/* Stats */}
<div className="grid md:grid-cols-4 gap-4 mb-8">
<Card className="bg-red-950/30 border-red-500/30">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-red-200/70">Open Reports</p>
<p className="text-3xl font-bold text-red-100">{stats.openReports}</p>
</div>
<Flag className="h-8 w-8 text-red-400" />
</div>
</CardContent>
</Card>
<Card className="bg-red-950/30 border-red-500/30">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-red-200/70">Open Disputes</p>
<p className="text-3xl font-bold text-red-100">{stats.openDisputes}</p>
</div>
<AlertTriangle className="h-8 w-8 text-red-400" />
</div>
</CardContent>
</Card>
<Card className="bg-red-950/30 border-red-500/30">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-red-200/70">Resolved Today</p>
<p className="text-3xl font-bold text-red-100">{stats.resolvedToday}</p>
</div>
<CheckCircle className="h-8 w-8 text-red-400" />
</div>
</CardContent>
</Card>
<Card className="bg-red-950/30 border-red-500/30">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-red-200/70">Flagged Users</p>
<p className="text-3xl font-bold text-red-100">{stats.flaggedUsers}</p>
</div>
<UserX className="h-8 w-8 text-red-400" />
</div>
</CardContent>
</Card>
</div>
{/* Tabs */}
<Tabs defaultValue="reports" className="space-y-6">
<TabsList className="bg-slate-800">
<TabsTrigger value="reports">Reports ({reports.length})</TabsTrigger>
<TabsTrigger value="disputes">Disputes ({disputes.length})</TabsTrigger>
<TabsTrigger value="users">Flagged Users ({flaggedUsers.length})</TabsTrigger>
</TabsList>
{/* Reports Tab */}
<TabsContent value="reports" className="space-y-4">
{reports.map((report) => (
<Card key={report.id} className="bg-slate-800/50 border-slate-700/50">
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Badge className={`border ${getStatusColor(report.status)}`}>
{report.status}
</Badge>
<Badge className={getTypeColor(report.target_type)}>
{report.target_type}
</Badge>
</div>
<p className="text-slate-200 font-medium mb-1">{report.reason}</p>
{report.details && (
<p className="text-sm text-slate-400 mb-2">{report.details}</p>
)}
<div className="flex items-center gap-4 text-xs text-slate-500">
<span>By: {report.reporter?.full_name || report.reporter?.email || "Unknown"}</span>
<span>{new Date(report.created_at).toLocaleDateString()}</span>
</div>
</div>
{report.status === "open" && (
<div className="flex gap-2">
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
onClick={() => setSelectedReport(report)}
>
<CheckCircle className="h-4 w-4 mr-1" />
Resolve
</Button>
<Button
size="sm"
variant="outline"
onClick={() => updateReport(report.id, "ignored")}
>
<XCircle className="h-4 w-4 mr-1" />
Ignore
</Button>
</div>
)}
</div>
</CardContent>
</Card>
))}
{reports.length === 0 && (
<div className="text-center py-12 text-slate-400">No reports found</div>
)}
</TabsContent>
{/* Disputes Tab */}
<TabsContent value="disputes" className="space-y-4">
{disputes.map((dispute) => (
<Card key={dispute.id} className="bg-slate-800/50 border-slate-700/50">
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Badge className={`border ${getStatusColor(dispute.status)}`}>
{dispute.status}
</Badge>
<Badge className="bg-purple-500/20 text-purple-300">
Contract Dispute
</Badge>
</div>
<p className="text-slate-200 font-medium mb-1">{dispute.reason}</p>
<div className="flex items-center gap-4 text-xs text-slate-500">
<span>Contract: {dispute.contract_id?.slice(0, 8)}...</span>
<span>By: {dispute.reporter?.full_name || dispute.reporter?.email || "Unknown"}</span>
<span>{new Date(dispute.created_at).toLocaleDateString()}</span>
</div>
</div>
{dispute.status === "open" && (
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
onClick={() => setSelectedDispute(dispute)}
>
<Eye className="h-4 w-4 mr-1" />
Review
</Button>
)}
</div>
</CardContent>
</Card>
))}
{disputes.length === 0 && (
<div className="text-center py-12 text-slate-400">No disputes found</div>
)}
</TabsContent>
{/* Flagged Users Tab */}
<TabsContent value="users" className="space-y-4">
{flaggedUsers.map((user) => (
<Card key={user.id} className="bg-slate-800/50 border-slate-700/50">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-slate-700 flex items-center justify-center">
{user.avatar_url ? (
<img src={user.avatar_url} alt="" className="w-10 h-10 rounded-full" />
) : (
<span className="text-slate-400">{user.full_name?.[0] || "?"}</span>
)}
</div>
<div>
<p className="text-slate-200 font-medium">{user.full_name || "Unknown"}</p>
<p className="text-sm text-slate-400">{user.email}</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
{user.is_banned && (
<Badge className="bg-red-500/20 text-red-300 border-red-500/30">
Banned
</Badge>
)}
{user.warning_count > 0 && (
<Badge className="bg-amber-500/20 text-amber-300 border-amber-500/30">
{user.warning_count} Warnings
</Badge>
)}
</div>
<div className="flex gap-2">
{user.is_banned ? (
<Button
size="sm"
variant="outline"
onClick={() => moderateUser(user.id, "unban")}
>
Unban
</Button>
) : (
<>
<Button
size="sm"
variant="outline"
className="border-amber-500/30 text-amber-300"
onClick={() => moderateUser(user.id, "warn")}
>
<AlertCircle className="h-4 w-4 mr-1" />
Warn
</Button>
<Button
size="sm"
className="bg-red-600 hover:bg-red-700"
onClick={() => moderateUser(user.id, "ban")}
>
<Ban className="h-4 w-4 mr-1" />
Ban
</Button>
</>
)}
</div>
</div>
</div>
</CardContent>
</Card>
))}
{flaggedUsers.length === 0 && (
<div className="text-center py-12 text-slate-400">No flagged users</div>
)}
</TabsContent>
</Tabs>
</div>
</div>
</div>
{/* Resolve Report Dialog */}
<Dialog open={!!selectedReport} onOpenChange={() => setSelectedReport(null)}>
<DialogContent className="bg-slate-800 border-slate-700">
<DialogHeader>
<DialogTitle className="text-red-100">Resolve Report</DialogTitle>
</DialogHeader>
{selectedReport && (
<div className="space-y-4">
<div className="p-4 bg-slate-700/50 rounded">
<p className="text-sm text-slate-400 mb-1">Reason</p>
<p className="text-slate-200">{selectedReport.reason}</p>
{selectedReport.details && (
<>
<p className="text-sm text-slate-400 mb-1 mt-3">Details</p>
<p className="text-slate-300 text-sm">{selectedReport.details}</p>
</>
)}
</div>
<Textarea
placeholder="Resolution notes (optional)"
value={resolution}
onChange={(e) => setResolution(e.target.value)}
className="bg-slate-700 border-slate-600 text-slate-100"
/>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setSelectedReport(null)}>
Cancel
</Button>
<Button
className="bg-green-600 hover:bg-green-700"
onClick={() => updateReport(selectedReport.id, "resolved")}
>
Resolve
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
{/* Review Dispute Dialog */}
<Dialog open={!!selectedDispute} onOpenChange={() => setSelectedDispute(null)}>
<DialogContent className="bg-slate-800 border-slate-700">
<DialogHeader>
<DialogTitle className="text-red-100">Review Dispute</DialogTitle>
</DialogHeader>
{selectedDispute && (
<div className="space-y-4">
<div className="p-4 bg-slate-700/50 rounded">
<p className="text-sm text-slate-400 mb-1">Contract</p>
<p className="text-slate-200 font-mono text-sm">{selectedDispute.contract_id}</p>
<p className="text-sm text-slate-400 mb-1 mt-3">Dispute Reason</p>
<p className="text-slate-200">{selectedDispute.reason}</p>
</div>
<Textarea
placeholder="Resolution notes"
value={resolution}
onChange={(e) => setResolution(e.target.value)}
className="bg-slate-700 border-slate-600 text-slate-100"
/>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setSelectedDispute(null)}>
Cancel
</Button>
<Button
className="bg-green-600 hover:bg-green-700"
onClick={() => updateDispute(selectedDispute.id, "resolved")}
>
Resolve
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</Layout>
);
}

View file

@ -1,402 +0,0 @@
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Calendar,
Video,
Phone,
MapPin,
ArrowLeft,
Clock,
Loader2,
MessageSquare,
CheckCircle2,
XCircle,
AlertCircle,
} from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { aethexToast } from "@/lib/aethex-toast";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
interface Interview {
id: string;
scheduled_at: string;
duration_minutes: number;
meeting_link: string | null;
meeting_type: string;
status: string;
notes: string | null;
candidate_feedback: string | null;
employer: {
full_name: string;
avatar_url: string | null;
email: string;
} | null;
}
interface GroupedInterviews {
upcoming: Interview[];
past: Interview[];
cancelled: Interview[];
}
export default function CandidateInterviews() {
const { session } = useAuth();
const [loading, setLoading] = useState(true);
const [interviews, setInterviews] = useState<Interview[]>([]);
const [grouped, setGrouped] = useState<GroupedInterviews>({
upcoming: [],
past: [],
cancelled: [],
});
const [filter, setFilter] = useState("all");
useEffect(() => {
if (session?.access_token) {
fetchInterviews();
}
}, [session?.access_token]);
const fetchInterviews = async () => {
try {
const response = await fetch("/api/candidate/interviews", {
headers: { Authorization: `Bearer ${session?.access_token}` },
});
if (response.ok) {
const data = await response.json();
setInterviews(data.interviews || []);
setGrouped(data.grouped || { upcoming: [], past: [], cancelled: [] });
}
} catch (error) {
console.error("Error fetching interviews:", error);
aethexToast.error("Failed to load interviews");
} finally {
setLoading(false);
}
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
};
const formatTime = (date: string) => {
return new Date(date).toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
};
const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase();
};
const getMeetingIcon = (type: string) => {
switch (type) {
case "video":
return <Video className="h-4 w-4" />;
case "phone":
return <Phone className="h-4 w-4" />;
case "in_person":
return <MapPin className="h-4 w-4" />;
default:
return <Video className="h-4 w-4" />;
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case "scheduled":
return (
<Badge className="bg-blue-500/20 text-blue-300 border-blue-500/30">
Scheduled
</Badge>
);
case "completed":
return (
<Badge className="bg-green-500/20 text-green-300 border-green-500/30">
Completed
</Badge>
);
case "cancelled":
return (
<Badge className="bg-red-500/20 text-red-300 border-red-500/30">
Cancelled
</Badge>
);
case "rescheduled":
return (
<Badge className="bg-yellow-500/20 text-yellow-300 border-yellow-500/30">
Rescheduled
</Badge>
);
case "no_show":
return (
<Badge className="bg-gray-500/20 text-gray-300 border-gray-500/30">
No Show
</Badge>
);
default:
return <Badge>{status}</Badge>;
}
};
const getFilteredInterviews = () => {
switch (filter) {
case "upcoming":
return grouped.upcoming;
case "past":
return grouped.past;
case "cancelled":
return grouped.cancelled;
default:
return interviews;
}
};
if (loading) {
return (
<Layout>
<SEO title="Interviews" description="Manage your interview schedule" />
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-violet-400" />
</div>
</Layout>
);
}
const filteredInterviews = getFilteredInterviews();
return (
<Layout>
<SEO title="Interviews" description="Manage your interview schedule" />
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
{/* Background effects */}
<div className="absolute inset-0 opacity-30">
<div className="absolute top-20 left-10 w-96 h-96 bg-violet-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
<div className="absolute bottom-20 right-10 w-96 h-96 bg-blue-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
</div>
<div className="relative z-10">
<div className="container mx-auto max-w-4xl px-4 py-16">
{/* Header */}
<div className="mb-8">
<Link href="/candidate">
<Button
variant="ghost"
size="sm"
className="text-violet-300 hover:text-violet-200 hover:bg-violet-500/10 mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Dashboard
</Button>
</Link>
<div className="flex items-center gap-3 mb-6">
<div className="p-3 rounded-lg bg-blue-500/20 border border-blue-500/30">
<Calendar className="h-6 w-6 text-blue-400" />
</div>
<div>
<h1 className="text-3xl font-bold text-violet-100">
Interviews
</h1>
<p className="text-violet-200/70">
Manage your interview schedule
</p>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4 mb-6">
<Card className="bg-slate-800/50 border-slate-700/50">
<CardContent className="pt-6 text-center">
<p className="text-2xl font-bold text-blue-400">
{grouped.upcoming.length}
</p>
<p className="text-sm text-slate-400">Upcoming</p>
</CardContent>
</Card>
<Card className="bg-slate-800/50 border-slate-700/50">
<CardContent className="pt-6 text-center">
<p className="text-2xl font-bold text-green-400">
{grouped.past.length}
</p>
<p className="text-sm text-slate-400">Completed</p>
</CardContent>
</Card>
<Card className="bg-slate-800/50 border-slate-700/50">
<CardContent className="pt-6 text-center">
<p className="text-2xl font-bold text-slate-400">
{interviews.length}
</p>
<p className="text-sm text-slate-400">Total</p>
</CardContent>
</Card>
</div>
{/* Filter */}
<Select value={filter} onValueChange={setFilter}>
<SelectTrigger className="w-48 bg-slate-800/50 border-slate-700 text-slate-100">
<SelectValue placeholder="Filter interviews" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Interviews</SelectItem>
<SelectItem value="upcoming">Upcoming</SelectItem>
<SelectItem value="past">Past</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
{/* Interviews List */}
<div className="space-y-4">
{filteredInterviews.length === 0 ? (
<Card className="bg-slate-800/50 border-slate-700/50">
<CardContent className="pt-12 pb-12 text-center">
<Calendar className="h-12 w-12 text-slate-600 mx-auto mb-4" />
<p className="text-slate-400 text-lg mb-2">
No interviews found
</p>
<p className="text-slate-500 text-sm">
{filter === "all"
? "You don't have any scheduled interviews yet"
: `No ${filter} interviews`}
</p>
</CardContent>
</Card>
) : (
filteredInterviews.map((interview) => (
<Card
key={interview.id}
className="bg-slate-800/50 border-slate-700/50 hover:border-violet-500/30 transition-all"
>
<CardContent className="pt-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="flex items-start gap-4">
<Avatar className="h-12 w-12">
<AvatarImage
src={interview.employer?.avatar_url || ""}
/>
<AvatarFallback className="bg-violet-500/20 text-violet-300">
{interview.employer?.full_name
? getInitials(interview.employer.full_name)
: "E"}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium text-violet-100">
Interview with{" "}
{interview.employer?.full_name || "Employer"}
</p>
<div className="flex items-center gap-4 text-sm text-slate-400 mt-1">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{formatDate(interview.scheduled_at)}
</span>
<span className="flex items-center gap-1">
<Clock className="h-4 w-4" />
{formatTime(interview.scheduled_at)}
</span>
<span className="flex items-center gap-1">
{getMeetingIcon(interview.meeting_type)}
{interview.duration_minutes} min
</span>
</div>
</div>
</div>
<div className="flex items-center gap-3">
{getStatusBadge(interview.status)}
{interview.meeting_link &&
interview.status === "scheduled" && (
<a
href={interview.meeting_link}
target="_blank"
rel="noopener noreferrer"
>
<Button
size="sm"
className="bg-blue-600 hover:bg-blue-700"
>
<Video className="h-4 w-4 mr-2" />
Join Meeting
</Button>
</a>
)}
</div>
</div>
{interview.notes && (
<div className="mt-4 p-3 rounded-lg bg-slate-700/30 border border-slate-600/30">
<p className="text-sm text-slate-400">
<span className="font-medium text-slate-300">
Notes:
</span>{" "}
{interview.notes}
</p>
</div>
)}
</CardContent>
</Card>
))
)}
</div>
{/* Tips */}
<Card className="mt-8 bg-slate-800/30 border-slate-700/30">
<CardContent className="pt-6">
<h3 className="font-medium text-violet-100 mb-3">
Interview Tips
</h3>
<ul className="space-y-2 text-sm text-slate-400">
<li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-green-400 mt-0.5" />
Test your camera and microphone before video calls
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-green-400 mt-0.5" />
Join 5 minutes early to ensure everything works
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-green-400 mt-0.5" />
Have your resume and portfolio ready to share
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-green-400 mt-0.5" />
Prepare questions to ask the interviewer
</li>
</ul>
</CardContent>
</Card>
</div>
</div>
</div>
</Layout>
);
}

View file

@ -1,591 +0,0 @@
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import {
Gift,
ArrowLeft,
DollarSign,
Calendar,
Building,
Loader2,
CheckCircle2,
XCircle,
Clock,
AlertTriangle,
ExternalLink,
} from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { aethexToast } from "@/lib/aethex-toast";
interface Offer {
id: string;
position_title: string;
company_name: string;
salary_amount: number | null;
salary_type: string | null;
start_date: string | null;
offer_expiry: string | null;
benefits: any[];
offer_letter_url: string | null;
status: string;
notes: string | null;
created_at: string;
employer: {
full_name: string;
avatar_url: string | null;
email: string;
} | null;
}
interface GroupedOffers {
pending: Offer[];
accepted: Offer[];
declined: Offer[];
expired: Offer[];
withdrawn: Offer[];
}
export default function CandidateOffers() {
const { session } = useAuth();
const [loading, setLoading] = useState(true);
const [offers, setOffers] = useState<Offer[]>([]);
const [grouped, setGrouped] = useState<GroupedOffers>({
pending: [],
accepted: [],
declined: [],
expired: [],
withdrawn: [],
});
const [filter, setFilter] = useState("all");
const [respondingTo, setRespondingTo] = useState<Offer | null>(null);
const [responseAction, setResponseAction] = useState<"accept" | "decline" | null>(null);
const [responseNotes, setResponseNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (session?.access_token) {
fetchOffers();
}
}, [session?.access_token]);
const fetchOffers = async () => {
try {
const response = await fetch("/api/candidate/offers", {
headers: { Authorization: `Bearer ${session?.access_token}` },
});
if (response.ok) {
const data = await response.json();
setOffers(data.offers || []);
setGrouped(
data.grouped || {
pending: [],
accepted: [],
declined: [],
expired: [],
withdrawn: [],
},
);
}
} catch (error) {
console.error("Error fetching offers:", error);
aethexToast.error("Failed to load offers");
} finally {
setLoading(false);
}
};
const respondToOffer = async () => {
if (!respondingTo || !responseAction || !session?.access_token) return;
setSubmitting(true);
try {
const response = await fetch("/api/candidate/offers", {
method: "PATCH",
headers: {
Authorization: `Bearer ${session.access_token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
id: respondingTo.id,
status: responseAction === "accept" ? "accepted" : "declined",
notes: responseNotes,
}),
});
if (!response.ok) throw new Error("Failed to respond to offer");
aethexToast.success(
responseAction === "accept"
? "Congratulations! You've accepted the offer."
: "Offer declined",
);
// Refresh offers
await fetchOffers();
// Close dialog
setRespondingTo(null);
setResponseAction(null);
setResponseNotes("");
} catch (error) {
console.error("Error responding to offer:", error);
aethexToast.error("Failed to respond to offer");
} finally {
setSubmitting(false);
}
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
});
};
const formatSalary = (amount: number | null, type: string | null) => {
if (!amount) return "Not specified";
const formatted = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format(amount);
const suffix =
type === "hourly" ? "/hr" : type === "monthly" ? "/mo" : "/yr";
return formatted + suffix;
};
const getStatusBadge = (status: string) => {
switch (status) {
case "pending":
return (
<Badge className="bg-yellow-500/20 text-yellow-300 border-yellow-500/30">
<Clock className="h-3 w-3 mr-1" />
Pending
</Badge>
);
case "accepted":
return (
<Badge className="bg-green-500/20 text-green-300 border-green-500/30">
<CheckCircle2 className="h-3 w-3 mr-1" />
Accepted
</Badge>
);
case "declined":
return (
<Badge className="bg-red-500/20 text-red-300 border-red-500/30">
<XCircle className="h-3 w-3 mr-1" />
Declined
</Badge>
);
case "expired":
return (
<Badge className="bg-gray-500/20 text-gray-300 border-gray-500/30">
Expired
</Badge>
);
case "withdrawn":
return (
<Badge className="bg-slate-500/20 text-slate-300 border-slate-500/30">
Withdrawn
</Badge>
);
default:
return <Badge>{status}</Badge>;
}
};
const getDaysUntilExpiry = (expiry: string | null) => {
if (!expiry) return null;
const diff = new Date(expiry).getTime() - new Date().getTime();
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
return days;
};
const getFilteredOffers = () => {
switch (filter) {
case "pending":
return grouped.pending;
case "accepted":
return grouped.accepted;
case "declined":
return grouped.declined;
case "expired":
return grouped.expired;
default:
return offers;
}
};
if (loading) {
return (
<Layout>
<SEO title="Job Offers" description="Review and respond to job offers" />
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-violet-400" />
</div>
</Layout>
);
}
const filteredOffers = getFilteredOffers();
return (
<Layout>
<SEO title="Job Offers" description="Review and respond to job offers" />
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
{/* Background effects */}
<div className="absolute inset-0 opacity-30">
<div className="absolute top-20 left-10 w-96 h-96 bg-violet-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
<div className="absolute bottom-20 right-10 w-96 h-96 bg-green-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
</div>
<div className="relative z-10">
<div className="container mx-auto max-w-4xl px-4 py-16">
{/* Header */}
<div className="mb-8">
<Link href="/candidate">
<Button
variant="ghost"
size="sm"
className="text-violet-300 hover:text-violet-200 hover:bg-violet-500/10 mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Dashboard
</Button>
</Link>
<div className="flex items-center gap-3 mb-6">
<div className="p-3 rounded-lg bg-green-500/20 border border-green-500/30">
<Gift className="h-6 w-6 text-green-400" />
</div>
<div>
<h1 className="text-3xl font-bold text-violet-100">
Job Offers
</h1>
<p className="text-violet-200/70">
Review and respond to offers
</p>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4 mb-6">
<Card className="bg-slate-800/50 border-slate-700/50">
<CardContent className="pt-6 text-center">
<p className="text-2xl font-bold text-yellow-400">
{grouped.pending.length}
</p>
<p className="text-sm text-slate-400">Pending</p>
</CardContent>
</Card>
<Card className="bg-slate-800/50 border-slate-700/50">
<CardContent className="pt-6 text-center">
<p className="text-2xl font-bold text-green-400">
{grouped.accepted.length}
</p>
<p className="text-sm text-slate-400">Accepted</p>
</CardContent>
</Card>
<Card className="bg-slate-800/50 border-slate-700/50">
<CardContent className="pt-6 text-center">
<p className="text-2xl font-bold text-red-400">
{grouped.declined.length}
</p>
<p className="text-sm text-slate-400">Declined</p>
</CardContent>
</Card>
<Card className="bg-slate-800/50 border-slate-700/50">
<CardContent className="pt-6 text-center">
<p className="text-2xl font-bold text-slate-400">
{offers.length}
</p>
<p className="text-sm text-slate-400">Total</p>
</CardContent>
</Card>
</div>
{/* Filter */}
<Select value={filter} onValueChange={setFilter}>
<SelectTrigger className="w-48 bg-slate-800/50 border-slate-700 text-slate-100">
<SelectValue placeholder="Filter offers" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Offers</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="accepted">Accepted</SelectItem>
<SelectItem value="declined">Declined</SelectItem>
<SelectItem value="expired">Expired</SelectItem>
</SelectContent>
</Select>
</div>
{/* Offers List */}
<div className="space-y-4">
{filteredOffers.length === 0 ? (
<Card className="bg-slate-800/50 border-slate-700/50">
<CardContent className="pt-12 pb-12 text-center">
<Gift className="h-12 w-12 text-slate-600 mx-auto mb-4" />
<p className="text-slate-400 text-lg mb-2">
No offers found
</p>
<p className="text-slate-500 text-sm">
{filter === "all"
? "You don't have any job offers yet"
: `No ${filter} offers`}
</p>
</CardContent>
</Card>
) : (
filteredOffers.map((offer) => {
const daysUntilExpiry = getDaysUntilExpiry(offer.offer_expiry);
const isExpiringSoon =
daysUntilExpiry !== null &&
daysUntilExpiry > 0 &&
daysUntilExpiry <= 3;
return (
<Card
key={offer.id}
className={`bg-slate-800/50 border-slate-700/50 hover:border-violet-500/30 transition-all ${
offer.status === "pending" && isExpiringSoon
? "border-yellow-500/50"
: ""
}`}
>
<CardContent className="pt-6">
<div className="flex flex-col gap-4">
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold text-violet-100">
{offer.position_title}
</h3>
<div className="flex items-center gap-2 text-slate-400 mt-1">
<Building className="h-4 w-4" />
{offer.company_name}
</div>
</div>
{getStatusBadge(offer.status)}
</div>
{/* Expiry Warning */}
{offer.status === "pending" && isExpiringSoon && (
<div className="flex items-center gap-2 p-2 rounded bg-yellow-500/10 border border-yellow-500/20 text-yellow-300">
<AlertTriangle className="h-4 w-4" />
<span className="text-sm">
Expires in {daysUntilExpiry} day
{daysUntilExpiry !== 1 ? "s" : ""}
</span>
</div>
)}
{/* Details */}
<div className="grid md:grid-cols-3 gap-4">
<div className="flex items-center gap-2 text-slate-300">
<DollarSign className="h-4 w-4 text-green-400" />
<span>
{formatSalary(
offer.salary_amount,
offer.salary_type,
)}
</span>
</div>
{offer.start_date && (
<div className="flex items-center gap-2 text-slate-300">
<Calendar className="h-4 w-4 text-blue-400" />
<span>Start: {formatDate(offer.start_date)}</span>
</div>
)}
{offer.offer_expiry && (
<div className="flex items-center gap-2 text-slate-300">
<Clock className="h-4 w-4 text-yellow-400" />
<span>
Expires: {formatDate(offer.offer_expiry)}
</span>
</div>
)}
</div>
{/* Benefits */}
{offer.benefits && offer.benefits.length > 0 && (
<div className="flex flex-wrap gap-2">
{offer.benefits.map((benefit: string, i: number) => (
<Badge
key={i}
variant="outline"
className="text-slate-300 border-slate-600"
>
{benefit}
</Badge>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-3 pt-2 border-t border-slate-700/50">
{offer.status === "pending" && (
<>
<Button
onClick={() => {
setRespondingTo(offer);
setResponseAction("accept");
}}
className="bg-green-600 hover:bg-green-700"
>
<CheckCircle2 className="h-4 w-4 mr-2" />
Accept Offer
</Button>
<Button
onClick={() => {
setRespondingTo(offer);
setResponseAction("decline");
}}
variant="outline"
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
>
<XCircle className="h-4 w-4 mr-2" />
Decline
</Button>
</>
)}
{offer.offer_letter_url && (
<a
href={offer.offer_letter_url}
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
className="border-violet-500/30 text-violet-300"
>
<ExternalLink className="h-4 w-4 mr-2" />
View Offer Letter
</Button>
</a>
)}
</div>
</div>
</CardContent>
</Card>
);
})
)}
</div>
</div>
</div>
</div>
{/* Response Dialog */}
<Dialog
open={!!respondingTo}
onOpenChange={() => {
setRespondingTo(null);
setResponseAction(null);
setResponseNotes("");
}}
>
<DialogContent className="bg-slate-800 border-slate-700">
<DialogHeader>
<DialogTitle className="text-violet-100">
{responseAction === "accept" ? "Accept Offer" : "Decline Offer"}
</DialogTitle>
<DialogDescription className="text-slate-400">
{responseAction === "accept"
? "Congratulations! Please confirm you want to accept this offer."
: "Are you sure you want to decline this offer? This action cannot be undone."}
</DialogDescription>
</DialogHeader>
{respondingTo && (
<div className="py-4">
<div className="p-4 rounded-lg bg-slate-700/30 border border-slate-600/30 mb-4">
<p className="font-medium text-violet-100">
{respondingTo.position_title}
</p>
<p className="text-slate-400">{respondingTo.company_name}</p>
<p className="text-green-400 mt-2">
{formatSalary(
respondingTo.salary_amount,
respondingTo.salary_type,
)}
</p>
</div>
<div className="space-y-2">
<label className="text-sm text-violet-200">
Notes (optional)
</label>
<Textarea
value={responseNotes}
onChange={(e) => setResponseNotes(e.target.value)}
placeholder={
responseAction === "accept"
? "Thank you for this opportunity..."
: "Reason for declining (optional)..."
}
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setRespondingTo(null);
setResponseAction(null);
setResponseNotes("");
}}
className="border-slate-600 text-slate-300"
>
Cancel
</Button>
<Button
onClick={respondToOffer}
disabled={submitting}
className={
responseAction === "accept"
? "bg-green-600 hover:bg-green-700"
: "bg-red-600 hover:bg-red-700"
}
>
{submitting ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : responseAction === "accept" ? (
<CheckCircle2 className="h-4 w-4 mr-2" />
) : (
<XCircle className="h-4 w-4 mr-2" />
)}
{responseAction === "accept" ? "Accept Offer" : "Decline Offer"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Layout>
);
}

View file

@ -1,620 +0,0 @@
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Briefcase,
FileText,
Calendar,
Star,
ArrowRight,
User,
Clock,
CheckCircle2,
XCircle,
Eye,
Loader2,
Send,
Gift,
TrendingUp,
} from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { aethexToast } from "@/lib/aethex-toast";
interface ProfileData {
profile: {
headline: string;
bio: string;
skills: string[];
profile_completeness: number;
availability: string;
} | null;
user: {
full_name: string;
avatar_url: string;
email: string;
} | null;
stats: {
total_applications: number;
pending: number;
reviewed: number;
accepted: number;
rejected: number;
};
}
interface Interview {
id: string;
scheduled_at: string;
duration_minutes: number;
meeting_type: string;
status: string;
employer: {
full_name: string;
avatar_url: string;
};
}
interface Offer {
id: string;
position_title: string;
company_name: string;
salary_amount: number;
salary_type: string;
offer_expiry: string;
status: string;
}
export default function CandidatePortal() {
const { session, user } = useAuth();
const [loading, setLoading] = useState(true);
const [profileData, setProfileData] = useState<ProfileData | null>(null);
const [upcomingInterviews, setUpcomingInterviews] = useState<Interview[]>([]);
const [pendingOffers, setPendingOffers] = useState<Offer[]>([]);
useEffect(() => {
if (session?.access_token) {
fetchData();
}
}, [session?.access_token]);
const fetchData = async () => {
try {
const [profileRes, interviewsRes, offersRes] = await Promise.all([
fetch("/api/candidate/profile", {
headers: { Authorization: `Bearer ${session?.access_token}` },
}),
fetch("/api/candidate/interviews?upcoming=true", {
headers: { Authorization: `Bearer ${session?.access_token}` },
}),
fetch("/api/candidate/offers", {
headers: { Authorization: `Bearer ${session?.access_token}` },
}),
]);
if (profileRes.ok) {
const data = await profileRes.json();
setProfileData(data);
}
if (interviewsRes.ok) {
const data = await interviewsRes.json();
setUpcomingInterviews(data.grouped?.upcoming || []);
}
if (offersRes.ok) {
const data = await offersRes.json();
setPendingOffers(data.grouped?.pending || []);
}
} catch (error) {
console.error("Error fetching data:", error);
} finally {
setLoading(false);
}
};
const getAvailabilityLabel = (availability: string) => {
const labels: Record<string, string> = {
immediate: "Available Immediately",
"2_weeks": "Available in 2 Weeks",
"1_month": "Available in 1 Month",
"3_months": "Available in 3 Months",
not_looking: "Not Currently Looking",
};
return labels[availability] || availability;
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase();
};
if (loading) {
return (
<Layout>
<SEO
title="Candidate Portal"
description="Manage your job applications and career"
/>
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-violet-400" />
</div>
</Layout>
);
}
const stats = profileData?.stats || {
total_applications: 0,
pending: 0,
reviewed: 0,
accepted: 0,
rejected: 0,
};
return (
<Layout>
<SEO
title="Candidate Portal"
description="Manage your job applications and career"
/>
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
{/* Background effects */}
<div className="absolute inset-0 opacity-30">
<div className="absolute top-20 left-10 w-96 h-96 bg-violet-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
<div className="absolute bottom-20 right-10 w-96 h-96 bg-purple-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
</div>
<div className="relative z-10">
<div className="container mx-auto max-w-6xl px-4 py-16">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-8">
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16 border-2 border-violet-500/30">
<AvatarImage src={profileData?.user?.avatar_url || ""} />
<AvatarFallback className="bg-violet-500/20 text-violet-300 text-lg">
{profileData?.user?.full_name
? getInitials(profileData.user.full_name)
: "U"}
</AvatarFallback>
</Avatar>
<div>
<h1 className="text-3xl font-bold text-violet-100">
Welcome back
{profileData?.user?.full_name
? `, ${profileData.user.full_name.split(" ")[0]}`
: ""}
!
</h1>
<p className="text-violet-200/70">
{profileData?.profile?.headline || "Your career dashboard"}
</p>
</div>
</div>
<div className="flex gap-3">
<Link href="/opportunities">
<Button className="bg-violet-600 hover:bg-violet-700">
<Briefcase className="h-4 w-4 mr-2" />
Browse Opportunities
</Button>
</Link>
<Link href="/candidate/profile">
<Button
variant="outline"
className="border-violet-500/30 text-violet-300 hover:bg-violet-500/10"
>
<User className="h-4 w-4 mr-2" />
Edit Profile
</Button>
</Link>
</div>
</div>
{/* Profile Completeness Alert */}
{profileData?.profile?.profile_completeness !== undefined &&
profileData.profile.profile_completeness < 80 && (
<Card className="bg-violet-500/10 border-violet-500/30 mb-8">
<CardContent className="pt-6">
<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<p className="text-violet-100 font-medium mb-2">
Complete your profile to stand out
</p>
<Progress
value={profileData.profile.profile_completeness}
className="h-2"
/>
<p className="text-sm text-violet-200/70 mt-1">
{profileData.profile.profile_completeness}% complete
</p>
</div>
<Link href="/candidate/profile">
<Button
size="sm"
className="bg-violet-600 hover:bg-violet-700"
>
Complete Profile
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
</Link>
</div>
</CardContent>
</Card>
)}
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
<Card className="bg-slate-800/50 border-slate-700/50">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded bg-violet-500/20 text-violet-400">
<Send className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-violet-100">
{stats.total_applications}
</p>
<p className="text-xs text-slate-400">Applications</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800/50 border-slate-700/50">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded bg-yellow-500/20 text-yellow-400">
<Clock className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-violet-100">
{stats.pending}
</p>
<p className="text-xs text-slate-400">Pending</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800/50 border-slate-700/50">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded bg-blue-500/20 text-blue-400">
<Eye className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-violet-100">
{stats.reviewed}
</p>
<p className="text-xs text-slate-400">In Review</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800/50 border-slate-700/50">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded bg-green-500/20 text-green-400">
<CheckCircle2 className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-violet-100">
{stats.accepted}
</p>
<p className="text-xs text-slate-400">Accepted</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800/50 border-slate-700/50">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded bg-red-500/20 text-red-400">
<XCircle className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-violet-100">
{stats.rejected}
</p>
<p className="text-xs text-slate-400">Rejected</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Main Content Grid */}
<div className="grid lg:grid-cols-3 gap-6">
{/* Quick Actions & Upcoming */}
<div className="lg:col-span-2 space-y-6">
{/* Quick Actions */}
<div className="grid md:grid-cols-2 gap-4">
<Link href="/candidate/applications">
<Card className="bg-slate-800/50 border-slate-700/50 hover:border-violet-500/50 transition-all cursor-pointer h-full">
<CardContent className="pt-6">
<div className="p-2 rounded bg-violet-500/20 text-violet-400 w-fit mb-3">
<FileText className="h-5 w-5" />
</div>
<h3 className="font-semibold text-violet-100 mb-1">
My Applications
</h3>
<p className="text-sm text-slate-400">
Track all your job applications
</p>
</CardContent>
</Card>
</Link>
<Link href="/candidate/interviews">
<Card className="bg-slate-800/50 border-slate-700/50 hover:border-violet-500/50 transition-all cursor-pointer h-full">
<CardContent className="pt-6">
<div className="p-2 rounded bg-blue-500/20 text-blue-400 w-fit mb-3">
<Calendar className="h-5 w-5" />
</div>
<h3 className="font-semibold text-violet-100 mb-1">
Interviews
</h3>
<p className="text-sm text-slate-400">
View and manage scheduled interviews
</p>
</CardContent>
</Card>
</Link>
<Link href="/candidate/offers">
<Card className="bg-slate-800/50 border-slate-700/50 hover:border-violet-500/50 transition-all cursor-pointer h-full">
<CardContent className="pt-6">
<div className="p-2 rounded bg-green-500/20 text-green-400 w-fit mb-3">
<Gift className="h-5 w-5" />
</div>
<h3 className="font-semibold text-violet-100 mb-1">
Offers
</h3>
<p className="text-sm text-slate-400">
Review and respond to job offers
</p>
</CardContent>
</Card>
</Link>
<Link href="/opportunities">
<Card className="bg-slate-800/50 border-slate-700/50 hover:border-violet-500/50 transition-all cursor-pointer h-full">
<CardContent className="pt-6">
<div className="p-2 rounded bg-orange-500/20 text-orange-400 w-fit mb-3">
<TrendingUp className="h-5 w-5" />
</div>
<h3 className="font-semibold text-violet-100 mb-1">
Browse Jobs
</h3>
<p className="text-sm text-slate-400">
Find new opportunities
</p>
</CardContent>
</Card>
</Link>
</div>
{/* Upcoming Interviews */}
<Card className="bg-slate-800/50 border-slate-700/50">
<CardHeader>
<CardTitle className="text-violet-100 flex items-center gap-2">
<Calendar className="h-5 w-5 text-violet-400" />
Upcoming Interviews
</CardTitle>
<CardDescription className="text-slate-400">
Your scheduled interviews
</CardDescription>
</CardHeader>
<CardContent>
{upcomingInterviews.length === 0 ? (
<p className="text-slate-400 text-center py-8">
No upcoming interviews scheduled
</p>
) : (
<div className="space-y-3">
{upcomingInterviews.slice(0, 3).map((interview) => (
<div
key={interview.id}
className="flex items-center justify-between p-3 rounded-lg bg-slate-700/30 border border-slate-600/30"
>
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarImage
src={interview.employer?.avatar_url || ""}
/>
<AvatarFallback className="bg-violet-500/20 text-violet-300">
{interview.employer?.full_name
? getInitials(interview.employer.full_name)
: "E"}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium text-violet-100">
Interview with{" "}
{interview.employer?.full_name || "Employer"}
</p>
<p className="text-sm text-slate-400">
{formatDate(interview.scheduled_at)} -{" "}
{interview.duration_minutes} min
</p>
</div>
</div>
<Badge
className={
interview.meeting_type === "video"
? "bg-blue-500/20 text-blue-300 border-blue-500/30"
: "bg-slate-700 text-slate-300"
}
>
{interview.meeting_type}
</Badge>
</div>
))}
</div>
)}
{upcomingInterviews.length > 0 && (
<Link href="/candidate/interviews">
<Button
variant="ghost"
className="w-full mt-4 text-violet-300 hover:text-violet-200 hover:bg-violet-500/10"
>
View All Interviews
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
</Link>
)}
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Pending Offers */}
{pendingOffers.length > 0 && (
<Card className="bg-gradient-to-br from-green-500/10 to-emerald-500/10 border-green-500/30">
<CardHeader className="pb-3">
<CardTitle className="text-green-100 text-lg flex items-center gap-2">
<Gift className="h-5 w-5 text-green-400" />
Pending Offers
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{pendingOffers.slice(0, 2).map((offer) => (
<div
key={offer.id}
className="p-3 rounded-lg bg-slate-800/50 border border-green-500/20"
>
<p className="font-medium text-green-100">
{offer.position_title}
</p>
<p className="text-sm text-slate-400">
{offer.company_name}
</p>
{offer.offer_expiry && (
<p className="text-xs text-yellow-400 mt-1">
Expires {new Date(offer.offer_expiry).toLocaleDateString()}
</p>
)}
</div>
))}
<Link href="/candidate/offers">
<Button
size="sm"
className="w-full bg-green-600 hover:bg-green-700"
>
Review Offers
</Button>
</Link>
</CardContent>
</Card>
)}
{/* Profile Summary */}
<Card className="bg-slate-800/50 border-slate-700/50">
<CardHeader className="pb-3">
<CardTitle className="text-violet-100 text-lg flex items-center gap-2">
<User className="h-5 w-5 text-violet-400" />
Your Profile
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-sm text-slate-400 mb-1">Completeness</p>
<Progress
value={profileData?.profile?.profile_completeness || 0}
className="h-2"
/>
<p className="text-xs text-slate-500 mt-1">
{profileData?.profile?.profile_completeness || 0}%
</p>
</div>
{profileData?.profile?.availability && (
<div>
<p className="text-sm text-slate-400">Availability</p>
<Badge className="mt-1 bg-violet-500/20 text-violet-300 border-violet-500/30">
{getAvailabilityLabel(profileData.profile.availability)}
</Badge>
</div>
)}
{profileData?.profile?.skills &&
profileData.profile.skills.length > 0 && (
<div>
<p className="text-sm text-slate-400 mb-2">Skills</p>
<div className="flex flex-wrap gap-1">
{profileData.profile.skills.slice(0, 5).map((skill) => (
<Badge
key={skill}
variant="outline"
className="text-xs border-slate-600 text-slate-300"
>
{skill}
</Badge>
))}
{profileData.profile.skills.length > 5 && (
<Badge
variant="outline"
className="text-xs border-slate-600 text-slate-400"
>
+{profileData.profile.skills.length - 5}
</Badge>
)}
</div>
</div>
)}
<Link href="/candidate/profile">
<Button
variant="outline"
size="sm"
className="w-full border-violet-500/30 text-violet-300 hover:bg-violet-500/10"
>
Edit Profile
</Button>
</Link>
</CardContent>
</Card>
{/* Tips Card */}
<Card className="bg-slate-800/30 border-slate-700/30">
<CardContent className="pt-6">
<div className="flex items-start gap-3">
<div className="p-2 rounded bg-yellow-500/20 text-yellow-400">
<Star className="h-5 w-5" />
</div>
<div>
<h3 className="font-medium text-violet-100 mb-1">
Pro Tip
</h3>
<p className="text-sm text-slate-400">
Candidates with complete profiles get 3x more
interview invitations. Make sure to add your skills
and work history!
</p>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
</div>
</Layout>
);
}

View file

@ -1,981 +0,0 @@
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Switch } from "@/components/ui/switch";
import {
User,
Briefcase,
GraduationCap,
Link as LinkIcon,
FileText,
ArrowLeft,
Plus,
Trash2,
Loader2,
Save,
CheckCircle2,
} from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { aethexToast } from "@/lib/aethex-toast";
interface WorkHistory {
company: string;
position: string;
start_date: string;
end_date: string;
current: boolean;
description: string;
}
interface Education {
institution: string;
degree: string;
field: string;
start_year: number;
end_year: number;
current: boolean;
}
interface ProfileData {
headline: string;
bio: string;
resume_url: string;
portfolio_urls: string[];
work_history: WorkHistory[];
education: Education[];
skills: string[];
availability: string;
desired_rate: number;
rate_type: string;
location: string;
remote_preference: string;
is_public: boolean;
profile_completeness: number;
}
const DEFAULT_PROFILE: ProfileData = {
headline: "",
bio: "",
resume_url: "",
portfolio_urls: [],
work_history: [],
education: [],
skills: [],
availability: "",
desired_rate: 0,
rate_type: "hourly",
location: "",
remote_preference: "",
is_public: false,
profile_completeness: 0,
};
export default function CandidateProfile() {
const { session } = useAuth();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [profile, setProfile] = useState<ProfileData>(DEFAULT_PROFILE);
const [newSkill, setNewSkill] = useState("");
const [newPortfolio, setNewPortfolio] = useState("");
useEffect(() => {
if (session?.access_token) {
fetchProfile();
}
}, [session?.access_token]);
const fetchProfile = async () => {
try {
const response = await fetch("/api/candidate/profile", {
headers: { Authorization: `Bearer ${session?.access_token}` },
});
if (response.ok) {
const data = await response.json();
if (data.profile) {
setProfile({
...DEFAULT_PROFILE,
...data.profile,
portfolio_urls: Array.isArray(data.profile.portfolio_urls)
? data.profile.portfolio_urls
: [],
work_history: Array.isArray(data.profile.work_history)
? data.profile.work_history
: [],
education: Array.isArray(data.profile.education)
? data.profile.education
: [],
skills: Array.isArray(data.profile.skills)
? data.profile.skills
: [],
});
}
}
} catch (error) {
console.error("Error fetching profile:", error);
} finally {
setLoading(false);
}
};
const saveProfile = async () => {
if (!session?.access_token) return;
setSaving(true);
try {
const response = await fetch("/api/candidate/profile", {
method: "POST",
headers: {
Authorization: `Bearer ${session.access_token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(profile),
});
if (!response.ok) throw new Error("Failed to save profile");
const data = await response.json();
setProfile((prev) => ({
...prev,
profile_completeness: data.profile.profile_completeness,
}));
aethexToast.success("Profile saved successfully!");
} catch (error) {
console.error("Error saving profile:", error);
aethexToast.error("Failed to save profile");
} finally {
setSaving(false);
}
};
const addSkill = () => {
if (newSkill.trim() && !profile.skills.includes(newSkill.trim())) {
setProfile((prev) => ({
...prev,
skills: [...prev.skills, newSkill.trim()],
}));
setNewSkill("");
}
};
const removeSkill = (skill: string) => {
setProfile((prev) => ({
...prev,
skills: prev.skills.filter((s) => s !== skill),
}));
};
const addPortfolio = () => {
if (newPortfolio.trim() && !profile.portfolio_urls.includes(newPortfolio.trim())) {
setProfile((prev) => ({
...prev,
portfolio_urls: [...prev.portfolio_urls, newPortfolio.trim()],
}));
setNewPortfolio("");
}
};
const removePortfolio = (url: string) => {
setProfile((prev) => ({
...prev,
portfolio_urls: prev.portfolio_urls.filter((u) => u !== url),
}));
};
const addWorkHistory = () => {
setProfile((prev) => ({
...prev,
work_history: [
...prev.work_history,
{
company: "",
position: "",
start_date: "",
end_date: "",
current: false,
description: "",
},
],
}));
};
const updateWorkHistory = (index: number, field: string, value: any) => {
setProfile((prev) => ({
...prev,
work_history: prev.work_history.map((item, i) =>
i === index ? { ...item, [field]: value } : item,
),
}));
};
const removeWorkHistory = (index: number) => {
setProfile((prev) => ({
...prev,
work_history: prev.work_history.filter((_, i) => i !== index),
}));
};
const addEducation = () => {
setProfile((prev) => ({
...prev,
education: [
...prev.education,
{
institution: "",
degree: "",
field: "",
start_year: new Date().getFullYear(),
end_year: new Date().getFullYear(),
current: false,
},
],
}));
};
const updateEducation = (index: number, field: string, value: any) => {
setProfile((prev) => ({
...prev,
education: prev.education.map((item, i) =>
i === index ? { ...item, [field]: value } : item,
),
}));
};
const removeEducation = (index: number) => {
setProfile((prev) => ({
...prev,
education: prev.education.filter((_, i) => i !== index),
}));
};
if (loading) {
return (
<Layout>
<SEO title="Edit Profile" description="Build your candidate profile" />
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-violet-400" />
</div>
</Layout>
);
}
return (
<Layout>
<SEO title="Edit Profile" description="Build your candidate profile" />
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
{/* Background effects */}
<div className="absolute inset-0 opacity-30">
<div className="absolute top-20 left-10 w-96 h-96 bg-violet-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
<div className="absolute bottom-20 right-10 w-96 h-96 bg-purple-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
</div>
<div className="relative z-10">
<div className="container mx-auto max-w-4xl px-4 py-16">
{/* Header */}
<div className="mb-8">
<Link href="/candidate">
<Button
variant="ghost"
size="sm"
className="text-violet-300 hover:text-violet-200 hover:bg-violet-500/10 mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Dashboard
</Button>
</Link>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-3 rounded-lg bg-violet-500/20 border border-violet-500/30">
<User className="h-6 w-6 text-violet-400" />
</div>
<div>
<h1 className="text-3xl font-bold text-violet-100">
Edit Profile
</h1>
<p className="text-violet-200/70">
Build your candidate profile to stand out
</p>
</div>
</div>
<Button
onClick={saveProfile}
disabled={saving}
className="bg-violet-600 hover:bg-violet-700"
>
{saving ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Save className="h-4 w-4 mr-2" />
)}
Save Changes
</Button>
</div>
{/* Profile Completeness */}
<Card className="mt-6 bg-slate-800/50 border-violet-500/30">
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-2">
<span className="text-violet-100 font-medium">
Profile Completeness
</span>
<span className="text-violet-300 font-bold">
{profile.profile_completeness}%
</span>
</div>
<Progress value={profile.profile_completeness} className="h-2" />
{profile.profile_completeness === 100 && (
<div className="flex items-center gap-2 mt-2 text-green-400">
<CheckCircle2 className="h-4 w-4" />
<span className="text-sm">Profile complete!</span>
</div>
)}
</CardContent>
</Card>
</div>
{/* Tabs */}
<Tabs defaultValue="basic" className="space-y-6">
<TabsList className="w-full bg-slate-800/50 border border-slate-700/50 p-1">
<TabsTrigger
value="basic"
className="flex-1 data-[state=active]:bg-violet-600"
>
<User className="h-4 w-4 mr-2" />
Basic Info
</TabsTrigger>
<TabsTrigger
value="experience"
className="flex-1 data-[state=active]:bg-violet-600"
>
<Briefcase className="h-4 w-4 mr-2" />
Experience
</TabsTrigger>
<TabsTrigger
value="education"
className="flex-1 data-[state=active]:bg-violet-600"
>
<GraduationCap className="h-4 w-4 mr-2" />
Education
</TabsTrigger>
<TabsTrigger
value="links"
className="flex-1 data-[state=active]:bg-violet-600"
>
<LinkIcon className="h-4 w-4 mr-2" />
Links
</TabsTrigger>
</TabsList>
{/* Basic Info Tab */}
<TabsContent value="basic">
<Card className="bg-slate-800/50 border-slate-700/50">
<CardHeader>
<CardTitle className="text-violet-100">
Basic Information
</CardTitle>
<CardDescription className="text-slate-400">
Your headline and summary
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label className="text-violet-200">Headline</Label>
<Input
value={profile.headline}
onChange={(e) =>
setProfile((prev) => ({
...prev,
headline: e.target.value,
}))
}
placeholder="e.g., Senior Full Stack Developer | React & Node.js"
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
<div className="space-y-2">
<Label className="text-violet-200">Bio</Label>
<Textarea
value={profile.bio}
onChange={(e) =>
setProfile((prev) => ({ ...prev, bio: e.target.value }))
}
placeholder="Tell employers about yourself..."
rows={4}
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-violet-200">Location</Label>
<Input
value={profile.location}
onChange={(e) =>
setProfile((prev) => ({
...prev,
location: e.target.value,
}))
}
placeholder="e.g., San Francisco, CA"
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
<div className="space-y-2">
<Label className="text-violet-200">
Remote Preference
</Label>
<Select
value={profile.remote_preference}
onValueChange={(value) =>
setProfile((prev) => ({
...prev,
remote_preference: value,
}))
}
>
<SelectTrigger className="bg-slate-700/50 border-slate-600 text-slate-100">
<SelectValue placeholder="Select preference" />
</SelectTrigger>
<SelectContent>
<SelectItem value="remote_only">
Remote Only
</SelectItem>
<SelectItem value="hybrid">Hybrid</SelectItem>
<SelectItem value="on_site">On-Site</SelectItem>
<SelectItem value="flexible">Flexible</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-violet-200">Availability</Label>
<Select
value={profile.availability}
onValueChange={(value) =>
setProfile((prev) => ({
...prev,
availability: value,
}))
}
>
<SelectTrigger className="bg-slate-700/50 border-slate-600 text-slate-100">
<SelectValue placeholder="Select availability" />
</SelectTrigger>
<SelectContent>
<SelectItem value="immediate">
Available Immediately
</SelectItem>
<SelectItem value="2_weeks">In 2 Weeks</SelectItem>
<SelectItem value="1_month">In 1 Month</SelectItem>
<SelectItem value="3_months">In 3 Months</SelectItem>
<SelectItem value="not_looking">
Not Currently Looking
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-violet-200">Desired Rate</Label>
<div className="flex gap-2">
<Input
type="number"
value={profile.desired_rate || ""}
onChange={(e) =>
setProfile((prev) => ({
...prev,
desired_rate: parseFloat(e.target.value) || 0,
}))
}
placeholder="0"
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
<Select
value={profile.rate_type}
onValueChange={(value) =>
setProfile((prev) => ({
...prev,
rate_type: value,
}))
}
>
<SelectTrigger className="w-32 bg-slate-700/50 border-slate-600 text-slate-100">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hourly">/hour</SelectItem>
<SelectItem value="monthly">/month</SelectItem>
<SelectItem value="yearly">/year</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Skills */}
<div className="space-y-2">
<Label className="text-violet-200">Skills</Label>
<div className="flex gap-2">
<Input
value={newSkill}
onChange={(e) => setNewSkill(e.target.value)}
placeholder="Add a skill..."
className="bg-slate-700/50 border-slate-600 text-slate-100"
onKeyDown={(e) => e.key === "Enter" && addSkill()}
/>
<Button
onClick={addSkill}
variant="outline"
className="border-violet-500/30 text-violet-300"
>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{profile.skills.map((skill) => (
<Badge
key={skill}
className="bg-violet-500/20 text-violet-300 border-violet-500/30"
>
{skill}
<button
onClick={() => removeSkill(skill)}
className="ml-2 hover:text-red-400"
>
&times;
</button>
</Badge>
))}
</div>
</div>
{/* Public Profile Toggle */}
<div className="flex items-center justify-between p-4 rounded-lg bg-slate-700/30 border border-slate-600/30">
<div>
<p className="font-medium text-violet-100">
Public Profile
</p>
<p className="text-sm text-slate-400">
Allow employers to discover your profile
</p>
</div>
<Switch
checked={profile.is_public}
onCheckedChange={(checked) =>
setProfile((prev) => ({ ...prev, is_public: checked }))
}
/>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Experience Tab */}
<TabsContent value="experience">
<Card className="bg-slate-800/50 border-slate-700/50">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-violet-100">
Work Experience
</CardTitle>
<CardDescription className="text-slate-400">
Your professional background
</CardDescription>
</div>
<Button
onClick={addWorkHistory}
variant="outline"
size="sm"
className="border-violet-500/30 text-violet-300"
>
<Plus className="h-4 w-4 mr-2" />
Add Experience
</Button>
</CardHeader>
<CardContent className="space-y-6">
{profile.work_history.length === 0 ? (
<p className="text-slate-400 text-center py-8">
No work experience added yet
</p>
) : (
profile.work_history.map((work, index) => (
<div
key={index}
className="p-4 rounded-lg bg-slate-700/30 border border-slate-600/30 space-y-4"
>
<div className="flex justify-between">
<h4 className="font-medium text-violet-100">
Position {index + 1}
</h4>
<Button
variant="ghost"
size="sm"
onClick={() => removeWorkHistory(index)}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-violet-200">Company</Label>
<Input
value={work.company}
onChange={(e) =>
updateWorkHistory(
index,
"company",
e.target.value,
)
}
placeholder="Company name"
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
<div className="space-y-2">
<Label className="text-violet-200">Position</Label>
<Input
value={work.position}
onChange={(e) =>
updateWorkHistory(
index,
"position",
e.target.value,
)
}
placeholder="Job title"
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-violet-200">
Start Date
</Label>
<Input
type="month"
value={work.start_date}
onChange={(e) =>
updateWorkHistory(
index,
"start_date",
e.target.value,
)
}
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
<div className="space-y-2">
<Label className="text-violet-200">End Date</Label>
<Input
type="month"
value={work.end_date}
onChange={(e) =>
updateWorkHistory(
index,
"end_date",
e.target.value,
)
}
disabled={work.current}
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={work.current}
onCheckedChange={(checked) =>
updateWorkHistory(index, "current", checked)
}
/>
<Label className="text-violet-200">
I currently work here
</Label>
</div>
<div className="space-y-2">
<Label className="text-violet-200">Description</Label>
<Textarea
value={work.description}
onChange={(e) =>
updateWorkHistory(
index,
"description",
e.target.value,
)
}
placeholder="Describe your responsibilities..."
rows={3}
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
</div>
))
)}
</CardContent>
</Card>
</TabsContent>
{/* Education Tab */}
<TabsContent value="education">
<Card className="bg-slate-800/50 border-slate-700/50">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-violet-100">Education</CardTitle>
<CardDescription className="text-slate-400">
Your academic background
</CardDescription>
</div>
<Button
onClick={addEducation}
variant="outline"
size="sm"
className="border-violet-500/30 text-violet-300"
>
<Plus className="h-4 w-4 mr-2" />
Add Education
</Button>
</CardHeader>
<CardContent className="space-y-6">
{profile.education.length === 0 ? (
<p className="text-slate-400 text-center py-8">
No education added yet
</p>
) : (
profile.education.map((edu, index) => (
<div
key={index}
className="p-4 rounded-lg bg-slate-700/30 border border-slate-600/30 space-y-4"
>
<div className="flex justify-between">
<h4 className="font-medium text-violet-100">
Education {index + 1}
</h4>
<Button
variant="ghost"
size="sm"
onClick={() => removeEducation(index)}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="space-y-2">
<Label className="text-violet-200">Institution</Label>
<Input
value={edu.institution}
onChange={(e) =>
updateEducation(
index,
"institution",
e.target.value,
)
}
placeholder="University or school name"
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-violet-200">Degree</Label>
<Input
value={edu.degree}
onChange={(e) =>
updateEducation(index, "degree", e.target.value)
}
placeholder="e.g., Bachelor's, Master's"
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
<div className="space-y-2">
<Label className="text-violet-200">
Field of Study
</Label>
<Input
value={edu.field}
onChange={(e) =>
updateEducation(index, "field", e.target.value)
}
placeholder="e.g., Computer Science"
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-violet-200">
Start Year
</Label>
<Input
type="number"
value={edu.start_year}
onChange={(e) =>
updateEducation(
index,
"start_year",
parseInt(e.target.value),
)
}
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
<div className="space-y-2">
<Label className="text-violet-200">End Year</Label>
<Input
type="number"
value={edu.end_year}
onChange={(e) =>
updateEducation(
index,
"end_year",
parseInt(e.target.value),
)
}
disabled={edu.current}
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={edu.current}
onCheckedChange={(checked) =>
updateEducation(index, "current", checked)
}
/>
<Label className="text-violet-200">
Currently studying
</Label>
</div>
</div>
))
)}
</CardContent>
</Card>
</TabsContent>
{/* Links Tab */}
<TabsContent value="links">
<Card className="bg-slate-800/50 border-slate-700/50">
<CardHeader>
<CardTitle className="text-violet-100">
Portfolio & Links
</CardTitle>
<CardDescription className="text-slate-400">
Your resume and portfolio links
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label className="text-violet-200">Resume URL</Label>
<Input
value={profile.resume_url}
onChange={(e) =>
setProfile((prev) => ({
...prev,
resume_url: e.target.value,
}))
}
placeholder="Link to your resume (Google Drive, Dropbox, etc.)"
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
<div className="space-y-2">
<Label className="text-violet-200">Portfolio Links</Label>
<div className="flex gap-2">
<Input
value={newPortfolio}
onChange={(e) => setNewPortfolio(e.target.value)}
placeholder="GitHub, Behance, personal website..."
className="bg-slate-700/50 border-slate-600 text-slate-100"
onKeyDown={(e) =>
e.key === "Enter" && addPortfolio()
}
/>
<Button
onClick={addPortfolio}
variant="outline"
className="border-violet-500/30 text-violet-300"
>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="space-y-2 mt-2">
{profile.portfolio_urls.map((url, index) => (
<div
key={index}
className="flex items-center justify-between p-3 rounded-lg bg-slate-700/30 border border-slate-600/30"
>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-violet-300 hover:text-violet-200 truncate flex-1"
>
{url}
</a>
<Button
variant="ghost"
size="sm"
onClick={() => removePortfolio(url)}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10 ml-2"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Save Button (Bottom) */}
<div className="mt-6 flex justify-end">
<Button
onClick={saveProfile}
disabled={saving}
className="bg-violet-600 hover:bg-violet-700"
>
{saving ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Save className="h-4 w-4 mr-2" />
)}
Save Changes
</Button>
</div>
</div>
</div>
</div>
</Layout>
);
}

View file

@ -137,7 +137,7 @@ export default function EthosGuild() {
<div className="pointer-events-none absolute top-40 right-20 w-96 h-96 bg-pink-500/20 rounded-full blur-3xl animate-blob" />
<div className="pointer-events-none absolute bottom-40 left-20 w-96 h-96 bg-cyan-500/15 rounded-full blur-3xl animate-blob animation-delay-2000" />
<div className="relative z-10 max-w-6xl mx-auto px-6 py-16 space-y-16">
<div className="relative z-10 max-w-7xl mx-auto px-6 py-16 space-y-16">
{/* Hero Section */}
<section className="space-y-6 text-center animate-slide-up">
<div className="space-y-3">

View file

@ -66,7 +66,7 @@ export default function MentorProfile() {
return (
<Layout>
<div className="container mx-auto max-w-6xl px-4 py-12">
<div className="container mx-auto max-w-7xl px-4 py-12">
<div className="mb-6">
<Badge variant="outline" className="mb-2">
Mentorship

View file

@ -352,7 +352,7 @@ export default function CreatorDirectory() {
</section>
<section className="py-12 lg:py-20">
<div className="container mx-auto max-w-6xl px-4">
<div className="container mx-auto max-w-7xl px-4">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
<div className="lg:col-span-1">
<div className="sticky top-24 space-y-4">

View file

@ -141,7 +141,7 @@ export default function FoundationDashboard() {
className={`min-h-screen bg-gradient-to-b from-black to-black py-8 ${theme.fontClass}`}
style={{ backgroundImage: theme.wallpaperPattern }}
>
<div className="container mx-auto px-4 max-w-6xl space-y-8">
<div className="container mx-auto px-4 max-w-7xl space-y-8">
{/* Header */}
<div className="space-y-4 animate-slide-down">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">

View file

@ -1,6 +1,6 @@
import { useState, useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import GameForgeLayout from "@/components/gameforge/GameForgeLayout";
import Layout from "@/components/Layout";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/contexts/AuthContext";
import { useArmTheme } from "@/contexts/ArmThemeContext";
@ -154,7 +154,7 @@ export default function GameForgeDashboard() {
if (!user) {
return (
<GameForgeLayout>
<Layout>
<div className="min-h-screen bg-gradient-to-b from-black via-green-950/30 to-black flex items-center justify-center px-4">
<div className="max-w-md text-center space-y-6">
<h1 className="text-4xl font-bold bg-gradient-to-r from-green-300 to-emerald-300 bg-clip-text text-transparent">
@ -169,17 +169,17 @@ export default function GameForgeDashboard() {
</Button>
</div>
</div>
</GameForgeLayout>
</Layout>
);
}
return (
<GameForgeLayout>
<Layout>
<div
className={`min-h-screen bg-gradient-to-b from-black to-black py-8 ${theme.fontClass}`}
style={{ backgroundImage: theme.wallpaperPattern }}
>
<div className="container mx-auto px-4 max-w-6xl space-y-8">
<div className="container mx-auto px-4 max-w-7xl space-y-8">
{sprint ? (
<>
{/* Active Sprint Header */}
@ -238,7 +238,7 @@ export default function GameForgeDashboard() {
className="w-full"
>
<TabsList
className="grid w-full grid-cols-2 sm:grid-cols-3 md:grid-cols-5 bg-green-950/30 border border-green-500/20 p-1"
className="grid w-full grid-cols-5 bg-green-950/30 border border-green-500/20 p-1"
style={{ fontFamily: theme.fontFamily }}
>
<TabsTrigger value="overview">Overview</TabsTrigger>
@ -505,6 +505,6 @@ export default function GameForgeDashboard() {
)}
</div>
</div>
</GameForgeLayout>
</Layout>
);
}

View file

@ -283,7 +283,7 @@ export default function LabsDashboard() {
className={`min-h-screen bg-gradient-to-b from-black to-black py-8 ${theme.fontClass}`}
style={{ backgroundImage: theme.wallpaperPattern }}
>
<div className="container mx-auto px-4 max-w-6xl space-y-8">
<div className="container mx-auto px-4 max-w-7xl space-y-8">
{/* Header */}
<div className="space-y-4 animate-slide-down">
<div className="flex items-center gap-3">
@ -306,7 +306,7 @@ export default function LabsDashboard() {
className="w-full"
>
<TabsList
className="grid w-full grid-cols-2 sm:grid-cols-4 bg-amber-950/30 border border-amber-500/20 p-1"
className="grid w-full grid-cols-4 bg-amber-950/30 border border-amber-500/20 p-1"
style={{ fontFamily: "Monaco, Courier New, monospace" }}
>
<TabsTrigger value="overview">Overview</TabsTrigger>

View file

@ -326,7 +326,7 @@ export default function NexusDashboard() {
return (
<Layout>
<div className="min-h-screen bg-gradient-to-b from-black via-purple-950/20 to-black py-8">
<div className="container mx-auto px-4 max-w-6xl space-y-8">
<div className="container mx-auto px-4 max-w-7xl space-y-8">
{/* Header with View Toggle */}
<div className="space-y-4 animate-slide-down">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
@ -421,7 +421,7 @@ export default function NexusDashboard() {
onValueChange={setActiveTab}
className="w-full"
>
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-4 bg-purple-950/30 border border-purple-500/20 p-1">
<TabsList className="grid w-full grid-cols-4 bg-purple-950/30 border border-purple-500/20 p-1">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="applications">Applications</TabsTrigger>
<TabsTrigger value="contracts">Contracts</TabsTrigger>
@ -876,7 +876,7 @@ export default function NexusDashboard() {
onValueChange={setActiveTab}
className="w-full"
>
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-4 bg-blue-950/30 border border-blue-500/20 p-1">
<TabsList className="grid w-full grid-cols-4 bg-blue-950/30 border border-blue-500/20 p-1">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="opportunities">Opportunities</TabsTrigger>
<TabsTrigger value="applicants">Applicants</TabsTrigger>

View file

@ -164,7 +164,7 @@ export default function StaffDashboard() {
className={`min-h-screen bg-gradient-to-b from-black to-black py-8 ${theme.fontClass}`}
style={{ backgroundImage: theme.wallpaperPattern }}
>
<div className="container mx-auto px-4 max-w-6xl space-y-8">
<div className="container mx-auto px-4 max-w-7xl space-y-8">
{/* Header */}
<div className="space-y-4 animate-slide-down">
<h1

View file

@ -1,4 +1,4 @@
import { DevPlatformLayout } from "@/components/dev-platform/layouts/DevPlatformLayout";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Breadcrumbs } from "@/components/dev-platform/Breadcrumbs";
import { ThreeColumnLayout } from "@/components/dev-platform/layouts/ThreeColumnLayout";
@ -67,7 +67,7 @@ export default function ApiReference() {
);
return (
<DevPlatformLayout>
<Layout>
<SEO pageTitle="API Reference" description="Complete documentation for the AeThex Developer API" />
<Breadcrumbs className="mb-6" />
<ThreeColumnLayout
@ -636,6 +636,6 @@ def make_request(url):
</section>
</div>
</ThreeColumnLayout>
</DevPlatformLayout>
</Layout>
);
}

View file

@ -1,5 +1,5 @@
import { useState } from "react";
import { DevPlatformLayout } from "@/components/dev-platform/layouts/DevPlatformLayout";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Breadcrumbs } from "@/components/dev-platform/Breadcrumbs";
import { ExampleCard } from "@/components/dev-platform/ExampleCard";
@ -165,9 +165,9 @@ export default function CodeExamples() {
});
return (
<DevPlatformLayout>
<Layout>
<SEO pageTitle="Code Examples Repository" description="Production-ready code examples for common use cases and integrations" />
<div className="max-w-6xl mx-auto space-y-8">
<div className="max-w-7xl mx-auto space-y-8">
{/* Hero Section */}
<div className="grid md:grid-cols-3 gap-4">
<Card className="p-6">
@ -274,6 +274,6 @@ export default function CodeExamples() {
<Button variant="outline">Submit Example</Button>
</div>
</div>
</DevPlatformLayout>
</Layout>
);
}

View file

@ -1,6 +1,6 @@
import React from "react";
import { Link } from "react-router-dom";
import { DevPlatformLayout } from "@/components/dev-platform/layouts/DevPlatformLayout";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Button } from "@/components/ui/button";
import { CodeBlock } from "@/components/dev-platform/ui/CodeBlock";
@ -57,12 +57,12 @@ await game.deploy(['roblox', 'fortnite', 'web']);`;
];
return (
<DevPlatformLayout>
<Layout>
<SEO pageTitle="AeThex Developer Platform" description="Build cross-platform games with AeThex. Ship to Roblox, Fortnite, Web, and Mobile from a single codebase." />
{/* Hero Section */}
<section className="container py-20 md:py-32">
<div className="mx-auto max-w-5xl text-center">
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl">
<h1 className="text-5xl font-bold tracking-tight sm:text-6xl lg:text-7xl">
Build Once.{" "}
<span className="text-primary">Deploy Everywhere.</span>
</h1>
@ -314,6 +314,6 @@ await game.deploy(['roblox', 'fortnite', 'web']);`;
</div>
</div>
</section>
</DevPlatformLayout>
</Layout>
);
}

View file

@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { DevPlatformLayout } from "@/components/dev-platform/layouts/DevPlatformLayout";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Breadcrumbs } from "@/components/dev-platform/Breadcrumbs";
import { StatCard } from "@/components/dev-platform/ui/StatCard";
@ -15,10 +15,10 @@ import {
Activity,
TrendingUp,
Clock,
AlertTriangle,
Loader2,
} from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { authFetch } from "@/lib/auth-fetch";
interface ApiKey {
id: string;
@ -58,14 +58,14 @@ export default function DeveloperDashboard() {
setIsLoading(true);
try {
// Load API keys
const keysRes = await authFetch("/api/developer/keys");
const keysRes = await fetch("/api/developer/keys");
if (keysRes.ok) {
const keysData = await keysRes.json();
setKeys(keysData.keys || []);
}
// Load developer profile
const profileRes = await authFetch("/api/developer/profile");
const profileRes = await fetch("/api/developer/profile");
if (profileRes.ok) {
const profileData = await profileRes.json();
setProfile(profileData.profile);
@ -102,7 +102,7 @@ export default function DeveloperDashboard() {
expiresInDays?: number;
}) => {
try {
const res = await authFetch("/api/developer/keys", {
const res = await fetch("/api/developer/keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
@ -133,7 +133,7 @@ export default function DeveloperDashboard() {
}
try {
const res = await authFetch(`/api/developer/keys/${id}`, {
const res = await fetch(`/api/developer/keys/${id}`, {
method: "DELETE",
});
@ -159,7 +159,7 @@ export default function DeveloperDashboard() {
const handleToggleActive = async (id: string, isActive: boolean) => {
try {
const res = await authFetch(`/api/developer/keys/${id}`, {
const res = await fetch(`/api/developer/keys/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ is_active: isActive }),
@ -196,12 +196,12 @@ export default function DeveloperDashboard() {
if (isLoading) {
return (
<DevPlatformLayout>
<Layout>
<SEO pageTitle="Developer Dashboard" description="Manage your API keys and monitor usage" />
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
</DevPlatformLayout>
</Layout>
);
}
@ -214,13 +214,13 @@ export default function DeveloperDashboard() {
});
return (
<DevPlatformLayout>
<Layout>
<SEO pageTitle="Developer Dashboard" description="Manage your API keys and monitor usage" />
<Breadcrumbs className="mb-6" />
<div className="space-y-8">
{/* Warning for expiring keys */}
{expiringSoon.length > 0 && (
<Callout type="warning">
<Callout variant="warning">
<p className="font-medium">
{expiringSoon.length} API key{expiringSoon.length > 1 ? "s" : ""} expiring
soon
@ -240,7 +240,7 @@ export default function DeveloperDashboard() {
icon={Activity}
trend={
stats?.totalRequests > 0
? { value: 12.5, isPositive: true }
? { value: 12.5, label: "vs last week" }
: undefined
}
/>
@ -252,13 +252,14 @@ export default function DeveloperDashboard() {
<StatCard
title="Recently Used"
value={stats?.recentlyUsed || 0}
description="Last 24 hours"
subtitle="Last 24 hours"
icon={Clock}
/>
<StatCard
title="Plan"
value={profile?.plan_tier || "free"}
icon={TrendingUp}
valueClassName="capitalize"
/>
</div>
@ -294,7 +295,7 @@ export default function DeveloperDashboard() {
</div>
{keys.length === 0 ? (
<Callout type="info">
<Callout variant="info">
<p className="font-medium">No API keys yet</p>
<p className="text-sm mt-1">
Create your first API key to start building with AeThex. You can
@ -325,7 +326,7 @@ export default function DeveloperDashboard() {
)}
{keys.length >= (profile?.max_api_keys || 3) && (
<Callout type="warning">
<Callout variant="warning">
<p className="font-medium">API Key Limit Reached</p>
<p className="text-sm mt-1">
You've reached the maximum number of API keys for your plan. Delete
@ -361,7 +362,7 @@ export default function DeveloperDashboard() {
chartType="bar"
/>
<Callout type="info">
<Callout variant="info">
<p className="text-sm">
<strong>Note:</strong> Real-time analytics are coming soon. This
preview shows sample data.
@ -369,7 +370,7 @@ export default function DeveloperDashboard() {
</Callout>
</div>
) : (
<Callout type="info">
<Callout variant="info">
<p className="font-medium">No usage data yet</p>
<p className="text-sm mt-1">
Start making API requests to see your usage analytics here.
@ -386,6 +387,6 @@ export default function DeveloperDashboard() {
onOpenChange={setCreateDialogOpen}
onCreateKey={handleCreateKey}
/>
</DevPlatformLayout>
</Layout>
);
}

View file

@ -1,4 +1,4 @@
import { DevPlatformLayout } from "@/components/dev-platform/layouts/DevPlatformLayout";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
@ -95,7 +95,7 @@ const steps = [
export default function DeveloperPlatform() {
return (
<DevPlatformLayout>
<Layout>
<SEO
pageTitle="AeThex Developer Platform"
description="Everything you need to build powerful applications with AeThex"
@ -313,6 +313,6 @@ export default function DeveloperPlatform() {
</div>
</section>
</div>
</DevPlatformLayout>
</Layout>
);
}

View file

@ -1,5 +1,5 @@
import { useParams, Link } from "react-router-dom";
import { DevPlatformLayout } from "@/components/dev-platform/layouts/DevPlatformLayout";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { CodeBlock } from "@/components/dev-platform/ui/CodeBlock";
import { CodeTabs } from "@/components/dev-platform/CodeTabs";
@ -377,7 +377,7 @@ export default function ExampleDetail() {
};
return (
<DevPlatformLayout>
<Layout>
<SEO pageTitle={example.title} description={example.description} />
<div className="max-w-5xl mx-auto space-y-8">
{/* Header */}
@ -531,6 +531,6 @@ SUPABASE_SERVICE_KEY=your_service_key`}
</Link>
</div>
</div>
</DevPlatformLayout>
</Layout>
);
}

View file

@ -1,5 +1,5 @@
import { useState } from "react";
import { DevPlatformLayout } from "@/components/dev-platform/layouts/DevPlatformLayout";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Breadcrumbs } from "@/components/dev-platform/Breadcrumbs";
import { MarketplaceCard } from "@/components/dev-platform/MarketplaceCard";
@ -175,9 +175,9 @@ export default function Marketplace() {
});
return (
<DevPlatformLayout>
<Layout>
<SEO pageTitle="Developer Marketplace" description="Premium integrations, plugins, and tools to supercharge your projects" />
<div className="max-w-6xl mx-auto space-y-8">
<div className="max-w-7xl mx-auto space-y-8">
{/* Hero Section */}
<div className="grid md:grid-cols-3 gap-4">
<Card className="p-6 border-primary/20 bg-primary/5">
@ -285,6 +285,6 @@ export default function Marketplace() {
</Button>
</div>
</div>
</DevPlatformLayout>
</Layout>
);
}

View file

@ -1,6 +1,6 @@
import { useState } from "react";
import { useParams, Link } from "react-router-dom";
import { DevPlatformLayout } from "@/components/dev-platform/layouts/DevPlatformLayout";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { CodeBlock } from "@/components/dev-platform/ui/CodeBlock";
import { Callout } from "@/components/dev-platform/ui/Callout";
@ -111,7 +111,7 @@ export default function MarketplaceItemDetail() {
const item = itemData[id || ""] || itemData["premium-analytics-dashboard"];
return (
<DevPlatformLayout>
<Layout>
<SEO pageTitle={item.name} description={item.description} />
<div className="max-w-6xl mx-auto space-y-8">
{/* Header */}
@ -441,6 +441,6 @@ npm install @aethex/${item.name.toLowerCase().replace(/\s+/g, "-")}
</Link>
</div>
</div>
</DevPlatformLayout>
</Layout>
);
}

View file

@ -1,4 +1,4 @@
import { DevPlatformLayout } from "@/components/dev-platform/layouts/DevPlatformLayout";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Breadcrumbs } from "@/components/dev-platform/Breadcrumbs";
import { ThreeColumnLayout } from "@/components/dev-platform/layouts/ThreeColumnLayout";
@ -84,7 +84,7 @@ export default function QuickStart() {
);
return (
<DevPlatformLayout>
<Layout>
<SEO pageTitle="Quick Start Guide" description="Get up and running with the AeThex API in minutes" />
<Breadcrumbs className="mb-6" />
<ThreeColumnLayout sidebar={sidebarContent} aside={asideContent}>
@ -509,6 +509,6 @@ jobs.data.forEach(job => {
</section>
</div>
</ThreeColumnLayout>
</DevPlatformLayout>
</Layout>
);
}

View file

@ -1,6 +1,6 @@
import { useState } from "react";
import { useParams, Link } from "react-router-dom";
import { DevPlatformLayout } from "@/components/dev-platform/layouts/DevPlatformLayout";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { CodeBlock } from "@/components/dev-platform/ui/CodeBlock";
import { CodeTabs } from "@/components/dev-platform/CodeTabs";
@ -110,7 +110,7 @@ export default function TemplateDetail() {
const runCommand = "npm run dev";
return (
<DevPlatformLayout>
<Layout>
<SEO pageTitle={template.name} description={template.description} />
<div className="max-w-6xl mx-auto space-y-8">
{/* Header */}
@ -423,6 +423,6 @@ async function getUserProfile() {
</Link>
</div>
</div>
</DevPlatformLayout>
</Layout>
);
}

View file

@ -1,5 +1,5 @@
import { useState } from "react";
import { DevPlatformLayout } from "@/components/dev-platform/layouts/DevPlatformLayout";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Breadcrumbs } from "@/components/dev-platform/Breadcrumbs";
import { TemplateCard } from "@/components/dev-platform/TemplateCard";
@ -165,9 +165,9 @@ export default function Templates() {
});
return (
<DevPlatformLayout>
<Layout>
<SEO pageTitle="Templates Gallery" description="Pre-built templates and starter kits to accelerate your development" />
<div className="max-w-6xl mx-auto space-y-8">
<div className="max-w-7xl mx-auto space-y-8">
{/* Search & Filters */}
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1 relative">
@ -253,6 +253,6 @@ export default function Templates() {
</Button>
</div>
</div>
</DevPlatformLayout>
</Layout>
);
}

View file

@ -301,18 +301,18 @@ const supplementalResources = [
export default function DocsCurriculum() {
return (
<div className="space-y-8 max-w-5xl">
<section className="relative overflow-hidden rounded-2xl border border-slate-800/60 bg-slate-900/80 p-6 md:p-8">
<div className="space-y-8">
<section className="relative overflow-hidden rounded-3xl border border-slate-800/60 bg-slate-900/80 p-8">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(124,58,237,0.2),transparent_60%)]" />
<div className="relative z-10 flex flex-col gap-6">
<div className="flex flex-col gap-3">
<Badge className="w-fit bg-purple-600/80 text-white">
AeThex Curriculum
</Badge>
<h1 className="text-2xl font-semibold text-white sm:text-3xl">
<h1 className="text-3xl font-semibold text-white sm:text-4xl">
Structured learning paths for builders, operators, and labs teams
</h1>
<p className="max-w-2xl text-sm text-slate-200 sm:text-base">
<p className="max-w-3xl text-base text-slate-200 sm:text-lg">
Progress through sequenced modules that combine documentation,
interactive labs, and project-based assignments. Graduate with
deployment-ready AeThex experiences and certification badges.
@ -344,7 +344,7 @@ export default function DocsCurriculum() {
</div>
</section>
<section className="grid gap-6 lg:grid-cols-1">
<section className="grid gap-6 lg:grid-cols-[minmax(0,2.2fr)_minmax(0,1fr)]">
<Card className="border-slate-800 bg-slate-900/70 shadow-xl backdrop-blur">
<CardHeader className="space-y-4">
<CardTitle className="flex items-center gap-3 text-2xl text-white">

View file

@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useParams, Link, useNavigate } from "react-router-dom";
import EthosLayout from "@/components/ethos/EthosLayout";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@ -90,17 +90,17 @@ export default function ArtistProfile() {
if (loading) {
return (
<EthosLayout>
<Layout>
<div className="py-20 text-center">Loading artist profile...</div>
</EthosLayout>
</Layout>
);
}
if (!artist) {
return (
<EthosLayout>
<Layout>
<div className="py-20 text-center">Artist not found</div>
</EthosLayout>
</Layout>
);
}
@ -115,7 +115,7 @@ export default function ArtistProfile() {
pageTitle={`${artist.user_profiles.full_name} - Ethos Guild Artist`}
description={artist.bio || "Ethos Guild artist profile"}
/>
<EthosLayout>
<Layout>
<div className="bg-slate-950 text-foreground min-h-screen">
{/* Profile Header */}
<section className="border-b border-slate-800 bg-gradient-to-b from-slate-900 to-slate-950 py-12">
@ -293,7 +293,7 @@ export default function ArtistProfile() {
</div>
</section>
</div>
</EthosLayout>
</Layout>
</>
);
}

View file

@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import EthosLayout from "@/components/ethos/EthosLayout";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -356,16 +356,16 @@ export default function ArtistSettings() {
if (loading) {
return (
<EthosLayout>
<Layout>
<div className="py-20 text-center">Loading settings...</div>
</EthosLayout>
</Layout>
);
}
return (
<>
<SEO pageTitle="Artist Settings - Ethos Guild" />
<EthosLayout>
<Layout>
<div className="bg-slate-950 text-foreground min-h-screen">
<div className="container mx-auto px-4 max-w-4xl py-12">
<div className="space-y-8">
@ -778,7 +778,7 @@ export default function ArtistSettings() {
</div>
)}
</div>
</EthosLayout>
</Layout>
</>
);
}

View file

@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import EthosLayout from "@/components/ethos/EthosLayout";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@ -131,9 +131,9 @@ export default function LicensingDashboard() {
if (loading) {
return (
<EthosLayout>
<Layout>
<div className="py-20 text-center">Loading agreements...</div>
</EthosLayout>
</Layout>
);
}
@ -143,7 +143,7 @@ export default function LicensingDashboard() {
return (
<>
<SEO pageTitle="Licensing Dashboard - Ethos Guild" />
<EthosLayout>
<Layout>
<div className="bg-slate-950 text-foreground min-h-screen">
<div className="container mx-auto px-4 max-w-4xl py-12">
{/* Header */}
@ -280,7 +280,7 @@ export default function LicensingDashboard() {
</Tabs>
</div>
</div>
</EthosLayout>
</Layout>
</>
);
}

View file

@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import EthosLayout from "@/components/ethos/EthosLayout";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -113,7 +113,7 @@ export default function TrackLibrary() {
pageTitle="Ethos Track Library"
description="Browse music and sound effects from Ethos Guild artists"
/>
<EthosLayout>
<Layout>
<div className="bg-slate-950 text-foreground min-h-screen">
{/* Hero Section */}
<section className="relative border-b border-slate-800 bg-gradient-to-b from-slate-900 to-slate-950 py-16">
@ -317,7 +317,7 @@ export default function TrackLibrary() {
</div>
</section>
</div>
</EthosLayout>
</Layout>
</>
);
}

View file

@ -1,130 +1,11 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import Layout from "@/components/Layout";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/contexts/AuthContext";
import { aethexToast } from "@/lib/aethex-toast";
import { supabase } from "@/lib/supabase";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Input } from "@/components/ui/input";
import LoadingScreen from "@/components/LoadingScreen";
import {
FileText,
ArrowLeft,
Search,
Download,
Eye,
Calendar,
DollarSign,
CheckCircle,
Clock,
AlertCircle,
FileSignature,
History,
Filter,
} from "lucide-react";
const API_BASE = import.meta.env.VITE_API_BASE || "";
interface Contract {
id: string;
title: string;
description: string;
status: "draft" | "active" | "completed" | "expired" | "cancelled";
total_value: number;
start_date: string;
end_date: string;
signed_date?: string;
milestones: any[];
documents: { name: string; url: string; type: string }[];
amendments: { date: string; description: string; signed: boolean }[];
created_at: string;
}
import { Card, CardContent } from "@/components/ui/card";
import { useNavigate } from "react-router-dom";
import { ArrowLeft, FileText } from "lucide-react";
export default function ClientContracts() {
const navigate = useNavigate();
const { user, loading: authLoading } = useAuth();
const [contracts, setContracts] = useState<Contract[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [selectedContract, setSelectedContract] = useState<Contract | null>(null);
useEffect(() => {
if (!authLoading && user) {
loadContracts();
}
}, [user, authLoading]);
const loadContracts = async () => {
try {
setLoading(true);
const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;
if (!token) throw new Error("No auth token");
const res = await fetch(`${API_BASE}/api/corp/contracts`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setContracts(Array.isArray(data) ? data : data.contracts || []);
}
} catch (error) {
console.error("Failed to load contracts", error);
aethexToast({ message: "Failed to load contracts", type: "error" });
} finally {
setLoading(false);
}
};
if (authLoading || loading) {
return <LoadingScreen message="Loading Contracts..." />;
}
const filteredContracts = contracts.filter((c) => {
const matchesSearch = c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.description?.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = statusFilter === "all" || c.status === statusFilter;
return matchesSearch && matchesStatus;
});
const getStatusColor = (status: string) => {
switch (status) {
case "active": return "bg-green-500/20 border-green-500/30 text-green-300";
case "completed": return "bg-blue-500/20 border-blue-500/30 text-blue-300";
case "draft": return "bg-yellow-500/20 border-yellow-500/30 text-yellow-300";
case "expired": return "bg-gray-500/20 border-gray-500/30 text-gray-300";
case "cancelled": return "bg-red-500/20 border-red-500/30 text-red-300";
default: return "";
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case "active": return <CheckCircle className="h-4 w-4" />;
case "completed": return <CheckCircle className="h-4 w-4" />;
case "draft": return <Clock className="h-4 w-4" />;
case "expired": return <AlertCircle className="h-4 w-4" />;
case "cancelled": return <AlertCircle className="h-4 w-4" />;
default: return null;
}
};
const stats = {
total: contracts.length,
active: contracts.filter(c => c.status === "active").length,
completed: contracts.filter(c => c.status === "completed").length,
totalValue: contracts.reduce((acc, c) => acc + (c.total_value || 0), 0),
};
return (
<Layout>
@ -133,7 +14,7 @@ export default function ClientContracts() {
<main className="relative z-10">
<section className="border-b border-slate-800 py-8">
<div className="container mx-auto max-w-6xl px-4">
<div className="container mx-auto max-w-7xl px-4">
<Button
variant="ghost"
size="sm"
@ -144,282 +25,30 @@ export default function ClientContracts() {
Back to Portal
</Button>
<div className="flex items-center gap-3">
<FileText className="h-10 w-10 text-blue-400" />
<div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-blue-300 to-cyan-300 bg-clip-text text-transparent">
Contracts
</h1>
<p className="text-gray-400">Manage your service agreements</p>
</div>
<FileText className="h-8 w-8 text-blue-400" />
<h1 className="text-3xl font-bold">Contracts</h1>
</div>
</div>
</section>
<div className="container mx-auto max-w-6xl px-4 py-8 space-y-6">
{/* Filters */}
<div className="flex flex-col md:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Search contracts..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-slate-800/50 border-slate-700"
/>
</div>
<Tabs value={statusFilter} onValueChange={setStatusFilter}>
<TabsList className="bg-slate-800/50 border border-slate-700">
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="active">Active</TabsTrigger>
<TabsTrigger value="completed">Completed</TabsTrigger>
<TabsTrigger value="draft">Draft</TabsTrigger>
</TabsList>
</Tabs>
</div>
{/* Contract List or Detail View */}
{selectedContract ? (
<Card className="bg-gradient-to-br from-blue-950/40 to-blue-900/20 border-blue-500/20">
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-2xl">{selectedContract.title}</CardTitle>
<CardDescription>{selectedContract.description}</CardDescription>
</div>
<Button variant="ghost" onClick={() => setSelectedContract(null)}>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to List
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Contract Overview */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-4 bg-black/30 rounded-lg border border-blue-500/20">
<p className="text-xs text-gray-400 uppercase">Status</p>
<Badge className={`mt-2 ${getStatusColor(selectedContract.status)}`}>
{getStatusIcon(selectedContract.status)}
<span className="ml-1 capitalize">{selectedContract.status}</span>
</Badge>
</div>
<div className="p-4 bg-black/30 rounded-lg border border-blue-500/20">
<p className="text-xs text-gray-400 uppercase">Total Value</p>
<p className="text-2xl font-bold text-white mt-1">
${selectedContract.total_value?.toLocaleString()}
</p>
</div>
<div className="p-4 bg-black/30 rounded-lg border border-blue-500/20">
<p className="text-xs text-gray-400 uppercase">Start Date</p>
<p className="text-lg font-semibold text-white mt-1">
{new Date(selectedContract.start_date).toLocaleDateString()}
</p>
</div>
<div className="p-4 bg-black/30 rounded-lg border border-blue-500/20">
<p className="text-xs text-gray-400 uppercase">End Date</p>
<p className="text-lg font-semibold text-white mt-1">
{new Date(selectedContract.end_date).toLocaleDateString()}
</p>
</div>
</div>
{/* Milestones */}
{selectedContract.milestones?.length > 0 && (
<div className="space-y-3">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Calendar className="h-5 w-5 text-cyan-400" />
Milestones
</h3>
<div className="space-y-2">
{selectedContract.milestones.map((milestone: any, idx: number) => (
<div
key={idx}
className="p-4 bg-black/30 rounded-lg border border-cyan-500/20 flex items-center justify-between"
>
<div className="flex items-center gap-3">
{milestone.status === "completed" ? (
<CheckCircle className="h-5 w-5 text-green-400" />
) : (
<Clock className="h-5 w-5 text-yellow-400" />
)}
<div>
<p className="font-semibold text-white">{milestone.title}</p>
<p className="text-sm text-gray-400">
Due: {new Date(milestone.due_date).toLocaleDateString()}
</p>
</div>
</div>
<div className="text-right">
<p className="font-semibold text-white">
${milestone.amount?.toLocaleString()}
</p>
<Badge className={milestone.status === "completed"
? "bg-green-500/20 text-green-300"
: "bg-yellow-500/20 text-yellow-300"
}>
{milestone.status}
</Badge>
</div>
</div>
))}
</div>
</div>
)}
{/* Documents */}
<div className="space-y-3">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<FileText className="h-5 w-5 text-blue-400" />
Documents
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{selectedContract.documents?.length > 0 ? (
selectedContract.documents.map((doc, idx) => (
<div
key={idx}
className="p-4 bg-black/30 rounded-lg border border-blue-500/20 flex items-center justify-between"
>
<div className="flex items-center gap-3">
<FileText className="h-5 w-5 text-blue-400" />
<div>
<p className="font-semibold text-white">{doc.name}</p>
<p className="text-xs text-gray-400 uppercase">{doc.type}</p>
</div>
</div>
<div className="flex gap-2">
<Button size="sm" variant="ghost">
<Eye className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost">
<Download className="h-4 w-4" />
</Button>
</div>
</div>
))
) : (
<div className="col-span-2 p-8 bg-black/30 rounded-lg border border-blue-500/20 text-center">
<FileText className="h-8 w-8 mx-auto text-gray-500 mb-2" />
<p className="text-gray-400">No documents attached</p>
</div>
)}
</div>
</div>
{/* Amendment History */}
{selectedContract.amendments?.length > 0 && (
<div className="space-y-3">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<History className="h-5 w-5 text-purple-400" />
Amendment History
</h3>
<div className="space-y-2">
{selectedContract.amendments.map((amendment, idx) => (
<div
key={idx}
className="p-4 bg-black/30 rounded-lg border border-purple-500/20 flex items-center justify-between"
>
<div>
<p className="font-semibold text-white">{amendment.description}</p>
<p className="text-sm text-gray-400">
{new Date(amendment.date).toLocaleDateString()}
</p>
</div>
<Badge className={amendment.signed
? "bg-green-500/20 text-green-300"
: "bg-yellow-500/20 text-yellow-300"
}>
{amendment.signed ? "Signed" : "Pending"}
</Badge>
</div>
))}
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-4 border-t border-blue-500/20">
<Button className="bg-blue-600 hover:bg-blue-700">
<Download className="h-4 w-4 mr-2" />
Download Contract PDF
</Button>
{selectedContract.status === "draft" && (
<Button className="bg-green-600 hover:bg-green-700">
<FileSignature className="h-4 w-4 mr-2" />
Sign Contract
</Button>
)}
</div>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{filteredContracts.length === 0 ? (
<Card className="bg-gradient-to-br from-blue-950/40 to-blue-900/20 border-blue-500/20">
<CardContent className="p-12 text-center">
<FileText className="h-12 w-12 mx-auto text-gray-500 mb-4" />
<p className="text-gray-400 mb-4">
{searchQuery || statusFilter !== "all"
? "No contracts match your filters"
: "No contracts yet"}
</p>
<Button variant="outline" onClick={() => navigate("/hub/client")}>
Back to Portal
</Button>
</CardContent>
</Card>
) : (
filteredContracts.map((contract) => (
<Card
key={contract.id}
className="bg-gradient-to-br from-blue-950/40 to-blue-900/20 border-blue-500/20 hover:border-blue-500/40 transition cursor-pointer"
onClick={() => setSelectedContract(contract)}
<section className="py-12">
<div className="container mx-auto max-w-7xl px-4">
<Card className="bg-slate-800/30 border-slate-700">
<CardContent className="p-12 text-center">
<FileText className="h-12 w-12 text-slate-600 mx-auto mb-4" />
<p className="text-slate-400 mb-6">
Contract management coming soon
</p>
<Button
variant="outline"
onClick={() => navigate("/hub/client")}
>
<CardContent className="p-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xl font-semibold text-white">
{contract.title}
</h3>
<Badge className={getStatusColor(contract.status)}>
{getStatusIcon(contract.status)}
<span className="ml-1 capitalize">{contract.status}</span>
</Badge>
</div>
<p className="text-gray-400 text-sm mb-3">
{contract.description}
</p>
<div className="flex flex-wrap gap-4 text-sm text-gray-400">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{new Date(contract.start_date).toLocaleDateString()} - {new Date(contract.end_date).toLocaleDateString()}
</span>
<span className="flex items-center gap-1">
<CheckCircle className="h-4 w-4" />
{contract.milestones?.filter((m: any) => m.status === "completed").length || 0} / {contract.milestones?.length || 0} milestones
</span>
</div>
</div>
<div className="text-right">
<p className="text-2xl font-bold text-white">
${contract.total_value?.toLocaleString()}
</p>
<Button
size="sm"
variant="outline"
className="mt-2 border-blue-500/30 text-blue-300 hover:bg-blue-500/10"
>
<Eye className="h-4 w-4 mr-2" />
View Details
</Button>
</div>
</div>
</CardContent>
</Card>
))
)}
Back to Portal
</Button>
</CardContent>
</Card>
</div>
)}
</div>
</section>
</main>
</div>
</Layout>

View file

@ -218,7 +218,7 @@ export default function ClientDashboard() {
return (
<Layout>
<div className="min-h-screen bg-gradient-to-b from-black via-blue-950/20 to-black py-8">
<div className="container mx-auto px-4 max-w-6xl space-y-8">
<div className="container mx-auto px-4 max-w-7xl space-y-8">
{/* Header */}
<div className="space-y-4 animate-slide-down">
<Button

View file

@ -147,7 +147,7 @@ export default function ClientHub() {
return (
<Layout>
<div className="min-h-screen bg-gradient-to-b from-black via-blue-950/20 to-black py-8">
<div className="container mx-auto px-4 max-w-6xl space-y-8">
<div className="container mx-auto px-4 max-w-7xl space-y-8">
{/* Header */}
<div className="space-y-4 animate-slide-down">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">

View file

@ -1,162 +1,11 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import Layout from "@/components/Layout";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/contexts/AuthContext";
import { aethexToast } from "@/lib/aethex-toast";
import { supabase } from "@/lib/supabase";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Input } from "@/components/ui/input";
import LoadingScreen from "@/components/LoadingScreen";
import {
Receipt,
ArrowLeft,
Search,
Download,
Eye,
Calendar,
DollarSign,
CheckCircle,
Clock,
AlertCircle,
CreditCard,
FileText,
ArrowUpRight,
Filter,
} from "lucide-react";
const API_BASE = import.meta.env.VITE_API_BASE || "";
interface Invoice {
id: string;
invoice_number: string;
description: string;
status: "pending" | "paid" | "overdue" | "cancelled";
amount: number;
tax: number;
total: number;
issued_date: string;
due_date: string;
paid_date?: string;
line_items: { description: string; quantity: number; unit_price: number; total: number }[];
payment_method?: string;
contract_id?: string;
created_at: string;
}
import { Card, CardContent } from "@/components/ui/card";
import { useNavigate } from "react-router-dom";
import { ArrowLeft, FileText } from "lucide-react";
export default function ClientInvoices() {
const navigate = useNavigate();
const { user, loading: authLoading } = useAuth();
const [invoices, setInvoices] = useState<Invoice[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [selectedInvoice, setSelectedInvoice] = useState<Invoice | null>(null);
useEffect(() => {
if (!authLoading && user) {
loadInvoices();
}
}, [user, authLoading]);
const loadInvoices = async () => {
try {
setLoading(true);
const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;
if (!token) throw new Error("No auth token");
const res = await fetch(`${API_BASE}/api/corp/invoices`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setInvoices(Array.isArray(data) ? data : data.invoices || []);
}
} catch (error) {
console.error("Failed to load invoices", error);
aethexToast({ message: "Failed to load invoices", type: "error" });
} finally {
setLoading(false);
}
};
const handlePayNow = async (invoice: Invoice) => {
try {
const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;
if (!token) throw new Error("No auth token");
const res = await fetch(`${API_BASE}/api/corp/invoices/${invoice.id}/pay`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
if (res.ok) {
const data = await res.json();
if (data.checkout_url) {
window.location.href = data.checkout_url;
} else {
aethexToast({ message: "Payment initiated", type: "success" });
loadInvoices();
}
} else {
throw new Error("Payment failed");
}
} catch (error) {
console.error("Payment error", error);
aethexToast({ message: "Failed to process payment", type: "error" });
}
};
if (authLoading || loading) {
return <LoadingScreen message="Loading Invoices..." />;
}
const filteredInvoices = invoices.filter((inv) => {
const matchesSearch = inv.invoice_number.toLowerCase().includes(searchQuery.toLowerCase()) ||
inv.description?.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = statusFilter === "all" || inv.status === statusFilter;
return matchesSearch && matchesStatus;
});
const getStatusColor = (status: string) => {
switch (status) {
case "paid": return "bg-green-500/20 border-green-500/30 text-green-300";
case "pending": return "bg-yellow-500/20 border-yellow-500/30 text-yellow-300";
case "overdue": return "bg-red-500/20 border-red-500/30 text-red-300";
case "cancelled": return "bg-gray-500/20 border-gray-500/30 text-gray-300";
default: return "";
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case "paid": return <CheckCircle className="h-4 w-4" />;
case "pending": return <Clock className="h-4 w-4" />;
case "overdue": return <AlertCircle className="h-4 w-4" />;
case "cancelled": return <AlertCircle className="h-4 w-4" />;
default: return null;
}
};
const stats = {
total: invoices.reduce((acc, i) => acc + (i.total || i.amount || 0), 0),
paid: invoices.filter(i => i.status === "paid").reduce((acc, i) => acc + (i.total || i.amount || 0), 0),
pending: invoices.filter(i => i.status === "pending").reduce((acc, i) => acc + (i.total || i.amount || 0), 0),
overdue: invoices.filter(i => i.status === "overdue").reduce((acc, i) => acc + (i.total || i.amount || 0), 0),
};
return (
<Layout>
@ -165,7 +14,7 @@ export default function ClientInvoices() {
<main className="relative z-10">
<section className="border-b border-slate-800 py-8">
<div className="container mx-auto max-w-6xl px-4">
<div className="container mx-auto max-w-7xl px-4">
<Button
variant="ghost"
size="sm"
@ -176,254 +25,30 @@ export default function ClientInvoices() {
Back to Portal
</Button>
<div className="flex items-center gap-3">
<Receipt className="h-10 w-10 text-cyan-400" />
<div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-cyan-300 to-blue-300 bg-clip-text text-transparent">
Invoices & Billing
</h1>
<p className="text-gray-400">Manage payments and billing history</p>
</div>
<FileText className="h-8 w-8 text-blue-400" />
<h1 className="text-3xl font-bold">Invoices</h1>
</div>
</div>
</section>
<div className="container mx-auto max-w-6xl px-4 py-8 space-y-6">
{/* Filters */}
<div className="flex flex-col md:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Search invoices..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-slate-800/50 border-slate-700"
/>
</div>
<Tabs value={statusFilter} onValueChange={setStatusFilter}>
<TabsList className="bg-slate-800/50 border border-slate-700">
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="pending">Pending</TabsTrigger>
<TabsTrigger value="paid">Paid</TabsTrigger>
<TabsTrigger value="overdue">Overdue</TabsTrigger>
</TabsList>
</Tabs>
</div>
{/* Invoice Detail or List */}
{selectedInvoice ? (
<Card className="bg-gradient-to-br from-cyan-950/40 to-cyan-900/20 border-cyan-500/20">
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-2xl">Invoice {selectedInvoice.invoice_number}</CardTitle>
<CardDescription>{selectedInvoice.description}</CardDescription>
</div>
<Button variant="ghost" onClick={() => setSelectedInvoice(null)}>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to List
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Invoice Overview */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-4 bg-black/30 rounded-lg border border-cyan-500/20">
<p className="text-xs text-gray-400 uppercase">Status</p>
<Badge className={`mt-2 ${getStatusColor(selectedInvoice.status)}`}>
{getStatusIcon(selectedInvoice.status)}
<span className="ml-1 capitalize">{selectedInvoice.status}</span>
</Badge>
</div>
<div className="p-4 bg-black/30 rounded-lg border border-cyan-500/20">
<p className="text-xs text-gray-400 uppercase">Total Amount</p>
<p className="text-2xl font-bold text-white mt-1">
${(selectedInvoice.total || selectedInvoice.amount)?.toLocaleString()}
</p>
</div>
<div className="p-4 bg-black/30 rounded-lg border border-cyan-500/20">
<p className="text-xs text-gray-400 uppercase">Issue Date</p>
<p className="text-lg font-semibold text-white mt-1">
{new Date(selectedInvoice.issued_date).toLocaleDateString()}
</p>
</div>
<div className="p-4 bg-black/30 rounded-lg border border-cyan-500/20">
<p className="text-xs text-gray-400 uppercase">Due Date</p>
<p className="text-lg font-semibold text-white mt-1">
{new Date(selectedInvoice.due_date).toLocaleDateString()}
</p>
</div>
</div>
{/* Line Items */}
{selectedInvoice.line_items?.length > 0 && (
<div className="space-y-3">
<h3 className="text-lg font-semibold text-white">Line Items</h3>
<div className="bg-black/30 rounded-lg border border-cyan-500/20 overflow-x-auto">
<table className="w-full min-w-[500px]">
<thead className="bg-cyan-500/10">
<tr className="text-left text-xs text-gray-400 uppercase">
<th className="p-4">Description</th>
<th className="p-4 text-right">Qty</th>
<th className="p-4 text-right">Unit Price</th>
<th className="p-4 text-right">Total</th>
</tr>
</thead>
<tbody>
{selectedInvoice.line_items.map((item, idx) => (
<tr key={idx} className="border-t border-cyan-500/10">
<td className="p-4 text-white">{item.description}</td>
<td className="p-4 text-right text-gray-300">{item.quantity}</td>
<td className="p-4 text-right text-gray-300">${item.unit_price?.toLocaleString()}</td>
<td className="p-4 text-right text-white font-semibold">${item.total?.toLocaleString()}</td>
</tr>
))}
</tbody>
<tfoot className="bg-cyan-500/10">
<tr className="border-t border-cyan-500/20">
<td colSpan={3} className="p-4 text-right text-gray-400">Subtotal</td>
<td className="p-4 text-right text-white font-semibold">
${selectedInvoice.amount?.toLocaleString()}
</td>
</tr>
{selectedInvoice.tax > 0 && (
<tr>
<td colSpan={3} className="p-4 text-right text-gray-400">Tax</td>
<td className="p-4 text-right text-white font-semibold">
${selectedInvoice.tax?.toLocaleString()}
</td>
</tr>
)}
<tr className="border-t border-cyan-500/20">
<td colSpan={3} className="p-4 text-right text-lg font-semibold text-white">Total</td>
<td className="p-4 text-right text-2xl font-bold text-cyan-400">
${(selectedInvoice.total || selectedInvoice.amount)?.toLocaleString()}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
)}
{/* Payment Info */}
{selectedInvoice.status === "paid" && selectedInvoice.paid_date && (
<div className="p-4 bg-green-500/10 rounded-lg border border-green-500/20">
<div className="flex items-center gap-3">
<CheckCircle className="h-6 w-6 text-green-400" />
<div>
<p className="font-semibold text-green-300">Payment Received</p>
<p className="text-sm text-gray-400">
Paid on {new Date(selectedInvoice.paid_date).toLocaleDateString()}
{selectedInvoice.payment_method && ` via ${selectedInvoice.payment_method}`}
</p>
</div>
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-4 border-t border-cyan-500/20">
<Button className="bg-cyan-600 hover:bg-cyan-700">
<Download className="h-4 w-4 mr-2" />
Download PDF
</Button>
{(selectedInvoice.status === "pending" || selectedInvoice.status === "overdue") && (
<Button
className="bg-green-600 hover:bg-green-700"
onClick={() => handlePayNow(selectedInvoice)}
>
<CreditCard className="h-4 w-4 mr-2" />
Pay Now
</Button>
)}
</div>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{filteredInvoices.length === 0 ? (
<Card className="bg-gradient-to-br from-cyan-950/40 to-cyan-900/20 border-cyan-500/20">
<CardContent className="p-12 text-center">
<Receipt className="h-12 w-12 mx-auto text-gray-500 mb-4" />
<p className="text-gray-400 mb-4">
{searchQuery || statusFilter !== "all"
? "No invoices match your filters"
: "No invoices yet"}
</p>
<Button variant="outline" onClick={() => navigate("/hub/client")}>
Back to Portal
</Button>
</CardContent>
</Card>
) : (
filteredInvoices.map((invoice) => (
<Card
key={invoice.id}
className="bg-gradient-to-br from-cyan-950/40 to-cyan-900/20 border-cyan-500/20 hover:border-cyan-500/40 transition cursor-pointer"
onClick={() => setSelectedInvoice(invoice)}
<section className="py-12">
<div className="container mx-auto max-w-7xl px-4">
<Card className="bg-slate-800/30 border-slate-700">
<CardContent className="p-12 text-center">
<FileText className="h-12 w-12 text-slate-600 mx-auto mb-4" />
<p className="text-slate-400 mb-6">
Invoice tracking coming soon
</p>
<Button
variant="outline"
onClick={() => navigate("/hub/client")}
>
<CardContent className="p-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xl font-semibold text-white">
{invoice.invoice_number}
</h3>
<Badge className={getStatusColor(invoice.status)}>
{getStatusIcon(invoice.status)}
<span className="ml-1 capitalize">{invoice.status}</span>
</Badge>
</div>
<p className="text-gray-400 text-sm mb-3">
{invoice.description}
</p>
<div className="flex flex-wrap gap-4 text-sm text-gray-400">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
Issued: {new Date(invoice.issued_date).toLocaleDateString()}
</span>
<span className="flex items-center gap-1">
<Clock className="h-4 w-4" />
Due: {new Date(invoice.due_date).toLocaleDateString()}
</span>
</div>
</div>
<div className="text-right space-y-2">
<p className="text-2xl font-bold text-white">
${(invoice.total || invoice.amount)?.toLocaleString()}
</p>
<div className="flex gap-2 justify-end">
{(invoice.status === "pending" || invoice.status === "overdue") && (
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
onClick={(e) => {
e.stopPropagation();
handlePayNow(invoice);
}}
>
<CreditCard className="h-4 w-4 mr-2" />
Pay Now
</Button>
)}
<Button
size="sm"
variant="outline"
className="border-cyan-500/30 text-cyan-300 hover:bg-cyan-500/10"
>
<Eye className="h-4 w-4 mr-2" />
View
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
))
)}
Back to Portal
</Button>
</CardContent>
</Card>
</div>
)}
</div>
</section>
</main>
</div>
</Layout>

Some files were not shown because too many files have changed in this diff Show more