diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..a547bf36d8d11a4f89c59c144f24795749086dd1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..7059a962adb0138b65dd10e0aee66ccfe984b8c6 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/Supabase/.temp/cli-latest b/Supabase/.temp/cli-latest new file mode 100644 index 0000000000000000000000000000000000000000..8c68db77089294cb4f34409f8507dd639ce8aea9 --- /dev/null +++ b/Supabase/.temp/cli-latest @@ -0,0 +1 @@ +v2.67.1 \ No newline at end of file diff --git a/Supabase/config.toml b/Supabase/config.toml new file mode 100644 index 0000000000000000000000000000000000000000..fe5b3ea7cd8afadd1d79ab59b1d50ec5cd5a2162 --- /dev/null +++ b/Supabase/config.toml @@ -0,0 +1,66 @@ + +[functions.verify-domain] +enabled = true +verify_jwt = true +import_map = "./functions/verify-domain/deno.json" +# Uncomment to specify a custom file path to the entrypoint. +# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx +entrypoint = "./functions/verify-domain/index.ts" +# Specifies static files to be bundled with the function. Supports glob patterns. +# For example, if you want to serve static HTML pages in your function: +# static_files = [ "./functions/verify-domain/*.html" ] + +[functions.invite-first-admin] +enabled = true +verify_jwt = true +import_map = "./functions/invite-first-admin/deno.json" +# Uncomment to specify a custom file path to the entrypoint. +# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx +entrypoint = "./functions/invite-first-admin/index.ts" +# Specifies static files to be bundled with the function. Supports glob patterns. +# For example, if you want to serve static HTML pages in your function: +# static_files = [ "./functions/invite-first-admin/*.html" ] + +[functions.generate-verification-token] +enabled = true +verify_jwt = true +import_map = "./functions/generate-verification-token/deno.json" +# Uncomment to specify a custom file path to the entrypoint. +# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx +entrypoint = "./functions/generate-verification-token/index.ts" +# Specifies static files to be bundled with the function. Supports glob patterns. +# For example, if you want to serve static HTML pages in your function: +# static_files = [ "./functions/generate-verification-token/*.html" ] + +[functions.initiate-admin-transfer] +enabled = true +verify_jwt = true +import_map = "./functions/initiate-admin-transfer/deno.json" +# Uncomment to specify a custom file path to the entrypoint. +# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx +entrypoint = "./functions/initiate-admin-transfer/index.ts" +# Specifies static files to be bundled with the function. Supports glob patterns. +# For example, if you want to serve static HTML pages in your function: +# static_files = [ "./functions/initiate-admin-transfer/*.html" ] + +[functions.otp] +enabled = true +verify_jwt = true +import_map = "./functions/otp/deno.json" +# Uncomment to specify a custom file path to the entrypoint. +# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx +entrypoint = "./functions/otp/index.ts" +# Specifies static files to be bundled with the function. Supports glob patterns. +# For example, if you want to serve static HTML pages in your function: +# static_files = [ "./functions/otp/*.html" ] + +[functions.send-interview-email] +enabled = true +verify_jwt = true +import_map = "./functions/send-interview-email/deno.json" +# Uncomment to specify a custom file path to the entrypoint. +# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx +entrypoint = "./functions/send-interview-email/index.ts" +# Specifies static files to be bundled with the function. Supports glob patterns. +# For example, if you want to serve static HTML pages in your function: +# static_files = [ "./functions/send-interview-email/*.html" ] diff --git a/Supabase/functions/_shared/cors.ts b/Supabase/functions/_shared/cors.ts new file mode 100644 index 0000000000000000000000000000000000000000..fd9401ff4209ea53465095ce096bc6612f9ad7df --- /dev/null +++ b/Supabase/functions/_shared/cors.ts @@ -0,0 +1,4 @@ +export const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +} \ No newline at end of file diff --git a/Supabase/functions/generate-verification-token/.npmrc b/Supabase/functions/generate-verification-token/.npmrc new file mode 100644 index 0000000000000000000000000000000000000000..48c6388638017e4edf6ef0263b42d4425fbb5adc --- /dev/null +++ b/Supabase/functions/generate-verification-token/.npmrc @@ -0,0 +1,3 @@ +# Configuration for private npm package dependencies +# For more information on using private registries with Edge Functions, see: +# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries diff --git a/Supabase/functions/generate-verification-token/deno.json b/Supabase/functions/generate-verification-token/deno.json new file mode 100644 index 0000000000000000000000000000000000000000..f6ca8454c56395e6085268c61831ffffe0326163 --- /dev/null +++ b/Supabase/functions/generate-verification-token/deno.json @@ -0,0 +1,3 @@ +{ + "imports": {} +} diff --git a/Supabase/functions/generate-verification-token/index.ts b/Supabase/functions/generate-verification-token/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..912640f174bdefa3b1ce6a5954648a003a623c62 --- /dev/null +++ b/Supabase/functions/generate-verification-token/index.ts @@ -0,0 +1,60 @@ +// supabase/functions/generate-verification-token/index.ts + +import { serve } from 'https://deno.land/std@0.177.0/http/server.ts' +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.8' +import { corsHeaders } from '../_shared/cors.ts' + +serve(async (req) => { + // This is the crucial block that handles the browser's preflight check + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }) + } + + try { + const { email } = await req.json(); + const domain = email.split('@')[1]; + if (!domain) { + throw new Error("Invalid email format."); + } + + const supabaseAdmin = createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! + ); + + const { data: blockedDomain } = await supabaseAdmin + .from('blocked_domains') + .select('domain') + .eq('domain', domain) + .single(); + + if (blockedDomain) { + throw new Error("Please use a business email. Free email providers are not allowed."); + } + + const { data, error } = await supabaseAdmin + .from('organizations') + .insert({ + name: domain, + verified_domain: domain, + }) + .select('verification_token') + .single(); + + if (error) throw error; + + return new Response(JSON.stringify({ + verification_token: data.verification_token, + domain: domain + }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 200, + }); + + } catch (error) { + return new Response(JSON.stringify({ error: error.message }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 400, + }); + } +}) \ No newline at end of file diff --git a/Supabase/functions/initiate-admin-transfer/.npmrc b/Supabase/functions/initiate-admin-transfer/.npmrc new file mode 100644 index 0000000000000000000000000000000000000000..48c6388638017e4edf6ef0263b42d4425fbb5adc --- /dev/null +++ b/Supabase/functions/initiate-admin-transfer/.npmrc @@ -0,0 +1,3 @@ +# Configuration for private npm package dependencies +# For more information on using private registries with Edge Functions, see: +# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries diff --git a/Supabase/functions/initiate-admin-transfer/deno.json b/Supabase/functions/initiate-admin-transfer/deno.json new file mode 100644 index 0000000000000000000000000000000000000000..f6ca8454c56395e6085268c61831ffffe0326163 --- /dev/null +++ b/Supabase/functions/initiate-admin-transfer/deno.json @@ -0,0 +1,3 @@ +{ + "imports": {} +} diff --git a/Supabase/functions/initiate-admin-transfer/index.ts b/Supabase/functions/initiate-admin-transfer/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..704d25819e32597f49607de33c7fe66de6124d96 --- /dev/null +++ b/Supabase/functions/initiate-admin-transfer/index.ts @@ -0,0 +1,80 @@ +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' +import { v4 as uuidv4 } from 'https://deno.land/std@0.106.0/uuid/mod.ts'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +} + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }) + } + + try { + const { newAdminEmail } = await req.json(); + if (!newAdminEmail) { + throw new Error("New admin's email is required."); + } + + // Create an admin client to bypass RLS + const supabaseAdmin = createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! + ); + + // Get the current user from the request's auth token + const authHeader = req.headers.get('Authorization')!; + const jwt = authHeader.replace('Bearer ', ''); + const { data: { user } } = await supabaseAdmin.auth.getUser(jwt); + if (!user) { + throw new Error("Could not identify the current user."); + } + + // 1. Generate a secure, unique token for the transfer + const transferToken = uuidv4(); + const expiryDate = new Date(); + expiryDate.setHours(expiryDate.getHours() + 24); // Token is valid for 24 hours + + // 2. Store the token and link it to the current user's company + // This assumes the 'companies' table has 'admin_transfer_token' and 'admin_transfer_expires_at' columns + const { data: profile, error: profileError } = await supabaseAdmin + .from('profiles').select('company_id').eq('id', user.id).single(); + if (profileError || !profile) throw new Error("Could not find the user's company."); + + const { error: updateError } = await supabaseAdmin + .from('companies') + .update({ + admin_transfer_token: transferToken, + admin_transfer_expires_at: expiryDate.toISOString(), + }) + .eq('id', profile.company_id); + if (updateError) throw new Error("Failed to store the transfer token."); + + // 3. Send a magic link email to the new admin + // This link should point to a page in your app that handles the token verification + const transferUrl = `${Deno.env.get('SITE_URL')}/accept-admin-transfer?token=${transferToken}`; + + const { error: emailError } = await supabaseAdmin.auth.admin.generateLink({ + type: 'magiclink', + email: newAdminEmail, + options: { + redirectTo: transferUrl + } + }); + + if (emailError) { + throw new Error("Could not send invitation email."); + } + + return new Response(JSON.stringify({ success: true, message: "Transfer invitation sent." }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 200, + }) + } catch (err) { + return new Response(JSON.stringify({ error: err.message }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 400, + }) + } +}) \ No newline at end of file diff --git a/Supabase/functions/invite-first-admin/.npmrc b/Supabase/functions/invite-first-admin/.npmrc new file mode 100644 index 0000000000000000000000000000000000000000..48c6388638017e4edf6ef0263b42d4425fbb5adc --- /dev/null +++ b/Supabase/functions/invite-first-admin/.npmrc @@ -0,0 +1,3 @@ +# Configuration for private npm package dependencies +# For more information on using private registries with Edge Functions, see: +# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries diff --git a/Supabase/functions/invite-first-admin/deno.json b/Supabase/functions/invite-first-admin/deno.json new file mode 100644 index 0000000000000000000000000000000000000000..f6ca8454c56395e6085268c61831ffffe0326163 --- /dev/null +++ b/Supabase/functions/invite-first-admin/deno.json @@ -0,0 +1,3 @@ +{ + "imports": {} +} diff --git a/Supabase/functions/invite-first-admin/index.ts b/Supabase/functions/invite-first-admin/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..90292c23b12e58d789b90e263eda45c157e31d1c --- /dev/null +++ b/Supabase/functions/invite-first-admin/index.ts @@ -0,0 +1,24 @@ +import { serve } from 'https://deno.land/std@0.177.0/http/server.ts' +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.8' +import { corsHeaders } from '../_shared/cors.ts' + +serve(async (req) => { + if (req.method === 'OPTIONS') { return new Response('ok', { headers: corsHeaders }) } + try { + const { adminEmail, domain } = await req.json(); + if (!adminEmail || !domain) throw new Error("Admin email and domain are required."); + if (adminEmail.split('@')[1] !== domain) throw new Error("Admin email must belong to the verified domain."); + + const supabaseAdmin = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!); + + const { data: orgData, error: orgError } = await supabaseAdmin.from('organizations').select('id').eq('verified_domain', domain).eq('is_verified', true).single(); + if (orgError || !orgData) throw new Error("Cannot send invite: Organization is not verified."); + + const { error: inviteError } = await supabaseAdmin.auth.admin.inviteUserByEmail(adminEmail); + if (inviteError) throw inviteError; + + return new Response(JSON.stringify({ success: true, message: `Invitation sent to ${adminEmail}.` }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } catch (error) { + return new Response(JSON.stringify({ error: error.message }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 400 }); + } +}) \ No newline at end of file diff --git a/Supabase/functions/otp/.npmrc b/Supabase/functions/otp/.npmrc new file mode 100644 index 0000000000000000000000000000000000000000..48c6388638017e4edf6ef0263b42d4425fbb5adc --- /dev/null +++ b/Supabase/functions/otp/.npmrc @@ -0,0 +1,3 @@ +# Configuration for private npm package dependencies +# For more information on using private registries with Edge Functions, see: +# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries diff --git a/Supabase/functions/otp/deno.json b/Supabase/functions/otp/deno.json new file mode 100644 index 0000000000000000000000000000000000000000..f6ca8454c56395e6085268c61831ffffe0326163 --- /dev/null +++ b/Supabase/functions/otp/deno.json @@ -0,0 +1,3 @@ +{ + "imports": {} +} diff --git a/Supabase/functions/otp/index.ts b/Supabase/functions/otp/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c1b934b198a0b04484390f4f1cca6d1be2126e1 --- /dev/null +++ b/Supabase/functions/otp/index.ts @@ -0,0 +1,137 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }); + } + + try { + // 1. Init Supabase Clients + const supabaseAdmin = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '' + ); + + const authHeader = req.headers.get('Authorization')!; + const supabaseClient = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_ANON_KEY') ?? '', + { global: { headers: { Authorization: authHeader } } } + ); + + // 2. Auth Check + const { data: { user }, error: authError } = await supabaseClient.auth.getUser(); + if (authError || !user) throw new Error("Unauthorized"); + + const { action, userCode } = await req.json(); + + // ========================================== + // ACTION: SEND SMS (VIA TWILIO) + // ========================================== + if (action === 'send') { + const { data: profile } = await supabaseAdmin + .from('profiles') + .select('phone') + .eq('id', user.id) + .single(); + + if (!profile?.phone) throw new Error("No phone number found in profile."); + + const phone = profile.phone; + const otp = Math.floor(100000 + Math.random() * 900000).toString(); + const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString(); + + // Upsert OTP to DB + const { error: upsertError } = await supabaseAdmin + .from('otp_verifications') + .upsert({ phone, otp_code: otp, expires_at: expiresAt, attempts_count: 0 }, { onConflict: 'phone' }); + + if (upsertError) throw upsertError; + + // --- TWILIO SENDING LOGIC STARTS HERE --- + const accountSid = Deno.env.get("TWILIO_ACCOUNT_SID"); + const authToken = Deno.env.get("TWILIO_AUTH_TOKEN"); + const fromNumber = Deno.env.get("TWILIO_PHONE_NUMBER"); + + if (!accountSid || !authToken || !fromNumber) { + throw new Error("Twilio secrets are missing in Supabase."); + } + + // Format parameters for Twilio API + const params = new URLSearchParams(); + params.append('To', phone); + params.append('From', fromNumber); + params.append('Body', `Your Verification Code is: ${otp}`); + + console.log(`Sending SMS to ${phone}...`); + + const twilioRes = await fetch( + `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`, + { + method: "POST", + headers: { + "Authorization": `Basic ${btoa(`${accountSid}:${authToken}`)}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params, + } + ); + + if (!twilioRes.ok) { + const errorText = await twilioRes.text(); + console.error("Twilio Error:", errorText); + throw new Error("Failed to send SMS. Check server logs."); + } + // --- TWILIO LOGIC ENDS HERE --- + + return new Response( + JSON.stringify({ message: "OTP sent successfully" }), + { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 200 } + ); + } + + // ========================================== + // ACTION: VERIFY + // ========================================== + if (action === 'verify') { + if (!userCode) throw new Error("Missing OTP code"); + + const { data: profile } = await supabaseAdmin.from('profiles').select('phone').eq('id', user.id).single(); + const phone = profile?.phone; + + const { data: record } = await supabaseAdmin.from('otp_verifications').select('*').eq('phone', phone).single(); + + if (!record) throw new Error("Invalid or expired OTP."); + if (new Date() > new Date(record.expires_at)) throw new Error("OTP has expired."); + if (record.attempts_count >= 3) throw new Error("Too many attempts."); + + if (record.otp_code !== userCode) { + await supabaseAdmin.from('otp_verifications').update({ attempts_count: record.attempts_count + 1 }).eq('phone', phone); + throw new Error("Incorrect OTP code."); + } + + // Success + await supabaseAdmin.from('profiles').update({ is_phone_verified: true }).eq('id', user.id); + await supabaseAdmin.from('otp_verifications').delete().eq('phone', phone); + + return new Response( + JSON.stringify({ message: "Phone verified successfully!" }), + { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 200 } + ); + } + + return new Response(JSON.stringify({ error: "Invalid Action" }), { status: 400, headers: corsHeaders }); + + } catch (error) { + return new Response( + JSON.stringify({ error: error.message }), + { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 400 } + ); + } +}); \ No newline at end of file diff --git a/Supabase/functions/send-interview-email/.npmrc b/Supabase/functions/send-interview-email/.npmrc new file mode 100644 index 0000000000000000000000000000000000000000..48c6388638017e4edf6ef0263b42d4425fbb5adc --- /dev/null +++ b/Supabase/functions/send-interview-email/.npmrc @@ -0,0 +1,3 @@ +# Configuration for private npm package dependencies +# For more information on using private registries with Edge Functions, see: +# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries diff --git a/Supabase/functions/send-interview-email/deno.json b/Supabase/functions/send-interview-email/deno.json new file mode 100644 index 0000000000000000000000000000000000000000..f6ca8454c56395e6085268c61831ffffe0326163 --- /dev/null +++ b/Supabase/functions/send-interview-email/deno.json @@ -0,0 +1,3 @@ +{ + "imports": {} +} diff --git a/Supabase/functions/send-interview-email/index.ts b/Supabase/functions/send-interview-email/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3e8b7d497f60c7fc263a207d86f0e9c894f04d8c --- /dev/null +++ b/Supabase/functions/send-interview-email/index.ts @@ -0,0 +1,51 @@ +// supabase/functions/send-interview-email/index.ts +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { Resend } from "npm:resend"; + +const resend = new Resend(Deno.env.get("RESEND_API_KEY")); + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", +}; + +serve(async (req) => { + if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); + + try { + const { candidateName, candidateEmail, date, time, meetingLink, role } = await req.json(); + + const { data, error } = await resend.emails.send({ + from: "Acme HR ", // Change this to your verified domain if you have one + to: [candidateEmail], + subject: `Interview Invitation: ${role}`, + html: ` +
+

Hi ${candidateName},

+

We are pleased to invite you to a Technical Interview for the ${role} position.

+ +
+

šŸ“… Date: ${date}

+

ā° Time: ${time}

+

šŸ”— Link: ${meetingLink}

+
+ +

Please join 5 minutes early.

+

Best,
Hiring Team

+
+ `, + }); + + if (error) throw error; + + return new Response(JSON.stringify(data), { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 200, + }); + } catch (error) { + return new Response(JSON.stringify({ error: error.message }), { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 500, + }); + } +}); \ No newline at end of file diff --git a/Supabase/functions/verify-domain/.npmrc b/Supabase/functions/verify-domain/.npmrc new file mode 100644 index 0000000000000000000000000000000000000000..48c6388638017e4edf6ef0263b42d4425fbb5adc --- /dev/null +++ b/Supabase/functions/verify-domain/.npmrc @@ -0,0 +1,3 @@ +# Configuration for private npm package dependencies +# For more information on using private registries with Edge Functions, see: +# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries diff --git a/Supabase/functions/verify-domain/deno.json b/Supabase/functions/verify-domain/deno.json new file mode 100644 index 0000000000000000000000000000000000000000..f6ca8454c56395e6085268c61831ffffe0326163 --- /dev/null +++ b/Supabase/functions/verify-domain/deno.json @@ -0,0 +1,3 @@ +{ + "imports": {} +} diff --git a/Supabase/functions/verify-domain/index.ts b/Supabase/functions/verify-domain/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d182660edf023e507d21957e53f366f15520d2c --- /dev/null +++ b/Supabase/functions/verify-domain/index.ts @@ -0,0 +1,32 @@ +import { serve } from 'https://deno.land/std@0.177.0/http/server.ts' +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.8' +import { corsHeaders } from '../_shared/cors.ts' + +serve(async (req) => { + if (req.method === 'OPTIONS') { return new Response('ok', { headers: corsHeaders }) } + try { + const { domain } = await req.json(); + if (!domain) throw new Error("Domain is required."); + + const supabaseAdmin = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!); + + const { data: orgData, error: orgError } = await supabaseAdmin.from('organizations').select('verification_token, is_verified').eq('verified_domain', domain).single(); + if (orgError) throw new Error("Could not find an organization for this domain."); + if (orgData.is_verified) return new Response(JSON.stringify({ success: true, message: 'Domain is already verified.' }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + + const expectedToken = `${orgData.verification_token}`; + let isVerified = false; + const txtRecords = await Deno.resolveDns(domain, "TXT"); + + for (const record of txtRecords) { if (record.includes(expectedToken)) { isVerified = true; break; } } + + if (isVerified) { + await supabaseAdmin.from('organizations').update({ is_verified: true, verification_token: null // <-- The added line }).eq('verified_domain', domain); + return new Response(JSON.stringify({ success: true, message: 'Domain verified!' }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); + } else { + throw new Error("Verification failed. TXT record not found or has not propagated yet."); + } + } catch (error) { + return new Response(JSON.stringify({ error: error.message }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 400 }); + } +}) \ No newline at end of file diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000000000000000000000000000000000000..a419b2714ec635e5287a0f3667fb3731dc449273 --- /dev/null +++ b/backend/.env @@ -0,0 +1,12 @@ +OPENAI_API_KEY=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 +GEMINI_API_KEY=AIzaSyA0lgoagdthXdxR_nMhqI5FSu5crY0gd7Y +# Supabase configuration (fill these with your project values) +# SUPABASE_URL: e.g. https://your-project.supabase.co +# SUPABASE_KEY: service role key or anon key (prefer service role for server-side ops) +SUPABASE_URL=https://obhychdzwbytlzwrjrbl.supabase.co +SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9iaHljaGR6d2J5dGx6d3JqcmJsIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc1ODg4MTA1OSwiZXhwIjoyMDc0NDU3MDU5fQ.8_-9OY1Ae89TOKMd8foK3ojilBhrHWhg_w2cz-YWsCA +# Optional: storage bucket and prefix to fetch resumes from +SUPABASE_BUCKET=resume +SUPABASE_PREFIX="" +# Set to 1/true/yes to enable automatic fetching from Supabase when running `run_pipeline.py` +USE_SUPABASE_RAW=1 \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..0912db1bf5749d9032801a133434c6a95789e210 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,26 @@ +# Secrets + + +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ +.venv/ +pip-log.txt +pip-delete-this-directory.txt +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.log +.pytest_cache/ + +# Data +data/ diff --git a/backend/add_experience_to_embeddings.sql b/backend/add_experience_to_embeddings.sql new file mode 100644 index 0000000000000000000000000000000000000000..8cdc499435f2156945a81e0f50981ac5a0890d9b --- /dev/null +++ b/backend/add_experience_to_embeddings.sql @@ -0,0 +1,5 @@ + +-- Add the missing 'experience' column to profile_embeddings +-- BAAI/bge-m3 uses 1024 dimensions +alter table profile_embeddings +add column if not exists experience vector(1024); diff --git a/backend/add_projects_to_profiles.sql b/backend/add_projects_to_profiles.sql new file mode 100644 index 0000000000000000000000000000000000000000..8de8276f5656fcc0536b5636bc622dc4ddc6ea84 --- /dev/null +++ b/backend/add_projects_to_profiles.sql @@ -0,0 +1,5 @@ + +-- Add 'projects' column to profiles table to store extracted project details +-- It will store a JSONB array of objects: [{ title, technologies_used, description }] +alter table profiles +add column if not exists projects jsonb default '[]'::jsonb; diff --git a/backend/api.py b/backend/api.py new file mode 100644 index 0000000000000000000000000000000000000000..0938459c5fc47b57678b9e2a097fdbac6296ef46 --- /dev/null +++ b/backend/api.py @@ -0,0 +1,157 @@ +# api.py +import os +from dotenv import load_dotenv + +# Load env BEFORE importing modules that depend on it +load_dotenv() + +from fastapi import FastAPI, HTTPException, UploadFile, Form, File +from pydantic import BaseModel +from supabase import create_client +from fastapi.middleware.cors import CORSMiddleware +from supabase_ingest import process_resume +from src.extraction.job_extractor import process_single_job +from src.services.ats_service import analyze_ats_compatibility + + +app = FastAPI() + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Allow all origins for dev; restrict in prod + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Setup Supabase Client +SUPABASE_URL = os.environ.get("SUPABASE_URL") +# Use Service Role Key if available to bypass RLS +SUPABASE_KEY = os.environ.get("SUPABASE_SERVICE_ROLE_KEY") or os.environ.get("SUPABASE_KEY") + +if not SUPABASE_URL or not SUPABASE_KEY: + raise RuntimeError("SUPABASE_URL and SUPABASE_KEY (or SUPABASE_SERVICE_ROLE_KEY) must be set in .env") + +client = create_client(SUPABASE_URL, SUPABASE_KEY) + +# Define the data we expect from the frontend +class ResumeRequest(BaseModel): + user_id: str + file_path: str # e.g., "user_123/resume.pdf" + +@app.post("/process-resume") +async def process_resume_endpoint(request: ResumeRequest): + print(f"šŸ”” Signal received: Process resume for {request.user_id}") + + try: + # Delegate everything to the unified function + extracted_data = process_resume(client, request.user_id, request.file_path) + return {"status": "success", "data": extracted_data} + + except Exception as e: + print(f"āŒ Error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +# --------------------------------------------------------------------- +# WEBHOOK ENDPOINT (Called by Supabase) +# --------------------------------------------------------------------- + +from typing import Dict, Any, Optional + +class StorageEventRequest(BaseModel): + type: str + table: str + record: Dict[str, Any] + schema: str + old_record: Optional[Dict[str, Any]] = None + +@app.post("/webhook/storage") +async def storage_webhook(request: StorageEventRequest): + """ + Handles Database Webhooks from Supabase (storage.objects insert). + """ + print(f"šŸ”” Webhook received: {request.type} on {request.table}") + + # We only care about INSERTs or UPDATEs (overwrites) to the 'resume' bucket + if request.type not in ["INSERT", "UPDATE"] or request.table != "objects": + return {"status": "ignored"} + + # Extract file details from the record + # Object path example: "user_123/123456_resume.pdf" + file_path = request.record.get("name") + bucket_id = request.record.get("bucket_id") + + # Check bucket + if bucket_id != "resume": + print(f"āš ļø Ignoring upload to bucket: {bucket_id}") + return {"status": "ignored", "reason": "wrong bucket"} + + # Extract User ID (assuming folder structure: user_id/filename) + try: + user_id = file_path.split("/")[0] + except Exception: + print(f"āŒ Could not extract user_id from {file_path}") + return {"status": "error", "message": "invalid file path structure"} + + print(f"ā–¶ļø Triggering processing for {file_path}") + + # Call the processing logic + try: + process_resume(client, user_id, file_path) + return {"status": "success"} + except Exception as e: + print(f"āŒ Processing failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/webhook/jobs") +async def jobs_webhook(request: StorageEventRequest): + """ + Handles Database Webhooks from Supabase (jobs table UPDATE/INSERT). + """ + print(f"šŸ”” Webhook received: {request.type} on {request.table}") + + if request.table != "jobs": + return {"status": "ignored", "reason": "wrong table"} + + # We care about INSERT and UPDATE + # For UPDATE, we might want to check if description changed, but for now we runs it anyway + + new_record = request.record + job_id = new_record.get("id") + description = new_record.get("description") + experience_level = new_record.get("experience_level") + + if not job_id: + print("āŒ Webhook missing job_id") + return {"status": "error", "message": "missing id"} + + print(f"ā–¶ļø Triggering job extraction for Job ID: {job_id}") + + try: + # Re-use global client from line 32 + process_single_job(client, job_id, description, experience_level) + return {"status": "success"} + except Exception as e: + print(f"āŒ Job processing failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/analyze-ats") +async def analyze_ats_endpoint( + resume: UploadFile = File(...), + job_description: str = Form(...) +): + """ + Real-time ATS analysis endpoint. + Does not save to DB (unless you want to add that logic). + """ + print(f"šŸ” Analyzing ATS compatibility for: {resume.filename}") + try: + result = await analyze_ats_compatibility(resume, job_description) + return {"status": "success", "data": result} + except Exception as e: + print(f"āŒ ATS Analysis failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# Run with: uvicorn api:app --reload \ No newline at end of file diff --git a/backend/create_profile_embeddings.sql b/backend/create_profile_embeddings.sql new file mode 100644 index 0000000000000000000000000000000000000000..0c0c7b76cf403749d1908c3ffbe20007bc0f6b44 --- /dev/null +++ b/backend/create_profile_embeddings.sql @@ -0,0 +1,32 @@ +-- Enable the pgvector extension to work with embedding vectors +create extension if not exists vector; + +-- Create a table to store embeddings for each profile column +-- We use 1024 dimensions for the BAAI/bge-m3 model +create table if not exists profile_embeddings ( + id uuid references profiles(id) on delete cascade primary key, + headline vector(1024), + summary vector(1024), + skills vector(1024), + technical_skills vector(1024), + experience vector(1024), + certifications vector(1024), + languages vector(1024), + created_at timestamp with time zone default timezone('utc'::text, now()) not null, + updated_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +-- Enable Row Level Security (RLS) +alter table profile_embeddings enable row level security; + +-- Create policies (Adjust based on your actual auth requirements) +-- Allow read access to everyone (or authenticated users) +create policy "Allow read access for all users" +on profile_embeddings for select +using ( true ); + +-- Allow update/insert only for service_role or the user who owns the profile +-- (Assuming auth.uid() matches the profile id) +create policy "Users can update their own embeddings" +on profile_embeddings for all +using ( auth.uid() = id ); diff --git a/backend/debug_payload.json b/backend/debug_payload.json new file mode 100644 index 0000000000000000000000000000000000000000..905d421b184130217f7f4eacd38be2a8f8fde04e --- /dev/null +++ b/backend/debug_payload.json @@ -0,0 +1,69 @@ +{ + "id": "81185bdc-85be-4ff2-99c7-16cf8356cb51", + "resume_url": "81185bdc-85be-4ff2-99c7-16cf8356cb51/resume.pdf", + "file_hash": "f6f9d1e0b3badc01329126aa9f249a3e26f1ba12e26d274de0323c359faa1c13", + "processed": true, + "updated_at": "now()", + "full_name": "med Raffi", + "summary": "Computer Science student proficient in Python, Java, and C with strong skills in Object-Oriented Programming. Experienced in software development and version control using Git. Adaptable team player focused on solving complex technical challenges.", + "phone": "+9195390771", + "email": "saheedmuhammedraffi@gmail.com", + "skills": [ + "Communication", + "Teamwork", + "Adaptability", + "Analytical Thinking" + ], + "technical_skills": "Python, Java, C, SQL, HTML, CSS, JavaScript, Flask, React, Pandas, Scikit-learn, NumPy, Git, VSCode, GoogleColab, Docker, TensorFlow", + "education": [ + { + "course": "B.Tech in Computer Science and Engineering", + "institution": "Carmel College of Engineering and Technology, Alappuzha", + "year": "2022 Present" + }, + { + "course": "Higher Secondary Education", + "institution": "S.D.V. English Medium Higher Secondary School, Alappuzha", + "year": null + } + ], + "work_experience": [ + { + "role": "AI/ML Intern", + "company": "ICT Academy of Kerala, Trivandrum", + "years": "Jun 2025 - Jul 2025", + "description": "Underwent a 1-month internship on Artificial Intelligence and Machine Learning. Collaborated with a 5-member team to deploy a prototype ML model tested on real-world datasets." + }, + { + "role": "Webmaster", + "company": "IEEE Computer Society", + "years": "July 2025 Present", + "description": "Developed a responsive web portal and admin dashboard to streamline real-time event tracking and member registration." + }, + { + "role": "TEDxCCET Curation Lead", + "company": "Dept. of Computer Science, CCET", + "years": "Nov 2025 Present", + "description": "Manage speaker logistics, schedules, and deliverables to ensure strict adherence to event timelines. Coordinate technical requirements and stage cues between speakers and the production team." + } + ], + "projects": [ + { + "tech_stack": [ + "React", + "Supabase" + ], + "description": "A full-stack milk management and distribution system that automates milk collection, farmer payments, billing, and delivery tracking through a centralized platform." + }, + { + "tech_stack": [ + "Python", + "TensorFlow", + "Flask", + "React" + ], + "description": "Developed an LSTM-based model to forecast short-term stock prices using live data. Integrated the trained model into a Flask API with a React interface for real-time trend prediction." + } + ], + "certifications": "AIML Internship ICT Academy of Kerala 2025, Python Foundation Certification Springboard 2025, Programming in Java NPTEL 2024" +} \ No newline at end of file diff --git a/backend/debug_resume.txt b/backend/debug_resume.txt new file mode 100644 index 0000000000000000000000000000000000000000..255e2bfd4fce562d70d0e73b3701decac006e658 --- /dev/null +++ b/backend/debug_resume.txt @@ -0,0 +1,6 @@ +Candidate Name: Jane Doe +Email: jane@example.com +Projects: +1. E-Commerce App +Tech Stack: React, Node.js +Description: A shopping site. \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..a5ac547a3146e27fac05e8d4a589700fba432b87 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,28 @@ +# ================== Core utilities ================== +python-dotenv>=1.0.0 +pandas>=2.0.0 +tqdm>=4.66.0 + +# ================== PDF / DOC processing ================== +pypdf>=3.0.0 +pdfplumber>=0.10.0 +python-docx>=0.8.11 +unicodedata2>=0.7.2 + +# ================== NLP preprocessing ================== +nltk>=3.8.1 + +# ================== Hugging Face / ML ================== +transformers>=4.44.0 +torch>=2.2.0 +sentence-transformers>=2.2.2 +datasets>=2.19.0 +accelerate>=0.30.0 + +# ================== APIs ================== +openai>=1.30.0 +supabase>=2.0.0 +fastapi>=0.109.0 +uvicorn>=0.27.0 +python-multipart>=0.0.9 +google-genai>=0.2.0 diff --git a/backend/src/__init__.py b/backend/src/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/src/embeddings/__init__.py b/backend/src/embeddings/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/src/embeddings/debug_embedding_storage.py b/backend/src/embeddings/debug_embedding_storage.py new file mode 100644 index 0000000000000000000000000000000000000000..2ca7766c9296e73df525393528fa18b355cbd4e5 --- /dev/null +++ b/backend/src/embeddings/debug_embedding_storage.py @@ -0,0 +1,62 @@ + +import sys +import os +import time + +# Add 'backend' directory to path so we can import 'supabase_ingest' directly +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) + +from supabase_ingest import safe_generate_and_store_embeddings, client + +# Mock data +user_id = "test_user_debug_123" +extracted_data = { + "headline": "Debug Engineer", + "summary": "This is a test summary for debugging.", + "skills": "Debug, Python", # DB stores as string + "technical_skills": "SQL, Vector DB", # DB stores as string + "certifications": "", + "languages": "English" # DB stores as string +} + +print(f"DEBUG: Testing embedding storage for User ID: {user_id}") + +# 1. Ensure user exists in profiles first (FK constraint) +try: + print("DEBUG: Ensuring profile exists...") + # UPSERT the mock data into the profiles table so the function can fetch it + profile_payload = { + "id": user_id, + "full_name": "Debug User", + "email": "debug@example.com", + "updated_at": "now()", + # Add the fields we expect to be there + "headline": extracted_data["headline"], + "summary": extracted_data["summary"], + "skills": extracted_data["skills"], + "technical_skills": extracted_data["technical_skills"], + "certifications": extracted_data["certifications"], + "languages": extracted_data["languages"] + } + client.table("profiles").upsert(profile_payload).execute() + print("DEBUG: Profile upserted.") +except Exception as e: + print(f"āŒ Failed to create test profile: {e}") + sys.exit(1) + +# 2. Run the function +print("DEBUG: Running safe_generate_and_store_embeddings...") +# Now it fetches from DB internally, so we don't pass extracted_data +safe_generate_and_store_embeddings(client, user_id) + +# 3. Check if it exists +try: + print("DEBUG: Verifying storage...") + resp = client.table("profile_embeddings").select("*").eq("id", user_id).execute() + if resp.data: + print("āœ… SUCCESS: Embedding record found!") + print(f"Data keys: {resp.data[0].keys()}") + else: + print("āŒ FAILURE: No record found in profile_embeddings.") +except Exception as e: + print(f"āŒ Verification failed: {e}") diff --git a/backend/src/embeddings/job_embed.py b/backend/src/embeddings/job_embed.py new file mode 100644 index 0000000000000000000000000000000000000000..e459f8ab3a85b5b04a403ccc16f3e0962176e8a6 --- /dev/null +++ b/backend/src/embeddings/job_embed.py @@ -0,0 +1,108 @@ +import os +import numpy as np +from typing import List +from dotenv import load_dotenv +from supabase import create_client +from sentence_transformers import SentenceTransformer + +# Load env +load_dotenv() + +SUPABASE_URL = os.environ.get("SUPABASE_URL") +SUPABASE_KEY = os.environ.get("SUPABASE_SERVICE_ROLE_KEY") or os.environ.get("SUPABASE_KEY") + +# Singleton model (same pattern as profile code) +_model = None + +def get_model(): + global _model + if _model is None: + print("šŸ“„ Loading BAAI/bge-m3 model for job embeddings...") + _model = SentenceTransformer("BAAI/bge-m3") + return _model + +def get_supabase(): + if not SUPABASE_URL or not SUPABASE_KEY: + print("āŒ Missing Supabase credentials for job embeddings.") + return None + return create_client(SUPABASE_URL, SUPABASE_KEY) + +# -------- Embedding helpers (IDENTICAL LOGIC) -------- + +def generate_embedding(text: str) -> List[float]: + if not text or not text.strip(): + return [0.0] * 1024 + + model = get_model() + embedding = model.encode(text, normalize_embeddings=True) + return embedding.tolist() + +def generate_list_embedding(items: List[str]) -> List[float]: + if not items: + return [0.0] * 1024 + + model = get_model() + embeddings = model.encode(items, normalize_embeddings=True) + mean_embedding = np.mean(embeddings, axis=0) + return mean_embedding.tolist() + +# ---------------------------------------------------- + +def safe_generate_and_store_job_embeddings(client, job_id: str) -> None: + """ + Fetches job entities, generates entity-wise embeddings, + and upserts them into job_embeddings table. + """ + print(f"🧬 Generating job embeddings for Job: {job_id}") + + # 1. Fetch job entities + resp = client.table("job_entities") \ + .select("*") \ + .eq("job_id", job_id) \ + .execute() + + if not resp.data: + print(f"āš ļø Job entities not found for job_id={job_id}") + return + + entities = resp.data[0] + + # 2. Parse list fields safely (same pattern) + def parse_list(val): + if not val: + return [] + if isinstance(val, list): + return val + if isinstance(val, str): + return [x.strip() for x in val.split(",") if x.strip()] + return [] + + skills = parse_list(entities.get("skills")) + technical_skills = parse_list(entities.get("technical_skills")) + tools = parse_list(entities.get("tools")) + certifications = parse_list(entities.get("certifications")) + + experience = entities.get("experience") or "" + education = entities.get("education") or "" + + try: + # 3. Generate embeddings (ENTITY-WISE) + payload = { + "job_id": job_id, + "skills": generate_list_embedding(skills), + "technical_skills": generate_list_embedding(technical_skills), + "tools": generate_list_embedding(tools), + "experience": generate_embedding(experience), + "education": generate_embedding(education), + "certifications": generate_list_embedding(certifications), + "updated_at": "now()" + } + + # 4. Upsert into job_embeddings + client.table("job_embeddings").upsert(payload).execute() + print(f"āœ… Job embeddings stored for job_id={job_id}") + + except Exception as e: + print(f"āŒ Job embedding generation failed: {e}") + + diff --git a/backend/src/embeddings/local_embedder.py b/backend/src/embeddings/local_embedder.py new file mode 100644 index 0000000000000000000000000000000000000000..cee045026ae30d9aef2adf44f0802779d20656d6 --- /dev/null +++ b/backend/src/embeddings/local_embedder.py @@ -0,0 +1,137 @@ + +import os +import json +import numpy as np +from typing import List, Any +from dotenv import load_dotenv +from supabase import create_client +from sentence_transformers import SentenceTransformer + +# Load env +load_dotenv() + +SUPABASE_URL = os.environ.get("SUPABASE_URL") +SUPABASE_KEY = os.environ.get("SUPABASE_SERVICE_ROLE_KEY") or os.environ.get("SUPABASE_KEY") + +# Initialize Model (Globals are bad but efficient for serverless-ish/script use) +# Using a singleton pattern to avoid reloading model on every call if imported +_model = None + +def get_model(): + global _model + if _model is None: + print("šŸ“„ Loading BAAI/bge-m3 model...") + _model = SentenceTransformer('BAAI/bge-m3') + return _model + +def get_supabase(): + if not SUPABASE_URL or not SUPABASE_KEY: + print("āŒ Missing Supabase credentials for embeddings.") + return None + return create_client(SUPABASE_URL, SUPABASE_KEY) + +def generate_embedding(text: str) -> List[float]: + if not text or not text.strip(): + return [0.0] * 1024 # BGE-M3 is 1024d + + model = get_model() + # BGE-M3 returns 1024 dim + embedding = model.encode(text, normalize_embeddings=True) + return embedding.tolist() + +def generate_list_embedding(items: List[str]) -> List[float]: + if not items: + return [0.0] * 1024 + + model = get_model() + embeddings = model.encode(items, normalize_embeddings=True) + # Mean pooling + mean_embedding = np.mean(embeddings, axis=0) + return mean_embedding.tolist() + +def safe_generate_and_store_embeddings(client, user_id: str) -> None: + """ + Fetches profile data, generates embeddings, and upserts to profile_embeddings. + """ + print(f"🧬 Generating embeddings for User: {user_id}") + + # 1. Fetch Profile + resp = client.table("profiles").select("*").eq("id", user_id).execute() + if not resp.data: + print(f"āš ļø Profile not found for {user_id}") + return + + profile = resp.data[0] + + # 2. Extract Fields + # Text fields + summary = profile.get("summary") or "" + headline = profile.get("headline") or "" + role = profile.get("role") or "" + + # Lists (CSV or Array) - Handle both just in case + def parse_list(val): + if not val: return [] + if isinstance(val, list): return val + if isinstance(val, str): return [x.strip() for x in val.split(",") if x.strip()] + return [] + + skills = parse_list(profile.get("skills")) + tech_skills = parse_list(profile.get("technical_skills")) + # For experience and education, we might need more complex parsing if stored as JSONB + # But for now let's assume simple text representation or skip if complex JSON + # If experience is JSONB, we'll serialize it to text for embedding + experience_raw = profile.get("work_experience") or [] + if isinstance(experience_raw, list): + # It's a list of objects or strings. Convert to list of strings. + experience_texts = [] + for item in experience_raw: + if isinstance(item, dict): + # Flatten: "Role at Company (Year): Description" + role_ = item.get("role") or "" + comp_ = item.get("company") or "" + desc_ = item.get("description") or "" + text = f"{role_} at {comp_}. {desc_}" + experience_texts.append(text) + elif isinstance(item, str): + experience_texts.append(item) + experience = experience_texts + else: + experience = [] + + # 3. Generate Embeddings (Extra fields for completeness) + certifications = parse_list(profile.get("certifications")) + + try: + current_position_emb = generate_embedding(f"{role} {headline}") + summary_emb = generate_embedding(summary) + skills_emb = generate_list_embedding(skills) + technical_skills_emb = generate_list_embedding(tech_skills) + experience_emb = generate_list_embedding(experience) + certifications_emb = generate_list_embedding(certifications) + + # 4. Upsert + # Matches columns in create_profile_embeddings.sql + payload = { + "id": user_id, + "headline": current_position_emb, + "summary": summary_emb, + "skills": skills_emb, + "technical_skills": technical_skills_emb, + "experience": experience_emb, + "certifications": certifications_emb, + "updated_at": "now()" + } + + client.table("profile_embeddings").upsert(payload).execute() + print(f"āœ… Embeddings stored for {user_id}") + + except Exception as e: + print(f"āŒ Embedding generation failed: {e}") + +if __name__ == "__main__": + # Test run + sb = get_supabase() + if sb: + # Replace with a valid ID for testing if needed + pass diff --git a/backend/src/embeddings/process_all_profiles.py b/backend/src/embeddings/process_all_profiles.py new file mode 100644 index 0000000000000000000000000000000000000000..6d72520f8e00a900d4341b104a681b44c5f79ca0 --- /dev/null +++ b/backend/src/embeddings/process_all_profiles.py @@ -0,0 +1,46 @@ + +import sys +import os +import time + +# Add backend to path +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) + +from supabase_ingest import client, safe_generate_and_store_embeddings + +def process_all_profiles(): + print("šŸ” Fetching all user IDs from 'profiles' table...") + + try: + # Fetch all profiles (just IDs needed to trigger the function) + response = client.table("profiles").select("id").execute() + + if not response.data: + print("āš ļø No profiles found in database.") + return + + profiles = response.data + total = len(profiles) + print(f"āœ… Found {total} profiles to process.") + + for i, profile in enumerate(profiles): + user_id = profile['id'] + print(f"\n[{i+1}/{total}] Processing User ID: {user_id}") + + # This function now handles: + # 1. Fetching the full profile data from DB + # 2. Parsing CSV lists + # 3. Generating BGE-M3 embeddings + # 4. Upserting to profile_embeddings + safe_generate_and_store_embeddings(client, user_id) + + # Small delay to be nice to the CPU/API + # time.sleep(0.1) + + print("\nšŸŽ‰ Batch processing complete!") + + except Exception as e: + print(f"āŒ Error fetching profiles: {e}") + +if __name__ == "__main__": + process_all_profiles() diff --git a/backend/src/embeddings/test_embedder.py b/backend/src/embeddings/test_embedder.py new file mode 100644 index 0000000000000000000000000000000000000000..0510b4c7b4f947801f3ea41ef9c9970d9b38e13c --- /dev/null +++ b/backend/src/embeddings/test_embedder.py @@ -0,0 +1,34 @@ + +import sys +import os +import numpy as np + +# Add backend to path +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..'))) + +from backend.src.embeddings.local_embedder import generate_embeddings + +sample_data = { + "headline": "Senior Software Engineer", + "summary": "Experienced in Python and AI.", + "skills": ["Communication", "Leadership", "Agile"], + "technical_skills": ["Python", "FastAPI", "React"], + "certifications": [], # Empty list + "languages": ["English", "Spanish"] +} + +print("Running Embedding Generation Test...") +embeddings = generate_embeddings(sample_data) + +print("\nResults:") +for key, vector in embeddings.items(): + vec_len = len(vector) + print(f"Field: {key:20} | Dimensions: {vec_len} | Sample: {vector[:3]}...") + + if vec_len != 1024: + print(f"āŒ ERROR: Expected 1024 dimensions, got {vec_len}") + +if "certifications" not in embeddings: + print("Field: certifications | Correctly skipped (empty)") + +print("\nDone.") diff --git a/backend/src/extraction/__init__.py b/backend/src/extraction/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/src/extraction/fallback_extractor.py b/backend/src/extraction/fallback_extractor.py new file mode 100644 index 0000000000000000000000000000000000000000..a083bece9fbb01948d8a3a19782cc5e06204dd97 --- /dev/null +++ b/backend/src/extraction/fallback_extractor.py @@ -0,0 +1,51 @@ +import re + +def extract_fallback(text: str) -> dict: + """ + A dumb Regex-based fallback extractor if Gemini fails. + Extracts basic info like Email, Phone, Links, and keyword-matched Skills. + """ + + # 1. Email (Basic) + email_params = r"[\w\.-]+@[\w\.-]+\.\w+" + email_match = re.search(email_params, text) + email = email_match.group(0) if email_match else None + + # 2. Phone (Very Basic - catches 10-12 digit numbers) + phone_match = re.search(r"(\+?\d{1,3}[-.\s]?)?(\(?\d{3}\)?[-.\s]?)?\d{3}[-.\s]?\d{4}", text) + phone = phone_match.group(0) if phone_match else None + + # 3. Links (LinkedIn / GitHub / Portfolio) + links = re.findall(r"https?://[^\s]+", text) + linkedin = next((l for l in links if "linkedin.com" in l), None) + github = next((l for l in links if "github.com" in l), None) + portfolio = next((l for l in links if l not in [linkedin, github]), None) + + # 4. Keyword Matching for Skills (Static List) + COMMON_SKILLS = [ + "Python", "Java", "JavaScript", "TypeScript", "C++", "C#", "SQL", "NoSQL", + "React", "Angular", "Vue", "Node.js", "Django", "Flask", "FastAPI", + "AWS", "Azure", "GCP", "Docker", "Kubernetes", "Git", "CI/CD", + "Machine Learning", "Deep Learning", "NLP", "Pandas", "NumPy", "TensorFlow", "PyTorch" + ] + + found_skills = [skill for skill in COMMON_SKILLS if re.search(r"\b" + re.escape(skill) + r"\b", text, re.IGNORECASE)] + + # 5. Construct Payload (Matches Schema) + return { + "headline": None, + "summary": text[:500] + "..." if len(text) > 500 else text, # Fallback summary is just first 500 chars + "skills": found_skills, + "technical_skills": found_skills, # Duplicate for safety + "education": [], + "work_experience": [], + "certifications": [], + "languages": [], + "experience_years": None, + # Extra fields specific to Supabase Ingest (mapped later) + # "email": email, # Backend doesn't use extracted email usually (uses auth), but good to have + "phone": phone, + "linkedin": linkedin, + "github": github, + "portfolio": portfolio + } diff --git a/backend/src/extraction/job_extractor.py b/backend/src/extraction/job_extractor.py new file mode 100644 index 0000000000000000000000000000000000000000..a8356c2bb84c6e5aa683c91ec9588fc060941e15 --- /dev/null +++ b/backend/src/extraction/job_extractor.py @@ -0,0 +1,181 @@ +import os +import re +import json +import time +from dotenv import load_dotenv +from typing import Any, Dict, List, Optional +from google import genai +from google.genai import types + +from supabase import create_client + +# ------------------ CONFIGURATION ------------------ +RAW_DIR = "data/jobs/raw" +PROCESSED_DIR = "data/jobs/entities" + +# ------------------ SETUP ------------------ +load_dotenv() +SUPABASE_URL = os.environ.get("SUPABASE_URL") +SUPABASE_KEY = os.environ.get("SUPABASE_SERVICE_ROLE_KEY") or os.environ.get("SUPABASE_KEY") +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") + +if GEMINI_API_KEY: + try: + client = genai.Client(api_key=GEMINI_API_KEY) + except Exception as e: + client = None + print(f"āš ļø Failed to initialize Gemini client: {e}") +else: + client = None + print("āš ļø GEMINI_API_KEY not set; extraction will be disabled.") + + +def clean_text(text: str) -> str: + text = re.sub(r"<.*?>", " ", text) + text = re.sub(r"[^\x00-\x7F]+", " ", text) + text = re.sub(r"\s+", " ", text) + return text.strip() + + +def extract_job_entities_gemini(text: str) -> Dict[str, Any]: + cleaned_text = clean_text(text) + + system_prompt = """ + You are an intelligent information extractor specialized in job descriptions. + Your task is to extract ONLY what is explicitly mentioned and categorize them into the following JSON structure. + + Output JSON Schema: + { + "skills": ["List of soft skills, general competencies..."], + "technical_skills": ["List of technical skills, programming languages, tools..."], + "qualification": ["List of educational qualifications..."], + "work_experience": ["List of work experience requirements..."], + "preferred_skills": ["List of preferred/nice-to-have skills..."] + } + + Rules: + - Extract exact text as it appears. + - Do NOT infer or add anything not stated. + - If no data for a category, return an empty list []. + - Output MUST be valid JSON. + """ + + if client is None: + print("āŒ Extraction disabled (no Client).") + return {} + + max_retries = 3 + for attempt in range(max_retries): + try: + response = client.models.generate_content( + model="gemini-2.5-flash-lite", + contents=system_prompt + "\n\nJOB DESCRIPTION:\n" + cleaned_text, + config=types.GenerateContentConfig( + temperature=0.1, + response_mime_type="application/json" + ) + ) + + extracted_text = response.text.strip() + # Clean potential markdown fences if present (though response_mime_type usually handles it) + if extracted_text.startswith("```json"): + extracted_text = extracted_text[7:] + if extracted_text.startswith("```"): + extracted_text = extracted_text[3:] + if extracted_text.endswith("```"): + extracted_text = extracted_text[:-3] + + return json.loads(extracted_text) + + except Exception as e: + error_str = str(e) + if "503" in error_str or "overloaded" in error_str.lower(): + wait_time = 2 ** (attempt + 1) + print(f"āš ļø Model overloaded. Retrying in {wait_time}s...") + time.sleep(wait_time) + else: + print(f"āŒ Gemini Extraction failed: {e}") + return {} + + return {} + + +def upsert_job_entities(sb, job_id: str, experience_level: str, data: Dict[str, Any]) -> None: + """ + Upserts the extracted entities into the jobs_entities table. + """ + payload = { + "job_id": job_id, + "experience_level": experience_level, + "skills": data.get("skills", []), + "technical_skills": data.get("technical_skills", []), + "qualification": data.get("qualification", []), + "work_experience": data.get("work_experience", []), + "preferred_skills": data.get("preferred_skills", []), + "updated_at": "now()" + } + + try: + sb.table("jobs_entities").upsert(payload).execute() + print(f"āœ… Database updated for Job ID: {job_id}") + except Exception as e: + print(f"āŒ DB Upsert Error for {job_id}: {e}") + + +def process_single_job(sb, job_id: str, description: str, experience_level: str = None) -> None: + """ + Processes a single job: extracts entities and upserts to DB. + """ + if not description or not description.strip(): + print(f"āš ļø Skipping empty description for job {job_id}") + return + + print(f"šŸ” Processing Job ID: {job_id}") + + extracted_data = extract_job_entities_gemini(description) + if not extracted_data: + print("āš ļø No entities extracted.") + return + + upsert_job_entities(sb, job_id, experience_level, extracted_data) + + + +def process_jobs_from_db() -> None: + if not SUPABASE_URL or not SUPABASE_KEY: + print("āš ļø SUPABASE_URL or SUPABASE_KEY not set; skipping job fetch") + return + + try: + sb = create_client(SUPABASE_URL, SUPABASE_KEY) + except Exception as e: + print(f"āš ļø Failed to create Supabase client: {e}") + return + + # Fetch jobs from 'jobs' table + try: + resp = sb.table("jobs").select("id, description, experience_level").execute() + except Exception as e: + print(f"āš ļø Supabase query failed: {e}") + return + + data = resp.data if hasattr(resp, "data") else [] + if not data: + print("āš ļø No job descriptions returned from Supabase.") + return + + print(f"found {len(data)} jobs to process.") + + os.makedirs(PROCESSED_DIR, exist_ok=True) + + for row in data: + job_id = row.get("id") + desc = row.get("description") or "" + + process_single_job(sb, job_id, desc, experience_level) + + +if __name__ == '__main__': + print("🧪 Starting job entity extraction (DB -> Gemini -> DB)...\n") + process_jobs_from_db() + print("\nšŸŽÆ All jobs processed.") \ No newline at end of file diff --git a/backend/src/extraction/person_details_extraction_gemini.py b/backend/src/extraction/person_details_extraction_gemini.py new file mode 100644 index 0000000000000000000000000000000000000000..1536adf39a71813ee7fc2b5a707559409113cbb4 --- /dev/null +++ b/backend/src/extraction/person_details_extraction_gemini.py @@ -0,0 +1,220 @@ +from src.ingestion.parser import parse_file +import json +from dotenv import load_dotenv +from pathlib import Path +import os +from google import genai +import google.genai.types as types +import time + +# Load env +load_dotenv() + + +client=genai.Client(api_key=os.getenv("GEMINI_API_KEY")) + +BASE_DIR = Path(__file__).resolve().parents[2] +RAW_DIR = BASE_DIR / "data" / "resumes" / "raw" + + +SYSTEM_PROMPT = """ +You are a precise resume entity extraction engine. + +TASK: +Extract ONLY the information explicitly present in the resume text. + +OUTPUT RULES: +- Output MUST be valid JSON +- Do NOT hallucinate. If a field is missing, use null. +-Include empty lists for missing array fields. +-Include all fields in the output, even if null or empty. +-Include only the fields specified in the schema below. +- Do NOT include any explanations, notes, or extra text outside the JSON. +- Ensure the JSON is properly formatted and parsable. +- Return "work_experience" as a LIST of objects with fields: role, company, year, duration, description. +- Calculate "duration" (e.g. "2 years", "6 months") from dates if not explicitly stated. +- Return "education" as a LIST of objects with fields: course, institution, year. +- For "skills", "technical_skills", "certifications", and "languages", return LISTS of strings. +- **CRITICAL**: "languages" refers ONLY to human spoken/written languages (e.g., English, Hindi, Spanish). Programming languages (Python, Java, etc.) MUST go into "technical_skills". +- For single-value fields like "role", "headline", "summary" and return STRING or null. +-Calculate experience_years as an INTEGER representing total years of experience, or null if not derivable. +-only use the field names and structure defined in the schema below. +-strictLY follow the JSON schema provided. + +JSON SCHEMA: +{ + "headline": string | null, + "summary": string | null, + "skills": string[], + "technical_skills": string[], <-- Put Programming Languages HERE + "education": [ + { + "course": string | null, + "institution": string | null, + "year": string | null + } + ], + "work_experience": [ + { + "role": string | null, + "company": string | null, + "years": string | null, + "duration": string | null, + "description": string | null + } + ], + "projects": [ + { + "title": "string | null", + "technologies_used": ["string"], + "description": string | null + } + ], + "projects": [ + { + "title": "string | null", + "technologies_used": ["string"], + "description": "string | null" + } + ], + "certifications": string[], + "languages": string[], + "experience_years": integer | null +} +"current_position": string | null, +""" + + +def extract_resume_entities_gemini(text: str) -> dict: + max_retries = 3 + + for attempt in range(max_retries): + response = client.models.generate_content( + model="gemini-2.5-flash-lite", + contents=SYSTEM_PROMPT + "\n\nRESUME TEXT:\n" + text, + config=types.GenerateContentConfig( + temperature=0, + # 2. CRITICAL: This forces Gemini to return raw JSON without Markdown formatting + response_mime_type="application/json" + ) + ) + + try: + # 3. Clean the response just in case (removes accidental backticks) + cleaned_text = response.text.strip() + if cleaned_text.startswith("```json"): + cleaned_text = cleaned_text[7:] + if cleaned_text.startswith("```"): + cleaned_text = cleaned_text[3:] + if cleaned_text.endswith("```"): + cleaned_text = cleaned_text[:-3] + + return json.loads(cleaned_text) + + except Exception as e: + # Check if it's the "Overloaded" (503) error + error_str = str(e) + if "503" in error_str or "overloaded" in error_str.lower(): + wait_time = 2 ** (attempt + 1) # Exponential backoff: 2s, 4s, 8s... + print(f"āš ļø Model overloaded. Retrying in {wait_time} seconds... (Attempt {attempt+1}/{max_retries})") + time.sleep(wait_time) + else: + # If it's a different error (like Auth), fail immediately + print(f"āŒ Gemini Error: {e}") + return {} + + except json.JSONDecodeError: + print(f"āŒ JSON Decode Error. Raw response was: {response.text}") + raise ValueError("Gemini returned invalid JSON") + except Exception as e: + # Catch model overload or safety filter blocks + print(f"āŒ Gemini Error: {e}") + return {} + + +def process_raw_resumes(): + if not RAW_DIR.exists(): + raise FileNotFoundError(f"Directory not found: {RAW_DIR}") + + for file_path in RAW_DIR.iterdir(): + if file_path.suffix.lower() not in [".pdf", ".docx", ".txt"]: + continue + + print(f"\nšŸ“„ Processing: {file_path.name}") + + try: + text = parse_file(str(file_path)) + entities = extract_resume_entities_gemini(text) + + print("āœ… Extracted entities:") + print(entities) + + except Exception as e: + print(f"āŒ Failed for {file_path.name}: {e}") + +from src.extraction.fallback_extractor import extract_fallback +from src.preprocess.regex_pii import extract_contact_info_regex, mask_contact_info_regex +from src.preprocess.anonymizer import extract_name_and_mask + +def process_single_resume(file_path: str) -> dict: + """ + Helper function for supabase_ingest.py to process a single downloaded file. + """ + text = "" + pii_data = {} + + try: + # 1. Convert file path to string just in case + path_str = str(file_path) + + # 2. Parse the text from the file (PDF/DOCX) + raw_text = parse_file(path_str) + + # 3a. Privacy Step 1: Extract and Mask Contact Info (Regex) + print("šŸ”’ [1/2] Masking Phone/Email/Links...") + pii_contact = extract_contact_info_regex(raw_text) + masked_text_v1 = mask_contact_info_regex(raw_text) + + # 3b. Privacy Step 2: Extract and Mask Candidate Name (NER) + print("šŸ”’ [2/2] Masking Names (NER)...") + ner_result = extract_name_and_mask(masked_text_v1) + final_masked_text = ner_result["masked_text"] + candidate_name = ner_result["candidate_name"] + + # Merge PII Data + pii_data = pii_contact + pii_data["full_name"] = candidate_name + + # Store masked text for error handling usage + text = final_masked_text + print(f"DEBUG: Final Masked Text Length: {len(text)}") + if len(text) < 50: + print("āš ļø WARNING: Masked text is suspiciously short!") + + # 4. Send FINAL MASKED text to Gemini + print("🧠 Sending to Gemini...") + extracted = extract_resume_entities_gemini(final_masked_text) + + # 5. Fallback if Gemini failed + if not extracted: + print("āš ļø Gemini returned empty. Using Regex Fallback.") + extracted = extract_fallback(final_masked_text) + + # 6. Merge PII back into results (whether from Gemini or Fallback) + extracted.update(pii_data) + + return extracted + + except Exception as e: + print(f"āŒ Error processing {file_path}: {e}") + # Final Fallback attempt + if text: + print("āš ļø Exception occurred. Using Regex Fallback on masked text.") + fallback_data = extract_fallback(text) + fallback_data.update(pii_data) + return fallback_data + return pii_data + + +if __name__ == "__main__": + process_raw_resumes() \ No newline at end of file diff --git a/backend/src/extraction/test_regex.py b/backend/src/extraction/test_regex.py new file mode 100644 index 0000000000000000000000000000000000000000..4e7054260448b1d56be19a038902e86c6adfa80f --- /dev/null +++ b/backend/src/extraction/test_regex.py @@ -0,0 +1,33 @@ +import sys +import os + +# Add backend to path so we can import +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..'))) + +from backend.src.extraction.fallback_extractor import extract_fallback + +test_cases = [ + "+91 9876543210", # India + "+919876543210", # India No Space + "9876543210", # India Local + "+1 212-555-0199", # US + "+44 7911 123456", # UK Mobile + "+971 50 1234567", # UAE + "+61 412 345 678", # Australia + "+49 151 12345678", # Germany + "+33 6 12 34 56 78", # France + "+81 90-1234-5678", # Japan + "Phone: +91 98765-43210", # In text + "Call me at 123-456-7890", # US Local in text + "No phone number here" +] + + +print("Testing extract_fallback Phone Extraction:") +with open("test_output.txt", "w", encoding="utf-8") as f: + for t in test_cases: + result = extract_fallback(t) + output_line = f"'{t}' -> {result.get('phone')}" + print(output_line) + f.write(output_line + "\n") + diff --git a/backend/src/ingestion/__init__.py b/backend/src/ingestion/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/src/ingestion/docx_reader.py b/backend/src/ingestion/docx_reader.py new file mode 100644 index 0000000000000000000000000000000000000000..2694cc658cd17f09260c5e68e822fce35930ac51 --- /dev/null +++ b/backend/src/ingestion/docx_reader.py @@ -0,0 +1,11 @@ +from docx import Document +from src.preprocess.cleaner import postprocess_extracted_text + +def parse_docx(path: str) -> str: + """ + Extract text from DOCX file. + Returns postprocessed text ready for NER and cleaning. + """ + doc = Document(path) + text = "\n".join([p.text for p in doc.paragraphs if p.text.strip()]) + return postprocess_extracted_text(text) diff --git a/backend/src/ingestion/parser.py b/backend/src/ingestion/parser.py new file mode 100644 index 0000000000000000000000000000000000000000..0f4145d37a41bf3e2ff72521119ad177674b118f --- /dev/null +++ b/backend/src/ingestion/parser.py @@ -0,0 +1,21 @@ +import os +from .pdf_reader import parse_pdf +from .docx_reader import parse_docx +from src.preprocess.cleaner import postprocess_extracted_text +from src.preprocess.cleaner import clean_text +from src.preprocess.anonymizer import remove_pii + +def parse_file(path: str) -> str: + """Detect file type and parse accordingly.""" + ext = os.path.splitext(path)[1].lower() + if ext == ".pdf": + text = parse_pdf(path) + elif ext == ".docx": + text = parse_docx(path) + elif ext == ".txt": + with open(path, "r", encoding="utf-8", errors="ignore") as f: + text = f.read() + else: + raise ValueError(f"Unsupported file type: {ext}") + + return postprocess_extracted_text(remove_pii(clean_text(text))) diff --git a/backend/src/ingestion/pdf_reader.py b/backend/src/ingestion/pdf_reader.py new file mode 100644 index 0000000000000000000000000000000000000000..56ae26affd250db446531374f2b8bf4da5a81645 --- /dev/null +++ b/backend/src/ingestion/pdf_reader.py @@ -0,0 +1,38 @@ +import pypdf +import pdfplumber +from src.preprocess.cleaner import postprocess_extracted_text + +def parse_pdf(path: str) -> str: + """ + Extract text from a PDF file. + Tries pdfplumber first, falls back to pypdf. + Returns postprocessed text. + """ + text = "" + + # --- pdfplumber extraction --- + try: + with pdfplumber.open(path) as pdf: + for page in pdf.pages: + page_text = page.extract_text() + if page_text: + text += page_text + "\n" + if text.strip(): + return postprocess_extracted_text(text) + except Exception as e: + print(f"āš ļø pdfplumber failed for {path}: {e}") + + # --- fallback to pypdf --- + try: + with open(path, "rb") as f: + reader = pypdf.PdfReader(f) + for page in reader.pages: + page_text = page.extract_text() + if page_text: + text += page_text + "\n" + if text.strip(): + return postprocess_extracted_text(text) + except Exception as e: + print(f"āŒ pypdf also failed for {path}: {e}") + + raise ValueError(f"Unable to extract text from PDF: {path}") diff --git a/backend/src/matching/__init__.py b/backend/src/matching/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/src/matching/similarity.py b/backend/src/matching/similarity.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/src/matching/trainer.py b/backend/src/matching/trainer.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/src/preprocess/__init__.py b/backend/src/preprocess/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..23c5d8193d9f995efa4ae6b327a8fbdc7a36a283 --- /dev/null +++ b/backend/src/preprocess/__init__.py @@ -0,0 +1,8 @@ +# src/preprocess/__init__.py + +from .cleaner import clean_text + +# Note: do not import `anonymizer` at package import time because it +# brings heavy dependencies (transformers / torch). Import it lazily +# where needed to avoid import-time failures on systems without +# working GPU/CUDA or where torch import can fail. diff --git a/backend/src/preprocess/anonymizer.py b/backend/src/preprocess/anonymizer.py new file mode 100644 index 0000000000000000000000000000000000000000..e1f0ab66c516cb732376d97dcbc0fe32b2c6e89b --- /dev/null +++ b/backend/src/preprocess/anonymizer.py @@ -0,0 +1,74 @@ +import re +from transformers import pipeline +from src.preprocess.cleaner import postprocess_extracted_text + +# Load Hugging Face NER pipeline once +ner_pipeline = pipeline( + "token-classification", + model="dslim/distilbert-NER", + aggregation_strategy="simple" +) + +def extract_name_and_mask(text: str) -> dict: + """ + Runs NER to: + 1. Extract the candidate's name (heuristic: first PER entity). + 2. Mask ALL Name (PER) entities in the text. + + Returns: + { + "masked_text": str, + "candidate_name": str or None + } + """ + # 1. Run NER + entities = ner_pipeline(text) + spans = [] + candidate_name = None + + # 2. Collect Spans + for ent in entities: + label = ent.get("entity_group") or ent.get("entity") + if label in {"PER"}: # Only mask PER + spans.append({ + "start": ent["start"], + "end": ent["end"], + "label": label, + "word": ent.get("word", text[ent["start"]:ent["end"]]) + }) + + # Heuristic: The earliest PER entity is likely the candidate name + if not candidate_name: + candidate_name = text[ent["start"]:ent["end"]].strip() + + # 3. Merge overlapping or adjacent spans + merged_spans = [] + for span in sorted(spans, key=lambda s: s["start"]): + if merged_spans and span["start"] <= merged_spans[-1]["end"] + 1: + merged_spans[-1]["end"] = max(span["end"], merged_spans[-1]["end"]) + else: + merged_spans.append(span) + + # 4. Refine Candidate Name from merged spans + # first_per_span = next((s for s in merged_spans if s["label"] == "PER"), None) + # if first_per_span: + # candidate_name = text[first_per_span["start"]:first_per_span["end"]] + + # WE DO NOT EXTRACT NAMES ANYMORE, ONLY MASK THEM. + candidate_name = None + + # 5. Mask Text (Apply placeholders) + # Replace from back to avoid shifting indices + masked_text = text + for span in reversed(merged_spans): + placeholder = f"[{span['label']}]" + masked_text = masked_text[:span["start"]] + placeholder + masked_text[span["end"]:] + + return { + "masked_text": postprocess_extracted_text(masked_text), + "candidate_name": candidate_name # Will be None + } + +def remove_pii(text: str) -> str: + """Deprecated wrapper. Use extract_name_and_mask instead.""" + return extract_name_and_mask(text)["masked_text"] diff --git a/backend/src/preprocess/cleaner.py b/backend/src/preprocess/cleaner.py new file mode 100644 index 0000000000000000000000000000000000000000..9932660a33b2c47cf3cc8a2b507a7642b87f07a0 --- /dev/null +++ b/backend/src/preprocess/cleaner.py @@ -0,0 +1,29 @@ +import re +import unicodedata +from nltk.corpus import stopwords + +# Load English stopwords +STOPWORDS = set(stopwords.words("english")) + +def postprocess_extracted_text(text: str) -> str: # space between lower-uppercase + text = re.sub(r'[\t\r\n]+', ' ', text) # remove tabs/newlines + #text = re.sub(r' {2,}', ' ', text).strip() # remove multiple spaces + return text + +def clean_text(text: str) -> str: + text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii") + + # Remove URLs + text = re.sub(r'http\S+|www\S+|https\S+', '', text) + + # Remove emails + text = re.sub(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b', '', text) + + # Remove stopwords + tokens = text.split() + tokens = [word for word in tokens if word not in STOPWORDS] + text = " ".join(tokens) + + # Normalize spaces + text = re.sub(r'\s+', ' ', text).strip() + return text diff --git a/backend/src/preprocess/job_preprocess.py b/backend/src/preprocess/job_preprocess.py new file mode 100644 index 0000000000000000000000000000000000000000..153bc927064a76126953fc29a0b6e85ea9c9f831 --- /dev/null +++ b/backend/src/preprocess/job_preprocess.py @@ -0,0 +1,49 @@ +import os +from typing import Optional + +from .cleaner import clean_text + + +def preprocess_jobs(raw_dir: str = "data/jobs/raw", out_dir: str = "data/jobs/preprocessed") -> Optional[int]: + """Read all .txt files from `raw_dir`, clean them using `clean_text`, + and write cleaned versions to `out_dir` preserving filenames. + + Returns the number of files processed or None on error. + """ + try: + if not os.path.isdir(raw_dir): + print(f"āš ļø Raw jobs dir does not exist: {raw_dir}") + return 0 + + os.makedirs(out_dir, exist_ok=True) + + files = [f for f in os.listdir(raw_dir) if f.lower().endswith(".txt")] + for fname in files: + src_path = os.path.join(raw_dir, fname) + dst_path = os.path.join(out_dir, fname) + # skip if already preprocessed + if os.path.exists(dst_path) and os.path.getsize(dst_path) > 0: + print(f"Skipping already preprocessed file: {dst_path}") + continue + try: + with open(src_path, "r", encoding="utf-8") as rf: + text = rf.read() + except Exception as e: + print(f"āš ļø Failed to read {src_path}: {e}") + continue + + cleaned = clean_text(text) + + try: + with open(dst_path, "w", encoding="utf-8") as wf: + wf.write(cleaned) + except Exception as e: + print(f"āš ļø Failed to write {dst_path}: {e}") + continue + + print(f"Preprocessed job file: {src_path} -> {dst_path}") + + return len(files) + except Exception as e: + print(f"āš ļø preprocess_jobs failed: {e}") + return None diff --git a/backend/src/preprocess/regex_pii.py b/backend/src/preprocess/regex_pii.py new file mode 100644 index 0000000000000000000000000000000000000000..0ec638efe22d534601a53dac247f907105a2c7dc --- /dev/null +++ b/backend/src/preprocess/regex_pii.py @@ -0,0 +1,78 @@ +import re + +# Regex Constants +EMAIL_REGEX = r"[\w\.-]+@[\w\.-]+\.\w+" +# Robust Regex for International & Indian formats +# Matches: +# +91 98765 43210 +# +91-98765-43210 +# 9876543210 +# 0987-654-3210 +# (0)9876543210 +PHONE_REGEX = r"(?:\+?(\d{1,3}))?[-. (]*(\d{2,5})[-. )]*(\d{2,5})[-. ]*(\d{2,5})(?:[-. ]*(\d{1,4}))?" +URL_REGEX = r"https?://[^\s,)\"']+" + +def extract_contact_info_regex(text: str) -> dict: + """ + Extracts Phone, Email, and Links (LinkedIn, GitHub, Portfolio) using Regex. + Returns a dictionary suitable for merging into the final profile payload. + """ + + # 1. Email + email_match = re.search(EMAIL_REGEX, text) + email = email_match.group(0) if email_match else None + + # 2. Phone + # Find all matches and pick the longest/most likely one + phone_matches = re.finditer(PHONE_REGEX, text) + phone = None + # Heuristic: Pick the first valid-looking match that is at least 10 chars + for match in phone_matches: + p = match.group(0) + if len(re.sub(r"\D", "", p)) >= 10: + phone = p.strip() + break + + # 3. Links + links = re.findall(URL_REGEX, text) + + linkedin = next((l for l in links if "linkedin.com" in l), None) + github = next((l for l in links if "github.com" in l), None) + + # Portfolio is any other link that isn't specific social media + # Excluding common junk like google.com or fonts.googleapis if they appear + exclude_domains = ["linkedin.com", "github.com", "google.com", "facebook.com", "twitter.com", "instagram.com"] + portfolio = None + for l in links: + if not any(d in l for d in exclude_domains): + portfolio = l + break + + return { + "email": email, # While auth handles this, extracting it doesn't hurt + "phone": phone, + "linkedin": linkedin, + "github": github, + "portfolio": portfolio + } + +def mask_contact_info_regex(text: str) -> str: + """ + Replaces Phone, Email, and Links with [REDACTED] placeholders + to prevent PII leakage to LLMs. + """ + + # Mask Emails + text = re.sub(EMAIL_REGEX, "[EMAIL_REDACTED]", text) + + # Mask Phone Numbers + # Using a slightly more aggressive regex for masking to catch variants + text = re.sub(PHONE_REGEX, "[PHONE_REDACTED]", text) + + # Mask Links + # We mask ALL links to be safe, or just the specific ones? + # User said extract specific ones. + # Safer to mask all URLs to prevent "portfolio" leaking personal domain names. + text = re.sub(URL_REGEX, "[LINK_REDACTED]", text) + + return text diff --git a/backend/src/services/ats_service.py b/backend/src/services/ats_service.py new file mode 100644 index 0000000000000000000000000000000000000000..8833d6985192ec231c576aa3d22c3080dfcf0a2d --- /dev/null +++ b/backend/src/services/ats_service.py @@ -0,0 +1,150 @@ + +import os +import shutil +import tempfile +from pathlib import Path +from fastapi import UploadFile +from src.ingestion.parser import parse_file +from src.extraction.person_details_extraction_gemini import extract_resume_entities_gemini +from src.extraction.job_extractor import extract_job_entities_gemini + +async def analyze_ats_compatibility(resume_file: UploadFile, job_description: str): + """ + Orchestrates the ATS analysis: + 1. Saves uploaded resume to temp file. + 2. Parses text from resume. + 3. Extracts entities from resume (using Gemini). + 4. Extracts entities from JD (using Gemini). + 5. Compares and calculates score. + """ + temp_file_path = None + try: + # 1. Save UploadFile to a temporary file + suffix = Path(resume_file.filename).suffix + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: + shutil.copyfileobj(resume_file.file, tmp) + temp_file_path = tmp.name + + # 2. Parse Text + resume_text = parse_file(temp_file_path) + + # 3. Extract Resume Entities + # We assume extract_resume_entities_gemini takes raw text + resume_data = extract_resume_entities_gemini(resume_text) + + # 4. Extract Job Entities + job_data = extract_job_entities_gemini(job_description) + + # 5. Compare and Score + analysis_result = calculate_ats_score(resume_data, job_data) + + return analysis_result + + finally: + # Cleanup + if temp_file_path and os.path.exists(temp_file_path): + os.remove(temp_file_path) + + +def calculate_ats_score(resume_data: dict, job_data: dict) -> dict: + """ + Compares resume entities with job requirements to generate a score and insights. + """ + score = 0 + max_score = 100 + matches = [] + recommendations = [] + + # --- 1. Skill Matching (Weight: 60%) --- + # Merge all job skills into a set for easier lookup + job_skills = set() + if job_data.get("technical_skills"): + job_skills.update([s.lower() for s in job_data["technical_skills"]]) + if job_data.get("skills"): + job_skills.update([s.lower() for s in job_data["skills"]]) + + # Merge all resume skills + resume_skills = set() + if resume_data.get("technical_skills"): + resume_skills.update([s.lower() for s in resume_data["technical_skills"]]) + if resume_data.get("skills"): + resume_skills.update([s.lower() for s in resume_data["skills"]]) + + # Calculate overlaps + found_skills = job_skills.intersection(resume_skills) + missing_skills = job_skills - resume_skills + + # Keyword Match Logic + total_job_skills = len(job_skills) + skill_score = 0 + + if total_job_skills > 0: + match_percentage = len(found_skills) / total_job_skills + skill_score = match_percentage * 60 # Max 60 points + else: + # If no skills extracted from JD, give full marks for this section (benefit of doubt) or 0? + # Let's give neutral 30 + skill_score = 30 + + # formatting matches for frontend + # Priority: High (Technical), Medium (General) - Simplified for now + for skill in found_skills: + matches.append({"keyword": skill.title(), "found": True, "importance": "High"}) + for skill in missing_skills: + matches.append({"keyword": skill.title(), "found": False, "importance": "High"}) + + # --- 2. Experience Matching (Weight: 20%) --- + # This is harder to match exactly without more complex logic, + # so we'll do a basic check if "experience" or "years" is mentioned in JD and Resume. + # For now, we'll give partial credit just for having an experience section. + experience_score = 0 + if resume_data.get("work_experience") and len(resume_data["work_experience"]) > 0: + experience_score = 20 + else: + recommendations.append("Add a 'Work Experience' section with detailed roles and achievements.") + + # --- 3. Education Matching (Weight: 10%) --- + education_score = 0 + if resume_data.get("education") and len(resume_data["education"]) > 0: + education_score = 10 + else: + recommendations.append("Include an 'Education' section listing your degrees and institutions.") + + # --- 4. Formatting/Completeness (Weight: 10%) --- + format_score = 0 + if resume_data.get("email") or resume_data.get("phone"): + format_score += 5 + else: + recommendations.append("Ensure your contact information (Email/Phone) is clearly visible.") + + if resume_data.get("summary"): + format_score += 5 + else: + recommendations.append("Add a professional summary at the top of your resume.") + + # --- Final Calculation --- + total_score = int(skill_score + experience_score + education_score + format_score) + + # Cap at 100 + total_score = min(100, max(0, total_score)) + + # Generate Summary + summary = "" + if total_score >= 80: + summary = "Excellent match! Your resume is well-optimized for this role." + elif total_score >= 60: + summary = "Good match, but there are some missing key skills." + else: + summary = "Low match. Consider tailoring your resume specifically for this job description." + + if missing_skills: + recommendations.insert(0, f"Add missing keywords: {', '.join(list(missing_skills)[:5])}...") + + return { + "score": total_score, + "matches": matches, + "summary": summary, + "recommendations": recommendations, + "debug_resume_skills": list(resume_skills), # Helpful for debugging + "debug_job_skills": list(job_skills) + } diff --git a/backend/src/utils/__init__.py b/backend/src/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/src/utils/file_utils.py b/backend/src/utils/file_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..d23d4424dbd64021e8a4b43cd45673f4a18f3017 --- /dev/null +++ b/backend/src/utils/file_utils.py @@ -0,0 +1,6 @@ +import os + +def save_text(text: str, path: str): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(text) diff --git a/backend/src/utils/logger.py b/backend/src/utils/logger.py new file mode 100644 index 0000000000000000000000000000000000000000..10ba00d7608de0f8c9cb6388ffeebb9d97b9afcb --- /dev/null +++ b/backend/src/utils/logger.py @@ -0,0 +1,8 @@ +import logging + +def get_logger(name=__name__): + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s" + ) + return logging.getLogger(name) diff --git a/backend/supabase_ingest.py b/backend/supabase_ingest.py new file mode 100644 index 0000000000000000000000000000000000000000..5f99f574581c4ee9115fed34ac33e1f894ed10b0 --- /dev/null +++ b/backend/supabase_ingest.py @@ -0,0 +1,306 @@ +""" +supabase_ingest.py + +- Recursively downloads resumes from Supabase Storage +- Extractor: Uses Google Gemini (via src.preprocess.person_details_extraction_gemini) +- Database: Maps JSON to 'profiles' table columns +- Updates: Uses User ID (id) as the unique key to prevent errors +""" + +from __future__ import annotations + +import argparse +import os +import hashlib +from typing import List, Dict, Any + +from dotenv import load_dotenv +from supabase import create_client + +# āœ… CORRECT IMPORT based on your file structure +from src.extraction.person_details_extraction_gemini import process_single_resume + +# --------------------------------------------------------------------- +# ENV SETUP +# --------------------------------------------------------------------- + +from pathlib import Path + +# Explicitly load .env from the backend directory +env_path = Path(__file__).resolve().parent / ".env" +load_dotenv(dotenv_path=env_path) + +# ENV SETUP +# --------------------------------------------------------------------- + +from pathlib import Path + +# Load env safely +env_path = Path(__file__).resolve().parent / ".env" +load_dotenv(dotenv_path=env_path) + +SUPABASE_URL = os.environ.get("SUPABASE_URL") +SUPABASE_KEY = os.environ.get("SUPABASE_SERVICE_ROLE_KEY") or os.environ.get("SUPABASE_KEY") + +# WARNING: Only raise error if running as MAIN script. +# If imported as a library, allow it to pass (API provides its own client). +client = None + +if SUPABASE_URL and SUPABASE_KEY: + if not SUPABASE_URL.endswith("/"): + SUPABASE_URL += "/" + try: + client = create_client(SUPABASE_URL, SUPABASE_KEY) + except Exception as e: + print(f"āš ļø Warning: Failed to create global Supabase client: {e}") +else: + print("āš ļø Warning: Supabase Credentials not found in environment. Only library functions will fail if called without a client.") + +ALLOWED_EXTENSIONS = {".pdf", ".docx"} + +# --------------------------------------------------------------------- +# UTILS +# --------------------------------------------------------------------- + +def ensure_dir(path: str) -> None: + os.makedirs(path, exist_ok=True) + +def compute_file_hash(path: str) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + h.update(chunk) + return h.hexdigest() + +def extract_user_id(object_path: str) -> str: + # Assumes folder structure: user_id/filename.pdf + return object_path.split("/", 1)[0] + +# --------------------------------------------------------------------- +# SUPABASE STORAGE HELPERS +# --------------------------------------------------------------------- + +def list_all_objects(client, bucket: str, prefix: str = "") -> List[str]: + storage = client.storage.from_(bucket) + results = [] + resp = storage.list(prefix) or [] + + for item in resp: + name = item.get("name") + if not name: + continue + + full_path = f"{prefix}/{name}".strip("/") + + # If it has 'id', it's a file. If not, it's a folder. + if item.get("id") and item.get("metadata"): + results.append(full_path) + else: + results.extend(list_all_objects(client, bucket, full_path)) + + return results + +def download_object(client, bucket: str, object_path: str, dest_root: str) -> str: + storage = client.storage.from_(bucket) + data = storage.download(object_path) + + local_path = os.path.join(dest_root, object_path.replace("/", os.sep)) + os.makedirs(os.path.dirname(local_path), exist_ok=True) + + with open(local_path, "wb") as f: + f.write(data) + + return local_path + +# --------------------------------------------------------------------- +# DATABASE & MAPPING HELPERS (The Logic Core) +# --------------------------------------------------------------------- + +def is_resume_processed(client, user_id: str, file_hash: str) -> bool: + """ + Checks if this specific user already has a processed resume with this hash. + """ + resp = ( + client.table("profiles") + .select("id") + .eq("id", user_id) + .eq("file_hash", file_hash) + .eq("processed", True) + .execute() + ) + return bool(resp.data) + +def build_resume_payload(user_id: str, extracted: Dict[str, Any], resume_path: str, file_hash: str) -> Dict[str, Any]: + """ + Translates Gemini JSON keys to Supabase 'profiles' table columns. + """ + # 1. Base Payload + payload = { + "id": user_id, + "resume_url": resume_path, + "file_hash": file_hash, + "processed": True, + "updated_at": "now()", + } + + # 2. Mapping Dictionary (Gemini JSON Key -> DB Column Name) + FIELD_MAP = { + # Identity + "full_name": "full_name", + "role": "role", + "headline": "headline", + "summary": "summary", + + + # Contact & Socials (Crucial Mismatches Fixed Here) + "phone": "phone", + "email": "email", + "linkedin": "linkedin", + "github": "github", + "portfolio": "portfolio", + + # Arrays & JSONB + "skills": "skills", + "technical_skills": "technical_skills", + "education": "education", + "current_position": "current_position", + # Experience + # Experience + "work_experience": "work_experience", + "experience_years": "experience_years", + + # Extra + "certifications": "certifications", + "languages": "languages", + "projects": "projects", + } + + # 3. Dynamic Mapping + for json_key, db_col in FIELD_MAP.items(): + val = extracted.get(json_key) + + # Only update if value is meaningful (not None or empty) + if val not in (None, "", [], {}): + + # SPECIAL HANDLING: Convert Lists to Comma-Separated Strings for specific 'text' columns + if json_key in ["certifications", "languages", "technical_skills"] and isinstance(val, list): + val = ", ".join(val) + + payload[db_col] = val + + return payload + +def upsert_profile(client, payload: Dict[str, Any]): + """ + Updates the profile for the user using 'id' as the key. + """ + try: + # āœ… FIX: on_conflict='id' ensures we update the specific User row + # instead of failing on duplicate file_hashes. + client.table("profiles").upsert( + payload, + on_conflict="id" + ).execute() + print(f"āœ… Database updated for User ID: {payload['id']}") + + except Exception as e: + print(f"āŒ DB Upsert Error for {payload['id']}: {e}") + raise e + +# --------------------------------------------------------------------- +# UNIFIED PROCESSING FUNCTION (Called by API and Main) +# --------------------------------------------------------------------- + +def process_resume(client, user_id: str, file_path: str, temp_dir: str = "data/resumes/raw") -> Dict[str, Any]: + """ + Downloads, extracts, and upserts a resume. + Used by both the API (real-time) and the main script (batch). + """ + try: + # 1. Download + print(f"ā¬‡ļø Downloading {file_path}...") + local_path = download_object(client, "resume", file_path, temp_dir) + + # 2. Extract + print("🧠 Sending to Gemini...") + extracted_data = process_single_resume(local_path) + + if not extracted_data: + raise ValueError("Gemini returned empty data") + + # 3. Hash + file_hash = compute_file_hash(local_path) + + # 4. Payload & Upsert + payload = build_resume_payload(user_id, extracted_data, file_path, file_hash) + upsert_profile(client, payload) + + # 5. Cleanup + if os.path.exists(local_path): + os.remove(local_path) + + return extracted_data + + except Exception as e: + print(f"āŒ Error processing resume {file_path}: {e}") + raise e + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--bucket", default="resume") + parser.add_argument("--prefix", default="") + parser.add_argument("--dest", default="data/resumes/raw") + args = parser.parse_args() + + ensure_dir(args.dest) + + print(f"šŸ” Scanning bucket '{args.bucket}'...") + objects = list_all_objects(client, args.bucket, args.prefix) + + if not objects: + print("āš ļø No resumes found in Supabase storage.") + return + + print(f"found {len(objects)} files.") + + for obj in objects: + # 1. Filter Extensions + if os.path.splitext(obj)[1].lower() not in ALLOWED_EXTENSIONS: + continue + + user_id = extract_user_id(obj) + + print(f"\nā¬‡ļø Processing User: {user_id} | File: {obj}") + + # 2. Download + local_path = download_object(client, args.bucket, obj, args.dest) + + # 3. Hash Check (Save Money) + current_hash = compute_file_hash(local_path) + + if is_resume_processed(client, user_id, current_hash): + print(" ā­ļø Skipped: Resume already processed and unchanged.") + continue + + # 4. Extract (Gemini) + print(" 🧠 Sending to Gemini for extraction...") + try: + # āœ… CALLING THE HELPER FUNCTION CORRECTLY + extracted_data = process_single_resume(local_path) + print(extracted_data) + + if not extracted_data: + print(" āš ļø Gemini returned no data. Skipping DB update.") + continue + + # 5. Build Payload (Map Keys) + payload = build_resume_payload(user_id, extracted_data, obj, current_hash) + + # 6. Upsert to DB + upsert_profile(client, payload) + + except Exception as e: + print(f" āŒ Pipeline failed for this file: {e}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/backend/test_phone_regex.py b/backend/test_phone_regex.py new file mode 100644 index 0000000000000000000000000000000000000000..6fc4da6e98d745fac2806f73d8053b1b50fe784c --- /dev/null +++ b/backend/test_phone_regex.py @@ -0,0 +1,35 @@ + +import re + +PHONE_REGEX = r"(?:\+?(\d{1,3}))?[-. (]*(\d{2,5})[-. )]*(\d{2,5})[-. ]*(\d{2,5})(?:[-. ]*(\d{1,4}))?" + +test_cases = [ + # Should match + "+919876543210", + "+91-9876543210", + "9876543210", + "123-456-7890", + "+1 (123) 456-7890", # Parentheses not handled currently + "09876543210", + "+91 98765 43210", # Space not handled + "Phone: +91 9876543210", +] + +print(f"Regex: {PHONE_REGEX}\n") + +for text in test_cases: + print(f"Testing: '{text}'") + + # Using the logic from regex_pii.py + phone_matches = re.finditer(PHONE_REGEX, text) + found = None + for match in phone_matches: + p = match.group(0) + # Check length of digits + if len(re.sub(r"\D", "", p)) >= 10: + found = p.strip() + print(f" āœ… Match: {found}") + break + + if not found: + print(" āŒ No valid match found") diff --git a/backend/tests/test_extraction.py b/backend/tests/test_extraction.py new file mode 100644 index 0000000000000000000000000000000000000000..77c51ac897b5a377f0a36293d6c874798157637e --- /dev/null +++ b/backend/tests/test_extraction.py @@ -0,0 +1,109 @@ +import os +import re +import pytest +from src.extraction.resume_extractor import extract_resume_information_as_lists, process_resume_file + +# -------- Helpers -------- # + +def is_valid_list_format(block: str) -> bool: + """ + Validate that a block of text looks like a Python list, e.g. ["a", "b"] or [] + """ + return bool(re.match(r'^\[.*\]$', block.strip(), re.DOTALL)) + +# -------- Tests -------- # + +def test_extract_from_text(monkeypatch): + """ + Test extracting entities from a small mock resume text. + Monkeypatch the OpenAI call to avoid API usage. + """ + + mock_resume = "John Doe\nSkills: Python, SQL\nEducation: B.Tech in Computer Science" + mock_output = """Hard Skills: +["Python", "SQL"] + +Soft Skills: +[] + +Work Experience: +[] + +Education: +["B.Tech in Computer Science"] + +Certifications: +[] + +Projects: +[]""" + + # Monkeypatch function to bypass API + monkeypatch.setattr( + "src.extraction.resume_extractor.extract_resume_information_as_lists", + lambda text: mock_output + ) + + extracted = extract_resume_information_as_lists(mock_resume) + + # Ensure all categories exist + assert "Hard Skills:" in extracted + assert "Soft Skills:" in extracted + assert "Work Experience:" in extracted + assert "Education:" in extracted + assert "Certifications:" in extracted + assert "Projects:" in extracted + + # Validate at least one block is a list + matches = re.findall(r'\[.*?\]', extracted, re.DOTALL) + assert all(is_valid_list_format(m) for m in matches) + +def test_process_resume_file(tmp_path, monkeypatch): + """ + Test end-to-end file processing: input resume -> output entity file. + """ + + # Create fake input resume file + resume_text = "Skills: Python, SQL\nEducation: B.Tech in CS" + input_file = tmp_path / "resume1.txt" + input_file.write_text(resume_text) + + # Expected fake output + mock_output = """Hard Skills: +["Python", "SQL"] + +Soft Skills: +[] + +Work Experience: +[] + +Education: +["B.Tech in CS"] + +Certifications: +[] + +Projects: +[]""" + + # Monkeypatch extractor + monkeypatch.setattr( + "src.extraction.resume_extractor.extract_resume_information_as_lists", + lambda text: mock_output + ) + + # Process file + output_dir = tmp_path / "entities" + process_resume_file(str(input_file), str(output_dir)) + + # Verify output file exists + out_file = output_dir / "resume1_entities.txt" + assert out_file.exists() + + # Verify contents + content = out_file.read_text() + assert "Hard Skills:" in content + assert "Soft Skills:" in content + assert "[]" in content + diff --git a/backend/tests/test_ingestion.py b/backend/tests/test_ingestion.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/tests/test_matching.py b/backend/tests/test_matching.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/tests/test_preprocess_jobs.py b/backend/tests/test_preprocess_jobs.py new file mode 100644 index 0000000000000000000000000000000000000000..d6cc1d8d77bec3087478817d6ab5fa5c18aa047f --- /dev/null +++ b/backend/tests/test_preprocess_jobs.py @@ -0,0 +1,24 @@ +import os +import unittest +from src.preprocess.job_preprocess import preprocess_jobs + +class TestJobPreprocessing(unittest.TestCase): + + def setUp(self): + self.raw_dir = os.path.join(os.path.dirname(__file__), "../data/jobs/raw") + self.out_dir = os.path.join(os.path.dirname(__file__), "../data/jobs/preprocessed") + os.makedirs(self.raw_dir, exist_ok=True) + with open(os.path.join(self.raw_dir, "sample_job.txt"), "w", encoding="utf-8") as f: + f.write("Senior Developer!!!\nMust have\tPython, ML & AI skills…") + + def test_job_cleaning(self): + preprocess_jobs(self.raw_dir, self.out_dir) + out_file = os.path.join(self.out_dir, "sample_job.txt") + with open(out_file, "r", encoding="utf-8") as f: + cleaned = f.read() + self.assertIn("senior developer", cleaned) + self.assertNotIn("!!!", cleaned) + self.assertTrue(cleaned.islower()) + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_preprocess_resume.py b/backend/tests/test_preprocess_resume.py new file mode 100644 index 0000000000000000000000000000000000000000..0ea97cf8639d4bc98c973286b3b4b3c3e38bd0b6 --- /dev/null +++ b/backend/tests/test_preprocess_resume.py @@ -0,0 +1,58 @@ +import unittest +from src.preprocess.cleaner import clean_text +from src.preprocess.anonymizer import remove_pii + +class TestPreprocessing(unittest.TestCase): + + def test_process_resumes_raw_to_preprocessed(self): + import os + from src.ingestion.pdf_reader import parse_pdf + + raw_dir = os.path.join(os.path.dirname(__file__), '../data/resumes/raw') + out_dir = os.path.join(os.path.dirname(__file__), '../data/resumes/preprocessed') + os.makedirs(out_dir, exist_ok=True) + + for fname in os.listdir(raw_dir): + if fname.lower().endswith('.pdf'): + in_path = os.path.join(raw_dir, fname) + out_path = os.path.join(out_dir, os.path.splitext(fname)[0] + '.txt') + try: + text = parse_pdf(in_path) + anonymized = remove_pii(text) + with open(out_path, 'w', encoding='utf-8') as f: + f.write(anonymized) + print(f"Processed {fname} -> {out_path}") + except Exception as e: + print(f"Failed to process {fname}: {e}") + + def test_clean_text_basic(self): + raw_text = "This is a test\nwith multiple\tspaces." + cleaned = clean_text(raw_text) + self.assertEqual(cleaned, "This is a test with multiple spaces.") + + def test_clean_text_non_ascii(self): + raw_text = "RĆ©sumĆ© with cafĆ© and naĆÆve characters." + cleaned = clean_text(raw_text) + self.assertEqual(cleaned, "Resume with cafe and naive characters.") + + def test_remove_pii_email_phone(self): + raw_text = "Contact me at john.doe@example.com or +1-123-456-7890" + anonymized = remove_pii(raw_text) + self.assertIn("[email]", anonymized) + self.assertIn("[phone]", anonymized) + + def test_remove_pii_entities(self): + raw_text = "John Doe works at OpenAI in San Francisco." + anonymized = remove_pii(raw_text) + self.assertIn("[name]", anonymized) + self.assertIn("[location]", anonymized) + + def test_full_pipeline(self): + raw_text = "Jane Smith, contact: jane.smith@example.com, lives in London, works at Google." + anonymized = remove_pii(raw_text) + self.assertIn("[email]", anonymized) + self.assertIn("[name]", anonymized) + self.assertIn("[location]", anonymized) + +if __name__ == "__main__": + unittest.main() diff --git a/backend/verify_genai_import.py b/backend/verify_genai_import.py new file mode 100644 index 0000000000000000000000000000000000000000..dfd6900df1b6469d6b69c60ca950078be0407232 --- /dev/null +++ b/backend/verify_genai_import.py @@ -0,0 +1,15 @@ +from google import genai +import google.genai.types as types +import os +from dotenv import load_dotenv + +load_dotenv() + +print("āœ… google-genai imported successfully.") + +try: + key = os.getenv("GEMINI_API_KEY") + client = genai.Client(api_key=key) + print("āœ… Client initialized successfully.") +except Exception as e: + print(f"āŒ Client initialization failed: {e}") diff --git a/backend/verify_setup.py b/backend/verify_setup.py new file mode 100644 index 0000000000000000000000000000000000000000..2de8b744b0b02c346ebb3895a7e388ae40c76964 --- /dev/null +++ b/backend/verify_setup.py @@ -0,0 +1,29 @@ + +try: + print("šŸ“¦ Imports check...") + import sentence_transformers + from sentence_transformers import SentenceTransformer + import torch + print(f"āœ… sentence-transformers version: {sentence_transformers.__version__}") + print(f"āœ… torch version: {torch.__version__}") + + print("\nšŸš€ Attempting to load BGE-M3 model (this might trigger download)...") + # Using 'cpu' to allow it to run on any environment for this test + # but the actual code uses cuda if available + model = SentenceTransformer('BAAI/bge-m3', device='cpu') + print("āœ… Model loaded successfully!") + + test_text = "This is a test resume sentence." + embedding = model.encode(test_text) + print(f"āœ… Generated embedding shape: {embedding.shape}") + + if embedding.shape[0] == 1024: + print("āœ… SUCCESS: Embedding dimension is 1024.") + else: + print(f"āŒ ERROR: Expected 1024 dimensions, got {embedding.shape[0]}") + +except ImportError as e: + print(f"āŒ Missing Dependency: {e}") + print("Run: pip install -r requirements.txt") +except Exception as e: + print(f"āŒ Error: {e}") diff --git a/check_buckets.sql b/check_buckets.sql new file mode 100644 index 0000000000000000000000000000000000000000..cb93baedba948214e51f212227b66e359126b6e4 --- /dev/null +++ b/check_buckets.sql @@ -0,0 +1,5 @@ +-- Check what storage buckets exist in your project +SELECT id, name, public, file_size_limit, allowed_mime_types, created_at +FROM storage.buckets +ORDER BY created_at; + diff --git a/create_avatars_bucket.sql b/create_avatars_bucket.sql new file mode 100644 index 0000000000000000000000000000000000000000..e781bd9c065a71f824344528ab099bd09849497f --- /dev/null +++ b/create_avatars_bucket.sql @@ -0,0 +1,23 @@ +-- Create the avatars bucket for profile photos +INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +VALUES ( + 'avatars', + 'avatars', + true, + 1048576, -- 1MB limit + ARRAY['image/jpeg', 'image/png', 'image/jpg'] +); + +-- Create RLS policies for the avatars bucket +CREATE POLICY "Users can upload their own avatar" ON storage.objects +FOR INSERT WITH CHECK (bucket_id = 'avatars' AND auth.uid()::text = (storage.foldername(name))[1]); + +CREATE POLICY "Users can update their own avatar" ON storage.objects +FOR UPDATE USING (bucket_id = 'avatars' AND auth.uid()::text = (storage.foldername(name))[1]); + +CREATE POLICY "Users can delete their own avatar" ON storage.objects +FOR DELETE USING (bucket_id = 'avatars' AND auth.uid()::text = (storage.foldername(name))[1]); + +CREATE POLICY "Avatar images are publicly accessible" ON storage.objects +FOR SELECT USING (bucket_id = 'avatars'); + diff --git a/create_storage_buckets.sql b/create_storage_buckets.sql new file mode 100644 index 0000000000000000000000000000000000000000..13aed9b1b93f59d672a8deff5a0951d100976b06 --- /dev/null +++ b/create_storage_buckets.sql @@ -0,0 +1,32 @@ +-- Create storage buckets for file uploads +INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +VALUES + ('avatars', 'avatars', true, 1048576, ARRAY['image/jpeg', 'image/png']), + ('resumes', 'resumes', true, 5242880, ARRAY['application/pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']); + +-- Create RLS policies for avatars bucket +CREATE POLICY "Users can upload their own avatar" ON storage.objects +FOR INSERT WITH CHECK (bucket_id = 'avatars' AND auth.uid()::text = (storage.foldername(name))[1]); + +CREATE POLICY "Users can update their own avatar" ON storage.objects +FOR UPDATE USING (bucket_id = 'avatars' AND auth.uid()::text = (storage.foldername(name))[1]); + +CREATE POLICY "Users can delete their own avatar" ON storage.objects +FOR DELETE USING (bucket_id = 'avatars' AND auth.uid()::text = (storage.foldername(name))[1]); + +CREATE POLICY "Avatar images are publicly accessible" ON storage.objects +FOR SELECT USING (bucket_id = 'avatars'); + +-- Create RLS policies for resumes bucket +CREATE POLICY "Users can upload their own resume" ON storage.objects +FOR INSERT WITH CHECK (bucket_id = 'resumes' AND auth.uid()::text = (storage.foldername(name))[1]); + +CREATE POLICY "Users can update their own resume" ON storage.objects +FOR UPDATE USING (bucket_id = 'resumes' AND auth.uid()::text = (storage.foldername(name))[1]); + +CREATE POLICY "Users can delete their own resume" ON storage.objects +FOR DELETE USING (bucket_id = 'resumes' AND auth.uid()::text = (storage.foldername(name))[1]); + +CREATE POLICY "Resume files are publicly accessible" ON storage.objects +FOR SELECT USING (bucket_id = 'resumes'); + diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000000000000000000000000000000000000..cee1e2c788d908e4a8ff796eb6edbe73ee73fffd --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, +]) diff --git a/fix_bucket_settings.sql b/fix_bucket_settings.sql new file mode 100644 index 0000000000000000000000000000000000000000..9384628a7ee4dbae597556bedce3794dbc7bbd27 --- /dev/null +++ b/fix_bucket_settings.sql @@ -0,0 +1,113 @@ +-- Check current bucket settings +SELECT id, name, public, file_size_limit, allowed_mime_types, created_at +FROM storage.buckets +WHERE name IN ('avatars', 'resume') +ORDER BY name; + +-- Update avatars bucket to be public and set proper limits +UPDATE storage.buckets +SET + public = true, + file_size_limit = 1048576, -- 1MB + allowed_mime_types = ARRAY['image/jpeg', 'image/png', 'image/jpg'] +WHERE name = 'avatars'; + +-- Update resume bucket to be public and set proper limits +UPDATE storage.buckets +SET + public = true, + file_size_limit = 5242880, -- 5MB + allowed_mime_types = ARRAY['application/pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'] +WHERE name = 'resume'; + +-- Check if RLS policies exist for avatars bucket +SELECT schemaname, tablename, policyname, permissive, roles, cmd, qual +FROM pg_policies +WHERE tablename = 'objects' AND policyname LIKE '%avatar%'; + +-- Create RLS policies for avatars bucket if they don't exist +DO $$ +BEGIN + -- Check if policy exists, if not create it + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE tablename = 'objects' + AND policyname = 'Users can upload their own avatar' + ) THEN + CREATE POLICY "Users can upload their own avatar" ON storage.objects + FOR INSERT WITH CHECK (bucket_id = 'avatars' AND auth.uid()::text = (storage.foldername(name))[1]); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE tablename = 'objects' + AND policyname = 'Users can update their own avatar' + ) THEN + CREATE POLICY "Users can update their own avatar" ON storage.objects + FOR UPDATE USING (bucket_id = 'avatars' AND auth.uid()::text = (storage.foldername(name))[1]); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE tablename = 'objects' + AND policyname = 'Users can delete their own avatar' + ) THEN + CREATE POLICY "Users can delete their own avatar" ON storage.objects + FOR DELETE USING (bucket_id = 'avatars' AND auth.uid()::text = (storage.foldername(name))[1]); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE tablename = 'objects' + AND policyname = 'Avatar images are publicly accessible' + ) THEN + CREATE POLICY "Avatar images are publicly accessible" ON storage.objects + FOR SELECT USING (bucket_id = 'avatars'); + END IF; +END $$; + +-- Create RLS policies for resume bucket if they don't exist +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE tablename = 'objects' + AND policyname = 'Users can upload their own resume' + ) THEN + CREATE POLICY "Users can upload their own resume" ON storage.objects + FOR INSERT WITH CHECK (bucket_id = 'resume' AND auth.uid()::text = (storage.foldername(name))[1]); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE tablename = 'objects' + AND policyname = 'Users can update their own resume' + ) THEN + CREATE POLICY "Users can update their own resume" ON storage.objects + FOR UPDATE USING (bucket_id = 'resume' AND auth.uid()::text = (storage.foldername(name))[1]); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE tablename = 'objects' + AND policyname = 'Users can delete their own resume' + ) THEN + CREATE POLICY "Users can delete their own resume" ON storage.objects + FOR DELETE USING (bucket_id = 'resume' AND auth.uid()::text = (storage.foldername(name))[1]); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE tablename = 'objects' + AND policyname = 'Resume files are publicly accessible' + ) THEN + CREATE POLICY "Resume files are publicly accessible" ON storage.objects + FOR SELECT USING (bucket_id = 'resume'); + END IF; +END $$; + +-- Final check of bucket settings +SELECT id, name, public, file_size_limit, allowed_mime_types +FROM storage.buckets +WHERE name IN ('avatars', 'resume') +ORDER BY name; diff --git a/index.html b/index.html new file mode 100644 index 0000000000000000000000000000000000000000..0c589eccd4d48e270e161a1ab91baee5e5f4b4bc --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..40f19faf4fc9c05055b612b660d7856217b2a2b0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3477 @@ +{ + "name": "loginpage", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "loginpage", + "version": "0.0.0", + "dependencies": { + "@supabase/supabase-js": "^2.53.0", + "date-fns": "^4.1.0", + "framer-motion": "^12.23.11", + "lucide-react": "^0.562.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "recharts": "^3.6.0" + }, + "devDependencies": { + "@eslint/js": "^9.30.1", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "eslint": "^9.30.1", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.3.0", + "vite": "^7.0.4" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", + "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.1", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz", + "integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/is-prop-valid": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", + "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emotion/memoize": "0.7.4" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", + "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz", + "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@supabase/auth-js": { + "version": "2.71.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.71.1.tgz", + "integrity": "sha512-mMIQHBRc+SKpZFRB2qtupuzulaUhFYupNyxqDj5Jp/LyPvcWvjaJzZzObv6URtL/O6lPxkanASnotGtNpS3H2Q==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.5.tgz", + "integrity": "sha512-v5GSqb9zbosquTo6gBwIiq7W9eQ7rE5QazsK/ezNiQXdCbY+bH8D9qEaBIkhVvX4ZRW5rP03gEfw5yw9tiq4EQ==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/node-fetch": { + "version": "2.6.15", + "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", + "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "1.19.4", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.19.4.tgz", + "integrity": "sha512-O4soKqKtZIW3olqmbXXbKugUtByD2jPa8kL2m2c1oozAO11uCcGrRhkZL0kVxjBLrXHE0mdSkFsMj7jDSfyNpw==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.11.15", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.11.15.tgz", + "integrity": "sha512-HQKRnwAqdVqJW/P9TjKVK+/ETpW4yQ8tyDPPtRMKOH4Uh3vQD74vmj353CYs8+YwVBKubeUOOEpI9CT8mT4obw==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.13", + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "isows": "^1.0.7", + "ws": "^8.18.2" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.10.4", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.10.4.tgz", + "integrity": "sha512-cvL02GarJVFcNoWe36VBybQqTVRq6wQSOCvTS64C+eyuxOruFIm1utZAY0xi2qKtHJO3EjKaj8iWJKySusDmAQ==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.53.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.53.0.tgz", + "integrity": "sha512-Vg9sl0oFn55cCPaEOsDsRDbxOVccxRrK/cikjL1XbywHEOfyA5SOOEypidMvQLwgoAfnC2S4D9BQwJDcZs7/TQ==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.71.1", + "@supabase/functions-js": "2.4.5", + "@supabase/node-fetch": "2.6.15", + "@supabase/postgrest-js": "1.19.4", + "@supabase/realtime-js": "2.11.15", + "@supabase/storage-js": "^2.10.4" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz", + "integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.8.0" + } + }, + "node_modules/@types/phoenix": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", + "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001727", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", + "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.189", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.189.tgz", + "integrity": "sha512-y9D1ntS1ruO/pZ/V2FtLE+JXLQe28XoRpZ7QCCo0T8LdQladzdcOVQZH/IWLVJvCw12OGMb6hYOeOAjntCmJRQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-toolkit": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz", + "integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.20", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", + "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/framer-motion": { + "version": "12.23.26", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz", + "integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.23", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.562.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", + "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/motion-dom": { + "version": "12.23.23", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", + "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-is": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz", + "integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/recharts": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz", + "integrity": "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.54.0", + "@rollup/rollup-android-arm64": "4.54.0", + "@rollup/rollup-darwin-arm64": "4.54.0", + "@rollup/rollup-darwin-x64": "4.54.0", + "@rollup/rollup-freebsd-arm64": "4.54.0", + "@rollup/rollup-freebsd-x64": "4.54.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", + "@rollup/rollup-linux-arm64-gnu": "4.54.0", + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-loong64-gnu": "4.54.0", + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-musl": "4.54.0", + "@rollup/rollup-linux-s390x-gnu": "4.54.0", + "@rollup/rollup-linux-x64-gnu": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0", + "@rollup/rollup-openharmony-arm64": "4.54.0", + "@rollup/rollup-win32-arm64-msvc": "4.54.0", + "@rollup/rollup-win32-ia32-msvc": "4.54.0", + "@rollup/rollup-win32-x64-gnu": "4.54.0", + "@rollup/rollup-win32-x64-msvc": "4.54.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/undici-types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", + "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000000000000000000000000000000000..5f6986a65a8388daf2c72e1fe0df6b122ab75f92 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "loginpage", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@supabase/supabase-js": "^2.53.0", + "date-fns": "^4.1.0", + "framer-motion": "^12.23.11", + "lucide-react": "^0.562.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "recharts": "^3.6.0" + }, + "devDependencies": { + "@eslint/js": "^9.30.1", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "eslint": "^9.30.1", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.3.0", + "vite": "^7.0.4" + } +} diff --git a/public/vite.svg b/public/vite.svg new file mode 100644 index 0000000000000000000000000000000000000000..e7b8dfb1b2a60bd50538bec9f876511b9cac21e3 --- /dev/null +++ b/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/setup-storage.js b/setup-storage.js new file mode 100644 index 0000000000000000000000000000000000000000..3bed9883d30d71d6245036d9a7b5b4892f940048 --- /dev/null +++ b/setup-storage.js @@ -0,0 +1,64 @@ +// Script to create Supabase storage buckets if they don't exist +import { supabase } from './src/supabaseClient.js'; + +async function setupStorageBuckets() { + try { + console.log('Setting up storage buckets...'); + + // Check if buckets exist + const { data: buckets, error: listError } = await supabase.storage.listBuckets(); + + if (listError) { + console.error('Error listing buckets:', listError); + return; + } + + const bucketNames = buckets.map(bucket => bucket.name); + console.log('Existing buckets:', bucketNames); + + // Create avatars bucket if it doesn't exist + if (!bucketNames.includes('avatars')) { + console.log('Creating avatars bucket...'); + const { data: avatarsData, error: avatarsError } = await supabase.storage.createBucket('avatars', { + public: true, + fileSizeLimit: 1048576, // 1MB + allowedMimeTypes: ['image/jpeg', 'image/png'] + }); + + if (avatarsError) { + console.error('Error creating avatars bucket:', avatarsError); + } else { + console.log('āœ… Avatars bucket created successfully'); + } + } else { + console.log('āœ… Avatars bucket already exists'); + } + + // Create resumes bucket if it doesn't exist + if (!bucketNames.includes('resumes')) { + console.log('Creating resumes bucket...'); + const { data: resumesData, error: resumesError } = await supabase.storage.createBucket('resumes', { + public: true, + fileSizeLimit: 5242880, // 5MB + allowedMimeTypes: ['application/pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'] + }); + + if (resumesError) { + console.error('Error creating resumes bucket:', resumesError); + } else { + console.log('āœ… Resumes bucket created successfully'); + } + } else { + console.log('āœ… Resumes bucket already exists'); + } + + console.log('Storage setup complete!'); + + } catch (error) { + console.error('Error setting up storage:', error); + } +} + +// Run the setup +setupStorageBuckets(); + diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000000000000000000000000000000000000..ae5e5008f9376fc326b9ebe2b809b60d9beafac2 --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,106 @@ +import React, { useState } from 'react'; + +// --- Imports --- +import LoginPage from './pages/LoginPage'; +import AdminLogin from './pages/AdminLogin'; +import AppliLogin from './pages/AppliLogin'; +import ClientDash from './pages/clientdash'; +import Admindash from './pages/Admindashboard'; + +// --- Applicant Pages --- +import ApplicantJobPage from './pages/ApplicantJobPage'; +import ApplicantProfile from './pages/ApplicantProfile'; // Make sure this file exists +import ApplicantATS from './pages/ApplicantATS'; // Make sure this file exists +import ApplicantInterviews from './pages/ApplicantInterviews'; // Make sure this file exists +import ApplicantMessages from './pages/ApplicantMessages'; // Make sure this file exists + +import { supabase } from './supabaseClient'; // Import at top + +export default function App() { + // Initialize state from localStorage if available, else login + const [currentPage, setCurrentPage] = useState('login'); + const [loading, setLoading] = useState(true); // START LOADING + + // --- PERSISTENCE LOGIC --- + React.useEffect(() => { + const checkSession = async () => { + // 1. Check if user is logged in + const { data: { session } } = await supabase.auth.getSession(); + + if (session) { + // 2. If logged in, recover last page or default to 'applicant-jobs' + const lastPage = localStorage.getItem('last_iris_page'); + if (lastPage && lastPage !== 'login') { + setCurrentPage(lastPage); + } else { + setCurrentPage('applicant-jobs'); + } + } + setLoading(false); // STOP LOADING + }; + checkSession(); + }, []); + + const handleNavigate = (page) => { + setCurrentPage(page); + // Persist navigation + if (page !== 'login' && page !== 'applicant' && page !== 'admin') { + localStorage.setItem('last_iris_page', page); + } + }; + + const renderPage = () => { + if (loading) { + // Simple Full-Screen Loader + return ( +
+

Restoring Session...

+
+ ); + } + + switch (currentPage) { + // --- AUTH --- + case 'login': return ; + case 'admin': return ; + case 'applicant': return ; + + // --- APPLICANT ROUTES --- + case 'applicant-jobs': + return ; + + case 'applicant-profile': + return ; + + case 'applicant-interviews': + return ; + + case 'applicant-ats': + return ; + + case 'applicant-messages': + return ; + + // --- ADMIN / RECRUITER --- + case 'dashboard': return ; + case 'admin-dashboard': return ; + + // --- DEFAULT --- + default: return ; + } + }; + + return ( +
+ + + {renderPage()} +
+ ); +} \ No newline at end of file diff --git a/src/assets/react.svg b/src/assets/react.svg new file mode 100644 index 0000000000000000000000000000000000000000..6c87de9bb3358469122cc991d5cf578927246184 --- /dev/null +++ b/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/Admin/AdminInterviewManagement.jsx b/src/components/Admin/AdminInterviewManagement.jsx new file mode 100644 index 0000000000000000000000000000000000000000..78c7747844eb0954ae91657ee4637cf284803a84 --- /dev/null +++ b/src/components/Admin/AdminInterviewManagement.jsx @@ -0,0 +1,227 @@ +import React, { useState, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { supabase } from '../../supabaseClient'; +import CandidateDrawer from '../CandidateDrawer'; +import ScheduleInterviewModal from '../ScheduleInterviewModal'; // <--- IMPORT NEW MODAL + +// --- ICONS --- +const SmallCalendarIcon = () => ( ); +const ChevronRightIcon = () => ( ); + +// --- Simple Message Modal (Kept Inline) --- +const MessageModal = ({ isOpen, onClose, onSend }) => { + const [message, setMessage] = useState(''); + const handleSend = () => { if (message.trim()) { onSend(message); onClose(); } else { alert('Message cannot be empty.'); } }; + if (!isOpen) return null; + return ( +
+
+

Compose Message

+ +
+ + +
+
+
+ ); +}; + +// --- MAIN COMPONENT --- +export default function AdminInterviewManagement() { + const [activeSubTab, setActiveSubTab] = useState('interviews'); + const [loading, setLoading] = useState(true); + const [applicants, setApplicants] = useState({ interviews: [], accepted: [], rejected: [] }); + + // Modals State + const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false); // <--- Updated Name + const [isMessageModalOpen, setIsMessageModalOpen] = useState(false); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + + const [selectedApplicant, setSelectedApplicant] = useState(null); + const [drawerCandidate, setDrawerCandidate] = useState(null); + + // 1. Fetch Data + useEffect(() => { fetchData(); }, []); + + const fetchData = async () => { + try { + setLoading(true); + const { data, error } = await supabase + .from('applications') + .select(` + id, created_at, status, experience, skills, match_score, + profiles ( id, full_name, email, avatar_url ), + jobs ( title ), + interviews ( id, date, time, status ) + `) + .order('created_at', { ascending: false }); + + if (error) throw error; + + const categorized = { interviews: [], accepted: [], rejected: [] }; + + data.forEach(app => { + const interviewData = app.interviews && app.interviews.length > 0 ? app.interviews[0] : null; + const formattedApp = { + ...app, + name: app.profiles?.full_name || 'Unknown User', + email: app.profiles?.email, + role: app.jobs?.title || 'Unknown Role', + avatar: app.profiles?.avatar_url, + experience: (app.experience === '0' || app.experience === 0) ? 'Fresher' : (app.experience ? `${app.experience} years` : 'N/A'), + skills: Array.isArray(app.skills) ? app.skills : (app.skills ? [app.skills] : []), + interviewId: interviewData?.id, + date: interviewData ? interviewData.date : 'Not Scheduled', + time: interviewData ? interviewData.time : '', + }; + + if (interviewData) categorized.interviews.push(formattedApp); + else if (app.status === 'Accepted' || app.status === 'Approved') categorized.accepted.push(formattedApp); + else if (app.status === 'Rejected') categorized.rejected.push(formattedApp); + }); + setApplicants(categorized); + } catch (error) { console.error('Error fetching data:', error); } + finally { setLoading(false); } + }; + + // 2. Updated Schedule Handler (Receives Object from Modal) + const handleScheduleConfirm = async (scheduleData) => { + if (!selectedApplicant) return; + + // Destructure data from modal + const { date, time, interviewType, mode, details, interviewerName, interviewerRole } = scheduleData; + + try { + const scheduledTimeISO = new Date(`${date}T${time}:00`).toISOString(); + + const interviewPayload = { + application_id: selectedApplicant.id, + scheduled_time: scheduledTimeISO, + date, time, + status: 'Scheduled', + interview_type: interviewType, + mode: mode, + // Conditionally save link or location + meeting_link: mode === 'Online' ? details : null, + location: mode === 'Offline' ? details : null, + interviewer_name: interviewerName, + interviewer_role: interviewerRole, + duration_mins: 45 // Default + }; + + // Database Insert + const { error: dbError } = await supabase.from('interviews').insert([interviewPayload]); + if (dbError) throw dbError; + + // Email Notification + if (selectedApplicant.email) { + await supabase.functions.invoke('send-interview-email', { + body: { + candidateName: selectedApplicant.name, + candidateEmail: selectedApplicant.email, + role: selectedApplicant.role, + date, time, mode, details + } + }); + } + + alert("Interview Scheduled Successfully!"); + setIsScheduleModalOpen(false); + fetchData(); + + } catch (error) { + console.error("Error scheduling:", error); + alert(`Failed to schedule: ${error.message}`); + } + }; + + const handleSendMessage = (message) => { alert(`Message sent to ${selectedApplicant?.name}: "${message}"`); }; + const openScheduleModal = (applicant) => { setSelectedApplicant(applicant); setIsScheduleModalOpen(true); }; + const openMessageModal = (applicant) => { setSelectedApplicant(applicant); setIsMessageModalOpen(true); }; + const openDrawer = (applicant) => { setDrawerCandidate(applicant); setIsDrawerOpen(true); }; + + // Styles + const primaryButtonStyle = { backgroundColor: '#EF4444', color: 'white', padding: '0.5rem 1rem', borderRadius: '9999px', fontWeight: '500', cursor: 'pointer', border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%' }; + const secondaryButtonStyle = { backgroundColor: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', color: 'white', padding: '0.5rem 1rem', borderRadius: '9999px', fontWeight: '500', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%' }; + + return ( +
+

Interview Management

+ + {/* Tabs */} +
+ +
+ + + + {loading ? (
Loading...
) : applicants[activeSubTab].length === 0 ? (
No candidates found.
) : ( +
+ {applicants[activeSubTab].map((applicant, index) => ( +
+ + {/* INFO SECTION */} +
+ {applicant.name} +
+

{applicant.name}

+

{applicant.role} • {applicant.experience}

+
+ {applicant.skills.map((skill, i) => ( {skill} ))} +
+ {activeSubTab === 'interviews' && applicant.time && ( +
+ Interview: {applicant.date} at {applicant.time} +
+ )} +
+
+ + {/* BUTTONS SECTION */} +
+ {activeSubTab === 'interviews' && ( <> + openScheduleModal(applicant)} whileHover={{ scale: 1.02 }} style={secondaryButtonStyle}>Reschedule + openMessageModal(applicant)} whileHover={{ scale: 1.02 }} style={secondaryButtonStyle}>Message + openDrawer(applicant)} whileHover={{ scale: 1.02 }} style={primaryButtonStyle}>View CV + )} + {activeSubTab === 'accepted' && ( <> + openScheduleModal(applicant)} whileHover={{ scale: 1.02 }} style={secondaryButtonStyle}>Schedule Interview + openMessageModal(applicant)} whileHover={{ scale: 1.02 }} style={secondaryButtonStyle}>Send Message + openDrawer(applicant)} whileHover={{ scale: 1.02 }} style={primaryButtonStyle}>View CV + )} + {activeSubTab === 'rejected' && ( <> + openMessageModal(applicant)} whileHover={{ scale: 1.02 }} style={secondaryButtonStyle}>Send Message + openDrawer(applicant)} whileHover={{ scale: 1.02 }} style={primaryButtonStyle}>View CV + )} +
+
+ ))} +
+ )} +
+
+ + {/* --- MOUNT NEW MODAL --- */} + + {isScheduleModalOpen && ( + setIsScheduleModalOpen(false)} + onConfirm={handleScheduleConfirm} + candidateName={selectedApplicant?.name} + /> + )} + + + {isMessageModalOpen && setIsMessageModalOpen(false)} onSend={handleSendMessage} />} + {isDrawerOpen && setIsDrawerOpen(false)} candidate={drawerCandidate} />} +
+ ); +} \ No newline at end of file diff --git a/src/components/Admin/AdminLayout.jsx b/src/components/Admin/AdminLayout.jsx new file mode 100644 index 0000000000000000000000000000000000000000..c85bf1ec11e2dd5c04b43585288d8fc8936727c0 --- /dev/null +++ b/src/components/Admin/AdminLayout.jsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { supabase } from '../../supabaseClient'; + +// --- Icons --- +const HomeIcon = () => ( ); +const BriefcaseIcon = () => ( ); +const MessageSquareIcon = () => ( ); +// āœ… UPDATED: Complete, robust Settings Icon (Gear) +const SettingsIcon = () => ( + + + + +); +const BriefcasePlusIcon = () => ( ); +const LogoutIcon = () => ( ); + +export default function AdminLayout({ children, activeTab, setActiveTab, onNavigate }) { + + // Global Logout Handler + const handleLogout = async () => { + const { error } = await supabase.auth.signOut(); + if (error) console.error('Error logging out:', error.message); + if (onNavigate) onNavigate('login'); + }; + + return ( +
+ + {/* Background Effects */} +
+
+
+
+ + {/* Sidebar */} + + + {/* Main Content Area */} +
+ + {/* āœ… GLOBAL LOGOUT BUTTON - Updated Styles for Alignment */} +
+ + Logout + +
+ + {children} +
+
+ ); +} + +// Helper Component for Navigation Buttons +const NavButton = ({ active, onClick, icon }) => ( + + {icon} + +); \ No newline at end of file diff --git a/src/components/Admin/AdminProfile.jsx b/src/components/Admin/AdminProfile.jsx new file mode 100644 index 0000000000000000000000000000000000000000..de536bd1b76fb1b9b95fc632f0d8795a622c605d --- /dev/null +++ b/src/components/Admin/AdminProfile.jsx @@ -0,0 +1,29 @@ +import React, { useEffect, useState } from 'react'; +import { supabase } from '../../supabaseClient'; // āœ… Imported Supabase +import SettingsPage from '../../pages/settingsPage'; // šŸ‘ˆ Check this path! + +export default function AdminProfile({ onNavigate }) { + const [loading, setLoading] = useState(true); + const [user, setUser] = useState(null); + + useEffect(() => { + const checkUser = async () => { + const { data: { user } } = await supabase.auth.getUser(); + setUser(user); + setLoading(false); + }; + checkUser(); + }, []); + + if (loading) { + return
Loading Profile...
; + } + + // Render the existing SettingsPage component + return ( +
+ {/* You can pass the user object down if SettingsPage needs it */} + +
+ ); +} \ No newline at end of file diff --git a/src/components/Admin/AdminSortingPage.jsx b/src/components/Admin/AdminSortingPage.jsx new file mode 100644 index 0000000000000000000000000000000000000000..a175d84d781ec6920e996c8eed75374ff6fce184 --- /dev/null +++ b/src/components/Admin/AdminSortingPage.jsx @@ -0,0 +1,473 @@ +import React, { useState, useMemo, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { supabase } from '../../supabaseClient'; +import CandidateDrawer from '../CandidateDrawer'; + +// āœ… IMPORT ICONS FROM YOUR SEPARATE FILE +import { + FilterIcon, ScoringIcon, ClearIcon, ViewIcon, + ChevronDownIcon, SearchIcon, ChevronLeftIcon, + ChevronRightIcon, CheckSquareIcon, MailIcon, LoaderIcon +} from '../../components/Icons'; + +// --- REUSABLE BUTTON COMPONENT --- +const BulkActionButton = ({ Icon, label, color, onClick }) => { + const [hover, setHover] = useState(false); + return ( + setHover(true)} + onMouseLeave={() => setHover(false)} + layout + style={{ + display: 'flex', alignItems: 'center', + backgroundColor: hover ? color : 'rgba(255,255,255,0.05)', + border: `1px solid ${hover ? color : 'rgba(255,255,255,0.2)'}`, + borderRadius: '20px', padding: '0.5rem', + height: '40px', minWidth: '40px', + cursor: 'pointer', color: hover ? 'white' : '#94a3b8', + boxShadow: hover ? `0 0 15px ${color}66` : 'none', + justifyContent: 'center', outline: 'none' + }} + transition={{ type: 'spring', stiffness: 500, damping: 30 }} + > + + + {hover && ( + + {label} + + )} + + + ); +}; + +// --- FILTER PANEL COMPONENT --- +const FilterPanel = ({ filters, setFilters }) => { + const languages = ["JavaScript", "TypeScript", "Python", "Java", "C++", "C#", "React", "Go", "Rust", "Swift", "Kotlin", "PHP"]; + const positions = ["Frontend Developer", "Backend Developer", "Full Stack", "UI Designer", "UX Researcher", "Data Scientist", "DevOps", "Mobile Dev", "QA Engineer"]; + + const toggleItem = (category, item) => { + const current = filters[category] || []; + const updated = current.includes(item) ? current.filter(i => i !== item) : [...current, item]; + setFilters({ ...filters, [category]: updated }); + }; + + return ( +
+
+ + {/* Status & Score Group */} +
+
+

Status

+
+ {['All', 'Pending', 'Accepted', 'Rejected'].map(status => ( + + ))} +
+
+
+

Min. Match Score

+
+ setFilters({ ...filters, minScore: parseInt(e.target.value) })} style={{ width: '100%', accentColor: '#EF4444', height: '4px', background: 'rgba(255,255,255,0.1)', borderRadius: '2px' }} /> + {filters.minScore}% +
+
+
+ + {/* List Group */} +
+
+

Languages

+
+
+ {languages.map(lang => ( + + ))} +
+
+
+
+

Job Positions

+
+
+ {positions.map(pos => ( + + ))} +
+
+
+
+
+ ); +}; + +// --- SCORING PANEL COMPONENT --- +const ScoringPanel = ({ config, setConfig, onReset, onClose }) => { + const handleChange = (key, value) => setConfig({ ...config, [key]: parseInt(value) }); + + // Internal slider for this component + const ConfigSlider = ({ label, value, min, max, onChangeKey }) => ( +
+
+ {label} + {value} +
+ handleChange(onChangeKey, e.target.value)} style={{ width: '100%', accentColor: '#EF4444', height: '4px', background: 'rgba(255,255,255,0.1)', borderRadius: '2px' }} /> +
+ ); + + return ( +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ ); +}; + +// --- MAIN PAGE COMPONENT --- +export default function AdminSortingPage() { + const [applicants, setApplicants] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [openPanel, setOpenPanel] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [drawerCandidate, setDrawerCandidate] = useState(null); + + // Pagination & Selection + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 5; + const [selectedIds, setSelectedIds] = useState([]); + + // Filters + const initialFilters = { sortBy: 'Match Score', status: 'All', minScore: 0, languages: [], positions: [] }; + const [filters, setFilters] = useState(initialFilters); + + // Scoring + const defaultScoring = { skillsWeight: 2, experienceWeight: 5, certBonus: 15, langWeight: 1 }; + const [scoringConfig, setScoringConfig] = useState(defaultScoring); + + //candidate overview + const handleViewCandidate = (candidate) => { + setDrawerCandidate(candidate); + setIsDrawerOpen(true); + }; + + // --- DATA FETCHING (Supabase) --- + useEffect(() => { + const fetchApplicants = async () => { + setIsLoading(true); + try { + const { data, error } = await supabase + .from('applications') + .select(` + id, + created_at, + status, + score: match_score, + skills, + profiles ( full_name, email, avatar_url,experience_years ), + jobs ( title ) + `); + + if (error) throw error; + + const formattedData = data.map(app => ({ + id: app.id, + name: app.profiles?.full_name || 'Unknown Candidate', + email: app.profiles?.email || 'No Email', + img: app.profiles?.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(app.profiles?.full_name || 'User')}&background=random`, + jobTitle: app.jobs?.title || app.profiles?.job_title || 'Applicant', + experience: parseInt(app.profiles?.experience_years) || 'Fresher', + skills: app.skills || [], + status: app.status, + score: app.score || 0 + })); + + setApplicants(formattedData); + } catch (error) { + console.error('Error fetching applicants:', error.message); + } finally { + setIsLoading(false); + } + }; + fetchApplicants(); + }, []); + + // --- BULK ACTIONS --- + const handleBulkReject = async () => { + if (!confirm(`Are you sure you want to REJECT ${selectedIds.length} candidates?`)) return; + + try { + const { error } = await supabase + .from('applications') + .update({ status: 'Rejected' }) // Update status to Rejected + .in('id', selectedIds); + if (error) throw error; + + // Update UI instantly + setApplicants(prev => prev.map(app => + selectedIds.includes(app.id) ? { ...app, status: 'Rejected' } : app + )); + + setSelectedIds([]); // Clear selection + alert('Candidates Rejected.'); + } catch (error) { + console.error('Error rejecting:', error.message); + alert('Failed to reject.'); + } + }; + + + + const handleBulkApprove = async () => { + if (!confirm(`Are you sure you want to approve ${selectedIds.length} candidates?`)) return; + try { + const { error } = await supabase + .from('applications') + .update({ status: 'Accepted' }) + .in('id', selectedIds); + + if (error) throw error; + + setApplicants(prev => prev.map(app => + selectedIds.includes(app.id) ? { ...app, status: 'Accepted' } : app + )); + setSelectedIds([]); + alert('Approved Successfully!'); + } catch (error) { + console.error('Error approving:', error.message); + alert('Failed to update.'); + } + }; + + const handleBulkEmail = () => { + const emails = applicants.filter(a => selectedIds.includes(a.id)).map(a => a.email).join(','); + window.location.href = `mailto:?bcc=${emails}&subject=Interview Update`; + }; + + // --- SORTING & FILTERING --- + const filteredApplicants = useMemo(() => { + return applicants.filter(app => { + const matchesSearch = app.name.toLowerCase().includes(searchQuery.toLowerCase()) || (app.jobTitle || '').toLowerCase().includes(searchQuery.toLowerCase()); + const matchesStatus = filters.status === 'All' || app.status === filters.status; + const matchesScore = (app.score || 0) >= filters.minScore; + + const matchesLang = (filters.languages || []).length === 0 || (filters.languages || []).some(l => (app.skills || []).includes(l)); + const matchesPos = (filters.positions || []).length === 0 || (filters.positions || []).includes(app.jobTitle); + + return matchesSearch && matchesStatus && matchesScore && matchesLang && matchesPos; + }).sort((a, b) => { + if (filters.sortBy === 'Match Score') return (b.score || 0) - (a.score || 0); + if (filters.sortBy === 'Experience') return (b.experience || 0) - (a.experience || 0); + if (filters.sortBy === 'Name') return a.name.localeCompare(b.name); + return 0; + }); + }, [searchQuery, filters, applicants]); + + // Check if every single selected person is already 'Accepted' + const allSelectedAreApproved = selectedIds.length > 0 && selectedIds.every(id => { + const applicant = applicants.find(app => app.id === id); + return applicant?.status === 'Accepted'; // Make sure this matches your DB value ('Accepted' or 'Approved') + }); + + // --- PAGINATION & SELECTION UTILS --- + const totalPages = Math.ceil(filteredApplicants.length / itemsPerPage); + const paginatedApplicants = filteredApplicants.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage); + + const toggleSelectAll = () => { + if (selectedIds.length === paginatedApplicants.length && paginatedApplicants.length > 0) { + setSelectedIds([]); + } else { + setSelectedIds(paginatedApplicants.map(a => a.id)); + } + }; + + const toggleSelectRow = (id) => { + if (selectedIds.includes(id)) setSelectedIds(selectedIds.filter(sid => sid !== id)); + else setSelectedIds([...selectedIds, id]); + }; + + const togglePanel = (panelName) => { + if (openPanel === panelName) setOpenPanel(null); + else setOpenPanel(panelName); + }; + + return ( +
+ + +
+

CV Sorting

+
+ + {/* Controls Bar */} +
+
+
+
+ setSearchQuery(e.target.value)} style={{ width: '100%', padding: '0.75rem 0.75rem 0.75rem 2.5rem', borderRadius: '0.5rem', border: '1px solid rgba(239, 68, 68, 0.3)', backgroundColor: 'rgba(255,255,255,0.05)', color: 'white' }} /> +
+ togglePanel('filter')} whileHover={{ scale: 1.02 }} style={{ backgroundColor: openPanel === 'filter' ? '#EF4444' : 'rgba(255,255,255,0.1)', color: 'white', padding: '0.75rem 1.2rem', borderRadius: '0.5rem', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px', fontWeight: '600' }}> + Filters + + togglePanel('scoring')} whileHover={{ scale: 1.02 }} style={{ backgroundColor: openPanel === 'scoring' ? '#EF4444' : 'rgba(255,255,255,0.1)', color: 'white', padding: '0.75rem 1.2rem', borderRadius: '0.5rem', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px', fontWeight: '600' }}> + Scoring + + { setSearchQuery(''); setFilters({ sortBy: 'Match Score', status: 'All', minScore: 0, languages: [], positions: [] }); }} whileHover={{ scale: 1.02 }} style={{ backgroundColor: 'rgba(255,255,255,0.1)', color: 'white', padding: '0.75rem 1rem', borderRadius: '0.5rem', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '6px' }}> + Clear + +
+
+ Sort By +
+ {['Match Score', 'Experience', 'Name', 'Date'].map(opt => ( + + ))} +
+
+ + {openPanel === 'filter' && } + {openPanel === 'scoring' && setScoringConfig(defaultScoring)} onClose={() => setOpenPanel(null)} />} + +
+ + {/* Results Table */} +
+
+

Applications ({filteredApplicants.length})

+ + {selectedIds.length > 0 && ( + + {selectedIds.length} Selected + + + + )} + +
+ + {isLoading ? ( +
+ +
+ ) : ( + <> +
+ + + + + + + + + + + + + + + {paginatedApplicants.map((app) => { + const isSelected = selectedIds.includes(app.id); + return ( + toggleSelectRow(app.id)} + > + + + + + + + + + ); + })} + + +
0 && selectedIds.length === paginatedApplicants.length} onChange={toggleSelectAll} style={{ accentColor: '#EF4444', cursor: 'pointer', width: '16px', height: '16px' }} />ApplicantExperienceJob TitleScoreStatusAction
+ { e.stopPropagation(); toggleSelectRow(app.id); }} style={{ accentColor: '#EF4444', cursor: 'pointer', width: '16px', height: '16px' }} /> + +
+ {app.name} +

{app.name}

{app.email}

+
+
{app.experience} years{app.jobTitle} 80 ? '#34d399' : '#fbbf24' }}>{app.score || 0}{app.status} + +
+
+ {totalPages > 1 && ( +
+ Showing {((currentPage - 1) * itemsPerPage) + 1}-{Math.min(currentPage * itemsPerPage, filteredApplicants.length)} of {filteredApplicants.length} +
+ + {currentPage} + +
+
+ )} + + )} +
+ + {/* Render the Drawer specifically for the sorting page */} + + {isDrawerOpen && ( + setIsDrawerOpen(false)} + candidate={drawerCandidate} + /> + )} + + +
+ ); +} \ No newline at end of file diff --git a/src/components/Admin/AdminSummary.jsx b/src/components/Admin/AdminSummary.jsx new file mode 100644 index 0000000000000000000000000000000000000000..560dedfb9cff22b5dfc2d6d413c5ab60ad3e2113 --- /dev/null +++ b/src/components/Admin/AdminSummary.jsx @@ -0,0 +1,162 @@ +import React, { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; + +// āœ… Go up 2 levels to reach src/supabaseClient +import { supabase } from "../../supabaseClient"; + +// āœ… Go up 1 level to reach components folder +import StatCard from "../StatCard"; +import ApplicationTrendsChart from "../ApplicationTrendsChart"; +import ExperienceChart from "../ExperienceChart"; + +// āœ… CORRECT +import UpcomingInterviews from "../Adminfront/UpcomingInterviews"; +import RecentApplications from "../Adminfront/RecentApplications"; +import TopPerformers from "../Adminfront/TopPerformers"; + +// Icons +const UsersIcon = () => ( ); + +export default function AdminSummary({ onNavigate }) { + const containerVariants = { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { staggerChildren: 0.1 } } }; + const itemVariants = { hidden: { opacity: 0, y: 20 }, visible: { opacity: 1, y: 0 } }; + + // State for dashboard data + const [stats, setStats] = useState({ total: 0, pending: 0, accepted: 0, rejected: 0 }); + const [applicants, setApplicants] = useState([]); // āœ… Store raw applicants here + const [loading, setLoading] = useState(true); + const [userName, setUserName] = useState('Admin'); + + useEffect(() => { + fetchDashboardData(); + }, []); + + const fetchDashboardData = async () => { + try { + setLoading(true); + + // 1. Fetch User Name + const { data: { user } } = await supabase.auth.getUser(); + + if (user) { + const { data: roleData } = await supabase + .from('user_roles') + .select('name') + .eq('user_id', user.id) + .single(); + + if (roleData && roleData.name) { + setUserName(roleData.name); + } + } + + // 2. Fetch Dashboard Stats & Data + const { data: data, error } = await supabase + .from('applications') + .select('id, status, created_at, experience'); + + if (error) throw error; + + if (data) { + setApplicants(data); // āœ… Save raw data to state + + // -- Calculate Summary Stats Only -- + const total = data.length; + const pending = data.filter(a => ['Pending', 'Screening', 'Interviewing'].includes(a.status)).length; + const accepted = data.filter(a => ['Hired', 'Offered', 'Accepted'].includes(a.status)).length; + const rejected = data.filter(a => a.status === 'Rejected').length; + + setStats({ total, pending, accepted, rejected }); + + // āŒ REMOVED: Trend Calculation + // āŒ REMOVED: Experience Calculation + } + + } catch (error) { + console.error('Error fetching dashboard data:', error); + } finally { + setLoading(false); + } + }; + + if (loading) { + return
Loading Dashboard...
; + } + + return ( + + {/* Header */} + +

Welcome, {userName}!

+
+ + {/* Stat Cards */} + + } value={stats.total} label="Total applicants" tint="239, 68, 68" /> + } value={stats.pending} label="Pending review" tint="239, 68, 68" /> + } value={stats.accepted} label="Accepted applications" tint="34, 197, 94" /> + } value={stats.rejected} label="Rejected applications" tint="100, 116, 139" /> + + + {/* Main Layout Grid */} +
+ + {/* --- LEFT COLUMN --- */} +
+ + {/* 1. Trends Chart */} + +

+ Application Trends +

+ +
+ {/* āœ… Pass raw applicants data */} + +
+
+ + {/* 2. Top Performers */} + + + +
+ + {/* --- RIGHT COLUMN --- */} +
+ + {/* 1. Experience Chart */} + +

Avg. Experience

+
+ {/* āœ… Pass raw applicants data (assuming ExperienceChart is updated similarly) */} + +
+
+ + {/* 2. Upcoming Interviews */} + + + + + {/* 3. Recent Applications */} + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/Adminfront/PerformersConfigModal.jsx b/src/components/Adminfront/PerformersConfigModal.jsx new file mode 100644 index 0000000000000000000000000000000000000000..54135459482b4521ecd08965bc45f24bdfc8291e --- /dev/null +++ b/src/components/Adminfront/PerformersConfigModal.jsx @@ -0,0 +1,199 @@ +import React, { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; + +export default function TopPerformersSection() { + // 1. Toggle for the Configuration Panel + const [showConfig, setShowConfig] = useState(true); + + // 2. State for Slider Values + const [config, setConfig] = useState({ + skillsWeight: 2, + experienceWeight: 5, + certificationBonus: 15, + referencesBonus: 10 + }); + + const handleChange = (key, e) => { + setConfig({ ...config, [key]: parseInt(e.target.value) }); + }; + + // --- STYLES (Dark Theme + Red Accents) --- + const containerStyle = { + backgroundColor: '#0f172a', // Dark Navy Background + padding: '2rem', + borderRadius: '1.5rem', + color: 'white', + fontFamily: 'sans-serif', + maxWidth: '800px', + margin: '0 auto' + }; + + const headerStyle = { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: '1.5rem' + }; + + // The "Box" for the Configuration (Inset style) + const configBoxStyle = { + backgroundColor: '#1e293b', // Slightly lighter dark for contrast + border: '1px solid #334155', // Subtle border + borderRadius: '1rem', + padding: '1.5rem', + marginBottom: '2rem', // Space between config and list + boxShadow: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.2)' // Inner shadow for depth + }; + + const labelRowStyle = { + display: 'flex', + justifyContent: 'space-between', + marginBottom: '0.5rem', + fontSize: '0.85rem', + fontWeight: '500', + color: '#94A3B8' // Muted text + }; + + // Helper for Red Gradient Slider + const getSliderStyle = (value, max) => { + const percentage = (value / max) * 100; + return { + width: '100%', + height: '6px', + borderRadius: '3px', + background: `linear-gradient(to right, #EF4444 0%, #EF4444 ${percentage}%, #334155 ${percentage}%, #334155 100%)`, + appearance: 'none', + outline: 'none', + cursor: 'pointer' + }; + }; + + // Dummy Data for the List + const candidates = [ + { name: "Elena Martinez", role: "Data Scientist", score: 87, img: "https://i.pravatar.cc/150?u=elena" }, + { name: "Sarah Johnson", role: "UX/UI Designer", score: 81, img: "https://i.pravatar.cc/150?u=sarah" }, + { name: "Iffah Fathima", role: "Student", score: 77, img: "https://i.pravatar.cc/150?u=iffah" }, + { name: "Rayaaan", role: "Senior Developer", score: 62, img: "https://i.pravatar.cc/150?u=rayaan" } + ]; + + return ( +
+ + {/* --- 1. Header Section --- */} +
+
+

Top Performers

+

+ Outstanding candidates by skills, experience and match +

+
+ + {/* Toggle Button */} + +
+ + {/* --- 2. Collapsible Configuration Box --- */} + + {showConfig && ( + +
+

Scoring Configuration

+ +
+ {/* Slider Item 1 */} +
+
+ Skills Weight + {config.skillsWeight} +
+ handleChange('skillsWeight', e)} style={getSliderStyle(config.skillsWeight, 10)} className="custom-range" /> +
+ {/* Slider Item 2 */} +
+
+ Experience Weight + {config.experienceWeight} +
+ handleChange('experienceWeight', e)} style={getSliderStyle(config.experienceWeight, 10)} className="custom-range" /> +
+ {/* Slider Item 3 */} +
+
+ Certification Bonus + {config.certificationBonus} +
+ handleChange('certificationBonus', e)} style={getSliderStyle(config.certificationBonus, 30)} className="custom-range" /> +
+ {/* Slider Item 4 */} +
+
+ References Bonus + {config.referencesBonus} +
+ handleChange('referencesBonus', e)} style={getSliderStyle(config.referencesBonus, 20)} className="custom-range" /> +
+
+
+
+ )} +
+ + {/* --- 3. Candidates List --- */} +
+ {candidates.map((person, index) => ( +
+
+ {person.name} +
+
+

{person.name}

+ + Score: {person.score} + +
+

{person.role}

+
+
+ +
+ ))} +
+ + {/* --- Global CSS for Sliders --- */} + +
+ ); +} \ No newline at end of file diff --git a/src/components/Adminfront/RecentApplications.jsx b/src/components/Adminfront/RecentApplications.jsx new file mode 100644 index 0000000000000000000000000000000000000000..4b2b3e520e35a7bd439443e3af57db885cd779e6 --- /dev/null +++ b/src/components/Adminfront/RecentApplications.jsx @@ -0,0 +1,123 @@ +import React, { useEffect, useState } from 'react'; +import { supabase } from '../../supabaseClient'; + +export default function RecentApplications() { + const [applications, setApplications] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchApps = async () => { + try { + // āœ… FIX: Using the EXACT constraint name from your error log + + const { data, error } = await supabase + .from('applications') + .select(` + id, created_at, status, experience, + jobs:jobs!applications_job_id_fkey ( title ), + profiles:profiles!fk_applications_profiles ( full_name, avatar_url ) + `) + .order('created_at', { ascending: false }) + .limit(4); + + if (error) throw error; + + console.log('Fetched Data:', data); + setApplications(data || []); + + } catch (error) { + console.error('Error fetching applications:', error); + } finally { + setLoading(false); + } + }; + + fetchApps(); + }, []); + + // Helper for Status Badges + const getStatusBadge = (status) => { + const s = status ? status.toLowerCase() : ''; + let bg = 'rgba(255,255,255,0.1)'; let text = '#D1D5DB'; + + if (s === 'accepted' || s === 'hired') { bg = 'rgba(16, 185, 129, 0.2)'; text = '#34D399'; } + else if (s === 'rejected') { bg = 'rgba(239, 68, 68, 0.2)'; text = '#F87171'; } + else if (s === 'pending') { bg = 'rgba(245, 158, 11, 0.2)'; text = '#FBBF24'; } + + return ( + + {status || 'Pending'} + + ); + }; + + return ( +
+

Recent Applications

+ +
+ {loading ? ( +

Loading...

+ ) : applications.length === 0 ? ( +

No applications yet.

+ ) : ( + applications.map((app) => { + // Safe access + const profile = app.profiles || {}; + const job = app.jobs || {}; + + const name = profile.full_name || 'Unknown User'; + const jobTitle = job.title || 'Unknown Role'; + const exp = app.experience ? `• ${app.experience} years` : ''; + const initial = name.charAt(0).toUpperCase(); + + return ( +
+
+ {profile.avatar_url ? ( + {name} + ) : ( +
+ {initial} +
+ )} + +
+
{name}
+
+ {jobTitle} {exp} +
+
+
+ +
+ + {new Date(app.created_at).toLocaleDateString()} + + {getStatusBadge(app.status)} +
+
+ ); + }) + )} +
+ + +
+ ); +} \ No newline at end of file diff --git a/src/components/Adminfront/TopPerformers.jsx b/src/components/Adminfront/TopPerformers.jsx new file mode 100644 index 0000000000000000000000000000000000000000..d2c47c00792d171dca903dca1979a74bbc1da53b --- /dev/null +++ b/src/components/Adminfront/TopPerformers.jsx @@ -0,0 +1,241 @@ +import React, { useEffect, useState } from 'react'; +import { supabase } from '../../supabaseClient'; +import { motion, AnimatePresence } from 'framer-motion'; + +export default function TopPerformers() { + // --- STATE --- + const [candidates, setCandidates] = useState([]); + const [loading, setLoading] = useState(true); + const [showConfig, setShowConfig] = useState(false); + + // Slider Config State + const [config, setConfig] = useState({ + skillsWeight: 2, + experienceWeight: 5, + certificationBonus: 15, + referencesBonus: 10 + }); + + const handleConfigChange = (key, e) => { + setConfig({ ...config, [key]: parseInt(e.target.value) }); + }; + + // --- DUMMY DATA (Fallback) --- + const dummyCandidates = [ + { + id: 'd1', experience: 8, + profiles: { full_name: 'Elena Martinez', avatar_url: 'https://i.pravatar.cc/150?u=elena' }, + jobs: { title: 'Data Scientist' } + }, + { + id: 'd2', experience: 5, + profiles: { full_name: 'Sarah Johnson', avatar_url: 'https://i.pravatar.cc/150?u=sarah' }, + jobs: { title: 'UX/UI Designer' } + }, + { + id: 'd3', experience: 12, + profiles: { full_name: 'Rayyan Ali', avatar_url: 'https://i.pravatar.cc/150?u=rayyan' }, + jobs: { title: 'Senior Developer' } + }, + { + id: 'd4', experience: 3, + profiles: { full_name: 'Iffah Fathima', avatar_url: 'https://i.pravatar.cc/150?u=iffah' }, + jobs: { title: 'Frontend Intern' } + }, + { + id: 'd5', experience: 6, + profiles: { full_name: 'Varun Nair', avatar_url: null }, // Test no avatar + jobs: { title: 'Product Manager' } + } + ]; + + // --- SUPABASE DATA FETCHING --- + useEffect(() => { + const fetchCandidates = async () => { + try { + const { data, error } = await supabase + .from('applications') + .select(` + id, experience, + profiles ( full_name, avatar_url ), + jobs ( title ) + `) + .limit(7); + + if (error) { + console.error("Supabase error, using dummy data:", error); + setCandidates(dummyCandidates); // Fallback on error + } else if (data && data.length > 0) { + setCandidates(data); + } else { + setCandidates(dummyCandidates); // Fallback if empty + } + } catch (error) { + console.error('Fetch error, using dummy data:', error); + setCandidates(dummyCandidates); + } finally { + setLoading(false); + } + }; + fetchCandidates(); + }, []); + + // --- STYLES --- + const containerStyle = { + backgroundColor: 'rgba(239, 68, 68, 0.05)', + border: '1px solid rgba(239, 68, 68, 0.2)', + borderRadius: '1rem', + padding: '1.5rem', + color: 'white', + height: '100%', + fontFamily: 'sans-serif' + }; + + const configBoxStyle = { + backgroundColor: 'rgba(0, 0, 0, 0.3)', + border: '1px solid rgba(255, 255, 255, 0.1)', + borderRadius: '0.75rem', + padding: '1.25rem', + marginBottom: '1.5rem', + marginTop: '0.5rem' + }; + + const labelRowStyle = { + display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem', + fontSize: '0.85rem', fontWeight: '500', color: '#D1D5DB' + }; + + const getSliderStyle = (value, max) => { + const percentage = (value / max) * 100; + return { + width: '100%', height: '6px', borderRadius: '3px', + background: `linear-gradient(to right, #EF4444 0%, #EF4444 ${percentage}%, #374151 ${percentage}%, #374151 100%)`, + appearance: 'none', outline: 'none', cursor: 'pointer' + }; + }; + + return ( +
+ + {/* --- HEADER --- */} +
+

Top Performers

+ + +
+ +

+ Outstanding candidates by match score +

+ + {/* --- CONFIGURATION PANEL --- */} + + {showConfig && ( + +
+

Scoring Weights

+
+
+
+ Skills {config.skillsWeight} +
+ handleConfigChange('skillsWeight', e)} style={getSliderStyle(config.skillsWeight, 10)} className="custom-range" /> +
+
+
+ Experience {config.experienceWeight} +
+ handleConfigChange('experienceWeight', e)} style={getSliderStyle(config.experienceWeight, 10)} className="custom-range" /> +
+
+
+ Certification {config.certificationBonus} +
+ handleConfigChange('certificationBonus', e)} style={getSliderStyle(config.certificationBonus, 30)} className="custom-range" /> +
+
+
+
+ )} +
+ + {/* --- CANDIDATES LIST --- */} +
+ {loading ? ( +

Loading...

+ ) : candidates.map((item, index) => { + const name = item.profiles?.full_name || 'Candidate'; + const role = item.jobs?.title || 'Applicant'; + const exp = item.experience ? `${item.experience} yrs` : 'N/A'; + const score = 90 - (index * 5); + + return ( +
+
+ {/* Avatar */} + {item.profiles?.avatar_url ? ( + {name} + ) : ( +
+ {name.charAt(0)} +
+ )} + + {/* Text Info */} +
+
+ {name} + + Score: {score} + +
+
+ {role} • {exp} • Certified +
+
+
+ + +
+ ); + })} +
+ + {/* Slider CSS Injection */} + +
+ ); +} \ No newline at end of file diff --git a/src/components/Adminfront/UpcomingInterviews.jsx b/src/components/Adminfront/UpcomingInterviews.jsx new file mode 100644 index 0000000000000000000000000000000000000000..ab6eedf8016544d52839183927c34f49af55800d --- /dev/null +++ b/src/components/Adminfront/UpcomingInterviews.jsx @@ -0,0 +1,94 @@ +import React, { useEffect, useState } from 'react'; +// āœ… Correct path to go back to src +import { supabase } from '../../supabaseClient'; + +export default function UpcomingInterviews() { + const [interviews, setInterviews] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchInterviews = async () => { + try { + const { data, error } = await supabase + .from('interviews') + .select(` + id, scheduled_at, status, + applications ( profiles ( full_name ) ) + `) + .order('scheduled_at', { ascending: true }) + .limit(3); + + if (error) throw error; + setInterviews(data || []); + } catch (error) { + console.error('Error fetching interviews:', error); + } finally { + setLoading(false); + } + }; + fetchInterviews(); + }, []); + + const formatDate = (dateString) => { + const date = new Date(dateString); + return { + date: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + time: date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) + }; + }; + + return ( + // šŸ”“ THEME MATCH: Dark Red Background & Border +
+

Upcoming Interviews

+ +
+ {loading ?

Loading...

: interviews.length === 0 ?

No upcoming interviews.

: interviews.map((item) => { + const { date, time } = formatDate(item.scheduled_at); + const name = item.applications?.profiles?.full_name || 'Candidate'; + + return ( +
+
+ {/* šŸ”“ THEME MATCH: Red Icon Box */} +
+ +
+ +
+
{name}
+
{date} • {time}
+
+
+ + +
+ ); + })} +
+ + +
+ ); +} \ No newline at end of file diff --git a/src/components/ApplicantLayout.jsx b/src/components/ApplicantLayout.jsx new file mode 100644 index 0000000000000000000000000000000000000000..0ca91f95c1045039e297cd66057ad408c1b83ee1 --- /dev/null +++ b/src/components/ApplicantLayout.jsx @@ -0,0 +1,126 @@ +import React, { useState, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { supabase } from '../supabaseClient'; +import { + LogoutIcon, BriefcaseIcon, UserCircleIcon, ChatIcon, + CalendarIcon, AtsCheckerIcon +} from './Icons'; + +export default function ApplicantLayout({ children, activePage, onNavigate }) { + + // āœ… FIX: Initialize state directly from LocalStorage. + // This removes the "flicker" because it grabs the name instantly before the page paints. + const [userName, setUserName] = useState(() => localStorage.getItem('applicant_name') || ''); + + useEffect(() => { + const fetchUserName = async () => { + try { + const { data: { user } } = await supabase.auth.getUser(); + if (user) { + const { data: profile } = await supabase + .from('profiles') + .select('full_name') + .eq('id', user.id) + .maybeSingle(); + + if (profile && profile.full_name) { + const firstName = profile.full_name.split(' ')[0]; + + // Update state + setUserName(firstName); + + // āœ… Save to LocalStorage for next time (Instant load on refresh) + localStorage.setItem('applicant_name', firstName); + } + } + } catch (error) { + console.error("Error fetching name:", error); + } + }; + fetchUserName(); + }, []); + + const handleLogout = async () => { + await supabase.auth.signOut(); + localStorage.removeItem('applicant_name'); // āœ… Clear cache so next user doesn't see your name + onNavigate('login'); + }; + + // Helper to check if a tab is active + const isActive = (key) => activePage === key; + + const navItems = [ + { key: 'applicant-jobs', icon: , label: 'Job Listings' }, + { key: 'applicant-profile', icon: , label: 'Profile' }, + { key: 'applicant-interviews', icon: , label: 'Interviews' }, + { key: 'applicant-ats', icon: , label: 'ATS Checker' }, + { key: 'applicant-messages', icon: , label: 'Messages' }, + ]; + + return ( +
+ + + {/* Background Blobs */} +
+
+
+
+ + {/* Header */} +
+ {/* Name appears instantly now if cached */} +

+ {userName ? `Hi, ${userName} šŸ‘‹` : 'Welcome šŸ‘‹'} +

+ + + + Logout + +
+ + {/* Navigation Bar */} +
+ +
+ + {/* Main Content */} +
+ + + {children} + + +
+
+ ); +}; \ No newline at end of file diff --git a/src/components/ApplicationTrendsChart.jsx b/src/components/ApplicationTrendsChart.jsx new file mode 100644 index 0000000000000000000000000000000000000000..3a95276ddb49c330732bdd90cc38e0c969f57f7a --- /dev/null +++ b/src/components/ApplicationTrendsChart.jsx @@ -0,0 +1,233 @@ +import React, { useState, useMemo } from 'react'; +import { motion } from 'framer-motion'; + +const ApplicationTrendsChart = ({ data }) => { + + // āœ… ADDED: Your calculation logic (derived from data prop) + const safeData = useMemo(() => { + // Alias data to 'applicants' to match your snippet + const applicants = data || []; + + // -- Calculate Trends (Last 30 Days) -- + const last30Days = Array.from({ length: 30 }, (_, i) => { + const d = new Date(); + d.setDate(d.getDate() - (29 - i)); + return d.toISOString().split('T')[0]; + }); + + const trends = last30Days.map(dateStr => { + const count = applicants.filter(a => a.created_at && a.created_at.startsWith(dateStr)).length; + return { name: dateStr.split('-')[2], value: count }; + }); + + return trends; + }, [data]); + + const maxValue = 4; // fixed scale like reference chart + const [hoverIndex, setHoverIndex] = useState(null); + + const width = 100; + const height = 100; + + const padding = { + top: 10, + right: 4, + bottom: 22, + left: 8 + }; + + const chartWidth = width - padding.left - padding.right; + const chartHeight = height - padding.top - padding.bottom; + + const getX = i => + padding.left + (i / (safeData.length - 1)) * chartWidth; + + // āœ… FIXED + const getY = v => + padding.top + chartHeight - (v / maxValue) * chartHeight; + + + const path = safeData + .map((d, i) => + `${i === 0 ? 'M' : 'L'} ${getX(i)} ${getY(d.value)}` + ) + .join(' '); + + // Dates for X axis + const formatDate = (index) => { + const date = new Date(); + date.setDate(date.getDate() - (safeData.length - 1 - index)); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric' + }); + }; + + // āœ… DEFINE Y TICKS + const yTicks = 3; // will show 2, 4, 6 style labels + + const getIndexFromX = (svgX) => { + const ratio = (svgX - padding.left) / chartWidth; + const index = Math.round(ratio * (safeData.length - 1)); + return Math.max(0, Math.min(safeData.length - 1, index)); + }; + + + return ( +
+ { + const rect = e.currentTarget.getBoundingClientRect(); + const x = ((e.clientX - rect.left) / rect.width) * width; + const index = getIndexFromX(x); + setHoverIndex(index); + }} + onMouseLeave={() => setHoverIndex(null)} + > + {/* X axis */} + + + {/* Y axis */} + + + {/* Y-axis labels */} + {[0, 2, 4].map((value) => ( + + {value} + + ))} + + {/* Hover vertical line */} + {hoverIndex !== null && ( + + )} + + + {/* Trend line */} + + + {/* Dots */} + {safeData.map((d, i) => ( + + ))} + + + {/* Tooltip */} + {hoverIndex !== null && ( + + + + + {formatDate(hoverIndex)} + + + + applications: {safeData[hoverIndex].value} + + + )} + + + {/* X-axis date labels */} + {safeData.map((_, i) => { + const isLast = i === safeData.length - 1; + const isSecondLast = i === safeData.length - 2; + + if (i % 4 !== 0 && !isLast) return null; + if (isSecondLast) return null; + + return ( + + {formatDate(i)} + + ); + })} + +
+ ); + +}; + +export default ApplicationTrendsChart; \ No newline at end of file diff --git a/src/components/ApplyModel.jsx b/src/components/ApplyModel.jsx new file mode 100644 index 0000000000000000000000000000000000000000..d5759a0f1f0fee31435044679fc795d6ccc64c0e --- /dev/null +++ b/src/components/ApplyModel.jsx @@ -0,0 +1,116 @@ +import React, { useState } from 'react'; +import ReactDOM from 'react-dom'; +import { motion, AnimatePresence } from 'framer-motion'; + +// --- Icons --- +const CloseIcon = () => ; +const UploadIcon = () => ; + +export default function ApplyModel({ job, onClose, onSubmit, isSubmitting }) { + const [coverLetter, setCoverLetter] = useState(''); + const [resumeLink, setResumeLink] = useState(''); + + const handleSubmit = (e) => { + e.preventDefault(); + // Pass data back to parent + onSubmit({ + cover_letter: coverLetter, + resume_url: resumeLink + }); + }; + + return ReactDOM.createPortal( + + + e.stopPropagation()} + > + {/* Header */} +
+
+

Apply for {job.title}

+

at {job.company}

+
+ +
+ + {/* Form */} +
+ + {/* Resume Link Input */} +
+ +
+
+ setResumeLink(e.target.value)} + style={{ + width: '100%', padding: '0.75rem 1rem 0.75rem 3rem', + backgroundColor: 'rgba(255,255,255,0.05)', border: '1px solid #374151', + borderRadius: '0.5rem', color: 'white', boxSizing: 'border-box' + }} + /> +
+
+ + {/* Cover Letter Input */} +
+ + +
+
+ Cancel + Post Job +
+ + + ); +}; + +// --- Date/Time Picker Modal --- +const DateTimeModal = ({ isOpen, onClose, onSchedule }) => { + const [selectedDate, setSelectedDate] = useState(''); + const [selectedTime, setSelectedTime] = useState(''); + + const handleSchedule = () => { + if (selectedDate && selectedTime) { + onSchedule(selectedDate, selectedTime); + onClose(); + } else { + alert('Please select both date and time.'); + } + }; + + if (!isOpen) return null; + + // Dynamically generate time slots for 15-minute intervals + const generateTimeSlots = () => { + const slots = []; + for (let i = 0; i < 24; i++) { + for (let j = 0; j < 4; j++) { + const hour = String(i).padStart(2, '0'); + const minute = String(j * 15).padStart(2, '0'); + slots.push(`${hour}:${minute}`); + } + } + return slots; + }; + + const timeSlots = generateTimeSlots(); + + return ( + + +

Schedule Interview

+
+ setSelectedDate(e.target.value)} style={{ padding: '0.75rem', borderRadius: '0.5rem', border: '1px solid rgba(239, 68, 68, 0.3)', backgroundColor: 'rgba(255,255,255,0.1)', color: 'white' }} /> + setSelectedTime(e.target.value)} style={{ padding: '0.75rem', borderRadius: '0.5rem', border: '1px solid rgba(239, 68, 68, 0.3)', backgroundColor: 'rgba(255,255,255,0.1)', color: 'white' }} /> +
+
+ Cancel + Schedule +
+
+
+ ); +}; + +// --- Message Modal --- +const MessageModal = ({ isOpen, onClose, onSend }) => { + const [message, setMessage] = useState(''); + + const handleSend = () => { + if (message.trim()) { + onSend(message); + onClose(); + } else { + alert('Message cannot be empty.'); + } + }; + + if (!isOpen) return null; + + return ( + + +

Compose Message

+ +
+ Cancel + Send Message +
+
+
+ ); +}; + +// --- Interview Management Page --- +const InterviewManagementPage = () => { + const [activeSubTab, setActiveSubTab] = useState('interviews'); + const applicants = { + interviews: [ { name: 'Varun', role: 'UI Designer', experience: '8 years', skills: ['game'], date: 'April 15th, 2025', time: '10:30 AM', status: 'Awaiting Response' }, ], + accepted: [ { name: 'Jane Smith', role: 'UI Designer', experience: '3 years', skills: ['Figma', 'Sketch'], date: 'N/A', time: '', status: 'Accepted' }, ], + rejected: [ { name: 'Peter Jones', role: 'Backend Developer', experience: '7 years', skills: ['Python', 'Django'], date: 'N/A', time: '', status: 'Rejected' }, ] + }; + const [isDateTimeModalOpen, setIsDateTimeModalOpen] = useState(false); + const [isMessageModalOpen, setIsMessageModalOpen] = useState(false); + const [selectedApplicant, setSelectedApplicant] = useState(null); + + const handleSchedule = (date, time) => { + console.log(`Scheduled for ${selectedApplicant?.name} on ${date} at ${time}`); + // Here you would typically update your backend/state for the scheduled interview + alert(`Interview scheduled for ${selectedApplicant?.name} on ${date} at ${time}`); + }; + + const handleSendMessage = (message) => { + console.log(`Message to ${selectedApplicant?.name}: ${message}`); + // Here you would typically send the message via an API + alert(`Message sent to ${selectedApplicant?.name}: "${message}"`); + }; + + const openDateTimeModal = (applicant) => { + setSelectedApplicant(applicant); + setIsDateTimeModalOpen(true); + }; + + const openMessageModal = (applicant) => { + setSelectedApplicant(applicant); + setIsMessageModalOpen(true); + }; + + const ApplicantCard = ({ data, tab }) => { + const statusColors = { 'Accepted': '#34D399', 'Awaiting Response': '#FBBF24', 'Rejected': '#EF4444' }; + return ( +
+
+
+ +
+
+
+

{data.name}

+ {data.status} +
+

{data.role} • {data.experience}

+
+ {data.skills.map(skill => ({skill}))} +
+ {data.date !== 'N/A' &&

Interview: {data.date} at {data.time}

} +
+
+
+ {tab === 'interviews' && ( + openDateTimeModal(data)} whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.98 }} style={{ backgroundColor: 'rgba(255,255,255,0.1)', border: '1px solid rgba(255,255,255,0.2)', color: 'white', padding: '0.5rem 1rem', borderRadius: '9999px', fontWeight: '500', cursor: 'pointer' }}>Reschedule + )} + {tab === 'accepted' && ( + openDateTimeModal(data)} whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.98 }} style={{ backgroundColor: 'rgba(255,255,255,0.1)', border: '1px solid rgba(255,255,255,0.2)', color: 'white', padding: '0.5rem 1rem', borderRadius: '9999px', fontWeight: '500', cursor: 'pointer' }}>Schedule + )} + {(tab === 'interviews' || tab === 'accepted' || tab === 'rejected') && ( + openMessageModal(data)} whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.98 }} style={{ backgroundColor: 'rgba(255,255,255,0.1)', border: '1px solid rgba(255,255,255,0.2)', color: 'white', padding: '0.5rem 1rem', borderRadius: '9999px', fontWeight: '500', cursor: 'pointer' }}>Send Message + )} + + View CV + +
+
+ ); + }; + const tabItems = [ { key: 'interviews', label: 'Interviews' }, { key: 'accepted', label: 'Accepted CVs' }, { key: 'rejected', label: 'Rejected CVs' }, ]; + return ( +
+
+

Interview Management

+
+
+ +
+ + +
+ {applicants[activeSubTab].map((applicant, index) => ( + + ))} +
+
+
+ + {isDateTimeModalOpen && ( + setIsDateTimeModalOpen(false)} + onSchedule={handleSchedule} + /> + )} + + + {isMessageModalOpen && ( + setIsMessageModalOpen(false)} + onSend={handleSendMessage} + /> + )} + +
+ ); +}; + +// --- CV Sorting Page Component --- +const CVSortingPage = () => { + const [searchQuery, setSearchQuery] = useState(''); + const [sortBy, setSortBy] = useState('Match Score'); + const [jobPositionFilter, setJobPositionFilter] = useState('All Positions'); + const [applicationStatusFilter, setApplicationStatusFilter] = useState('All Statuses'); + + const applicants = [ + { name: 'Elena Martinez', email: 'elena.martinez@example.com', experience: 10, skills: ['Python', 'TensorFlow', 'Pandas'], jobTitle: 'N/A', status: 'Pending', score: 90, img: 'https://i.pravatar.cc/150?u=elena' }, + { name: 'Sarah Johnson', email: 'sarah.johnson@example.com', experience: 8, skills: ['Figma', 'Adobe XD', 'Sketch'], jobTitle: 'Programmer', status: 'Accepted', score: 82, img: 'https://i.pravatar.cc/150?u=sarah' }, + { name: 'Varun', email: 'vaaran@gmail.com', experience: 8, skills: ['game'], jobTitle: 'N/A', status: 'Accepted', score: 59, img: 'https://i.pravatar.cc/150?u=varun' }, + { name: 'Rey Misterio', email: 'rey@gm.com', experience: 3, skills: ['JavaScript', 'TypeScript', 'React'], jobTitle: 'UI Designer', status: 'Accepted', score: 56, img: 'https://i.pravatar.cc/150?u=rey' }, + ]; + + const filteredAndSortedApplicants = useMemo(() => { + return applicants + .filter(a => + (jobPositionFilter === 'All Positions' || a.jobTitle === jobPositionFilter) && + (applicationStatusFilter === 'All Statuses' || a.status === applicationStatusFilter) && + ( + a.name.toLowerCase().includes(searchQuery.toLowerCase()) || + a.skills.some(s => s.toLowerCase().includes(searchQuery.toLowerCase())) || + a.jobTitle.toLowerCase().includes(searchQuery.toLowerCase()) + ) + ) + .sort((a, b) => { + switch (sortBy) { + case 'Experience': return b.experience - a.experience; + case 'Name': return a.name.localeCompare(b.name); + case 'Date': return 0; // Assuming date is not available yet + case 'Match Score': return b.score - a.score; // Match Score + default: return b.score - a.score; // Match Score + } + }); + }, [searchQuery, sortBy, jobPositionFilter, applicationStatusFilter]); + + const StatusBadge = ({ status }) => { + const style = { + padding: '0.25rem 0.75rem', + borderRadius: '9999px', + fontSize: '0.75rem', + fontWeight: 'bold', + color: 'white', + }; + if (status === 'Accepted') style.backgroundColor = 'rgba(16, 185, 129, 0.2)'; + else if (status === 'Rejected') style.backgroundColor = 'rgba(239, 68, 68, 0.2)'; + else style.backgroundColor = 'rgba(251, 191, 36, 0.2)'; + return {status}; + }; + + return ( +
+
+

CV Sorting

+
+ +
+
+ setSearchQuery(e.target.value)} style={{ flexGrow: 1, padding: '0.75rem', borderRadius: '0.5rem', border: '1px solid rgba(239, 68, 68, 0.3)', backgroundColor: 'rgba(255,255,255,0.1)', color: 'white' }} /> + Filters + Scoring + Clear +
+ +
+ Sort by: + {['Match Score', 'Experience', 'Name', 'Date'].map(sortKey => ( + + ))} +
+ + {/* Filter Applications Panel */} +
+
+

Filter Applications

+ Filters +
+ +
+ {/* Job Position Dropdown */} +
+ + +
+ + {/* Application Status Dropdown */} +
+ + +
+ + {/* Only show pending toggle */} +
+ Only show pending + +
+
+
+
+ +
+

Applications

+
+ + + + {['Applicant', 'Experience', 'Skills', 'Job Title', 'Status', 'Score', 'Actions'].map(header => ( + + ))} + + + + + {filteredAndSortedApplicants.map((app, index) => ( + + + + + + + + + + ))} + + +
{header}
+ {app.name} +
+

{app.name}

+

{app.email}

+
+
{app.experience} years +
+ {app.skills.map(skill => {skill})} +
+
{app.jobTitle}{app.score} + +
+
+
+
+ ); +}; + +// --- Chart Components --- +const BarChart = () => { + const generateRandomData = () => { + const data = []; + const today = new Date(); + for (let i = 0; i < 30; i++) { + const date = new Date(today); + date.setDate(today.getDate() - (29 - i)); + const month = date.toLocaleString('en-us', { month: 'short' }); + const day = date.getDate(); + data.push({ name: `${month} ${day < 10 ? '0' : ''}${day}`, value: Math.floor(Math.random() * 5) + 1 }); + } + return data; + }; + + const data = generateRandomData(); + const maxValue = Math.max(...data.map(d => d.value)); + + return ( +
+
+ {data.map(d => ( + + ))} +
+
+ {data.map(d => {d.name})} +
+
+ ); +}; + +const DoughnutChart = () => { + const generateRandomExperienceData = () => { + const total = 100; + const val1 = Math.floor(Math.random() * (total / 2)); + const val2 = Math.floor(Math.random() * (total - val1)); + const val3 = total - val1 - val2; + + const p1 = (val1 / total) * 100; + const p2 = (val2 / total) * 100; + const p3 = (val3 / total) * 100; + + const start2 = p1; + const start3 = p1 + p2; + + return { + gradient: `conic-gradient(#EF4444 0% ${p1}%, #DC2626 ${start2}% ${start3}%, #B91C1C ${start3}% 100%)`, + avgExp: (Math.random() * 10).toFixed(1) // Random average experience + }; + }; + + const { gradient, avgExp } = generateRandomExperienceData(); + + return ( +
+
+
+
+

{avgExp}

+

Avg. Exp

+
+
+

Showing experience distribution across all candidates

+
+ ); +}; + +// --- Dashboard Content Component --- +const DashboardContent = ({ onNavigate, setIsModalOpen }) => { + const containerVariants = { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { staggerChildren: 0.1 } } }; + const itemVariants = { hidden: { opacity: 0, y: 20 }, visible: { opacity: 1, y: 0 } }; + + return ( + + +

Hello, Roman Reigns!

+ onNavigate('login')} whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.98 }} style={{ backgroundColor: '#EF4444', color: 'white', display: 'flex', alignItems: 'center', padding: '0.75rem 1.5rem', borderRadius: '0.5rem', fontWeight: 'bold', cursor: 'pointer', border: 'none' }}> + Logout + +
+ + + } value={Math.floor(Math.random() * 50) + 10} label="Total applicants" tint="239, 68, 68" /> + } value={Math.floor(Math.random() * 10) + 1} label="Pending review" tint="239, 68, 68" /> + } value={Math.floor(Math.random() * 15) + 5} label="Accepted applications" tint="239, 68, 68" /> + } value={Math.floor(Math.random() * 8) + 1} label="Rejected applications" tint="239, 68, 68" /> + + +
+
+ +

Application Trends

+
+ +
+
+ +
+

Top Performers

+ + Config + +
+
+ {[...Array(5)].map((_, index) => ( +
+
+ performer +
+

{['Elena Martinez', 'Sarah Johnson', 'Iffathfatimakp', 'Rayaaan', 'Varun'][index]}

+

{['Data Scientist', 'UX/UI Designer', 'Studing', 'Senior developer', 'UI Designer'][index]} • {Math.floor(Math.random() * 10) + 1} yrs • {Math.floor(Math.random() * 5) + 3} skills • Certified

+
+
+
+ Score: {Math.floor(Math.random() * 40) + 60} + View +
+
+ ))} +
+
+ + View all candidates + +
+
+
+
+ +

Experience Distribution

+
+ +
+
+ +

Upcoming Interviews

+
+ {[...Array(3)].map((_, index) => { + const names = ['Varun', 'Sarah Johnson', 'dhanoon kp']; + const roles = ['UI Designer', 'Data Scientist', 'Software Engineer']; + const statuses = ['Awaiting Response', 'Accepted']; + const dates = ['April 15, 2025', 'April 15, 2025', 'April 21, 2025']; + const times = ['10:30 AM', '1:30 PM', '1:30 PM']; + const status = statuses[Math.floor(Math.random() * statuses.length)]; + const statusColor = status === 'Accepted' ? '#34D399' : '#FBBF24'; + const bgColor = status === 'Accepted' ? 'rgba(16, 185, 129, 0.2)' : 'rgba(251, 191, 36, 0.2)'; + + return ( +
+
+ interviewer +
+

{names[index]}

+

{dates[index]} at {times[index]}

+
+
+
+ {status} + View +
+
+ ); + })} +
+
+ + Manage all interviews + +
+
+ +

Recent Applications

+
+ {[...Array(4)].map((_, index) => { + const names = ['sameer s', 'iffahfathimakp', 'Raayaaan', 'Raayaaan']; + const roles = ['ui developer', 'Studing', 'Senior developer', 'Senior developer']; + const dates = ['4/11/2025', '4/10/2025', '4/3/2025', '4/3/2025']; + const statuses = ['pending', 'rejected', 'pending', 'pending']; + const status = statuses[index]; + const statusColor = status === 'pending' ? '#FBBF24' : (status === 'rejected' ? '#EF4444' : '#34D399'); + const bgColor = status === 'pending' ? 'rgba(251, 191, 36, 0.2)' : (status === 'rejected' ? 'rgba(239, 68, 68, 0.2)' : 'rgba(16, 185, 129, 0.2)'); + + return ( +
+
+ applicant +
+

{names[index]}

+

{roles[index]} • {Math.floor(Math.random() * 10) + 1} years

+
+
+
+

{dates[index]}

+ {status} +
+
+ ); + })} +
+
+ + View all applications + +
+
+
+
+
+ ); +}; + + +// --- Main Admin Dashboard Component --- +export default function Admindash({ onNavigate }) { + const [activeTab, setActiveTab] = useState('dashboard'); + const [isModalOpen, setIsModalOpen] = useState(false); + const [showSuccessToast, setShowSuccessToast] = useState(false); + + const handlePostSuccess = () => { + setShowSuccessToast(true); + setTimeout(() => { + setShowSuccessToast(false); + }, 3000); + }; + + const contentVariants = { hidden: { opacity: 0, y: 10 }, visible: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -10 } }; + + const renderContent = () => { + switch (activeTab) { + case 'dashboard': + return ; + case 'jobs': return ; + case 'messages': return ; + case 'job-management': return ; + case 'settings': return ; + default: return null; + } + }; + + return ( +
+ +
+
+
+
+ +
+ + + {renderContent()} + + +
+ + {isModalOpen && setIsModalOpen(false)} onPostSuccess={handlePostSuccess} />} + + + {showSuccessToast && ( + + + Job Posted Successfully! + + )} + +
+ ); +} diff --git a/src/pages/balance.js b/src/pages/balance.js new file mode 100644 index 0000000000000000000000000000000000000000..88263e77b47f6a091dba059ab012e6b501af34eb --- /dev/null +++ b/src/pages/balance.js @@ -0,0 +1,120 @@ +// --- Settings Page Component --- +const SettingsPage = ({ onNavigate }) => { + const [profilePhoto, setProfilePhoto] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [saveSuccess, setSaveSuccess] = useState(false); + const [isEditing, setIsEditing] = useState(false); + + const handlePhotoChange = (e) => { + const file = e.target.files[0]; + if (file && (file.type === "image/jpeg" || file.type === "image/png" || file.type === "image/webp")) { + setProfilePhoto(file); + } else { + alert("Please upload a JPG, PNG, or WEBP file."); + } + }; + + const handleSaveSettings = () => { + setIsSaving(true); + setSaveSuccess(false); + setTimeout(() => { + setIsSaving(false); + setSaveSuccess(true); + setIsEditing(false); + setTimeout(() => setSaveSuccess(false), 3000); + }, 1500); + }; + + const companySettings = ( +
+
+

Company Settings

+ {!isEditing && ( + setIsEditing(true)} whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.98 }} style={{ backgroundColor: 'rgba(255,255,255,0.1)', color: 'white', padding: '0.5rem 1rem', borderRadius: '0.5rem', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer' }}> + Edit Profile + + )} +
+
+
+ + {isEditing ? ( + + ) : ( +

GroupMoo

+ )} +
+
+ + {isEditing ? ( + + ) : ( +

Roman Reigns

+ )} +
+ {isEditing && ( + <> +
+ +
+ + +
+
+
+ + {isSaving && } {isSaving ? 'Saving...' : 'Save Settings'} + + + {saveSuccess && Settings Saved!} + +
+ + )} +
+
+ ); + + const profilePhotoSection = ( +
+

Profile Photo

+
+ {profilePhoto ? ( Profile Preview ) : ( Profile )} +
+ + +
+
+
+ ); + + + + return ( +
+
+

Settings

+ onNavigate('login')} whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.98 }} style={{ backgroundColor: '#EF4444', color: 'white', display: 'flex', alignItems: 'center', padding: '0.75rem 1.5rem', borderRadius: '0.5rem', fontWeight: 'bold', cursor: 'pointer', border: 'none' }}> + Logout + +
+
+ {profilePhoto ? ( + <> + {profilePhotoSection} + {companySettings} + + ) : ( + <> + {companySettings} + {profilePhotoSection} + + )} +
+
+ ); +}; \ No newline at end of file diff --git a/src/pages/clientdash.jsx b/src/pages/clientdash.jsx new file mode 100644 index 0000000000000000000000000000000000000000..9dbac00e70cc7f2e1d27fb8c7dbb478cae630910 --- /dev/null +++ b/src/pages/clientdash.jsx @@ -0,0 +1,49 @@ +import React, { useState } from 'react'; + +// āœ… Import your new modular page components +// Make sure these paths match exactly where you saved the files +import ApplicantProfile from './ApplicantProfile'; +import ApplicantJobPage from './ApplicantJobPage'; +import ApplicantInterviews from './ApplicantInterviews'; +import ApplicantATS from './ApplicantATS'; +import ApplicantMessages from './ApplicantMessages'; + +export default function ClientDash({ onNavigate: globalNavigate }) { + + // Default to the profile page when logging in + const [activePage, setActivePage] = useState('applicant-profile'); + + // šŸ›‘ Traffic Controller Function + // This decides if we are switching TABS (internal) or LOGGING OUT (external) + const handleNavigation = (destination) => { + if (destination === 'login') { + // Pass 'login' up to App.js to handle logout + globalNavigate('login'); + } else { + // Otherwise, just switch the local tab + setActivePage(destination); + } + }; + + // Render the correct component based on the activePage state + // We pass our new 'handleNavigation' down to the pages so their buttons work + switch (activePage) { + case 'applicant-profile': + return ; + + case 'applicant-jobs': + return ; + + case 'applicant-interviews': + return ; + + case 'applicant-ats': + return ; + + case 'applicant-messages': + return ; + + default: + return ; + } +} \ No newline at end of file diff --git a/src/pages/settingsPage.jsx b/src/pages/settingsPage.jsx new file mode 100644 index 0000000000000000000000000000000000000000..56de20129752282983295adc7bbcca274e5761d2 --- /dev/null +++ b/src/pages/settingsPage.jsx @@ -0,0 +1,272 @@ +import React, { useState, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { supabase } from '../supabaseClient'; +import AdminSettings from '../components/adminSettings'; + +// --- Icons --- +const UploadIcon = () => ; +const SpinnerIcon = () => ; + +export default function SettingsPage() { + // --- State for UI control --- + const [loading, setLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [saveSuccess, setSaveSuccess] = useState(false); + const [isEditing, setIsEditing] = useState(false); + + // --- State for data --- + const [companyName, setCompanyName] = useState(''); + const [recruiterName, setRecruiterName] = useState(''); + const [logoUrl, setLogoUrl] = useState(null); + const [logoFile, setLogoFile] = useState(null); + const [companyEmail, setCompanyEmail] = useState(''); + const [currentCompanyId, setCurrentCompanyId] = useState(null); // To track which company to update + + // --- 1. Fetch Data from 'user_roles' and 'companies' --- + useEffect(() => { + const fetchData = async () => { + setLoading(true); + try { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) throw new Error("User not found"); + + // A. Get Recruiter/Admin Details from 'user_roles' + const { data: roleData, error: roleError } = await supabase + .from('user_roles') + .select('name, company_id') + .eq('user_id', user.id) + .single(); + + if (roleError) { + // Handle case where user exists in Auth but not in user_roles table yet + if (roleError.code !== 'PGRST116') throw roleError; + } + + if (roleData) { + setRecruiterName(roleData.name || ''); + + // B. If they have a company assigned, fetch Company Details + if (roleData.company_id) { + setCurrentCompanyId(roleData.company_id); + + const { data: companyData, error: companyError } = await supabase + .from('companies') + .select('*') + .eq('id', roleData.company_id) + .single(); + + if (companyError) throw companyError; + + if (companyData) { + setCompanyName(companyData.name || ''); + setLogoUrl(companyData.logo_url || null); + setCompanyEmail(companyData.company_email || ''); + } + } + } + + } catch (error) { + console.error("Error fetching settings:", error.message); + } finally { + setLoading(false); + } + }; + fetchData(); + }, []); + + const handlePhotoChange = (e) => { + const file = e.target.files[0]; + if (file) { + setLogoFile(file); + setLogoUrl(URL.createObjectURL(file)); + } + }; + + // --- 2. Save Data to 'user_roles' and 'companies' --- + const handleSaveSettings = async () => { + setIsSaving(true); + setSaveSuccess(false); + + try { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) throw new Error("User not found"); + + // --- A. Upload Logo if changed --- + let updatedLogoUrl = logoUrl; + if (logoFile) { + // Ensure you have a bucket named 'company_logos' or similar + const filePath = `company_logos/${user.id}-${Date.now()}-${logoFile.name}`; + const { error: uploadError } = await supabase.storage + .from('company_logos') // Make sure this bucket exists! + .upload(filePath, logoFile, { upsert: true }); + + if (uploadError) throw uploadError; + + const { data: urlData } = supabase.storage + .from('company_logos') + .getPublicUrl(filePath); + + updatedLogoUrl = urlData.publicUrl; + } + + // --- B. Update or Create Company --- + const domain = companyEmail ? companyEmail.split('@')[1] : null; + const companyUpdateData = { + name: companyName, + company_email: companyEmail, + domain: domain, + logo_url: updatedLogoUrl + }; + + let finalCompanyId = currentCompanyId; + + if (currentCompanyId) { + // Update existing company + const { error: companyError } = await supabase + .from('companies') + .update(companyUpdateData) + .eq('id', currentCompanyId); + if (companyError) throw companyError; + } else { + // Create new company if none exists + const { data: newCompany, error: createError } = await supabase + .from('companies') + .insert(companyUpdateData) + .select('id') + .single(); + + if (createError) throw createError; + finalCompanyId = newCompany.id; + setCurrentCompanyId(finalCompanyId); + } + + // --- C. Update Recruiter Profile in 'user_roles' --- + const { error: roleUpdateError } = await supabase + .from('user_roles') + .update({ + name: recruiterName, + company_id: finalCompanyId + }) + .eq('user_id', user.id); + + if (roleUpdateError) throw roleUpdateError; + + // Success UI + setSaveSuccess(true); + setIsEditing(false); + setLogoFile(null); + setTimeout(() => setSaveSuccess(false), 3000); + + } catch (error) { + console.error("Error saving settings:", error.message); + alert("Error: " + error.message); + } finally { + setIsSaving(false); + } + }; + + if (loading) { + return
Loading Settings...
; + } + + // --- Component JSX (UI) remains mostly the same, just using the new state --- + const profilePhotoSection = ( +
+

Profile Photo

+
+ {logoUrl ? ( + Profile Preview + ) : ( + // āœ… CHANGED: Default icon is now a user profile icon + Profile + )} + + {isEditing && ( +
+ + +
+ )} +
+
+ ); + + const companySettings = ( +
+
+

Company Settings

+ {!isEditing && ( + setIsEditing(true)} whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.98 }} style={{ backgroundColor: 'rgba(255,255,255,0.1)', color: 'white', padding: '0.5rem 1rem', borderRadius: '0.5rem', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer' }}> + Edit Profile + + )} +
+ +
+
+ + {isEditing ? ( + setCompanyName(e.target.value)} style={{ width: '100%', padding: '0.75rem', borderRadius: '0.5rem', border: '1px solid rgba(239, 68, 68, 0.3)', backgroundColor: 'rgba(255,255,255,0.1)', color: 'white', boxSizing: 'border-box' }} /> + ) : ( +

{companyName || 'Not set'}

+ )} +
+ +
+ + {isEditing ? ( + setRecruiterName(e.target.value)} style={{ width: '100%', padding: '0.75rem', borderRadius: '0.5rem', border: '1px solid rgba(239, 68, 68, 0.3)', backgroundColor: 'rgba(255,255,255,0.1)', color: 'white', boxSizing: 'border-box' }} /> + ) : ( +

{recruiterName || 'Not set'}

+ )} +
+ +
+ + {isEditing ? ( + setCompanyEmail(e.target.value)} style={{ width: '100%', padding: '0.75rem', borderRadius: '0.5rem', border: '1px solid rgba(239, 68, 68, 0.3)', backgroundColor: 'rgba(255,255,255,0.1)', color: 'white', boxSizing: 'border-box' }} /> + ) : ( +

{companyEmail || 'Not set'}

+ )} +
+ + {isEditing && ( +
+ + {isSaving && } {isSaving ? 'Saving...' : 'Save Settings'} + + + {saveSuccess && Settings Saved!} + +
+ )} +
+
+ ); + + return ( +
+
+ {profilePhotoSection} + {companySettings} +
+

Danger Zone

+

Transferring ownership is a permanent action. The new admin will have full control, and your admin privileges will be revoked.

+ +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/supabaseClient.ts b/src/supabaseClient.ts new file mode 100644 index 0000000000000000000000000000000000000000..f8e533e23df9222b03910ff8de4bdea1e6a0e499 --- /dev/null +++ b/src/supabaseClient.ts @@ -0,0 +1,8 @@ +import { createClient } from '@supabase/supabase-js' + +// Get your URL and Key from your Supabase project's API settings +const supabaseUrl = 'https://obhychdzwbytlzwrjrbl.supabase.co' // <-- Paste your Project URL here +const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9iaHljaGR6d2J5dGx6d3JqcmJsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTg4ODEwNTksImV4cCI6MjA3NDQ1NzA1OX0.DJQuvRMD98-9HO_jm-JItK9wVFRButJJ_iK4IXzCE40' // <-- Paste your 'anon' public key here + +// This creates the connection +export const supabase = createClient(supabaseUrl, supabaseAnonKey) \ No newline at end of file diff --git a/update_profile_table.sql b/update_profile_table.sql new file mode 100644 index 0000000000000000000000000000000000000000..02f2246355e8a1c6cde0a068606e2b890a393fea --- /dev/null +++ b/update_profile_table.sql @@ -0,0 +1,48 @@ +-- Update profiles table to include all extended profile fields +-- This will add all the fields that are currently only in the UI + +ALTER TABLE profiles +ADD COLUMN IF NOT EXISTS email TEXT, +ADD COLUMN IF NOT EXISTS phone TEXT, +ADD COLUMN IF NOT EXISTS current_position TEXT, +ADD COLUMN IF NOT EXISTS address TEXT, +ADD COLUMN IF NOT EXISTS linkedin TEXT, +ADD COLUMN IF NOT EXISTS github TEXT, +ADD COLUMN IF NOT EXISTS portfolio TEXT, +ADD COLUMN IF NOT EXISTS experience_years TEXT, +ADD COLUMN IF NOT EXISTS education TEXT, +ADD COLUMN IF NOT EXISTS certifications TEXT, +ADD COLUMN IF NOT EXISTS technical_skills TEXT, +ADD COLUMN IF NOT EXISTS languages TEXT, +ADD COLUMN IF NOT EXISTS professional_references TEXT, +ADD COLUMN IF NOT EXISTS desired_salary TEXT, +ADD COLUMN IF NOT EXISTS industry_experience TEXT, +ADD COLUMN IF NOT EXISTS career_goals TEXT, +ADD COLUMN IF NOT EXISTS willing_to_relocate BOOLEAN DEFAULT FALSE, +ADD COLUMN IF NOT EXISTS available_remote BOOLEAN DEFAULT FALSE; + +-- Add indexes for better performance on commonly searched fields +CREATE INDEX IF NOT EXISTS idx_profiles_technical_skills ON profiles USING gin(to_tsvector('english', technical_skills)); +CREATE INDEX IF NOT EXISTS idx_profiles_willing_to_relocate ON profiles(willing_to_relocate); +CREATE INDEX IF NOT EXISTS idx_profiles_available_remote ON profiles(available_remote); +CREATE INDEX IF NOT EXISTS idx_profiles_experience_years ON profiles(experience_years); + +-- Add comments to document the new fields +COMMENT ON COLUMN profiles.email IS 'User email address'; +COMMENT ON COLUMN profiles.phone IS 'User phone number'; +COMMENT ON COLUMN profiles.current_position IS 'Current job title/position'; +COMMENT ON COLUMN profiles.address IS 'User address'; +COMMENT ON COLUMN profiles.linkedin IS 'LinkedIn profile URL'; +COMMENT ON COLUMN profiles.github IS 'GitHub profile URL'; +COMMENT ON COLUMN profiles.portfolio IS 'Portfolio website URL'; +COMMENT ON COLUMN profiles.experience_years IS 'Years of work experience'; +COMMENT ON COLUMN profiles.education IS 'Educational background'; +COMMENT ON COLUMN profiles.certifications IS 'Professional certifications'; +COMMENT ON COLUMN profiles.technical_skills IS 'Technical skills (comma-separated)'; +COMMENT ON COLUMN profiles.languages IS 'Languages known (comma-separated)'; +COMMENT ON COLUMN profiles.professional_references IS 'Professional references'; +COMMENT ON COLUMN profiles.desired_salary IS 'Desired salary range'; +COMMENT ON COLUMN profiles.industry_experience IS 'Industry experience description'; +COMMENT ON COLUMN profiles.career_goals IS 'Career goals and aspirations'; +COMMENT ON COLUMN profiles.willing_to_relocate IS 'Willing to relocate for work'; +COMMENT ON COLUMN profiles.available_remote IS 'Available for remote work'; diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000000000000000000000000000000000000..8b0f57b91aeb45c54467e29f983a0893dc83c4d9 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +})